前阵子在调一块通过串口上报姿态数据的IMU板子,协议文档里只写了一句“角速度字段为IEEE 754 half float”,然后就丢过来一串裸字节。板子本身是一颗不带FPU的低功耗MCU,源码里也没有现成的半精度转换库。我盯着0x4510这种16位二进制数据,第一反应是“这个数到底是多少”。后来自己推了一遍,发现手动还原半精度浮点数其实可以压缩成一个很顺手的式子:(mant | 0x400) << shift。这个式子看着像魔法数字,实际上就是把16位half里的10位尾数重新拼回有效数字。这篇文章把推导过程和踩过的坑完整写一遍,给要在嵌入式环境里解析half float的人做个参考。
1. 为什么半精度数据会被打成裸二进制丢给我
1.1 设备端能省一点算一点,接收端就得自己扛
很多传感器、IMU、磁力计、激光雷达,甚至音频处理链路,都会选择用半精度浮点数做数据交换。原因很简单:16位占空间小,功耗低,传输带宽省一半。设备端往往只负责把内存里的二进制位模式直接拷贝到缓冲区,再按照通信协议发出去;它不会帮你转成十进制,也不会帮你算好一个float32。你在接收端看到的,就是两个字节,比如0x4510。
如果你不知道这两个字节是half float,很容易把它当成一个普通整数去读。0x4510直接按整数打印出来是17680,但它在半精度格式里实际是5.0625。这不是什么玄学,只是IEEE 754 binary16的位排布问题。但问题在于:很多MCU工程里根本没有现成的half转换函数,加一个完整的浮点库成本又太高,尤其当你的目标设备连硬件FPU都没有时,一次浮点乘法可能都要靠软件模拟,耗时和Flash占用都很肉疼。
我当时的做法是打开IEEE 754规格,把16位拆成符号、指数、尾数三段,然后手写还原逻辑。核心公式绕来绕去最后就变成标题里那一行位运算。弄明白之后,整个函数只有十几行,而且不依赖任何第三方库,放到任何C环境里都能跑。
1.2 binary16 的位排布:5位指数、10位尾数、偏置15
半精度浮点数的16位长这样:
bit 15 : 符号位 S bit 14 - 10 : 指数位 E(5位) bit 9 - 0 : 尾数位 M(10位)它和单精度float32一样,是IEEE 754家族的一员,只是字段更短。半精度指数偏置是15,也就是说实际指数是E - 15。对于普通规格化数,有效数字是1.M,那个1.是隐含的,不存进16位里。
几个典型值可以直接对照:
| half位模式 | 符号 | 指数段 | 尾数段 | 实际值 |
|---|---|---|---|---|
| 0x3C00 | 0 | 11110 (30) | 0000000000 | 1.0 |
| 0xC000 | 1 | 10000 (16) | 0000000000 | -2.0 |
| 0x7BFF | 0 | 11110 (30) | 1111111111 | 65504.0 |
| 0x0001 | 0 | 00000 (0) | 0000000001 | 最小次正规数 2^-24 |
| 0x7C00 | 0 | 11111 (31) | 0000000000 | +inf |
| 0x7E00 | 0 | 11111 (31) | 1000000000 | NaN的一类 |
规格化数的公式是:
value = (-1)^S * (1 + M / 2^10) * 2^(E - 15)这就是后面所有位运算推导的起点。
2.(mant | 0x400)这一步到底在补什么
2.1 10位尾数里没有整数位1
很多人第一次拆half时容易犯一个错误:把尾数位段直接拿来用,比如0x3C00的尾数段是0x000,于是认为这个数非常小。实际上0x3C00就是1.0。问题出在:尾数段只存小数点后面的部分,规格化数最高位的那个1被省略了。
所以手动还原的时候,第一件事就是把那个隐含的1补回去。由于尾数只有低10位,隐含位应该放在第10位,也就是二进制的bit 10,对应的数值是0x400。
举个例子,某个half的尾数段是0x300,也就是二进制1100000000,它表示小数点后的部分是0.1100000000,有效数字其实是1.1100000000,十进制是1.75。只看0x300会以为有效数字是0.75,这就差了一倍。补上隐含位后:
0x300 | 0x400 = 0x300 | 0b10000000000 = 0x700然后把0x700除以1024:
0x700 / 1024 = 1792 / 1024 = 1.75正好对上。
这一步的语义就是“把被存储格式拿掉的整数位1放回原位”。所以0x400不是随便挑的数,它等于1 << 10,正好是10位尾数往左数一位的位置。
2.2 为什么用|而不是加1024
既然尾数段只有低10位,那bit 10一定是0。所以:
mant | 0x400和
mant + 1024数学上完全等价。但用位或更符合这里的目的:你不是在“加一个量”,而是在“把缺失的位拼回去”。编译器也更容易把| 0x400直接编译成一条OR指令,不会产生进位链依赖。写代码时我更推荐写成(1u << 10),而不是裸写0x400,这样后来看代码的人一眼就知道这是第10位。
2.3 次正规数不能照搬这个写法
mant | 0x400只对规格化数成立。半精度里指数段全0时是次正规数,也就是说有效数字是0.M,根本没有隐含的1。此时如果强行补上0x400,会把一个并不存在的整数位加进去,数值直接算错。
次正规数有自己的公式:
value = (-1)^S * (M / 2^10) * 2^(-14)化简一下:
value = (-1)^S * M * 2^(-24)所以次正规数不需要补隐含位,直接把尾数段乘以2^-24就行。这个坑我在实际项目里踩过一次,后面专门写代码时会把这段单独处理。
3. 移位的来源:从2^(E-25)到(mant | 0x400) << shift
3.1 先把规格化公式整理成移位友好的形式
规格化half的值是:
value = (-1)^S * (1 + M / 2^10) * 2^(E - 15)因为(1 + M / 2^10)可以写成(0x400 | M) / 1024,代入后得到:
value = (-1)^S * ((0x400 | M) / 1024) * 2^(E - 15)1024本身是2^10,所以指数合并:
value = (-1)^S * (0x400 | M) * 2^(E - 25)这个形式非常关键。它说明:规格化half的还原,本质上就是“一个11位整数”乘以“2的整数次幂”。其中0x400 | M就是那个11位整数,E - 25是指数偏移量。
3.2shift不是拍脑袋定的,它由定点精度决定
如果直接想用移位运算,会遇到一个问题:E - 25可能是负数。比如E = 17时,E - 25 = -8,你没法对整数做负数次左移。
解决方法是引入一个“定点精度”P。我们先把这个11位有效数字放大2^P倍,让它变成一个定点整数:
fixed = (0x400 | M) * 2^P然后再根据指数衰减回来。于是真正的移位量是:
shift = (E - 25) + P只要选的P足够大,shift就不会是负数,就能写出:
fixed = (mant | 0x400) << shift这句话就是标题那一行的完整含义。shift不是指数本身,而是“指数偏移”和“定点精度”的合计。
| P的取值 | E=15时 shift | 说明 |
|---|---|---|
| P=10 | 0 | 有效数字不放大,等价于直接除1024 |
| P=13 | -10 | 不适合直接左移 |
| P=23 | 13 | 对齐float32的小数位宽度 |
| P=25 | 15 | 整数位更充足,适合更大指数 |
3.3 一个完整的正向推导例子
拿0x4510来算一遍。
拆开:
S = 0 E = (0x4510 >> 10) & 0x1F = 17 M = 0x4510 & 0x3FF = 0x110于是:
mant | 0x400 = 0x110 | 0x400 = 0x510定点精度选P = 23,那么:
shift = (17 - 25) + 23 = 15 fixed = 0x510 << 15 = 0x2880000最后把放大过的定点数缩回去:
0x2880000 * 2^(-31) = 5.0625和IEEE标准公式算出来完全一致。手动推一遍之后,你会对<< shift产生直觉:左移就是在搬小数点,位数由指数决定,额外再留几位就是为了不让偶发的负数移位尴尬。
3.4 当 shift 为负数怎么办
不是所有场景都值得为负数移位硬写一套逻辑。工程上最省事的做法是用ldexpf这类指数缩放函数:
value = ldexpf((float)(mant | 0x400), (int)exp - 25);ldexpf(x, n)本质上是算x * 2^n,只是它内部会正确处理负数n。如果你所在的嵌入式工具链连这个函数都没有,再考虑自己实现定点路径,比如用更大的P把所有移位量都变成非负,或者干脆用64位整数中间量,最后再转float。
4. 手写还原函数的完整实现与边界处理
4.1 一份能跑的C代码
下面这个函数覆盖了规格化数、次正规数、0、正负无穷和NaN,不依赖第三方库,只用到标准C的memcpy和ldexpf:
#include <stdint.h> #include <string.h> #include <math.h> static float half_to_float(uint16_t h) { uint32_t sign = (h >> 15) & 1u; uint32_t exp = (h >> 10) & 0x1Fu; uint32_t mant = h & 0x3FFu; float value; if (exp == 0x1Fu) { uint32_t bits = (sign << 31) | 0x7F800000u | (mant << 13); memcpy(&value, &bits, sizeof(value)); return value; } if (exp == 0u) { value = ldexpf((float)mant, -24); if (sign) { value = -value; } return value; } value = ldexpf((float)(mant | 0x400), (int)exp - 25); if (sign) { value = -value; } return value; }规格化数那一行正好对应标题式子:mant | 0x400补隐含位,exp - 25是那个shift(这里用ldexpf处理负数情况)。0x7F800000是float32的无穷大指数段,mant << 13把10位NaN payload搬到位数对应的位置。
4.2 次正规数为什么是-24
半精度的次正规数公式是M * 2^-24,这个指数不是直接写死的,它由两部分组成:次正规的固定指数是2^-14,尾数段的缩放是2^-10,合并起来就是2^(-14-10) = 2^-24。
我说个容易错的地方:最小次正规数0x0001对应的值是:
1 * 2^-24 = 5.960464477539063e-8很多半精度转换代码在这里容易多乘一个1024,因为潜意识里总觉得尾数要再除以1024。但实际上次正规数的公式已经把“没有隐含位”这件事吃进去了,直接用M * 2^-24。
如果你希望把所有half统一成(mant | 0x400) << shift的处理方式,次正规数需要先做归一化:找到尾数里的最高非零位,把它左移到bit 9,再相应调整指数。这样处理也能得到正确结果,但代码量会明显变多。实测下来,直接分情况处理比强行归一化更稳,也更容易review。
4.3 如果连浮点函数都不想用:纯整数定点版本
低端MCU的数学库可能连ldexpf都没有,或者你希望把整个解析链路都保持在定点数域里。这时可以这么写:
#include <stdint.h> /* * 返回定点值 fixed,实际代表 fixed * 2^-25。 * NaN/inf 需要单独用 flag 表示,这里先不展开。 */ static int64_t half_to_fixed(uint16_t h) { uint32_t exp = (h >> 10) & 0x1Fu; uint32_t mant = h & 0x3FFu; if (exp == 0u) { return (int64_t)mant << 1; } return (int64_t)(mant | 0x400) << exp; }为什么次正规数是mant << 1?因为我们要统一用fixed * 2^-25作为最终解释。次正规值原来是mant * 2^-24,等价于(mant << 1) * 2^-25。规格化数则是(mant | 0x400) * 2^(exp - 25),也就是(mant | 0x400) << exp后再整体乘2^-25。
这个版本的移位量正好就是half的指数段,和标题里<< shift长得一模一样,而且不需要处理负数移位。代价是exp最大到30,mant | 0x400最大约2047,乘起来后可能超过32位整数范围,所以要用64位。如果你的目标设备是32位MCU,且传感器数值范围固定,可以提前分析指数上限,选一个更小的P来压进32位。
4.4 字节序和严格别名这两个坑必须单独提
从串口或I2C读到的两个字节,顺序不一定是主机内存顺序。有些传感器按大端发,MCU却是小端,直接强转成uint16_t就会得到反过来的数。我一般用显式组合:
static uint16_t load_be16(const uint8_t *p) { return (uint16_t)(((uint16_t)p[0] << 8) | p[1]); } static uint16_t load_le16(const uint8_t *p) { return (uint16_t)((uint16_t)p[0] | ((uint16_t)p[1] << 8)); }另外,绝对不要用*(float *)&bits这种方式把整数位模式转成float,它在C语言里触发严格别名未定义行为。编译器优化后可能给你一个完全错误的值。正确做法是memcpy,或者用联合体这种在实践中广泛接受的写法。我在多个编译器的-O2优化下对比过,memcpy生成的机器码和直接mov差不多,不需要担心性能。
5. 验证方法:不靠肉眼判断半精度还原
5.1 用Python快速生成测试向量
手写转换函数最怕的就是“看起来对,边界错”。靠肉眼判断0x3C00 = 1.0很容易,但0x7BFF这种边界值,少一个位算出来就差很多。我习惯先用Python生成一组测试向量,再喂给C代码。
import struct def half_to_float_py(h): sign = (h >> 15) & 1 exp = (h >> 10) & 0x1f mant = h & 0x3ff if exp == 0: val = mant * (2.0 ** -24) elif exp == 0x1f: val = float('inf') if mant == 0 else float('nan') else: val = (mant | 0x400) * (2.0 ** (exp - 25)) return -val if sign else val for h in [0x0000, 0x8000, 0x3C00, 0xC000, 0x7BFF, 0x0001, 0x0400, 0x7C00]: print(hex(h), half_to_float_py(h))在有NumPy的环境里,还可以直接让官方实现生成对照:
import numpy as np arr = np.array([0.0, -0.0, 1.0, -2.0, 65504.0, 5.9604645e-8, 0.00006103515625, np.inf, np.nan], dtype=np.float16) bits = arr.view(np.uint16) print([hex(x) for x in bits])5.2 有限数值应该做到位级完全一致
半精度转单精度是精确转换,float32的指数范围和尾数宽度都更大,所以除了NaN的payload和符号可能不同外,有限数值理论上应该位级相等。测试时可以直接比较两个float的二进制位模式,不要只比较十进制打印。
我写过一个Autotest:对16位地址空间抽几千个随机数,用C函数和Python对照函数分别算出float,再memcmp位模式。规格化数、次正规数、0、负数全都跑一遍,基本能把指数偏移和移位量的问题一次暴露干净。
5.3 边界值清单
| half位模式 | 期望值 | 备注 |
|---|---|---|
| 0x0000 | 0.0 | 正零 |
| 0x8000 | -0.0 | 负零 |
| 0x0001 | 5.9604645e-8 | 最小次正规数 |
| 0x03FF | 0.0000609756... | 最大次正规数 |
| 0x0400 | 0.00006103515625 | 最小规格化数 |
| 0x3C00 | 1.0 | 常规值 |
| 0xC000 | -2.0 | 负值 |
| 0x7BFF | 65504.0 | 最大有限half |
| 0xFBFF | -65504.0 | 最小有限half |
| 0x7C00 | +inf | 正无穷 |
| 0xFC00 | -inf | 负无穷 |
| 0x7E00 | NaN | 安静NaN的典型编码 |
这个清单建议直接放进测试文件里。半精度转换这函数太底层,一但出错,上层姿态解算全都会跟着错,而且症状五花八门,排查成本极高。
6. 我的工程取舍:什么时候用手写,什么时候直接用库
6.1 替代方案其实不少
如果编译器支持_Float16或者ARM的__fp16,有些场景可以直接做隐式转换,编译器内部会调用工具链自带的函数。GCC在部分目标上也有__extendhfsf2这类软浮点辅助函数可用。还有开源的half库,API很完善,处理了舍入模式、NaN规范、编译器内建指令等一堆细节。
但这些东西不一定总能用到。一个老的ARM编译器可能不支持_Float16,一个裁剪过的嵌入式C库可能没带half转换相关的辅助函数,项目组可能也不允许引入第三方库。手写函数的优势是可控:依赖只有标准整数类型和memcpy,代码逻辑完全在自己手里。
6.2 速度不是唯一标准
我在几块不同的嵌入式目标上对比过,手写版配合软浮点时,单个half转换大概是几十到几百个指令周期,主要开销集中在ldexpf或软浮点乘法上。如果只用纯整数定点版,不触发浮点运算,速度会更快,但代价是后续所有计算都要留在定点数域里。
这里有个经验:如果你的应用只是把传感器half数据解析出来显示在日志里,直接走定点版转字符串其实最省心;如果后面要做姿态解算、惯性导航这种大量浮点计算的算法,那不如一次转换成float,后续直接用数学库。不要为了省一次转换去写一套定点算法库,那是更大的工程。
6.3 长期维护值得注意的三件事
第一,把公式写成注释。(mant | 0x400) * 2^(exp - 25)这个式子三个月后回看会有点抽象,但注释里写一行“隐含位在第10位,1024是尾数缩放因子”,后面接手的同事会少掉很多头绪。
第二,测试向量不能只放几个常见值。一定要放最小次正规数、最大有限数、正负零、NaN。我见过不少库在NaN payload上偷懒,虽然绝大多数业务感知不到,但在协议兼容性测试里会被揪出来。
第三,边界处理要跟通信协议一起定。比如你的传感器会不会输出NaN?如果协议里明确说不会,那函数里可以简化;但如果不确定,最好保留完整处理,否则一个异常half直接算出inf后,上游滤波算法可能跟着发散。实际项目中,这种问题往往比转换精度本身更隐蔽。