2. IEEE 754浮点格式的工程解读
要真正控制浮点误差,第一步不是急着写代码,而是把浮点数在机器里的“解剖图”看清楚。IEEE 754 把任意一个浮点数拆成三段:符号位、指数位、尾数位。以最常见的单精度 binary32 为例,总共 32 位,从左到右依次是 1 位符号、8 位指数、23 位尾数;双精度 binary64 则是 1 位符号、11 位指数、52 位尾数。这三段的布局决定了浮点数能表示多大、多小、多精确,也决定了哪些运算会悄悄丢精度。
先明确一个贯穿全文的约定:符号位 0 表示正、1 表示负;指数采用“偏置码”存储,单精度偏置为 127,双精度偏置为 1023。也就是说,存储的指数值 E 减去偏置才是真实的二进制指数。尾数采用“隐含前导 1”的规格化表示,即实际有效数字是 1.f(f 为尾数位),这个隐含的 1 不占存储空间,相当于白送一位精度。理解这三点,后面所有边界行为都能推出来。
把这张图读透,就能解释很多“反直觉”的现象。规格化数是主力军,指数范围 1~254(单精度),对应真实指数 −126~127,配合隐含的 1,单精度能表示约 1.18×10⁻³⁸ 到 3.4×10³⁸ 的量级。非规格化数出现在指数位全 0 且尾数非零时,此时不再有隐含的 1,实际值是 0.f × 2⁻¹²⁶。它的意义在于“渐进下溢”:当数值小到规格化数无法表示时,非规格化数用牺牲有效位数的方式继续向下延伸,直到最小非零值 2⁻¹⁴⁹(单精度)。这避免了两个很小的数相减直接变成 0 的突变。
特殊值则承担“异常语义”。指数位全 1、尾数为 0 表示无穷大,用于表达溢出或除以零的结果;指数位全 1、尾数非零表示 NaN(非数),用于表达 0/0、∞−∞ 这类无定义运算。NaN 还分 quiet NaN 与 signaling NaN,前者在运算中静默传播,后者用于触发异常。工程上要特别注意:NaN 与任何数比较(包括它自己)都为假,所以判断一个值是否为 NaN 不能用 x == NaN,而要用 x != x 或语言提供的 isnan()。
下面用一段可复现的示例说明如何“看见”这些位。以单精度数 0.1 为例,它在二进制下是无限循环小数,存进 32 位必然被截断。用 Python 可以打印出它的真实存储值:
import struct
x = 0.1
bits = struct.unpack('>I', struct.pack('>f', x))[0]
print(f"{bits:032b}")
# 输出: 00111101110011001100110011001101
# 符号0 | 指数01111011(=123, 真实指数-4) | 尾数1001100...
print(struct.unpack('>f', struct.pack('>I', bits))[0])
# 输出: 0.10000000149011612
可以看到,0.1 存进去再取出来已经不是 0.1,而是 0.10000000149011612。误差来源正是尾数只有 23 位,无法容纳无限循环的二进制展开。这个例子说明:判断浮点误差不能只看十进制字面,必须回到位布局去理解“哪些位被保留、哪些位被舍入”。
边界行为还有几个工程上高频的坑。第一,溢出:当结果超过最大规格化数,会变成 ±∞,而不是回绕成一个大负数,这一点与整数溢出完全不同。第二,下溢:结果小于最小非规格化数时舍入为 ±0,此时若后续做除法会得到 ∞,形成连锁异常。第三,有符号零:+0 与 −0 在比较时相等,但 1/+0 = +∞、1/−0 = −∞,在涉及极限或符号敏感的算法中必须区分。第四,非规格化数在某些硬件上运算速度显著下降,对性能敏感的数值内核需要评估是否开启“flush-to-zero”模式,但这会牺牲极小量的精度,属于精度与性能的显式权衡。
float 布局一致。对精度敏感的累加场景,可改用 Kahan 求和或双精度累加,从源头减少舍入累积。