0.1 + 0.2 为什么不等于 0.3:把浮点数拆到 IEEE 754 的每一位
大一某个晚上,我在 C 里写了一行本以为最没悬念的代码:
printf("%.17f\n", 0.1 + 0.2);我等着看 0.30000000000000000,屏幕却给我吐出:
0.300000000000000040.1 加 0.2,不等于 0.3。末尾冒出一个不知从哪来的 4。我第一反应是编译器坏了,换了台机器,一样;用 Python 试,0.1 + 0.2 还是 0.30000000000000004。
这不是某个语言的 bug,是几乎所有语言共享的同一个真相。想弄明白它,得把浮点数一层层拆开,一直拆到 IEEE 754 标准里那 64 个比特是怎么排布的。
问题的根:二进制存不下 0.1
在怪浮点之前,先接受一个朴素的事实:有些小数,天生就写不完。
十进制里,1/3 = 0.33333...,无限循环,你永远写不完整。这不是十进制的错,只是 3 这个分母和 10 凑不到一起。
二进制也一样,只不过”凑不到一起”的分母换了。二进制的小数位,代表的是 1/2、1/4、1/8……这些 2 的负次幂。一个小数能不能在二进制里写完,取决于它能不能被拆成有限个 1/2^k 之和。
0.5 可以(就是 0.1₂),0.25 可以(0.01₂),但 0.1 不行。我们用”乘 2 取整法”手动转一下就看到了:
0.1 × 2 = 0.2 → 取整数位 0
0.2 × 2 = 0.4 → 0
0.4 × 2 = 0.8 → 0
0.8 × 2 = 1.6 → 1,剩 0.6
0.6 × 2 = 1.2 → 1,剩 0.2
0.2 × 2 = 0.4 → 0 ← 0.2 又出现了,从这里开始循环
...所以 0.1 在二进制里是 0.0001100110011001100...,那个 0011 无限循环下去。计算机的存储位是有限的,装不下无限循环,只能在某一位截断。从你写下 0.1 那一刻起,存进内存的就已经不是精确的 0.1 了,而是一个离它极近、但不相等的值。
0.1 和 0.2 各带着这么一点点误差,相加之后误差累加,再拿去和”同样存不准的 0.3”比较,自然对不上。开头那个 4,就是这些微小误差浮出水面的结果。
哪些小数二进制存得下,哪些存不下
那到底哪些小数能被精确表示?规律其实很干净:一个十进制有限小数能在二进制里精确表示,当且仅当它化成最简分数后,分母只含质因子 2。
因为二进制小数就是若干 1/2^k 的和,能被它精确表示的数,分母必然是 2 的幂。
0.5 = 1/2 → 0.1₂ 精确
0.25 = 1/4 → 0.01₂ 精确
0.75 = 1/2 + 1/4 → 0.11₂ 精确
0.125 = 1/8 → 0.001₂ 精确
0.1 = 1/10 = 1/(2×5) → 分母有 5 存不下
0.3 = 3/10 → 分母有 5 存不下这里藏着一个特别反直觉的地方:0.5、0.25 这种”看起来像分数”的,反而能精确存;而 0.1、0.2、0.3 这种”看起来最整”的,恰恰存不准。原因就是十进制的分母天生带着 5(10 = 2 × 5),而 5 在二进制里除不尽。
想通这条,你就能一眼判断一个小数会不会有精度问题,而不用每次都去实验。
有限的位,怎么兼顾大范围和高精度
既然位数有限,那怎么用它最划算?这里有个关键的设计抉择。
一种办法叫定点:把小数点钉死在固定位置,比如前 16 位表示整数、后 16 位表示小数。简单,但很憋屈——你既想表示 0.0000001 这种极小的数,又想表示 1000000000 这种极大的数,固定的小数点没法同时照顾两头。
另一种办法,借了科学计数法的智慧。人类写很大或很小的数时,不会老老实实写一长串零,而是写成 6.022 × 10²³ 或 1.6 × 10⁻¹⁹。一个尾数,一个指数,小数点的位置随指数浮动——这就是”浮点”的字面意思。
同样的位数,浮点能覆盖的范围比定点大得多,代价是精度不再均匀(数越大,能分辨的最小间隔越粗)。1985 年,这套做法被统一成一个标准:IEEE 754。今天你在几乎任何语言里写的 float 和 double,遵循的都是它。
拆开 IEEE 754:符号、阶码、尾数
IEEE 754 把一个浮点数的比特切成三段。以最常用的双精度 double(64 位)为例:
double (64 位):
┌─┬────────────┬───────────────────────────────────┐
│S│ 阶码 E │ 尾数 M │
│1│ 11 位 │ 52 位 │
└─┴────────────┴───────────────────────────────────┘
63 62 52 51 0单精度 float(32 位)是同样的结构,只是位数少一些:1 位符号 + 8 位阶码 + 23 位尾数。
三段各管一件事,合起来的解读公式是:
逐段看清楚:
符号位 S:最简单,0 是正,1 是负。
尾数 M(也叫有效数):存的是科学计数法里小数点后面那部分。这里有个精妙的小手段——任何二进制数规格化后,小数点前面必然是 1(比如 110.1 写成 1.101 × 2²,前导总是 1)。既然总是 1,那就干脆不存它,只存后面的小数部分。这个”隐含的 1”让你白赚一位精度,所以 52 位尾数实际有 53 位的效果。
阶码 E:存指数。指数有正有负(大数用正指数,小数用负指数),但这里没有单独的符号位,用的是偏移码(bias):真实指数加上一个固定偏移量再存。double 的 bias 是 1023,float 是 127。这样一来,存进去的永远是非负数,比较大小也更简单。
用一个数走一遍编码
抽象的公式,拿个具体的数跑一遍就透了。以 6.5 为例,把它编码成单精度 float:
第一步,转二进制: 6.5 = 110.1₂
第二步,规格化: 110.1 = 1.101 × 2²
第三步,拆三段(float):
符号 S = 0 (正数)
指数 = 2,加 bias 127 → E = 129 = 10000001₂
尾数 M = 101 后面补满 23 位 = 10100000000000000000000
拼起来:
0 10000001 10100000000000000000000
= 0x40D00000你可以让 C 验证这个位模式。反过来,把 0.1 编码成 double,再看它的比特:
#include <stdio.h>
#include <stdint.h>
#include <string.h>
int main(void) {
double d = 0.1;
uint64_t bits;
memcpy(&bits, &d, sizeof d); // 按位取出 double 的 8 个字节
printf("0x%016llx\n", (unsigned long long)bits); // 0x3fb999999999999a
return 0;
}输出是 0x3fb999999999999a。把它摊开:符号位是 0,阶码段解出真实指数是 -4,而尾数那一长串 1001 1001 1001 ... 1010,正是我们前面手算出的 0.0001100110011... 循环节,被塞进 52 位后截断得到的。
末尾那个 a:舍入到最近的偶数
这里值得停一下。0.1 的位模式尾部是 ...9999a,而不是 ...99999。最后一位为什么是 a(二进制 1010),而不是循环节该有的 9(1001)?
因为截断不是简单地”一刀切掉后面”,而是要舍入。0.1 的二进制在第 52 位之后还拖着无穷无尽的 1100...,这部分超过了”半个最低位”,按四舍五入的逻辑,得往上进位,于是末位从 9 被抬成了 a。
IEEE 754 默认的舍入规则,叫就近舍入、平局取偶(round to nearest, ties to even),也就是俗称的”银行家舍入”:
- 就近:选最接近真实值的那个可表示浮点数;
- 平局取偶:如果真实值正好卡在两个可表示值的正中间,选最低位是 0(偶)的那个。
为什么平局时要”取偶”,而不是一律向上?因为如果每次卡中间都向上,海量的舍入会系统性地整体偏大,误差越滚越多。取偶让”向上”和”向下”各占一半,长期下来正负误差相互抵消,统计上更公正。
0.1 的尾数(第 52 位之后还有无穷的 1100...):
...1001 1001 1001 | 1001 1001...
↑ 第 52 位在这里截断
截断点后面的部分 > 半个最低位 → 向上进位
→ 末位 9(1001) 变成 a(1010)那串停不下来的 9,加上末尾这个被舍入抬上来的 a,就是”0.1 存不准”这件事留在内存里的物证。
精度到底有多少位
尾数的位数,直接决定了浮点数能保证几位十进制有效数字。
double 有 52 位尾数(算上隐含的 1 是 53 位)。2^53 ≈ 9 × 10^15,换算成十进制,大约是 15 到 17 位有效数字。float 只有 23 位尾数,2^24 ≈ 1.6 × 10^7,大约 7 位有效数字。
类型 尾数位 十进制有效数字 常见用途
float 23 位 约 7 位 省内存、图形、对精度不敏感处
double 52 位 约 15-17 位 默认选择,科学计算这解释了一条实用经验:如果你的计算涉及超过 7 位有效数字,float 就会开始丢精度,该换 double。而当 double 的 15 位也不够时(比如高精度金融、天文),就得上更专门的手段了。
为什么偏偏是 %.17f:唯一还原一个 double 要几位
前面我一直用 %.17f 打印,这个 17 不是随手写的。它背后是两个特别容易搞混的数字。
一个 double 有 53 位有效二进制位。要用十进制唯一地区分所有相邻的 double(也就是打印出来、再读回去,能一字不差地还原成同一个 double),需要 17 位有效数字。少于 17 位,某些本来不同的 double 会被打印成同一串十进制,信息就丢了。
另一个数是 15:如果你反过来,先写一个不超过 15 位有效数字的十进制数,把它存进 double 再打印,它保证能原样还原,不失真。
15 是”double 能保证的十进制精度”,管的是”从十进制进去能不能不走样”;17 是”唯一区分一个 double 所需的位数”,管的是”从 double 出来能不能被认出来”。两个数方向相反,别记混。
所以想认真调试浮点、或把浮点数序列化到文件再读回来还不丢精度,就得老老实实用 17 位;或者干脆用 C99 的 %a,它直接把浮点数打印成十六进制的精确位模式,一位不差。
非规格化数:填补 0 附近的空洞
隐含的前导 1 让我们白赚一位精度,但它也埋了个麻烦。既然规格化数长成 1.M × 2^E,那能表示的最小正数,是 1.0 × 2^(最小指数)——它离 0 还有一段不小的距离。
问题来了:从这个最小规格化数,到 0,中间是一片空洞。落在这个空洞里的、更接近 0 的小数,岂不是没法表示,只能”啪”地一下直接跳到 0?这种”突然下溢”会让一些本该成立的性质翻车,比如 x - y == 0 明明应该等价于 x == y,却可能因为差值掉进空洞被强行归零而出错。
IEEE 754 的解法很巧:当阶码全为 0 时,启用一类特殊的数,叫非规格化数(subnormal,也叫 denormal)。这时前导不再默认是 1,而是 0,数值变成 0.M × 2^(最小指数)。这样一来,数值可以从最小规格化数继续平滑地、一小步一小步地趋近 0,而不是断崖式跳到 0。这个设计叫渐进下溢(gradual underflow)。
规格化数 非规格化数 0
...──┼──┼──┼──┤ ← 空洞 → 断崖式跳到 0(没有非规格化数时)
...──┼──┼──┼──┼─┼─┼─┼─┼─┼─0 平滑趋近(有非规格化数)代价是非规格化数越靠近 0,有效精度越低,而且不少 CPU 处理它们要慢上一截(所以某些追求性能的场景会开启 flush-to-zero,主动放弃这段精度)。但它换来的,是浮点数在 0 附近行为的连续和可预测。
那些不是数的”数”:0、无穷、NaN
除了非规格化数,IEEE 754 还用阶码段的极值,留了几个表示”正常数值之外”的位模式:
阶码全 0,尾数全 0 → ±0(是的,有 +0 和 -0)
阶码全 0,尾数非 0 → 非规格化数(就是上一节讲的)
阶码全 1,尾数全 0 → ±无穷(例如 1.0/0.0)
阶码全 1,尾数非 0 → NaN(Not a Number,例如 0.0/0.0)其中 NaN 最有个性。它表示”这不是一个有效数字”,比如 0.0/0.0 或对负数开平方的结果。它有一条反直觉到经常坑人的性质:
double nan = 0.0 / 0.0; // 在遵循 IEEE 754 的实现上得到 NaN
printf("%d\n", nan == nan); // 输出 0,也就是 falseNaN 不等于它自己。这是整个体系里唯一一个 x == x 为假的值。它的用途正在于此:想检测一个浮点结果是不是 NaN,不能写 x == NaN(永远为假),得靠 x != x 或标准库的 isnan()。
至于 ±0,虽然有两个零的位模式,但 IEEE 754 贴心地规定 +0 == -0 为真,不会再重演补码之前原码那个”两个零”的麻烦。
机器 epsilon 与灾难性抵消
理解了精度,还有两个概念能让你真正会用浮点,而不只是知道它不准。
第一个是机器 epsilon:1.0 和它相邻的下一个可表示浮点数之间的间隔。对 double,这个值约是 2.2 × 10⁻¹⁶(C 里是 DBL_EPSILON)。它衡量的是浮点数的相对精度——注意是相对,数越大,相邻两个浮点数之间的绝对间隔就越宽。这也是为什么用一个固定的容差(比如 1e-9)去比较浮点并不总靠谱:对很大的数,1e-9 可能比相邻浮点的间隔还小,根本达不到。
第二个更要命,叫灾难性抵消(catastrophic cancellation):两个很接近的浮点数相减,会让有效位大量蒸发。
double a = 1.0000001;
double b = 1.0000000;
printf("%.17g\n", a - b); // 你以为是 0.0000001,实际带着一堆误差a 和 b 各自有约 16 位有效数字,可它们前面十几位完全相同,一相减,那些相同的高位全抵消归零,结果只剩下最末尾那几位本就最不精确的数字。原本藏在末位的舍入误差,一下子被放大成了结果的主要成分。
这个陷阱在真实计算里反复出现。最经典的是求根公式:
当 b² 远大于 4ac 时,sqrt(b² - 4ac) 会非常接近 |b|,-b 和它一相加(当 b 为正)就发生剧烈抵消,算出来的根精度崩塌。数值稳定的做法是换一条等价但不触发抵消的公式来算这个根。计算方差时用 E[x²] - E[x]² 也有同样的毛病,两个大数相减,于是才有了 Welford 这类逐步更新、避免抵消的算法。
这才是”浮点很危险”的真正含义
大多数人对浮点的警惕停留在”0.1 + 0.2 不等于 0.3”。但真正会在工程里咬你的,是灾难性抵消:它不报错、不溢出,只是悄悄把你结果的有效数字吃掉大半。知道它的存在,你才会在写数值代码时多问一句”这里会不会有两个相近的数在相减”。
同一份浮点代码,换台机器结果可能不一样
还有一个反直觉到让我当年抓狂的事实:同样的浮点运算,换个编译器、换个优化级别、换个 CPU,结果可能有细微差别。几个常见的元凶:
- 老式的 x87 FPU 内部用 80 位扩展精度算中间结果,比 64 位 double 还精;可一旦把中间结果写回内存,又被截断成 64 位。于是”中间多算的那几位”让结果和纯 64 位运算略有出入。
- FMA(乘加融合,fused multiply-add)把
a * b + c用一条指令算完,中间只舍入一次;而分开写成先乘后加,会舍入两次。少一次舍入,结果就差那么一点。 -ffast-math允许编译器假装浮点满足结合律、交换律,去自由重排运算换取速度,代价就是牺牲可复现性。
这就是为什么科学计算、金融结算、以及需要跨机器严格一致的游戏联机,对浮点的可复现性特别敏感,有时得强制走 IEEE 严格模式、关掉 fast-math。想通了浮点的本质是”近似加舍入”,这些差异就不再是灵异事件,而是”舍入在不同地方、发生了不同次数”的必然结果。
用浮点时,这几个坑迟早会踩
把前面的原理收束成几条工程铁律,它们都是自然推论,而不是死记的规矩。
第一,别用 == 比较浮点数。 既然 0.1 + 0.2 和 0.3 存的根本不是同一个位模式,直接 == 就会失败。正确做法是判断两者之差的绝对值是否小于一个很小的容差,数值跨度大时还要用相对误差:
#include <math.h>
if (fabs(a - b) < 1e-9) { /* 视为相等 */ }第二,大数会”吃掉”小数。 浮点精度是相对的,数越大,能分辨的最小间隔越粗。当一个数大到一定程度,给它加个 1 可能毫无变化:
printf("%.1f\n", 1e16 + 1.0); // 10000000000000000.0,那个 +1 凭空消失了因为 1e16 已经超过了 2^53,超出这个界的整数,double 连相邻整数都分不清了,+1 落在精度之外,直接被舍掉。这也直接导致浮点加法不满足结合律:(a + b) + c 和 a + (b + c) 可能给出不同结果,取决于先加谁。
第三,钱绝对不要用浮点存。 一分钱的误差在浮点里被反复累加,最后账就对不平了。金额要么用整数存”多少分”,要么用定点/十进制类型。
浮点数很好用,但它的等号和你想的不一样
把浮点数当成”带小数点的精确数”是最常见的误区。它是对实数的近似,不是实数本身。凡是涉及相等判断、大小悬殊的累加、金额结算、相近数相减,都要绕开它的这些性质。想清楚这一点,一大半浮点 bug 都能提前避开。
亲手把这些现象逼出来
把前面的关键现象凑成一个程序,自己跑一遍,比读十遍都记得牢:
#include <stdio.h>
int main(void) {
// 1. 经典的不相等
printf("%.17f\n", 0.1 + 0.2); // 0.30000000000000004
printf("%d\n", 0.1 + 0.2 == 0.3); // 0,false
// 2. 三个数各自存的都不是"整数值"
printf("%.17f\n", 0.1); // 0.10000000000000001
printf("%.17f\n", 0.3); // 0.29999999999999999
// 3. NaN 不等于自己
double nan = 0.0 / 0.0;
printf("%d\n", nan == nan); // 0
// 4. 大数吃掉小数
printf("%.1f\n", 1e16 + 1.0); // 10000000000000000.0
// 5. 灾难性抵消:有效位被吃掉
double a = 1.0000001, b = 1.0000000;
printf("%.17g\n", a - b); // 远不是干净的 0.0000001
return 0;
}再配上前面那段 memcpy 看位模式的代码,你就能同时从”现象”和”比特”两个层面,看清 0.1 到底被存成了什么。
写在最后
搞懂 IEEE 754 之后,我对那个末尾的 4 的感觉,从”这什么鬼”变成了”原来如此”。浮点数从来不是一个不精确的残次品,它是人类在一个硬约束下做出的极其聪明的妥协:用固定的 64 个比特,去逼近从亚原子尺度到宇宙尺度、连续而无限的实数。它做不到处处精确,但它用统一的一套格式,配上隐含前导 1、偏移阶码、就近舍入、渐进下溢这些精心设计的零件,让这件几乎不可能的事变得可用、可移植、可预测。
回头看,这一篇和上一篇其实是同一个故事的两半。整数用有限的位,换来了精确,代价是范围有限,一越界(溢出)就翻车;浮点用有限的位,换来了广阔的范围,代价是精度有限,时时刻刻在做舍入。它们面对的是同一道题——有限的比特,如何承载我们想表示的数——只是一个选择了精确,一个选择了范围。
理解一个抽象最好的方式,是去看它为了给你便利,在背后偷偷付出了什么代价。浮点数的代价,就写在 0.1 那串停不下来的 9 里,也写在两个相近的数一相减就崩掉的有效位里。