--- title: "04-浮点数:IEEE 754 掰开揉碎" aliases: - 浮点数 created: 2026-08-29 tags: - 基础与理论 - 信息的表示与处理 --- # 浮点数: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——正负指数都覆盖了,却**不用补码**。好处有两个: > > 1. 比较浮点数大小时,exp 按无符号比就行,硬件简单 > 2. 更精妙的是:同符号浮点数的**位模式字典序 = 数值大小序**,可以把浮点数当整数排序 > > (用补码存指数的话,负指数的最高位是 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`。金额请用字符串或整数分传输。 ## 十、盲点自测 1. **为什么 frac 前面的 1 不用存?** ——规格化后小数点前必为 1,二进制非 0 即 1,省一位硬件,凭空多一位精度。 2. **Bias 为什么是 127 而不是 128?** ——规格化 exp 取 1~254,E = exp-127 得 -126~127,正负范围最对称;存 128 会让正指数少一档。且无符号 exp 让"位模式序=数值序"这个金性质成立。 3. **0.1 + 0.2 ≠ 0.3 的病根?** ——0.1 和 0.2 在二进制下都是无限循环小数,存储时被舍入成近似值,加法后误差显形。换 double 只是误差变小,不会消失。 4. **float 能精确表示所有 int 吗?** ——不能。23+1 位有效 vs int 的 31 位数值位,2²⁴ 之后 float 开始跳数。 5. **对阶为什么小阶向大阶?** ——尾数右移只丢低位、左移丢高位;丢高位等于毁数。 6. **-0.0 和 0.0 相等吗?** ——\=\= 为真,但 `1/x` 结果一正一负无穷,除法才能分辨。 7. **怎么判断一个数是 NaN?** ——`x != x`(NaN 是唯一不等于自己的值),或用内置 isNaN。 8. **浮点除零会崩吗?** ——不会,返回 ±∞ 或 NaN;整数除零才会崩。 ## 十一、动手玩一把 ```c #include #include 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-字符编码]] ⬅️ [[03-位运算应用|位运算应用]] 🏠 [[00-信息的表示与处理]] ➡️ [[05-字符编码|字符编码]]