小数 — 定点、IEEE 754 与浮点误差的显微镜¶
这一章讲什么: 之前只会算整数,本章回答「小数怎么存、怎么算、为什么会算错」。 两条路线:定点(二进制点位置固定,数按比例缩成整数)和浮点(IEEE 754, 科学记数法的二进制版)。路线选完还有一半内容:浮点误差的解剖—— 舍入、吸收、抵消,以及「(x+y)+z ≠ x+(y+z)」这个反数学常识的事实。 主线例子: 123.4 + 567.8 在单精度下等于 691.200012,不是 691.2—— 我们用 gdb 把这个误差一路追到存储格式里。
1. 二进制小数:0.9 为什么存不精确¶
二进制点右边的权重是 2 的倒数幂¶
十进制小数点右边是 10⁻¹、10⁻²……;二进制点右边是 2⁻¹(1/2)、2⁻²(1/4)、2⁻³(1/8)……。 所以1:
1.6875₁₀ = 1×2⁰ + 1×2⁻¹ + 0×2⁻² + 1×2⁻³ + 1×2⁻⁴ = 1.1011₂ ← 精确
0.9₁₀ = 0.1110011001100…₂ ← 无限循环,存不下!
「二进制小数的精确表示仅限于 2 的倒数幂之和」——0.9、0.1 这类常见的十进制小数 在二进制里永远除不尽,只能舍入到固定位数。8 位舍入后 0.9 ≈ 0.11100110₂ = 0.8984375, 误差 0.00156251。「0.1 + 0.2 ≠ 0.3」的根源就在这里,与语言、与编译器都无关。
2. 定点数:把小数点钉死在某两位之间¶
思想:别存小数,存「放大后的整数」¶
定点(fixed-point)本质是按比例缩放的整数:二进制点的位置是约定,不存进数据里。 缩放系数是 2 的幂时,乘除就是移位2。书里给的两个程序把这个思想演得很透:
例一:英寸 + 1/16 英寸(缩放系数 16 = 2⁴)。
32 位数里,高 28 位存英寸、低 4 位存十六分之几。读入:sal eax, 4 腾位,
& 0xf 保证小数不超范围,相加;显示:shr 4 出整数、
& 0xf 出小数3。加法本身一条普通 add——因为两个数的二进制点在同一位置。
代价:范围缩成 0 ~ 268435455+15/16(32 位范围的 1/16),换到 1/16 的分辨率。
例二:美元 + 美分(缩放系数 100,不是 2 的幂)。
这时移位不够,得真乘:mul 100 把美元化成美分,加美分;显示时
div 100——商是美元、余数(edx)是美分4。范围 0 ~ 42,949,672.95,
分辨率 0.01。定点用范围换精度,位数就是那笔预算。
主线的对照结论¶
两个例子合起来一句话:定点数的加法和整数完全一样(点位置相同), 代价是程序员必须自己记住点在哪、并且范围被分辨率吃掉一块。 12.2 节的练习里 那个魔鬼细节:输入 $42949672.95 + $0.01 会溢出——42,949,672.95 美分已经顶到 32 位无符号上限,加 1 分就绕回 05。
3. 浮点数:用科学记数法换范围¶
先立本章最重要的概念¶
浮点数不是实数。 实数是连续的;浮点数只是实数的有限子集, 相邻浮点数之间有间隙,而且间隙随指数变化——越大的数,间隔越宽。 这些间隙就是大部分浮点错误的来源,而且错误出现在中间结果里,难以调试6。
IEEE 754:符号 + 偏置指数 + 尾数¶
格式(以 4 字节 float 为例,共 32 位):1 位符号 + 8 位指数 + 23 位尾数7:
N = (−1)ˢ × 1.f × 2ᵉ
符号 s:0 正 1 负
尾数 f:规格化后二进制点左边恒为 1 —— 既然恒为 1,就不存了!这叫「隐藏位」
指数 e:不是补码,而是「真指数 + 偏置」——float 偏置 127,double 偏置 1023
两个设计决定值得记住为什么8:
- 隐藏位:白捡一个比特的精度——24 位有效位只用 23 位存储;
- 偏置指数(biased exponent):70 年代浮点昂贵,而「比较两个数」极常见。 加了偏置的指数可以当无符号整数比,普通的整数比较指令直接用——用空间换速度的先例。
四个特例(全靠指数/尾数的特殊位模式编码)9:
| 特例 | 位模式 | 用途 |
|---|---|---|
| ±0 | 指数全 0、尾数全 0 | 保留收敛到 0 的符号 |
| 去规范化数 | 指数全 0、尾数非 0 | 隐藏位不再假定,填补最小规格化数以下的空隙 |
| ±∞ | 指数全 1、尾数全 0 | 非零数 ÷ 0 的结果;还能继续比大小 |
| NaN | 指数全 1、尾数非 0 | 错误标记(0÷0、未初始化);应被视为程序错误 |
4. SSE2:x86-64 的浮点硬件¶
一段简史与一张寄存器图¶
浮点曾经住在独立协处理器芯片(x87,用 10 字节扩展格式);1997 年 MMX 引入 SIMD (单指令多数据),1999 年 SSE 补上浮点,2011 年 AVX 再加宽。x86-64 至少包含 SSE2, 本书只讲它10:
- 16 个 xmm 寄存器(xmm0–xmm15),每个 128 位,与整数通用寄存器完全分开;
- SSE2 指令分标量(只碰 xmm 低位 32/64 位,本书用这个)和向量 (一条指令并行处理 4 个 float 或 2 个 double,本书不展开);
- 浮点有自己的状态寄存器 mxcsr:位 0–5 是异常标志(精度 PE、被零除 ZE 等),
位 7–12 是对应的屏蔽位——默认全部屏蔽,所以 1.0/3.0 的精度误差安静发生,
不打扰程序;想让它打扰,用 C 的 fenv 库(
man fenv)或 stmxcsr/ldmxcsr 指令11; - 浮点比较不碰 rflags(有 4 条专门比较指令 comiss/ucomiss 系列例外)—— 整数那套「cmp + jcc」在浮点世界不通用12。
三条指令走一个加法¶
清单 19-7(addFloats.s)的核心三行13:
movss xmm2, x[rbp] # 把 float 从内存搬进 xmm 低 32 位
addss xmm2, y[rbp] # 单精度标量加:xmm2 = x + y
cvtss2sd xmm0, x[rbp] # 转双精度(printf 要 double 参数)
指令命名有套路:ss = 标量单精度、sd = 标量双精度,前缀 mov/add/cvt(转换)组合。 外加一个 ABI 细节:printf 的浮点参数在 xmm 寄存器里传,且必须给 double—— 所以传参前要把 float 全部 cvtss2sd,al 里写 xmm 寄存器的个数(mov eax, 3)13。
5. 主线例子:123.4 + 567.8 的误差解剖¶
程序输出 123.400002 + 567.799988 = 691.200012。书里的 gdb 实录一层层剥14:
- 看存储:
x/3xw显示 x 的位模式 0x42f6cccd。拆:指数 133 − 127 = 6, 尾数 1.11101101100110011001101₂ × 2⁶ = 123.40000152587890625—— 输入那一刻,舍入就已经发生了(float 只有 24 位有效位); - 看寄存器:
p $xmm0.v2_double显示转 double 后的值 123.40000152587891——float→double 的转换只在尾数低位补 0,不增加任何精度; 信息在第一次舍入时就丢了,转换救不回来; - 看输出:printf 的 %f 只显示 6 位小数,两个相近的误差值都四舍五入成 3.600000 的样子——显示的整齐掩盖了存储的参差。
三种典型误差,各一个实证¶
| 误差 | 机制 | 书里的实证 |
|---|---|---|
| 舍入 | 位数有限 + 二进制除不尽,每步都可能舍 | 上面 123.4 的全过程14 |
| 吸收(absorption) | 大小悬殊的数相加,小数的高位「够不着」大数的低位,被整体截断 | 16777215.0 + 0.1 = 16777215.0(0.1 被整个吃掉)15 |
| 抵消(cancellation) | 相近数相减,高位相消,结果的有效位只剩低位——低位恰恰是有舍入误差的地方 | 1677721.5 − 1677721.4 = 0.125,相对误差 25%(-1677721.4 被舍入成 -1677721.375)16 |
抵消还分两级:两个数都精确时是良性抵消(结果仍对),有一个带舍入误差就是 灾难性抵消(误差被放大成主角)16。
最隐蔽的一个:加法不结合¶
清单 19-8 的 threeFloats:输入 1.1、1.2、1.3,(x+y)+z 得 3.60000014,
x+(y+z) 得 3.5999999——数学上相等的两个式子,浮点下不相等,
而 printf 把两者都显示成 3.600000,肉眼根本分不出17。
(x+y)+z ≠ x+(y+z) 意味着:编译器不能随便给你重排浮点加法,
也意味着你重构代码改变运算顺序时,结果会悄悄变。
6. 实用建议与边界¶
书末给了五条数值精确性建议,照单收18:
- 能定点就定点——缩放数据用整数算术,误差可控;
- 用 double 不用 float——精度高,而且多数 C/C++ 库函数本来就要 double, 传 float 反而多一次转换;
- 安排运算顺序:让大小相近的数先相加减;
- 避免复杂算术表达式:会掩盖有问题的中间结果;
- 选会难为算法的测试数据:程序处理小数,就故意放没有二进制等值的数(0.1、0.9……)。
外加两个好消息:64 位整数的范围已经是 ±9.2×10¹⁸,很多场景够用了; 要更高精度还有任意精度库。想深入读 Goldberg 1991 年的经典论文 《What Every Computer Scientist Should Know About Floating-Point Arithmetic》; 减少累加误差的实用技术见 Kahan 求和算法(维基百科)——均为作者推荐、我们未复核19。
判断(我们的,不是书里的): 本章 gdb 实录的教学价值在于它示范了 「误差该去哪看」:不是盯着 printf 的输出猜,而是
x/xw看位模式、p $xmm0.v2_double看寄存器、分步执行看中间结果——浮点调试的第一课 是绕过显示层,直接看存储层。显示层(printf、计算器、甚至 gdb 的默认浮点格式) 都会再舍入一次,把真相藏起来。 如果错,会错在: 有些误差在 double 下根本不存在(本章练习 1 让你自己验证), 「一切小数都不安全」是过度泛化;正确的格言是「float 几乎永远不够用,double 是 起点」,而不是「浮点不可用」。判断(我们的,不是书里的): 定点 vs 浮点的选择,本章给了一个常被忽略的视角: 定点把误差变成「可见的范围损失」,浮点把误差变成「不可见的精度损失」。 对要审计的场景(钱!),可见的溢出远比不可见的舍入好——这就是为什么金融系统 大量用定点/整数(美分、基点),即便浮点硬件更快。 如果错,会错在: 现代 decimal 类型(C#、Python decimal)和分单位整数各有利弊; 且有些高频场景为了追求速度用浮点加事后对账——选择永远看约束,不看教条。
7. 作者的判断与证据¶
| 说法 | 性质 |
|---|---|
| 二进制小数精确表示限于 2 的倒数幂之和;0.9 是无限循环 | 数学事实,给了 8 位舍入数值1 |
| 定点加法是普通 add;范围与分辨率互换 | 由两个程序实证34 |
| 浮点不是实数,间隙随指数变化 | 本章立场句,由后续误差实证支撑6 |
| IEEE 754 布局、隐藏位、偏置及四个特例 | 标准事实,教学化呈现89 |
| float→double 转换不增加精度 | gdb 实证(补 0 而已)14 |
| 吸收、抵消、不结合性 | 三个具体数值实验,均可复现151617 |
| 五条精确性建议 | 作者的工程经验总结18 |
| Goldberg 论文与 Kahan 算法推荐 | 作者推荐,未复核19 |
8. 边界与局限¶
- x87 与 AVX 都不讲,理由书里给了:x86-64 至少含 SSE2,AVX 向下兼容10。
- 向量 SIMD 只给了概念(多媒体/科学计算有用),没有指令级演示。
- 浮点异常的「解除屏蔽」只点到 stmxcsr/ldmxcsr,完整的 fenv 编程没展开。
- 十进制小数(IEEE 754 decimal 格式)未提——那是另一套标准,本书止步二进制浮点。
- 比较指令 comiss/ucomiss 一笔带过;浮点与整数混算的转换指令族(cvt 系)只演示了一种。
9. 可带走的¶
- 二进制点右边是 1/2、1/4、1/8……;0.1、0.9 无限循环,「0.1+0.2≠0.3」是表示问题;
- 定点 = 缩放的整数;点位置是约定;加法不变,范围换分辨率;
- IEEE 754:符号 + 偏置指数 + 尾数;隐藏位白捡一位;float 偏置 127、double 1023;
- 四个特例:±0、去规范化、±∞、NaN;NaN 是错误信号;
- SSE2:16 个 128 位 xmm;ss=单精度 sd=双精度;标量指令只碰低 32/64 位;
- mxcsr 管浮点异常;默认全屏蔽,1.0/3.0 安静舍入;
- printf 的浮点参数要 double、走 xmm;
- float→double 补 0 不补精度;精度死在第一次舍入;
- 三大误差:舍入(每步)、吸收(大小悬殊)、抵消(相近相减;分良性/灾难性);
- 浮点加法不结合——重构运算顺序会改结果;
- 调试浮点:绕过显示层,看位模式、看 xmm、分步看中间值;
- 建议:能整数就整数;用 double;相近先算;拆复杂式;测试数据放「二进制不友好」的数。
10. 原文地图¶
| 主题 | 原书章 | 原文位置 |
|---|---|---|
| 二进制小数与 0.9 的舍入 | 第19 章 小数 | text/25-ch19.txt:18(搜「二进制小数」) · text/25-ch19.txt:39(搜「0.9」) |
| 定点思想(缩放整数) | 第19 章 小数 | text/25-ch19.txt:57(搜「按比例缩放」) |
| 英寸 1/16 程序 | 第19 章 小数 | text/25-ch19.txt:78(搜「清单 19-1」) · text/25-ch19.txt:208(搜「清单 19-3」) |
| 美元美分程序 | 第19 章 小数 | text/25-ch19.txt:270(搜「清单 19-4」) · text/25-ch19.txt:402(搜「清单 19-6」) |
| 浮点不是实数 | 第19 章 小数 | text/25-ch19.txt:465(搜「不是实数」) |
| IEEE 754 布局与隐藏位 | 第19 章 小数 | text/25-ch19.txt:488(搜「IEEE 754」) · text/25-ch19.txt:508(搜「隐藏位」) |
| 偏置与四个特例 | 第19 章 小数 | text/25-ch19.txt:513(搜「偏置」) · text/25-ch19.txt:518(搜「零值」) |
| SSE2 历史与 xmm | 第19 章 小数 | text/25-ch19.txt:529(搜「SSE2」) · text/25-ch19.txt:564(搜「xmm0」) |
| mxcsr 与舍入模式 | 第19 章 小数 | text/25-ch19.txt:572(搜「mxcsr」) · text/25-ch19.txt:602(搜「舍入模式」) |
| addFloats 三条指令 | 第19 章 小数 | text/25-ch19.txt:632(搜「清单 19-7」) · text/25-ch19.txt:677(搜「movss」) |
| gdb 误差解剖 | 第19 章 小数 | text/25-ch19.txt:762(搜「0x42f6cccd」) · text/25-ch19.txt:787(搜「v2_double」) |
| 吸收误差 | 第19 章 小数 | text/25-ch19.txt:825(搜「吸收」) · text/25-ch19.txt:848(搜「0x4b7fffff」) |
| 抵消与灾难性抵消 | 第19 章 小数 | text/25-ch19.txt:859(搜「抵消」) · text/25-ch19.txt:891(搜「灾难性抵消」) |
| 不结合的加法实验 | 第19 章 小数 | text/25-ch19.txt:894(搜「结合性」) · text/25-ch19.txt:926(搜「not the same」) |
| 五条精确性建议 | 第19 章 小数 | text/25-ch19.txt:1011(搜「5 点」) |
| Goldberg/Kahan 推荐 | 第19 章 小数 | text/25-ch19.txt:1024(搜「Goldberg」) |
-
出处:「第19 章 小数」第 18–55 段(
text/25-ch19.txt:26,搜「2−1」;text/25-ch19.txt:38,搜「仅限于」;text/25-ch19.txt:39,搜「0.9」;text/25-ch19.txt:54,搜「0.0015625」)。 ↩↩↩ -
出处:「第19 章 小数」第 56–65 段(
text/25-ch19.txt:57,搜「按比例缩放」;text/25-ch19.txt:61,搜「1234510」)。 ↩ -
出处:「第19 章 小数」第 66–257 段(英寸程序,
text/25-ch19.txt:126,搜「sal eax, 4」;text/25-ch19.txt:186,搜「shr edi, 4」;text/25-ch19.txt:192,搜「fractionMask」;text/25-ch19.txt:143,搜「1/16」)。 ↩↩ -
出处:「第19 章 小数」第 259–451 段(美元程序,
text/25-ch19.txt:280,搜「dollar2cents」;text/25-ch19.txt:320,搜「mul ecx」;text/25-ch19.txt:372,搜「div ecx」;text/25-ch19.txt:451,搜「0.01」)。 ↩↩ -
出处:「第19 章 小数」第 448–451 段(
text/25-ch19.txt:450,搜「42 949 672.95」)。 ↩ -
出处:「第19 章 小数」第 464–470 段(
text/25-ch19.txt:465,搜「不是实数」;text/25-ch19.txt:468,搜「间隙」)。 ↩↩ -
出处:「第19 章 小数」第 471–508 段(
text/25-ch19.txt:490,搜「IEEE 754」;text/25-ch19.txt:504,搜「N = (−1)」;text/25-ch19.txt:508,搜「隐藏位」)。 ↩ -
出处:「第19 章 小数」第 510–515 段(
text/25-ch19.txt:513,搜「偏置」;text/25-ch19.txt:514,搜「127」;允许整数比较的动机见第 511–513 段,text/25-ch19.txt:138,搜「getUInt 函数来读」)。 ↩↩ -
出处:「第19 章 小数」第 516–526 段(四个特例,
text/25-ch19.txt:518,搜「零值」;text/25-ch19.txt:521,搜「无穷大」;text/25-ch19.txt:523,搜「非数字」;text/25-ch19.txt:526,搜「0 除以 0」)。 ↩↩ -
出处:「第19 章 小数」第 529–559 段(SSE2 历史与寄存器,
text/25-ch19.txt:531,搜「协处理器」;text/25-ch19.txt:548,搜「至少包括 SSE2」;text/25-ch19.txt:564,搜「xmm0」;标量/向量之分见第 552–559 段,text/25-ch19.txt:557,搜「标量」)。 ↩↩ -
出处:「第19 章 小数」第 572–624 段(mxcsr,
text/25-ch19.txt:575,搜「mxcsr」;屏蔽默认见第 613–616 段,text/25-ch19.txt:613,搜「PM 位设置为 1」;比较指令例外见第 617–624 段,text/25-ch19.txt:619,搜「comisd」)。 ↩ -
出处:「第19 章 小数」第 617–624 段(
text/25-ch19.txt:617,搜「没有能够测试」;text/25-ch19.txt:624,搜「条件跳转指令」)。 ↩ -
出处:「第19 章 小数」第 627–721 段(addFloats 与指令卡,
text/25-ch19.txt:677,搜「movss」;text/25-ch19.txt:678,搜「addss」;text/25-ch19.txt:680,搜「cvtss2sd」;printf 参数要求见第 697–698 段,text/25-ch19.txt:698,搜「双精度」)。 ↩↩ -
出处:「第19 章 小数」第 722–811 段(gdb 解剖,
text/25-ch19.txt:762,搜「0x42f6cccd」;text/25-ch19.txt:766,搜「123.40000152587890625」;text/25-ch19.txt:788,搜「123.40000152587891」;转换不补精度见第 803–804 段,text/25-ch19.txt:803,搜「注意,从 float 到」)。 ↩↩↩ -
出处:「第19 章 小数」第 825–858 段(吸收,
text/25-ch19.txt:826,搜「吸收」;text/25-ch19.txt:848,搜「0x4b7fffff」;text/25-ch19.txt:853,搜「已经被浮点加法吸收」)。 ↩↩ -
出处:「第19 章 小数」第 859–893 段(抵消,
text/25-ch19.txt:860,搜「抵消」;25% 误差见第 872 段,text/25-ch19.txt:872,搜「0.25 = 25%」;灾难性/良性见第 891–893 段,text/25-ch19.txt:891,搜「灾难性抵消」)。 ↩↩↩ -
出处:「第19 章 小数」第 894–1001 段(不结合实验,
text/25-ch19.txt:897,搜「不等于」;text/25-ch19.txt:926,搜「not the same」;显示舍入掩盖见第 1000–1001 段,text/25-ch19.txt:1001,搜「舍入为 3.600000」)。 ↩↩ -
出处:「第19 章 小数」第 1006–1022 段(
text/25-ch19.txt:1012,搜「尝试缩放数据」;text/25-ch19.txt:1013,搜「双精度代替单精度」;text/25-ch19.txt:1016,搜「大小相近」;text/25-ch19.txt:1018,搜「测试数据」)。 ↩↩ -
出处:「第19 章 小数」第 1023–1027 段(
text/25-ch19.txt:1024,搜「Goldberg」;text/25-ch19.txt:1027,搜「Kahan」)。 ↩↩