浮点数与 IEEE 754:二进制表示、精度陷阱与数值工程

系统讲解 IEEE 754 浮点数:符号/阶码/尾数的二进制布局、规格化与非规格化数、NaN 与 Inf 语义、0.1+0.2 精度损失的来源、epsilon 与 ULP 比较、金额与定点数、Kahan 求和与数值稳定性,以及格式化解析与跨语言差异。

引言

0.1 + 0.2 == 0.3 在大多数语言里是 false——这不是语言 bug,而是 IEEE 754 二进制浮点的必然结果。理解浮点数不是"背几个坑",而是掌握一套在有限位宽下逼近实数的工程约定:它怎么存、怎么算、误差从哪来、什么时候该换定点数、怎么比较才算对。本文从二进制布局讲起,把符号位、阶码、尾数的分工说清楚,再解释为什么 0.1 无法精确表示、NaN 为什么不等于自己、epsilon 比较为什么也常常是错的。

前置:二进制与编码工具:Base64、Hex、xxd 与字节调试、概率统计基础实战。计算机底层的位运算与数据表示见 计算机基础专题。


目录


1. 浮点数的二进制解剖:符号、阶码、尾数

IEEE 754 把一个实数拆成三段存储。以最常见的 binary32(单精度,4 字节)为例:

 31      30-23           22-0
+---+--------------+-----------------------+
| S |   exponent   |      mantissa         |
| 1 |      8       |          23           |
+---+--------------+-----------------------+

value = (-1)^S × 1.mantissa × 2^(exponent - 127)

binary64(双精度,8 字节)则是 1 位符号 + 11 位阶码 + 52 位尾数,偏置值 1023。

名称字节符号阶码尾数偏置
binary162151015
binary3241823127
binary648111521023

尾数前的隐含 1:规格化数里尾数总写成 1.xxxxx,那个前导 1 不存储——省下一位,白赚约 1 bit 精度。这是"规格化"的核心红利。

偏置阶码的用意:阶码存的是无符号整数,实际指数 = 存储值 − 偏置。这样"指数大小比较"等价于"无符号整数比较",硬件比较电路可以复用整数比较器。

import struct

def bits(f, fmt='>f'):
    u = struct.unpack('>I', struct.pack(fmt, f))[0]
    return f'{u:032b}'

print(bits(1.0))     # 00111111100000000000000000000000
print(bits(-1.0))    # 10111111100000000000000000000000
print(bits(0.5))     # 00111111000000000000000000000000

从这三行就能读出规律:符号位一改正负翻转;1.0 的阶码是 01111111(127,指数 0);0.5 的阶码是 126(指数 −1)。

记忆:浮点 = 符号 + 阶码(偏置)+ 尾数(隐含前导 1)——它存的是"科学计数法的二进制版"。


2. 规格化、非规格化与特殊值:NaN 与 Inf

阶码的两个极端取值被保留给特殊情况:

阶码尾数含义
全 0全 0有符号零(+0 / −0)
全 0非 0非规格化数(subnormal)
全 1全 0±Infinity
全 1非 0NaN(尾数区分 quiet/signaling)
其它任意规格化数

非规格化数解决"下溢到 0 的悬崖":最小规格化数约 2^-126,再往下如果直接变 0,a - b 在 a≈b 时会瞬间归零,误差跳变。非规格化数让精度平滑衰减,代价是部分硬件上运算变慢(历史上有的 CPU 会触发慢路径)。

NaN 的三个反直觉性质:

nan = float('nan')
print(nan == nan)          # False!NaN 不等于自己
print(nan != nan)          # True
print(nan in [nan])        # True(对象身份,不是 ==)
print(sorted([3, nan, 1])) # 排序结果不可依赖——NaN 比较全是 False

判 NaN 要用专门函数:math.isnan(x)、x != x(技巧),绝不要用 x == float('nan')。

Inf 的算术:1/0.0 是 inf(不是异常,整数除法 1/0 才抛异常);inf - inf 是 nan;0.0/0.0 是 nan。

print(1 / 0.0)        # inf
print(-1 / 0.0)       # -inf
print(float('inf') - float('inf'))   # nan
print(0.0 / 0.0)      # nan(ValueError 只在整数 0/0 时)

记忆:阶码全 0 是零/非规格化、阶码全 1 是 Inf/NaN——NaN 恒不等于自己,判它必须用 isnan。


3. 0.1 + 0.2 之谜:精度损失从哪来

根因:十进制小数 0.1 在二进制下是无限循环小数,就像 1/3 在十进制下是 0.333…。二进制只能存有限位,必须截断 → 存储值 ≈ 0.1 但不等于 0.1。

from decimal import Decimal
print(Decimal(0.1))        # 0.1000000000000000055511151231257827021181583404541015625
print(Decimal(0.2))        # 0.200000000000000011102230246251565404236316680908203125
print(0.1 + 0.2)           # 0.30000000000000004
print(0.1 + 0.2 == 0.3)    # False

哪些小数能精确表示:只有"分母是 2 的幂"的分数——0.5、0.25、0.125、0.75 精确;0.1、0.2、0.3、0.7 全都不精确。

十进制二进制精确
0.50.1是
0.250.01是
0.10.0001100110011…否(循环)
0.20.001100110011…否(循环)
0.30.010011001100…否(循环)

误差不是"bug"而是"舍入":每次运算都会把结果舍入回最近的可表示值,误差在运算链中累积、抵消或放大。0.1 + 0.2 的结果恰好比 0.3 大了约 5.55e-17。

print(abs((0.1 + 0.2) - 0.3))   # 5.551115123125783e-17
print(f"{0.1 + 0.2:.17f}")      # 0.30000000000000004

记忆:十进制小数在二进制里多为无限循环,必须截断——误差是"舍入的必然",不是"语言的错"。


4. 比较浮点数:epsilon 与 ULP

永远别用 == 比浮点结果,除非你确定两个值都是精确表示(如赋的常量、整数运算结果)。正确做法有两种:

方案 A:绝对 + 相对 epsilon

import math

def close(a, b, rel=1e-9, abs_tol=1e-12):
    return math.isclose(a, b, rel_tol=rel, abs_tol=abs_tol)

print(close(0.1 + 0.2, 0.3))    # True
print(close(1e16, 1e16 + 1))    # True(相对容差在大数上很宽松)
print(close(0.0, 1e-13))        # False(abs_tol 兜底小量级)

math.isclose 同时给相对与绝对容差,大数用相对、小量级用绝对——单一 epsilon 在量级跨十几个数量级时必然失效。

方案 B:ULP(Unit in the Last Place)比较——直接比"相差多少个可表示单位":

import struct
def ulp_diff(a, b):
    to_int = lambda f: struct.unpack('>q', struct.pack('>d', f))[0]
    return abs(to_int(a) - to_int(b))   # 负数需修正符号-幅度编码,简化示意
print(ulp_diff(1.0, 1.0 + 2**-52))      # 1(相邻可表示数)

ULP 比较是库作者级的精确做法:它不受量级影响,且能给出"误差 N 个 ULP"这种可度量的结论。

比较的三种正确姿势:

1. 整数/精确值         → 直接 ==(如 1.5 * 2 == 3.0)
2. 一般数值结果        → math.isclose(相对 + 绝对容差)
3. 数值库/精度敏感     → ULP 距离比较

记忆:浮点比较用 isclose(相对 + 绝对双容差),量级敏感场景用 ULP 距离——单一 epsilon 是幻觉。


5. 舍入模式与浮点环境

IEEE 754 定义了 5 种舍入模式,默认是"最近偶数舍入"(round-half-to-even):

模式行为场景
roundTiesToEven就近,正中取偶默认,最公平
roundTiesToAway就近,正中远离 0部分十进制场景
roundTowardZero向 0 截断C 的 (int) 转换
roundTowardPositive向 +∞区间算术
roundTowardNegative向 −∞区间算术

“最近偶数"为什么是默认:如果正中一律进位(round-half-up),大量累加会系统性偏大;取偶则让偏大偏小各半,误差期望归零。Python 的 round 也是最近偶数——round(0.5)=0、round(1.5)=2、round(2.5)=2、round(3.5)=4。

注意:round 返回的是浮点就近值,不是"四舍五入”。做金额显示要用 Decimal.quantize:

from decimal import Decimal, ROUND_HALF_UP
print(Decimal('2.5').quantize(Decimal('1'), rounding=ROUND_HALF_UP))  # 3

FMA(融合乘加):a*b + c 若用 FMA 指令只舍入一次(而非乘、加各舍一次),精度更高。这是 -ffast-math 与 math.fma(Python 3.13+)背后的东西。

记忆:默认舍入是"最近偶数",不是四舍五入——金额显示必须显式指定 ROUND_HALF_UP。


6. 金额与定点数:别用 float 算钱

金额绝不能用二进制浮点,原因有二:0.01 不精确(同上),以及误差会在对账、分账、累计利息时被放大到分位。

三条出路:

方案存储优点缺点
整数分int64,单位"分"精确、快、可 DB 存需约定单位
Decimal十进制定点精确、可指定精度比 float 慢
字符串文本无损、可审计运算要解析

整数分是最常见的工程解:所有金额以"分"为单位存 int64,只在展示层除 100。

from decimal import Decimal

price = Decimal('19.99')
qty = 3
total = price * qty
print(total)                       # 57.97(精确)
print(total.quantize(Decimal('0.01')))   # 57.97

# 整数分方案
price_cents = 1999
total_cents = price_cents * 3      # 5997 分 = 59.97 元
print(total_cents / 100)           # 59.97(仅展示层转换)

Decimal 的上下文陷阱:Decimal 的除法精度由 context 决定,默认 28 位;1/3 会得到 28 位近似,仍需 quantize 收口。

from decimal import getcontext
getcontext().prec = 6
print(Decimal(1) / Decimal(3))     # 0.333333

税率、汇率、分摊这些"除法密集"场景,规则要显式写进业务:先乘后除、最后一步收口、舍入方向写死,否则不同实现差一分钱。

记忆:钱用整数分或 Decimal,绝不用 float——除法场景先乘后除、末尾统一舍入,规则必须写死。


7. Kahan 求和与数值稳定性

累加大量浮点会误差累积:每次 sum += x 都可能舍入,小量被大量"吃掉"(大数 + 小数 = 大数)。

# 经典反例:0.1 累加 10 次
s = 0.0
for _ in range(10):
    s += 0.1
print(s)             # 0.9999999999999999
print(s == 1.0)      # False

Kahan 求和用一个"补偿项"记录每次舍入的残差,把它加回下一次运算:

def kahan_sum(nums):
    total = 0.0
    c = 0.0                      # 补偿:上次损失的误差
    for x in nums:
        y = x - c
        t = total + y
        c = (t - total) - y      # 恢复出被舍掉的部分
        total = t
    return total

Neumaier 改进版处理"下一次比累计大"的情况(Kahan 原版在 |x| > |total| 时失效):

def neumaier_sum(nums):
    total = 0.0
    c = 0.0
    for x in nums:
        t = total + x
        if abs(total) >= abs(x):
            c += (total - t) + x
        else:
            c += (x - t) + total
        total = t
    return total + c

Python 的 math.fsum 就是精确求和(内部用扩展精度),算"精确到最后一舍":

import math
print(sum([0.1] * 10))          # 0.9999999999999999
print(math.fsum([0.1] * 10))    # 1.0

其他稳定性技巧:求方差用两遍算法或 Welford 在线算法;避免"大数相减"(灾难性抵消,如 1e16 + 1 - 1e16);求和先按量级排序。

print(1e16 + 1 - 1e16)   # 0.0(灾难性抵消,1 被吃掉)

记忆:累加会吃掉小量——用 math.fsum / Kahan / Neumaier;避免大数相减造成的灾难性抵消。


8. 格式化与解析:往返安全

往返安全(round-trip)指 parse(format(x)) == x。这要求格式化输出足够多的有效数字,否则解析回来就丢了精度。

Python 3 的 repr 是往返安全的(最短可往返表示):

x = 0.1 + 0.2
print(repr(x))       # 0.30000000000000004(最短且能还原)
print(str(x))        # 0.30000000000000004(同 repr)
print(f"{x:.2f}")    # 0.30(展示用,不往返)

位宽与有效数字:binary64 有约 15–17 位十进制有效数字——%.17g 保证往返,%.15g 通常够但可能丢。

格式有效数字用途
%.15g15显示,可能丢
%.17g17保证往返
repr(Python)最短可往返序列化
JSON 数字实现相关跨语言要小心

JSON 的浮点坑:很多语言的 JSON 序列化默认保留有限位,大整数或高精度小数会丢。跨语言交换金额/ID 时,用字符串或整数,别赌对方的浮点实现。

import json
print(json.dumps(0.1))            # 0.1(Python 用最短往返)
print(json.loads("0.30000000000000004"))  # 保留

记忆:序列化浮点要"最短可往返"——显示用 %.2f、传输用 repr/17g,跨语言金额用字符串或整数。


9. 跨语言与硬件差异

浮点并非处处一致,工程上有几处跨平台裂缝:

差异点说明
x87 扩展精度老 32 位 x86 用 80 位寄存器,中间结果更精确但结果不同
FMA 使用编译器是否用 FMA 影响末位
快速数学-ffast-math 破坏 NaN/Inf 与结合律
舍入模式某些 DSP/GPU 默认非就近偶数
denormal 处理部分平台 flush-to-zero,非规格化数变 0

结论:浮点结果不保证跨编译选项、跨架构逐位一致。要可复现就必须:禁用 fast-math、固定 FMA 策略、避免依赖末位、必要时用整数/定点。

GPU 与 SIMD 的额外陷阱:float32 在 GPU 上通常无 FMA 一致性保证,且 rsqrt/sin 等超越函数是近似实现,误差可达若干 ULP。

可复现三原则:① 禁用 fast-math / 近似超越函数;② 固定运算顺序(不依赖编译器重排);③ 敏感计算用整数或 Decimal,别赌浮点末位。

一个真实教训:同一份物理仿真代码在 x86 与 ARM 上跑出不同轨迹——不是 bug,是浮点末位差异经混沌系统放大。需要确定性时,浮点本身就是风险源。

记忆:浮点不保证逐位可复现——fast-math、FMA、x87 扩展精度、denormal 处理都是裂缝,要确定性就用整数/定点。


10. 速查表与一句话记忆

全篇速查:

主题结论
布局符号 + 偏置阶码 + 尾数(隐含前导 1)
特殊值阶码全 0 → 零/非规格化;全 1 → Inf/NaN
NaN不等于自己,必须用 isnan 判
精度只有分母为 2 的幂的小数精确
比较isclose(相对 + 绝对)或 ULP,禁用 ==
舍入默认最近偶数,金额显式 ROUND_HALF_UP
金额整数分或 Decimal,绝不用 float
求和math.fsum / Kahan / Neumaier
抵消避免大数相减
可复现禁 fast-math、固定顺序、必要时用定点

一句话记忆:浮点是"二进制科学计数法"——符号、偏置阶码、隐含前导 1 的尾数,只能精确表示分母为 2 的幂的分数,所以 0.1 天生不精确;NaN 不等于自己、Inf 是合法值;比较用 isclose 或 ULP 而非 ==,舍入默认最近偶数;钱用整数分或 Decimal,累加用 math.fsum/Kahan,避开大数相减的灾难性抵消;序列化要往返安全,跨平台别赌浮点逐位一致——需要确定性时,浮点本身就是风险源。


延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「others」更多文章

  1. Git 内部原理:对象、引用与 packfile 的底层机制
  2. 列式数据格式:Parquet、ORC 与 Arrow 的原理与选型
  3. 网络诊断工具箱:从 ping 到抓包的分层排障