浮点数:IEEE 754 掰开揉碎
总篇 00-信息的表示与处理 第五章是浮点数的速查表,这篇负责把每个数字掰开:0.1 为什么存不精确(完整推导)、Bias 为什么是 127、float 的精度到底怎么算的、408 要考的对阶是怎么回事。
一、从科学计数法说起
IEEE 754 的思想完全就是十进制科学计数法的二进制版:
十进制:1234.5 = 1.2345 × 10³
二进制:1100.011 = 1.100011 × 2³
统一形式:V = (-1)^s × M × 2^E
s 符号位(Sign)—— 决定正负
E 指数(Exponent)—— 决定数量级(小数点挪几位)
M 尾数(Mantissa)—— 决定有效数字(精度)
"规格化"就是小数点前留一位非零数字。二进制里更妙:非零只能是 1,所以这一位根本不用存——这就是"隐含的前导 1",凭空多出一位精度。
二、三个字段怎么分工
单精度 float(32 位):
┌──────┬──────────┬───────────────────────┐
│ 1位 │ 8位 │ 23位 │
│ s │ exp │ frac │
└──────┴──────────┴───────────────────────┘
双精度 double(64 位):
┌──────┬───────────┬────────────────────────────────────┐
│ 1位 │ 11位 │ 52位 │
└──────┴───────────┴────────────────────────────────────┘
| 字段 | float | double | 作用 |
|---|---|---|---|
| s | 1 位 | 1 位 | 符号 |
| exp | 8 位 | 11 位 | 存"移码后的指数" |
| frac | 23 位 | 52 位 | 存小数点后的尾数 |
🤔 为什么指数不直接存,要减个 Bias(float 是 127)?
exp 是个 8 位无符号数(0~255),实际指数 E = exp - 127,这样 E 的范围是 -126 ~ 127——正负指数都覆盖了,却不用补码。好处有两个:
- 比较浮点数大小时,exp 按无符号比就行,硬件简单
- 更精妙的是:同符号浮点数的位模式字典序 = 数值大小序,可以把浮点数当整数排序
(用补码存指数的话,负指数的最高位是 1,按位比较就乱套了。)
三、完整例题:8.25 的编码
整数会转二进制(01-进制与进制转换),小数部分用乘 2 取整:
8.25 = 1000.01₂ (0.25 = 0.01,恰好精确)
规格化:1.00001 × 2³
s = 0
E = 3 → exp = 3 + 127 = 130 = 1000 0010
frac = 00001 后面补零到 23 位
拼起来:
0 | 10000010 | 00001000000000000000000
= 0100 0001 0000 0100 0000 0000 0000 0000
= 0x41040000
8.25 是运气好的——小数部分 0.25 = 2⁻²,精确。运气不好的看下面。
四、0.1 为什么存不精确:完整推导
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 × 2 = 1.2 → 1
0.2 × 2 = 0.4 → 0 ← 从这开始循环
...
0.1 = 0.000110011001100110011...₂ (0011 无限循环)
= 1.1001100110011... × 2⁻⁴
循环了。 就像十进制写 1/3 = 0.3333… 写不完一样,0.1 在二进制里是无限循环小数,而 frac 只有 23 位(double 52 位),必须截断+舍入:
float(0.1):
E = -4 → exp = 123 = 0111 1011
frac 取 1.10011001100110011001100|1100...
↑ 第 24 位开始还是循环的 1100
向偶数舍入进位 →
0 | 01111011 | 10011001100110011001101
= 0x3DCCCCCD
所以内存里的 0.1 其实是 0.10000000149...(float)——不是 0.1,是个很接近它的数。0.2 同理。两个"近似 0.1"相加,误差互相记账:
0.1 + 0.2 = 0.30000000000000004 (double)
0.1 × 3 = 0.30000000000000004
0.1 加 10 次 = 0.9999999999999999 ≠ 1.0
💡 这和总篇"连续与离散"一节是同一件事的两面:浮点数是离散的、稠密分布在数轴上,0.1 恰好不落在任何格点上。 换 double 只是把格点变密,不改变"不精确"的本质。
五、精度账:float 到底能精确到哪
有效数字位数:
| 类型 | 有效位 | 十进制有效数字 | 来源 |
|---|---|---|---|
| float | 24 位(23 frac + 隐含 1) | ≈ 7 位 | 24 × log₁₀2 ≈ 7.2 |
| double | 53 位(52 + 1) | ≈ 15~16 位 | 53 × log₁₀2 ≈ 16 |
"约 7 位"的具体含义——2²⁴ = 16777216 是 float 的分水岭:
float f = 16777216.0; f + 1 = ? → 还是 16777216!
float f = 16777218.0; → 存成 16777218(偶数才行)
float f = 16777217.0; → 存成 16777216(舍入到最近的偶数)
因为 2²⁴ 之后相邻两个可表示 float 的间隔已经是 2,2²⁵ 之后是 4……指数每升 1,格点间隔翻一倍——这就是"越远离 0 越稀疏"的原理(总篇 5.4 那句话的账本)。
六、三种情况与那两个 0
| exp | 含义 | E 与 M |
|---|---|---|
| 既不全 0 也不全 1 | 规格化 | E = exp - Bias,M = 1.frac(隐含 1) |
| 全 0 | 非规格化 | E = 1 - Bias,M = 0.frac(无隐含 1) |
| 全 1 | 特殊值 | frac=0 → ±∞;frac≠0 → NaN |
非规格化不只是"多存几个小数"——它的妙处在平滑衔接:最小非规格化数与最小规格化数之间的间隔,和规格化区间内部的间隔一样大(都是 2⁻¹⁴⁹),下溢时精度均匀过渡,不会突然掉崖。
0 有两个:+0(全 0)和 -0(符号位 1 其余全 0)。比较时 -0.0 == 0.0 为真(IEEE 规定),但 1.0 / -0.0 = -∞、1.0 / +0.0 = +∞——除法才暴露它们的身份。
NaN 的自白:√(-1)、0.0/0.0、∞-∞ 都产 NaN,而且 NaN 和任何数比较都是 false,包括和自己(NaN == NaN 为 false)——判断 NaN 得用"自己不等于自己"。
七、舍入:向偶数舍入为什么公平
IEEE 754 默认向偶数舍入:恰好卡在中间时,舍入到末位是偶数的那个。
1.5 → 2 2.5 → 2 3.5 → 4
为什么不用"四舍五入"?因为四舍五入逢 .5 必进位,大量数据累加会单方向漂移;向偶数舍入时 .5 有一半进、一半舍,统计上抵消。这也是浮点版"银行家舍入"的由来。
八、408 考点:对阶
浮点加法的流程:对阶 → 尾数相加 → 规格化 → 舍入 → 溢出判断。
对阶的核心规则:小阶向大阶看齐。
为什么不能大阶向小阶对?——对阶靠移尾数:阶小的尾数要右移(丢低位,损失小数位精度);如果反过来让阶大的尾数左移,丢掉的是高位有效数字,直接毁掉这个数。两害相权取其轻。
例:1.00×2⁴ + 1.80×2² → 把后者变成 0.018×2⁴(尾数右移 2 位)再相加。
九、实战避坑清单
| 场景 | 错误姿势 | 正确姿势 |
|---|---|---|
| 金额计算 | float/double 存钱 |
整数分做单位;Java 用 BigDecimal,MySQL 用 DECIMAL,Python 用 Decimal |
| 浮点比较 | a == b |
fabs(a - b) < EPSILON |
| 累加求和 | 直接 sum += x 一路加到底 |
数据量大时先分组/排序再累加(提一句:Kahan 补偿求和专门治这个,算法区再会) |
| 判 NaN | x == NaN(永远 false) |
x != x 或语言内置 isNaN |
| 除零期待异常 | 浮点除零不抛异常 | 返回 ±∞ / NaN,要自己检查 isinf/isnan |
⚠️ 2026 年还有一个经典事故模式要记住:JSON 里的金额到了 JS 的 Number(就是 double)手里会自己长毛——后端发
0.1,前端显示0.1000000000000000055。金额请用字符串或整数分传输。
十、盲点自测
- 为什么 frac 前面的 1 不用存? ——规格化后小数点前必为 1,二进制非 0 即 1,省一位硬件,凭空多一位精度。
- Bias 为什么是 127 而不是 128? ——规格化 exp 取 1~254,E = exp-127 得 -126~127,正负范围最对称;存 128 会让正指数少一档。且无符号 exp 让"位模式序=数值序"这个金性质成立。
- 0.1 + 0.2 ≠ 0.3 的病根? ——0.1 和 0.2 在二进制下都是无限循环小数,存储时被舍入成近似值,加法后误差显形。换 double 只是误差变小,不会消失。
- float 能精确表示所有 int 吗? ——不能。23+1 位有效 vs int 的 31 位数值位,2²⁴ 之后 float 开始跳数。
- 对阶为什么小阶向大阶? ——尾数右移只丢低位、左移丢高位;丢高位等于毁数。
- -0.0 和 0.0 相等吗? ——== 为真,但
1/x结果一正一负无穷,除法才能分辨。 - 怎么判断一个数是 NaN? ——
x != x(NaN 是唯一不等于自己的值),或用内置 isNaN。 - 浮点除零会崩吗? ——不会,返回 ±∞ 或 NaN;整数除零才会崩。
十一、动手玩一把
#include <stdio.h>
#include <math.h>
int main() {
float f = 0.1f;
double d = 0.1;
printf("float 0.1 = %a\n", f); // 0x1.99999ap-4 ← 就是那个近似值
printf("double 0.1 = %a\n", d); // 0x1.999999999999ap-4
printf("0.1+0.2 == 0.3 ? %d\n", 0.1 + 0.2 == 0.3); // 0
printf("%.20f\n", 0.1 + 0.2); // 0.30000000000000004000
float big = 16777216.0f;
printf("16777216+1 = %.1f\n", big + 1); // 16777216.0,加不动了
printf("NaN == NaN ? %d\n", sqrt(-1) == sqrt(-1)); // 0
return 0;
}
%a 是十六进制浮点格式,一眼看穿机器里存的是什么。
数的表示到这就彻底闭环了:整数有补码、小数有 IEEE 754。还差一块拼图——文字怎么变成 01?→ 05-字符编码
⬅️ 位运算应用 🏠 00-信息的表示与处理 ➡️ 字符编码
💬 评论