2. IEEE 754浮点格式的工程解读

要真正控制浮点误差,第一步不是急着写代码,而是把浮点数在机器里的“解剖图”看清楚。IEEE 754 把任意一个浮点数拆成三段:符号位、指数位、尾数位。以最常见的单精度 binary32 为例,总共 32 位,从左到右依次是 1 位符号、8 位指数、23 位尾数;双精度 binary64 则是 1 位符号、11 位指数、52 位尾数。这三段的布局决定了浮点数能表示多大、多小、多精确,也决定了哪些运算会悄悄丢精度。

先明确一个贯穿全文的约定:符号位 0 表示正、1 表示负;指数采用“偏置码”存储,单精度偏置为 127,双精度偏置为 1023。也就是说,存储的指数值 E 减去偏置才是真实的二进制指数。尾数采用“隐含前导 1”的规格化表示,即实际有效数字是 1.f(f 为尾数位),这个隐含的 1 不占存储空间,相当于白送一位精度。理解这三点,后面所有边界行为都能推出来。

IEEE 754 位布局与数值分类 单精度 binary32(32 位) 符号 1 位 指数(偏置 127) 8 位 尾数 f(隐含前导 1) 23 位 双精度 binary64(64 位) 符号 1 位 指数(偏置 1023) 11 位 尾数 f(隐含前导 1) 52 位 按指数位取值划分的数值类别(以单精度为例) 指数位 E 尾数 f 类别 实际值形式 1 ~ 254 任意 规格化数 ±1.f × 2^(E−127) 0 非零 非规格化数 ±0.f × 2^(−126) 0 0 有符号零 ±0 255 0 无穷大 ±∞

把这张图读透,就能解释很多“反直觉”的现象。规格化数是主力军,指数范围 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”模式,但这会牺牲极小量的精度,属于精度与性能的显式权衡。

工程建议:在跨平台传输或持久化浮点数据时,优先使用 IEEE 754 规定的 binary32/binary64 交换格式,并明确字节序;不要假设不同语言、不同编译器的 float 布局一致。对精度敏感的累加场景,可改用 Kahan 求和或双精度累加,从源头减少舍入累积。