0x9a.com

保持简单,保持好奇

0.1 + 0.2 为什么不等于 0.3:从手算到二进制编码

2026-07-13

0.1 + 0.2 听起来像是一道幼儿园算术题,但把它扔给 Python,事情突然变得诡异起来:

>>> 0.1 + 0.2
0.30000000000000004

好家伙,计算机天天号称每秒能算几百亿次,结果连 0.1 + 0.2 都能翻车?

其实计算机没有算错。真正的问题是:当你输入 0.1 的那一刻,它拿到的就已经不是精确的 0.1 了。

今天我们不拿“浮点数有误差”这句正确的废话糊弄人,也不钻进满屏公式里自我感动。咱们直接动手:先把 0.1 拆成二进制,再造一台只有 8 位精度的“袖珍计算机”,亲手把那个误差算出来。

最后,我们再化身内存侦探,用 Python 的 struct.pack 给数字拍一张“X 光片”,看看 0.30.30000000000000004 到底差在哪一个比特。

一、 手算 0.1:无限循环的开始

整数转二进制,大家可能听过“除 2 取余”。

小数正好反过来:不断乘 2,每次拿走整数部分。

1. 把 0.1 扔进乘 2 机器 (Decimal to Binary)

我们拿纸笔算:

计算 拿走的整数 剩下的小数
0.1 × 2 = 0.2 0 0.2
0.2 × 2 = 0.4 0 0.4
0.4 × 2 = 0.8 0 0.8
0.8 × 2 = 1.6 1 0.6
0.6 × 2 = 1.2 1 0.2

把中间那列从上往下抄下来:

0.00011...

注意最后一行:余数又变回了 0.2

这意味着从第二行开始,整个过程会像卡住的唱片一样不断循环:

0.1 = 0.0001100110011001100110011...₂
                  └─0011─┘ 无限循环

第一处真相出现了:十进制的 0.1,在二进制世界里是个无限循环小数。

2. 那 0.2 呢?

0.2 刚好等于 0.1 × 2

二进制乘 2 不需要重新列竖式,只要把小数点向右挪一格:

0.1 = 0.00011001100110011...₂
0.2 = 0.00110011001100110...₂

它们身后都拖着一条永远写不完的 0011 尾巴。

这不是二进制“不够高级”。十进制同样写不完 1 ÷ 3,我们只能写成:

0.3333333333333333...

不同进位制,只是各自害怕的分数不同。

💡 程序员的顿悟

0.1 在十进制里看起来干净利落,是因为我们的计数系统以 10 为底。

计算机以 2 为底,只有那些能不断除以 2 的小数才特别听话,例如 0.50.250.1250.1 的分母里藏着一个 5,进了二进制世界就只能无限循环。


二、 袖珍计算机:亲手制造一次浮点误差

真实的 Python 浮点数有 53 位有效精度。要在纸上抄 53 个 0 和 1,不仅不酷,还很容易把自己数瞎。

所以我们先造一台只有 8 位二进制小数的袖珍计算机。

它的规矩很简单:

1. 先把 0.1 塞进去

0.1 的真实二进制是:

0.00011001100110011...

机器只有 8 个座位:

保留:0.00011001
丢掉:        100110011...

被丢掉的第一位是 1,说明后半段超过了半格,需要进位:

0.00011001
           + 1
──────────────
0.00011010

所以袖珍计算机里的 0.1 实际上是:

0.00011010₂

把它翻译回十进制也能手算。小数点后的位权依次是:

1/2  1/4  1/8  1/16  1/32  1/64  1/128  1/256

0.00011010 里,第 4、5、7 位是 1:

1/16 + 1/32 + 1/128
= 16/256 + 8/256 + 2/256
= 26/256
= 0.1015625

我们明明输入的是 0.1,机器拿到的却是 0.1015625

第一笔误差,已经悄悄混进来了。

2. 再把 0.2 塞进去

0.2 的二进制是:

0.0011001100110011...

保留 8 位:

保留:0.00110011
丢掉:        00110011...

被丢掉的第一位是 0,这次不用进位。

0.2 ≈ 0.00110011₂

翻译回十进制:

1/8 + 1/16 + 1/128 + 1/256
= 32/256 + 16/256 + 2/256 + 1/256
= 51/256
= 0.19921875

这次结果比真正的 0.2 小了一点。

3. 见证奇迹:开始二进制加法

现在两个数都已经住进内存。接下来没有任何魔法,就是从右往左做普通加法:

  0.00011010
+ 0.00110011
────────────
  0.01001101

把结果换回十进制:

1/4 + 1/32 + 1/64 + 1/256
= 64/256 + 8/256 + 4/256 + 1/256
= 77/256
= 0.30078125

Bingo!

我们亲手制造出了一个“不等于 0.3”的答案:

0.1 + 0.2 ≈ 0.30078125

它和 Python 的答案长得不一样,是因为这台实验机只有可怜的 8 位,误差被放大得非常明显。

但它已经把浮点误差的整个犯罪过程演示清楚了:

  1. 0.1 先被舍入一次;
  2. 0.2 也先被舍入一次;
  3. 加法器算的是这两个“替身”,不是数学世界里的原值;
  4. 计算结果还可能需要再次舍入。

三、 真实的 IEEE 754:换一把更精细的尺子

Python 使用的不是我们的玩具格式,而是 IEEE 754 双精度浮点数,也叫 binary64

一共 64 位,被切成三块:

┌────────┬────────────┬──────────────────────────────────────┐
│ 符号 1 │ 指数 11     │ 小数部分 52                            │
└────────┴────────────┴──────────────────────────────────────┘

1. 三个区域分别干什么?

小数部分虽然只有 52 位,但开头固定的 1 可以省略不存,所以实际能提供 53 位有效精度。

你可以把它想成科学计数法:

十进制:1.23 × 10⁵
二进制:1.1001 × 2⁻⁴

只是计算机把底数从 10 换成了 2。

2. 0.1 和 0.2 住进 binary64

规格化后的 0.1 是:

0.1 = 1.100110011001100110011... × 2⁻⁴

0.2 是:

0.2 = 1.100110011001100110011... × 2⁻³

有效数字部分完全相同,区别只是小数点移动的位置。

但那条 0011 仍然无限循环。binary64 即使比袖珍计算机多出几十个座位,也终究不是无限旅馆。到了第 53 位,照样要关门、舍入。

3. 浮点加法的第一步:对阶 (Alignment)

两个浮点数的指数不同,不能直接相加。先把 0.1 的有效数字右移一位,让它和 0.2 使用相同指数:

  0.110011001100110011... × 2⁻³
+ 1.100110011001100110... × 2⁻³
────────────────────────────────
 10.011001100110011001... × 2⁻³

再整理一下:

1.0011001100110011001... × 2⁻²

如果内存可以无限大,这串数字继续写下去,正好就是十进制的 0.3

但 IEEE 754 必须在第 53 位停下。停下之后,结果落到了紧挨着 0.3 上方的那个浮点数:

下面一格:0.299999999999999988897769753748...
目标位置:0.3
上面一格:0.300000000000000044408920985006...

这次加法选择了上面一格。

Python 为这个浮点数寻找了一个最短、而且重新读回来还能得到同一组 64 位数据的十进制写法:

0.30000000000000004

💡 程序员的顿悟

浮点数不是在数轴上随便找个位置站着,而是只能站在一个个固定的“刻度”上。

0.3 恰好落在刻度之间。直接写 0.3 时,选择了下面的刻度;计算 0.1 + 0.2 时,结果落到了上面的刻度。所以它们看起来极其接近,底层却不是同一个数。


四、 内存侦探:给浮点数拍一张 X 光片

说了这么多,怎么证明 Python 真的保存了这些东西?

轮到 struct.pack 登场了。

它可以把 Python 数字按照指定格式打包成原始字节。这里使用 >d

import struct

values = [0.1, 0.2, 0.3, 0.1 + 0.2]

for value in values:
    raw = struct.pack('>d', value)
    print(f'{value!r:<20} {raw.hex()}')

运行结果:

0.1                  3fb999999999999a
0.2                  3fc999999999999a
0.3                  3fd3333333333333
0.30000000000000004  3fd3333333333334

盯住最后两行:

0.3                 -> ...3333
0.1 + 0.2           -> ...3334

前面 15 个十六进制数字全部相同,只有最后一位从 3 变成了 4

这意味着它们是相邻的两个 binary64 浮点数,中间已经塞不下第三个浮点数了。

所以这句判断返回 False,不是 Python 故意找茬:

>>> 0.1 + 0.2 == 0.3
False

直接查看浮点数的十六进制形式

Python 还提供了 float.hex()

>>> (0.1).hex()
'0x1.999999999999ap-4'

>>> (0.2).hex()
'0x1.999999999999ap-3'

>>> (0.3).hex()
'0x1.3333333333333p-2'

>>> (0.1 + 0.2).hex()
'0x1.3333333333334p-2'

这里的 p-4 表示乘以 2 的 -4 次方。再次观察最后两行,仍然只差最后那个 34

如果想看它完整的十进制展开:

>>> format(0.1 + 0.2, '.55f')
'0.3000000000000000444089209850062616169452667236328125'

Python 平时只打印 0.30000000000000004,是为了给你一个足够区分它、同时又尽量短的结果。否则每次输出浮点数都拖着几十位尾巴,终端早就没法看了。


五、 同一串 01,能演出多少种数字?

浮点数只是数字编码的一种。

内存里的比特本身没有“整数”“小数”或“负数”的标签。11111111 到底是什么,完全取决于程序拿哪本规则手册来解释。

1. 无符号整数 (Unsigned Integer)

这是最朴素的玩法,每一位按 1、2、4、8、16……累加:

00101010₂
= 32 + 8 + 2
= 42
>>> struct.pack('>I', 42).hex()
'0000002a'

8 位无符号整数可以表示 0 到 255。它没有负数,每个比特都拿来扩大正数范围。

2. 补码整数 (Two's Complement)

要保存 -42,现代计算机通常使用补码。

手算也只有两步:全部取反,然后加一。

 42:00101010
取反:11010101
加一:11010110

所以 -42 的低 8 位是 d6。扩展成 32 位时,高位全部补 1:

>>> struct.pack('>i', -42).hex()
'ffffffd6'

补码最漂亮的地方是:CPU 不需要为负数再造一套加法器。同一个二进制加法电路,正数和负数都能算。

3. 定点数 (Fixed Point)

如果你在做支付系统,最直接的办法是:别存元,存分。

0.10 元 -> 整数 10
0.20 元 -> 整数 20
相加          30 -> 0.30 元

这就是定点数。底层仍然是整数,只是所有人提前约定“小数点藏在后两位”。

4. BCD:十进制数字各住各的房间

BCD(Binary-Coded Decimal)干脆不把整个数字转成二进制,而是每 4 位保存一个十进制数字:

十进制 59 -> 0101 1001 -> 0x59

这里有个很容易踩的坑:

同样的字节,不同的规则,直接变成两个数字。

struct 没有内置 BCD,但我们可以自己打包:

def pack_bcd(number):
    digits = str(number)
    if len(digits) % 2:
        digits = '0' + digits

    return bytes(
        (int(digits[index]) << 4) | int(digits[index + 1])
        for index in range(0, len(digits), 2)
    )

print(pack_bcd(20260713).hex())
# 输出:20260713

BCD 会浪费一些比特,但它和人类看到的十进制数字转换简单,因此计算器、电子钟和一些金融设备很喜欢它。

5. Decimal:让十进制留在十进制世界

Python 的 Decimal 专门处理十进制小数:

from decimal import Decimal

>>> Decimal('0.1') + Decimal('0.2')
Decimal('0.3')

注意参数是字符串。

如果写成 Decimal(0.1),那么 0.1 会先被做成有误差的 binary64,再交给 Decimal。这就像先把照片压成满屏马赛克,再要求修图软件恢复原图——神仙也救不回来。


六、 字节序:数字没变,座位换了 (Endianness)

struct.pack 里的 > 是什么意思?这就要聊到大端序和小端序。

把 16 位整数 1000 写成十六进制,是 03e8。它占两个字节:03e8

>>> struct.pack('>h', 1000).hex()
'03e8'

>>> struct.pack('<h', 1000).hex()
'e803'

数字还是同一个 1000,只是两个字节交换了座位。

这件事听起来无关紧要,却是二进制文件和网络协议里的经典事故现场。如果写入方用小端,读取方却按大端解释,1000 当场就会变成另一个数字。

struct 常用格式可以记住这些:

格式 含义 大小
b / B 有符号 / 无符号字节 1 字节
h / H 有符号 / 无符号短整数 2 字节
i / I 有符号 / 无符号整数 4 字节
q / Q 有符号 / 无符号长整数 8 字节
f 单精度浮点数 4 字节
d 双精度浮点数 8 字节

⚠️ 协议开发避雷指南

写文件格式或网络协议时,一定显式指定 ><!

默认的 @ 会跟着本机的字节序、类型大小和内存对齐走。文件换台机器就可能变脸,不适合拿来做跨平台格式。


七、 工程实战:浮点数到底应该怎么比?

既然浮点数站在离散刻度上,是不是以后再也不能比较了?

当然不是。问题不在于“比较”,而在于你是否真的要求两个数的每一个比特都相同。

1. 科学计算:判断是否足够接近

Python 提供了 math.isclose()

import math

>>> math.isclose(0.1 + 0.2, 0.3)
True

还可以明确给出相对容差和绝对容差:

math.isclose(value, expected, rel_tol=1e-9, abs_tol=1e-12)

容差不是越小越专业,而应该来自你的业务:测量温度、计算星球轨道和判断账户余额,需要的精度根本不是一回事。

2. 金额计算:不要让 float 上场

涉及金额时,优先选择:

不要先用 float 算出一串误差,再指望最后调用一次 round() 就能洗白全部中间过程。

3. 浮点数是不是很糟糕?

恰恰相反,浮点数是一个极其成功的工程设计。

它用固定的 64 位,同时表示非常小和非常大的数字,现代 CPU 还能直接用硬件高速计算。图形渲染、物理模拟、机器学习和科学计算都离不开它。

它不是“精确实数”,而是一套在范围、精度、速度和存储空间之间做出的优秀折中。


总结

现在再看 0.1 + 0.2,整个案发过程已经非常清楚:

如果你想自己验证全文,下面这段代码就够了:

import math
import struct
from decimal import Decimal

result = 0.1 + 0.2

print(result)
print(format(result, '.55f'))
print(struct.pack('>d', 0.3).hex())
print(struct.pack('>d', result).hex())
print(math.isclose(result, 0.3))
print(Decimal('0.1') + Decimal('0.2'))

输出:

0.30000000000000004
0.3000000000000000444089209850062616169452667236328125
3fd3333333333333
3fd3333333333334
True
0.3

0.30000000000000004 不是计算机算错后留下的尴尬尾巴,而是有限位二进制编码留下的一枚指纹。

数字写进计算机之前,必须先选择一种表示方法;选择了表示方法,也就选择了它的范围、精度与代价。

← 返回首页