每个程序员第一次写 print(0.1 + 0.2) 时,都会经历一次小型信仰崩塌——屏幕上跳出的不是干干净净的 0.3,而是 0.30000000000000004。这不是 bug,也不是语言的锅,Python、JavaScript、Java、C、Rust,几乎地球上所有通用语言都会给你同样的答案。因为它们背后用的是同一套硬件标准:IEEE 754 浮点数。
jan Misali 这支播放量 45 万、时长近 18 分钟的《how floating point works》,是我见过把"为什么小数会算错"讲得最通透的视频之一。他没有停留在"浮点数不精确"这句正确的废话上,而是从二进制的数学根出发,让你亲眼看到 0.1 为什么在计算机里根本无法被精确表示,再一层层揭开浮点数是"科学记数法而非小数"、数轴上疏密不均、甚至连加法结合律都会失效的真相。
如果你写过任何涉及金额、坐标、物理模拟或机器学习的代码,这 18 分钟能帮你少踩一类最隐蔽、最难复现的 bug。本文在视频基础上补全了 IEEE 754 的工程细节和真实灾难案例。
| 项目 | 内容 |
|---|---|
| 视频标题 | how floating point works |
| 频道 | jan Misali |
| 时长 | 17 分 48 秒 |
| 播放量 | 约 45 万(截至 2026 年) |
| 频道订阅 | 约 35.1 万 |
| 核心主题 | IEEE 754 浮点数:为什么小数算不准,以及它是怎么表示的 |
| 适合人群 | 所有写过小数运算的程序员、计算机专业学生 |
视频开篇就直击要害。我们觉得 0.1 是个"干净"的数,那只是十进制给我们的幻觉。十进制之所以能精确表示 0.1,是因为分母 10 = 2×5,而 0.1 = 1/10。一个分数能被某进制精确表示,当且仅当它的分母(约分后)只含该进制基数的质因子。十进制的基数是 10,质因子是 2 和 5,所以 1/10、1/5、1/2、1/4、1/20 都能精确表示。
但计算机是二进制,基数是 2,唯一的质因子就是 2。于是 1/2 = 0.1₂、1/4 = 0.01₂、1/8 = 0.001₂ 都能精确表示,可一旦分母里掺进一个 5,比如 1/10、1/5、1/20,在二进制下就变成了无限循环小数。
具体地,0.1 转成二进制是 0.0001100110011001100…,"1100"这四位永远循环下去,和十进制里 1/3 = 0.333… 本质完全一样。既然它无限长,而存储空间(double 是 52 位尾数)有限,计算机就只能截断它,于是存进去的 0.1 实际上是 0.1000000000000000055511151231257827…。两个这样的数加起来,再舍入,就得到了那个经典的 0.30000000000000004。
延伸:这不是"实现得不好",而是数学的必然。任何有限位二进制小数都无法精确表示 0.1,就像有限位十进制无法精确表示 1/3。
视频接着纠正一个根深蒂固的误解:很多人以为浮点数是"带小数点的整数",其实它更像是二进制版的科学记数法。一个 IEEE 754 数的本质形式是:
$$(-1)^{\text{sign}} \times 1.\text{mantissa} \times 2^{\text{exponent}}$$
一个 64 位 double(float64)被切成三段:1 位符号位、11 位指数、52 位尾数(mantissa,也叫 significand)。float32 则是 1 + 8 + 23。这个结构决定了它最核心的权衡——它追求的是"范围",而不是"精度"。一个 double 能表示从约 $1.8\times10^{308}$ 到约 $5\times10^{-324}$ 的天文级跨度,代价是在这个范围内的数只能离散地、近似地取到。
这也解释了名字的由来:浮点(floating point)——小数点的位置会随着指数"浮动",固定小数位数的叫定点数(fixed point)。浮点数用指数让有效数字自动对齐,从而在小数和大数上都能分配合理的有效位。
视频展示了 IEEE 754 设计里两个精打细算的细节。
隐含的前导 1(hidden leading bit):二进制科学记数法规范化的结果,小数点前永远是 1(形如 1.xxxx × 2ⁿ),既然这个 1 一定存在,存它就浪费一个 bit。于是标准规定:这个前导 1 不存,只在运算时由硬件补回。这一招凭空给 float64 多抠出了 1 位精度(53 位有效位),堪称"省一毛是一毛"的典范。
指数偏移(exponent bias):11 位指数本身是无符号的(能表示 0–2047),但我们既要表示很大的数(正指数),也要表示很小的数(负指数)。标准没用补码,而是约定:真实指数 = 存储值 − 1023。这样存储值 1023 对应指数 0,存储值 0 和 2047 被保留给特殊用途(见第 6 节)。float32 的偏移量则是 127。
这两个设计让浮点运算能用相对简单的硬件高速实现,是 IEEE 754 在 1985 年能一统江湖的关键。
这是视频里最反直觉、也最重要的一点。我们直觉上以为浮点数在数轴上像整数一样均匀分布,完全不是。
由于指数的存在,相邻两个可表示数之间的间距(称为 ULP,unit in the last place)会随着数的增大而指数级膨胀:在 1 附近,相邻数差约 $2.2\times10^{-16}$(这就是机器 epsilon);在 1000 附近,差约 $10^{-13}$;而到了 $2^{53} \approx 9\times10^{15}$ 附近,相邻数之间已经差了整整 2——这意味着 9 000 000 000 000 001 在 double 里根本存不进去,它会被舍入成 9 000 000 000 000 000。
推论极其重要:当一个小数加到一个足够大的数上时,小数会被"吃掉"。 试一试 1e16 + 1.0 == 1e16,结果是 True。那个 +1 在这个量级的 ULP 面前,就像往太平洋里倒一杯水,水面纹丝不动。这种丢失叫吸收(absorption),是浮点运算最常见的隐形杀手之一。
视频后半段触及了浮点数最让人脊背发凉的性质——它破坏了我们从小学就奉为真理的算术定律。
由于每次运算都要舍入,浮点加法不满足结合律:
$$(a + b) + c \neq a + (b + c)$$
一个经典反例(float32):(1e8 + 1.0) + 1.0 与 1e8 + (1.0 + 1.0) 结果可能不同,因为前者那个 1.0 在第一次加法时就被吸收了。更隐蔽的是灾难性抵消(catastrophic cancellation):当两个非常接近的数相减时,高位的有效数字全部消掉,剩下的全是噪声位,相对误差瞬间爆炸。比如算两点距离 $\sqrt{x^2+y^2}$,当 x、y 量级悬殊时,平方项会放大到极大、再做差抵消,精度几乎全军覆没。
这对工程实践的含义是颠覆性的:你不能假定同一个公式、只是换了计算顺序,还能得到一样的结果。 并行归约求和、SIMD 向量化、不同编译器优化级别,都可能让同一段代码给出不同的最后几位,从而让"数值复现"变成一门单独的学问。这也是为什么数值线性代数库(LAPACK、BLAS)要精心设计运算顺序。
视频还讲了 IEEE 754 那一票"特殊值",它们的设计其实极其优雅。
+0 == -0 为真,但 1/+0 = +Inf、1/-0 = -Inf,方向信息被保留,在复变和极限运算里有用。if (x != x) 就能一行检测出 NaN。Python 的 math.isnan 底层就是这么干的。视频点到为止的地方,我用两个真实案例把它补全,因为这才是浮点数该被严肃对待的理由。
爱国者导弹灾难(1991,达兰兵营):爱国者系统的内部时钟用"十分之一秒"为单位计数,再乘以 0.1 换算回秒。而 0.1 在二进制里正是上面说的无限循环小数,每次换算都产生约 $9.5\times10^{-8}$ 秒的微小误差。系统连续运行 100 小时不重启,误差累积到约 0.34 秒。一枚来袭的飞毛腿导弹每秒飞约 1676 米,0.34 秒就是约 570 米的偏差——拦截窗口完全错开。当夜 28 名美军丧生。这是软件工程史上被引用最多的浮点数案例。
温哥华证券交易所(1982–1983):交易所指数每次更新都把结果截断(而不是四舍五入)到 3 位小数,每次都朝下偏那么一点点。22 个月累积下来,这个本应反映大盘走势的指数,凭空蒸发了约 50%,明明市场是在涨的。修复方法是改成"用整数累加、只显示时再除"。
这两个案例的共同教训:当浮点误差被放在一个"循环累加"的回路里,它就会从可忽略的噪声,变成系统性的、单向的、致命的偏移。
flowchart TB
A["0.1 + 0.2 ≠ 0.3<br/>浮点数为什么算不准"] --> B{"根因:分母含质因子 5<br/>0.1 在二进制是无限循环小数"}
B --> C["有限位存储只能截断<br/>存进去的 0.1 已有微误差"]
A --> D["本质:科学记数法 (-1)^s × 1.m × 2^e"]
D --> D1["1 位 符号位"]
D --> D2["11 位 指数(偏移 1023)<br/>float32 为 8 位 偏移 127"]
D --> D3["52 位 尾数<br/>前导 1 隐含不存 → 实为 53 位"]
D2 --> E["特殊值:指数全0/全1"]
E --> E1["±0、±Inf"]
E --> E2["NaN(NaN≠NaN)"]
E --> E3["次正规数 subnormal<br/>渐进下溢"]
D3 --> F["数轴分布不均匀<br/>ULP 随指数翻倍"]
F --> F1["靠近 0 越密"]
F --> F2["大数 + 小数 → 吸收<br/>1e16 + 1 == 1e16"]
F2 --> G["加法失去结合律<br/>(a+b)+c ≠ a+(b+c)"]
G --> G1["灾难性抵消<br/>相近数相减丢精度"]
G --> G2["累加误差单向漂移"]
G2 --> H["真实灾难"]
H --> H1["爱国者导弹 0.34 秒<br/>28 人丧生"]
H --> H2["温哥华指数 22 月<br/>蒸发 50%"]
明白了原理,工程对策其实很清晰,核心思想是"该精确的地方,绝不用浮点"。
钱永远别用 float/double。 金融系统计算必须用整数(以分为单位)或任意精度的十进制类型——Python 的 decimal.Decimal、Java 的 BigDecimal、C# 的 decimal。0.1 元存成整数 10 分,加减乘除全程在整数域,最后再格式化。这一条能消灭一类最常见的"账对不上"bug。
永远不要用 == 比较浮点数。 正确做法是判断差的绝对值是否小于一个阈值(epsilon):abs(a - b) < 1e-9。更稳健的是基于 ULP 的比较(如 C++ 的 std::abs(a-b) <= epsilon * max(abs(a),abs(b)),或直接用 Google Test 的 EXPECT_NEAR)。
累加时用 Kahan 求和(补偿求和)。 它用一个额外的"补偿项"把每次舍入丢掉的尾巴记下来,下轮补回去,能把 N 次求和的误差从 $O(N)$ 降到近乎 $O(1)$。Python 3.12+ 的 math.fsum 和 NumPy 的 np.sum 在关键路径上用的就是这类技术。如果只能用朴素求和,先排序、从小到大加,能显著减少吸收。
对顺序敏感的代码,要锁定浮点模型。 CUDA 的 -ftz、--fmad、编译器的 -ffast-math、不同 BLAS 实现都会改变结果。如果你的科学计算/模型推理要求可复现,要么关掉这些激进优化,要么记录浮点环境,否则换台机器就"对不上"了。
物理与图形:接受误差,但控制量级。 游戏引擎和物理模拟普遍用 float32 换性能,但要警惕"远离原点"的坐标(如大世界坐标)让 ULP 暴涨。做法是用相对坐标/分层坐标把物体拉回原点附近,或者对超大世界用 double 做位置、float 做旋转。Far Lands(Minecraft 早期著名的地图崩坏)就是 float32 坐标溢出的经典反面教材。
浮点数不是"不精确的小数",而是一套用有限位数逼近极大范围的科学记数法——它用"0.1 永远存不准""加法没有结合律""数轴疏密不均"这些代价,换来了在芯片上高速处理天文级数字的能力,看懂这笔交易,你就再也不会被
0.1 + 0.2吓到。
本文由「51学通信」技术团队出品。51学通信由通信行业18年老兵爱卫生(微信ID: gprshome201101)维护,聚焦5G/6G核心网、3GPP标准解读、通信前沿技术深度分析。 出版作品有《5G核心网原理与实践》《IMS原理与实践》等4本通信专业书籍。知识星球「51学通信」提供深度技术答疑和行业洞察。
[!tip] 51学通信知识星球 星主:爱卫生(微信:gprshome201101)——公众号「51学通信」创办者,19 年通信行业运维与讲师经验,著有《GPRS网络信令实例详解》《EPC原理与实践》《5G核心网原理与实践》三本纸质书(各大电商平台有售)。 星球定位:深耕 5G 核心网、IMS、NFV/AI、云原生等 ICT 领域,主打运维岗位与信令分析。 当前规模:5200 篇主题 / 990 个回答,198 元/年。 加入福利:加入星球后请加爱卫生微信,拉您进知识星球年度陪伴与答疑群——不限量回复您的各种问题,另有群内分享。 AI 加持:51学通信知识星球已作为 skill 接入 WorkBuddy,使用 deepseek-4-flash 在海量星球帖子中精准找贴、找视频、找 FAQ 和案例,做总结、找文档和 PPT 等场景全覆盖。
