如果你写过几年代码大概率被浮点数坑过。最经典的就是在 JavaScript 里输入0.1 0.2结果不是0.3而是0.30000000000000004在 C 语言里写if (0.1 0.2 0.3)条件永远为假。很多人遇到这种问题第一反应是“语言有 bug”其实底层原因完全一样CPU 做浮点加减法时本来就不是按十进制小数来算的而是按 IEEE 754 标准里的二进制浮点格式一步一步完成对阶、尾数求和、规格化、舍入这些操作。整个过程每一步都会引入误差最后结果自然和直觉对不上。这篇不打算画大饼就是老老实实把浮点加减法的计算过程拆开讲透包括浮点数在内存里的存储格式、两个浮点数相加时 CPU 到底做了什么、减法和加法的关系、舍入规则怎么影响结果以及工程里怎么应对精度问题。内容适合被精度问题折磨过的开发者、正在学计算机组成原理的学生以及所有想把“浮点”这个东西彻底搞清楚的人。读完你可以手动复算一个浮点加法也能解释清楚0.1 0.2为什么不是0.3。1. 先把浮点数的“地基”搞清楚内存里的二进制结构1.1 符号位、指数位、尾数位的设计逻辑要说浮点加减法得先知道浮点数长什么样。IEEE 754 标准把浮点数拆成三个字段符号位、指数位、尾数位。以最常用的单精度float32 位为例它长这样第 31 位符号位S0 表示正数1 表示负数第 23 到 30 位指数位E占 8 位第 0 到 22 位尾数位M也叫有效数字位占 23 位。双精度double64 位同理只是指数位变成 11 位尾数位变成 52 位符号位还是 1 位。这个结构和十进制科学计数法是同一个思路。比如123.45用科学计数法写成1.2345 × 10^2其中1.2345是“有效数字”2是“指数”。浮点数里的尾数就对应“有效数字”指数位就对应“指数”符号位管正负。只是底层用的是二进制所以一个浮点数V的数学形式是V (-1)^S × 1.M × 2^(E-127)注意这里有个隐藏的1.。IEEE 754 规定规格化浮点数的尾数部分最高位一定是 1这个 1 太规律了就不用真存到内存里只存小数点后面的部分。所以 23 位尾数实际能表达 24 位精度这叫“隐藏位”或“隐含位”。这个细节后面算加法的时候非常重要因为对阶时要把这个隐藏的 1 先“请出来”参与计算。1.2 为什么指数不存补码而要存“移码”指数位存的是“移码”也就是真实指数加一个固定偏置。单精度偏置是 127双精度偏置是 1023。比如一个浮点数的真实指数是-1那存进指数位的是-1 127 126。为什么这么设计因为这样指数部分就成了一种“无符号数”比较两个浮点数大小时可以直接按无符号整数来比指数部分符号位和指数位拼起来的整数大小关系和真实浮点数大小关系基本一致。硬件做大小判断会快很多。这也是为什么 IEEE 754 的格式设计会被几乎所有 CPU 采纳它不光是省空间更重要的是方便硬件做比较和排序运算。还有一个直接影响加减法的细节指数位不是普通的二进制补码所以对阶时的“比较阶码大小”不能用补码比较器而是用无符号比较。不同阶码的两个浮点数做加减必须先对齐小数点这一步叫“对阶”。1.3 用 Python 把内存二进制“扒开”看看理论说多了容易晕直接上代码。Python 的float就是 IEEE 754 双精度用struct可以把它的内存 bits 打出来非常直观import struct def double_bits(f): # 把浮点数打包成8字节再解析成无符号64位整数 return struct.unpack(Q, struct.pack(d, f))[0] def show_double(f): bits double_bits(f) sign (bits 63) 1 exp (bits 52) 0x7FF frac bits 0xFFFFFFFFFFFFF print(f值: {f!r}) print(f符号位: {sign}) print(f指数位(移码): 0x{exp:03X}真值 {exp - 1023}) print(f尾数位: 0x{frac:013X}) print(f完整64位: {bits:064b}) print(- * 50) show_double(1.0) show_double(0.75) show_double(-0.625)跑一下会看到1.0符号位 0指数位是0x3FF十进制的 1023真值1023 - 1023 0尾数全 0。因为1.0 1.0 × 2^0隐藏位是 1尾数部分全都是 0。0.75的二进制是0.11 1.1 × 2^-1所以指数真值是-1存的是1022尾数部分存的是1后面的0.1也就是第 52 位最高尾数位为 1。这个视角很重要。真正做加减法时CPU 看到的不是“0.75 这个十进制数字”而是这三段 bits。运算时再把三段 bits 还原成完整的科学计数法形式然后按规则计算。2. 一个浮点加法的完整流程手算 1.5 0.252.1 五个关键步骤对阶、尾数求和、规格化、舍入、溢出判断现在用一个最简单的例子把浮点加法的完整流程走一遍。先看十进制1.5 0.25 1.75这个没问题。但浮点硬件不会直接算十进制它会把两个数拆成二进制科学计数法1.5 1.1 × 2^00.25 1.0 × 2^-2这时候两个数的阶码不一样一个是0一个是-2没法直接把尾数相加。就像十进制里1.5 × 10^0和2.5 × 10^2不能直接把1.5和2.5相加一样必须先把指数对齐。IEEE 754 的做法是“小阶向大阶看齐”阶码小的数尾数右移阶码增大直到两个数阶码相同。所以0.25 1.0 × 2^-2要变成以2^0为基准的数。阶码从-2变到0增大了 2尾数就要右移 2 位1.0 → 0.01这里的尾数右移用的是完整的尾数包括隐藏位1。右移两位后变成0.01也就是原来的1.0除以2^2 4还是0.25数学上没有变化。对阶之后两个数变成1.5 1.10 × 2^00.25 0.01 × 2^0现在阶码相同直接把尾数相加1.10 0.01 1.11所以结果是1.11 × 2^0。此时尾数1.11已经满足“最高位为 1”的规格化要求不需要再调整阶码。换算成十进制1.11 × 2^0 1.11₂ 1 0.5 0.25 1.75和预期完全一致。这个例子太整齐了掩盖了很多麻烦。真实场景更可能是这样两个数阶码差得远尾数右移后低位直接被丢掉这就是精度损失的根源。比如1.5和0.25还好要是2^20和2^-20相加小数的尾数右移 40 位后 40 位全变成 0结果就只剩大数了。完整的浮点加法规程应该是这样的零操作数处理如果某个操作数是 0、无穷大或 NaN直接走特殊分支对阶比较阶码小阶向大阶对齐尾数右移尾数求和/求差恢复隐藏位后按原码加法或减法规则处理规格化如果尾数溢出右移规格化阶码加 1如果最高位为 0左移规格化阶码减 1舍入根据舍入模式处理右移丢掉的多余位溢出判断检查阶码是否上溢或下溢。硬件里这六个步骤是一条流水线但逻辑上和手算完全一致。2.2 减法怎么处理符号判断与原码加减浮点减法不是单独一套逻辑硬件会把a - b转换成a (-b)。也就是说先把减数的符号位取反然后走同一套加法流程。所以在尾数运算阶段真正的逻辑不是“加法器”和“减法器”两套而是根据两个操作数的符号决定做“同号相加”还是“异号相减”。举个例子1.5 - 0.25。硬件先把它变成1.5 (-0.25)符号不同所以尾数要做减法。对阶后1.5 1.10 × 2^00.25 0.01 × 2^0尾数相减1.10 - 0.01 1.01结果是1.01 × 2^0 1.25正确。但如果两个正数相减小的减大的怎么办比如0.25 - 1.5。硬件会先比较对阶后的尾数大小发现0.01 1.10就交换两个尾数用大的减小的结果的符号位取被减数原本的符号。这个细节很多讲浮点的文章不会展开但它是原码加减法的核心思路符号位单独处理尾数部分只做正数之间的加减。这里有一个容易忽略的点对阶时如果阶码差距超过尾数的位数比如单精度尾数加隐藏位只有 24 位两个数阶码差 25 位以上小数的尾数右移后就会变成 0。这种情况下结果就等于大数本身不管循环加多少次小数值根本不会被“看见”。2.3 舍入规则为什么不是简单的四舍五入十进制有四舍五入二进制浮点也有类似的舍入但 IEEE 754 默认用的是“就近舍入”而且额外加了一条规则如果正好落在两个可表示数正中间取偶数值。这个规则也叫“银行家舍入”中文里俗称“四舍六入五成双”。为什么要这样因为如果总是向上或向下舍入大量运算之后误差会朝一个方向累积。取偶数可以让舍入误差在统计上尽量相互抵消。比如单精度里某个尾数右移丢掉的部分正好是1000...恰好是半个最小精度单位那结果就往偶数方向靠。举个例子一个二进制数1.01101要舍入到三位尾数保留1.011丢掉的部分是01小于半个单位直接舍弃。如果丢掉的部分是11大于半个单位尾数加 1变成1.100。如果丢掉的部分是10恰好是一半就看保留的最后一位如果当前最后一位是 1则进位变成 0如果是 0则不进位保持偶数。这个规则在手工计算里看似很细但如果你用 Python 或 C 处理大量浮点累加误差方向和大小都受它影响。理解舍入规则才能解释为什么某些计算序列产生特定方向的误差。3. 0.1 0.2 不等于 0.3问题出在哪一步3.1 十进制的 0.1 在二进制里是无限循环小数要搞清楚0.1 0.2的问题先得转一个二进制。十进制整数转二进制用除 2 取余十进制小数转二进制用乘 2 取整0.1 × 2 0.2整数部分 00.2 × 2 0.4整数部分 00.4 × 2 0.8整数部分 00.8 × 2 1.6整数部分 10.6 × 2 1.2整数部分 10.2 × 2 0.4整数部分 0……后面就开始循环了。0.1的二进制是0.0001100110011001100110011...无限循环下去。IEEE 754 双精度只有 52 位尾数存不下这么多位必然截断。也就是说内存里的0.1本来就不是精确的0.1而是一个“非常接近 0.1 但略大或略小”的二进制数。0.2同理也是一个近似值。所以0.1 0.2在硬件里执行时不是精确的十进制0.1 0.2而是两个截断后的二进制近似值做一次浮点加法结果再做一次舍入。整个过程叠了三层误差两次输入近似一次结果舍入。3.2 内存位模式对比用代码看真相用上一章show_double函数把0.1、0.2、0.3和0.10.2的位模式全打出来show_double(0.1) show_double(0.2) show_double(0.3) show_double(0.1 0.2)运行结果里0.3和0.10.2的完整 64 位二进制串是不一样的差了最后几个 bit。这就是0.1 0.2 0.3为假的直接原因内存里那两个 float 对象的 bits 根本不同。有意思的是如果你打印0.1 0.2的值Python 会显示0.30000000000000004。这是 Python 在把二进制浮点数转回十进制字符串时特意选了“能唯一区分这个二进制数的最短十进制表示”所以看起来只多了个4但它已经是另一个数了。如果用print(f{0.1 0.2:.20f})打印更多位数你会看到更完整的尾巴。3.3 “大数吃小数”的机制阶码差是罪魁祸首比0.1 0.2更隐蔽的大坑是“大数吃小数”。看这个例子a 1e16 b 1.0 print(a b) # 结果是 1e161e16转成二进制后它的阶码很大尾数精度只能区分大约 2 的整数倍。1.0的对阶过程中尾数要右移超过 52 位最后直接变成 0。所以1e16 1.0的结果和1e16完全一样那个1.0被“吃掉”了。这在大规模累加场景里非常危险。比如你在写一个统计程序累加几千万元素每个元素都是零点几的小数如果加的次序不对先加了大数再加小数后面的小数可能全部失效。后面我会说怎么用 Kahan 求和解决但至少现在你明白了浮点加法不满足结合律。(a b) c和a (b c)在浮点世界里可能差很多这不是 bug是格式本身的物理限制。4. 工程里的浮点精度控制实操4.1 别用 那用什么epsilon 的正确选法看到这里你应该能理解直接比较两个浮点数是否相等是不靠谱的。但“用abs(a - b) epsilon”这句话至少一半人没用对。epsilon 选多少取决于你的数据量级。如果你的数都在1左右选1e-9或1e-10基本够用。但如果你的数在1e10量级双精度的绝对误差可能已经到1e-6甚至更大这时候1e-9的 epsilon 会导致所有比较都失败。比较科学的做法是“相对 epsilon”import math def almost_equal(a, b, rel_tol1e-9, abs_tol0.0): return abs(a - b) max(rel_tol * max(abs(a), abs(b)), abs_tol)Python 3.5 以后的math.isclose就是这个逻辑。rel_tol是相对容差abs_tol是绝对容差通常绝对容差设一个很小的值用来处理两个数都在 0 附近的情况。工程上写过一次这种函数后面所有业务判断都复用就行。4.2 Kahan 求和补偿累加误差的超实用技巧前面说过大数吃小数会让累加结果失真。Kahan 求和算法能显著缓解这个问题核心思路是维护一个“补偿变量”把每次加法中丢失的低位信息记录下来下次加进去。def kahan_sum(values): total 0.0 compensation 0.0 for value in values: # 补偿上次丢失的低位 adjusted value - compensation # 当前总和加上调整后的值 new_total total adjusted # 真实误差 (当前结果 - 原来的总和) - 调整值 # 这个误差会在下一次迭代时补偿回来 compensation (new_total - total) - adjusted total new_total return total验证一下效果。拿10000000个1e-8相加数学上的正确答案是0.1。普通累加可能得到0.09999999...或0.10000000...不稳定Kahan 求和基本能稳定在0.1附近误差小好几个数量级。这段代码在数值计算、统计分析、物理引擎里都有实用价值。但不要指望 Kahan 是万能的。它改善的是累加顺序带来的误差不能解决单次加法里的舍入误差也不能让0.1 0.2 0.3成立。4.3 业务场景怎么选Decimal、字符串还是继续用 float如果你的项目是金融、订单、账务这种对精度有硬性要求的地方别自己造轮子直接用十进制浮点类型。Python 里有decimal.DecimalJava 有BigDecimalC# 有decimal都能做精确的十进制小数运算。但用Decimal也有代价速度比原生float慢很多。所以策略应该是分场景图形学、物理模拟、机器学习、数据分析用float精度足够速度第一金额计算、税率计算、对账用Decimal或整数分需要存储和传输尽量用十进制字符串别用二进制浮点序列化后直接比较。我自己踩过的最深的一个坑就是数据库里存金额用DECIMAL读出来是字符串代码里没转直接强转成float做运算再写回库。跑了一个季度后对账差了几分钱查了两天才发现问题出在float累加误差上。后来统一约定凡是金额一律不进float全部Decimal整个世界清净了。5. 常见问题与避坑速查5.1 一张表看清常见浮点问题和处理方式问题现象根本原因推荐方案0.1 0.2 ! 0.3二进制无法精确表示十进制小数输入和结果都有舍入误差用math.isclose或Decimal1e16 1.0 1e16阶码差太大小数的尾数对阶后归零调整运算顺序先加小数或使用 Kahan 求和服务器之间计算结果不一致不同平台舍入模式或优化级别不同如-ffast-math固定编译器优化策略或统一用十进制数值期望接近 0但结果总是带小尾巴相减抵消了有效位造成 catastrophic cancellation改用代数等价式避免两个接近的数直接相减循环累加结果越来越大误差方向一致导致累计偏差使用 Kahan 补偿或按绝对值从小到大排序5.2 几个很实用但很难搜到的经验第一调试浮点问题时不要打印默认精度的数字先打印完整精度。C 语言里用printf(%.17g\n, x)Python 里用format(x, .20f)这能让你看到真正参与计算的数值而不是被语言“包装”过的短字符串。第二检查一个浮点数是不是“整数”时不要直接取余判断。比如判断x % 1 0如果x是巨大数字取余本身就有误差。更好的方式是abs(x - round(x)) eps。第三写单元测试时不要期待浮点结果和预期值完全相等。pytest 的pytest.approx、JUnit 的assertEquals(double, double, delta)就是干这个的。哪怕你预期结果是一个很“整”的数中间过程经过几次运算后最后一位也可能差 1 或 2 个ulp。第四如果发现同一个算法在不同语言里结果不一致先检查是不是编译器开了快速数学优化。比如 GCC 的-ffast-math会假设不涉及 NaN 和无穷大并允许改变运算顺序这会让结果和严格 IEEE 754 不一致。跨语言对账时要么两边都关掉这种优化要么都指定-std相关标准。5.3 关于浮点加减的最后一点心得我自己做数据处理这些年最大的体会是浮点数的精度不是“够不够”的问题而是“你知不知道哪里会丢精度”的问题。float就像一个小数部分的“限位器”存储位有限运算必然有取舍。只要你在设计阶段就清楚哪些步骤会产生误差哪些数据量级容易触发问题完全可以在绝大多数场景里安心用float只在账务等关键场景切换到Decimal。还有一个很多人忽略的点浮点加减法性能其实非常高现代 CPU 的 FPU 是流水线化的一条加法指令延迟通常只有几个时钟周期。所以不要为了“更精确”而把简单加法改成一堆判断分支那样精度没提升多少性能倒先崩了。先算出结果再评估误差是否在业务可接受范围内这才是工程上最靠谱的思路。如果你还没被浮点问题坑过那大概率是因为你的数据量级还不够极端。等哪天真遇到0.1 0.2不等于0.3这种诡异问题回来再看这篇文章应该能少走不少弯路。