编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

    • 入门教程

    • 综合案例

    • 专栏博客

      • README
      • 进程虚拟地址空间
      • 栈与堆底层对决
      • 指针本质与多级解引
      • 指针运算底层真相
      • 函数指针与回调机制
      • 限定符与指针语义
      • 补码与位运算原理
      • IEEE754浮点本质
        • 1. 案例引入
          • 1.1 一段崩在哪
          • 1.2 顺藤摸到根因
          • 1.3 我们要回答什么
        • 2. 架构概览
          • 2.1 浮点编码全景图
          • 2.2 为什么离不开浮点
        • 3. IEEE754三段布局
          • 3.1 32位float拆解
          • 3.2 64位double拆解
          • 3.3 浮点值计算公式
          • 3.4 二进制手工转浮点
        • 4. 规格化与非规格化
          • 4.1 规格数隐含的1
          • 4.2 非规格数的渐变下溢
          • 4.3 float的最小正值
        • 5. 特殊值无穷大NaN
          • 5.1 正负无穷的产生
          • 5.2 NaN的多种形式
          • 5.3 NaN传染性与陷阱
        • 6. 十进制转二进制丢失
          • 6.1 0.1的二进制困境
          • 6.2 0.1+0.2为何不等0.3
          • 6.3 累加的大数吃小数
        • 7. 浮点比较正确姿势
          • 7.1 绝对误差比较
          • 7.2 相对误差与ULP
          • 7.3 直接将float当map键
        • 8. 字节序与类型双关
          • 8.1 大小端与浮点布局
          • 8.2 union类型双关
          • 8.3 浮点转十六进制打印
        • 9. 浮点的舍入模式
          • 9.1 四种舍入方向
          • 9.2 银行家舍入原理
          • 9.3 舍入导致的灾难
        • 10. 综合案例串讲
          • 10.1 案例真相揭晓
          • 10.2 浮点运算安全方针
          • 10.3 设计哲学回扣
          • 10.4 速查表
      • 数组与指针的纠葛
      • 结构体对齐与优化
      • 字符串存储与安全
      • 预处理器宏与条件编译
      • 编译到汇编全流程
      • 链接器符号与重定位
      • 静态库与动态库对比
      • Make与CMake构建
      • 文件IO与系统调用
      • 动态内存管理揭秘
      • 未定义行为与防御
      • C工程化与设计哲学
    • 标准集库

  • Cpp入门到精通

  • Java入门精通

  • Go入门到精通

  • JavaScript入门

  • CodeX
  • C语言入门精通
  • 专栏博客
杨充
2026-06-10
目录

IEEE754浮点本质

# 08.IEEE754浮点本质

# 目录介绍

  • 1. 案例引入
    • 1.1 一段崩在哪
    • 1.2 顺藤摸到根因
    • 1.3 我们要回答什么
  • 2. 架构概览
    • 2.1 浮点编码全景图
    • 2.2 为什么离不开浮点
  • 3. IEEE754三段布局
    • 3.1 32位float拆解
    • 3.2 64位double拆解
    • 3.3 浮点值计算公式
    • 3.4 二进制手工转浮点
  • 4. 规格化与非规格化
    • 4.1 规格数隐含的1
    • 4.2 非规格数的渐变下溢
    • 4.3 float的最小正值
  • 5. 特殊值无穷大NaN
    • 5.1 正负无穷的产生
    • 5.2 NaN的多种形式
    • 5.3 NaN传染性与陷阱
  • 6. 十进制转二进制丢失
    • 6.1 0.1的二进制困境
    • 6.2 0.1+0.2为何不等0.3
    • 6.3 累加的大数吃小数
  • 7. 浮点比较正确姿势
    • 7.1 绝对误差比较
    • 7.2 相对误差与ULP
    • 7.3 直接将float当map键
  • 8. 字节序与类型双关
    • 8.1 大小端与浮点布局
    • 8.2 union类型双关
    • 8.3 浮点转十六进制打印
  • 9. 浮点的舍入模式
    • 9.1 四种舍入方向
    • 9.2 银行家舍入原理
    • 9.3 舍入导致的灾难
  • 10. 综合案例串讲
    • 10.1 案例真相揭晓
    • 10.2 浮点运算安全方针
    • 10.3 设计哲学回扣
    • 10.4 速查表

# 1. 案例引入

# 1.1 一段崩在哪

看一段在金融风控系统中跑了半年后才被审计发现的代码——每天处理 100 万笔交易的对账模块,理论上永远不该出错:

// reconciliation.c —— 交易对账引擎
#include <stdio.h>
#include <stdint.h>

/* 用浮点数累加每天的佣金——金额以元为单位 */
float commission_total = 0.0f;

void add_commission(float amount) {
    commission_total += amount;
}

/* 对账——比对累加器和数据库SUM */
int reconcile(float db_total) {
    printf("累加器: %.2f, 数据库: %.2f\n",
           commission_total, db_total);
    if (commission_total == db_total) {
        return 0;  /* 对平 */
    }
    return -1;     /* 差异 */
}

int main(void) {
    /* 模拟一天的 1 万笔小额交易 */
    for (int i = 0; i < 10000; i++) {
        add_commission(0.01f);         /* 每笔 1 分钱佣金 */
    }

    /* 模拟一笔大额交易 */
    add_commission(100.00f);

    float expected = 10000 * 0.01f + 100.00f;   /* 期望:200元 */
    int result = reconcile(expected);

    printf("对账结果: %s\n", result == 0 ? "✓ 平" : "✗ 不平!");
    return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

现象:

  • 前 1000 笔交易后累加:10.00 == 10.00 ✓
  • 前 5000 笔交易后累加:50.00 == 50.00 ✓
  • 前 10000 笔交易后累加:100.00 == 100.00 ✓
  • 加上 100 元大额后:199.998 == 200.00 ✗ 不平!

差了两厘钱——在金融系统中,这足以触发审计告警甚至合规问题。

第一反应:float 是不是精度不够?换成 double 能解决吗?换成 double 后:

对账结果: ✓ 平   (double 下通过)
1

但这不是根本原因——把 float 换成 double 只是让累加误差推迟到更大的数值。如果用 double 累加 1 亿笔 0.01 元——误差同样会出现。真正的问题是:浮点数不是实数。

# 1.2 顺藤摸到根因

用 printf("%.10f") 打印每个中间值:

0.01f 的精确值:  0.009999999776...  ← 不是 0.01!
10000 次累加后: 99.998...           ← 不是 100.00!
+100 元后:      199.998...          ← 不是 200.00!
1
2
3

0.01 在 float 中根本无法精确表示——它被存储为一个极接近 0.01 但不是 0.01 的二进制浮点数。累加 1 万次后,这个微小的近似误差被放大到约 0.002 元。

根因链条:

  1. 十进制小数 0.01 在二进制中是无限循环小数(就像 1/3 在十进制中是 0.3333...)
  2. float 只有 23 位尾数,只能近似存储——产生约 2^-28 的相对误差
  3. 1 万次累加将这个相对误差线性放大——最终偏差达到可观测的量级
  4. 加上 100 元的大额后,大数和小数混合计算加剧了舍入误差

这段代码暴露了 6 个关于浮点数的深度问题:

① float 的 32 位 bit 是怎么分配的?符号/指数/尾数各占多少? → 第 3 章
② 0.01 转成二进制后,被截断成什么样了?                       → 第 6 章
③ 为什么累加会产生误差?大数加小数有什么问题?                 → 第 6.3
④ 为什么 float 换成 double 暂时能行?精度差多少?              → 第 3.2
⑤ 怎样才是安全的浮点比较?== 从来不应该用吗?                  → 第 7 章
⑥ 浮点数的大端小端是怎么决定的——网络传输怎么办?                → 第 8 章
1
2
3
4
5
6

# 1.3 我们要回答什么

这个案例就是本篇的主线。我们从 IEEE 754 的三个段(符号、指数、尾数)开始,推导为什么 0.1 无法精确表示,再深入浮点比较的正确姿势,最后用"分"代替"元"彻底解决金融对账问题。

编码全景图 (第 2 章)
   ↓
三段布局拆解 (第 3 章) ─→ 解开①
   ↓
规格化与特殊值 (第 4-5 章)
   ↓
精度丢失推导 (第 6 章) ─→ 解开②~③
   ↓
浮点比较姿势 (第 7 章) ─→ 解开④~⑤
   ↓
字节序与舍入 (第 8-9 章) ─→ 解开⑥
   ↓
综合案例 (第 10 章) ─→ 金融级安全 rewrite
1
2
3
4
5
6
7
8
9
10
11
12
13

# 2. 架构概览

# 2.1 浮点编码全景图

IEEE 754 浮点数的三段结构:

┌──────────────────────────────────────────────────────────────────────┐
│ 类型       │ 总位宽 │ 符号位 │ 指数位 │  尾数位  │ 偏置值(bias)     │
├────────────┼────────┼────────┼────────┼──────────┼─────────────────┤
│ float      │  32    │   1    │   8    │   23     │  127            │
│ double     │  64    │   1    │  11    │   52     │  1023           │
│ long double│  80/128│   1    │  15    │   64     │  16383          │
└──────────────────────────────────────────────────────────────────────┘

float 的 32 位布局:
  [31]    [30.......23]    [22....................0]
   符号       指数(8bit)         尾数(23bit)

double 的 64 位布局:
  [63]    [62.......52]    [51.....................0]
   符号       指数(11bit)         尾数(52bit)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# 2.2 为什么离不开浮点

场景 为什么不能用整数
物理仿真 速度、加速度、力——本质是连续量
信号处理 傅里叶变换产生大量的无理数结果
3D 图形 矩阵变换中的三角函数和小数
机器学习 梯度值是无线小数的浮点权重

浮点数的设计目标不是精确——而是覆盖从亚原子到天文尺度的数值范围,且保持统一的相对精度。

# 3. IEEE754三段布局

# 3.1 32位float拆解

float f = -6.625f;
uint32_t bits;
memcpy(&bits, &f, sizeof(bits));  /* 安全的类型双关 */

printf("hex: 0x%08X\n", bits);    /* 0xC0D40000 */

/* 三段解析 */
int sign     = (bits >> 31) & 0x1;           /* bit 31 → 1 (负数) */
int exponent = (bits >> 23) & 0xFF;          /* bits 30-23 → 129 */
int mantissa = bits & 0x7FFFFF;             /* bits 22-0 → 0x540000 */
1
2
3
4
5
6
7
8
9
10

二进制表示:

-6.625 的 float 编码:
  1 10000001 10101000000000000000000
  ↑ ↑       ↑
  符 指数    尾数
  号 (129)   (0x540000 = 1.10101 - 1 = 0.10101)
1
2
3
4
5

用户可见的"值"(通过格式化)和内存中的"物理表示"(32 位二进制)——是两个完全不同的世界。0xC0D40000 是物理现实,-6.625 是人类友好的解释。

# 3.2 64位double拆解

double d = -6.625;
uint64_t bits;
memcpy(&bits, &d, sizeof(bits));

int sign     = (bits >> 63) & 0x1;           /* bit 63 */
int exponent = (bits >> 52) & 0x7FF;         /* bits 62-52 → 1025 */
uint64_t mantissa = bits & 0xFFFFFFFFFFFFF;  /* bits 51-0 */
1
2
3
4
5
6
7

float vs double 精度对比:

指标 float double
十进制有效位 ~7 位 ~15-16 位
最小正规格数 1.175e-38 2.225e-308
最大正数 3.403e+38 1.798e+308
epsilon(相邻两数的间距) 1.192e-07 2.220e-16

# 3.3 浮点值计算公式

规范化的浮点数(exponent ≠ 0 且 exponent ≠ 255/2047):

value = (-1)^sign × (1 + mantissa/2^M) × 2^(exponent - bias)

其中: M = 尾数位宽 (float=23, double=52)
     bias = 2^(指数位宽-1) - 1 (float=127, double=1023)
1
2
3
4

以 -6.625f 为例:

sign     = 1 → (-1)^1 = -1
exponent = 129 → E = 129 - 127 = 2
mantissa = 0x540000 = 5505024 / 2^23 = 0.65625

value = -1 × (1 + 0.65625) × 2^2 = -1 × 1.65625 × 4 = -6.625 ✅
1
2
3
4
5

# 3.4 二进制手工转浮点

疑惑:-6.625 是怎么变成 0xC0D40000 的?

论证——手工推导:

步骤 1:转成二进制
  6 = 110
  0.625 = 0.101
  6.625 = 110.101

步骤 2:规格化(小数点左移 2 位)
  110.101 = 1.10101 × 2^2
  尾数 = 10101(去掉隐含的 1.)
  指数 = 2 + bias = 2 + 127 = 129 = 10000001

步骤 3:组装 32 位
  符号 = 1 (负数)
  指数 = 10000001
  尾数 = 10101000000000000000000 (23位,右侧补零)

  完整: 1 10000001 10101000000000000000000
        ↓
  十六进制: 0xC0D40000
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

结论:任意一个人类眼中的"有小数点的数",在 IEEE 754 下都被重新编码为 (-1)^s × 1.m × 2^e 的形式——小数点在二进制中的位置由指数决定,不是硬编码在二进制表示中。

# 4. 规格化与非规格化

# 4.1 规格数隐含的1

疑惑:为什么尾数的公式是 1 + mantissa/2^23,而不是直接用 mantissa/2^23?

论证——这个 1. 是 IEEE 754 最精妙的设计:

如果尾数直接用 mantissa/2^23(不隐含 1):
  1.0 = 0.0 × 2^0    → 尾数 = 0(23个0)——可以
  0.0 = 0.0 × 2^0    → 尾数 = 0(23个0)——和 1.0 冲突了!
1
2
3

没有隐含 1,0.0 和 1.0 的尾数都是全零——无法区分。加上隐含 1 后:规格数的尾数范围从 1.0 到 1.999...,多了一位精度。

这多出的一位精度是免费的——因为规格数的第一位二进制总是 1,不需要存储。

# 4.2 非规格数的渐变下溢

当 exponent = 0 时,尾数的隐含位从 1. 变成 0.:

规格数    (指数 ≠ 0): value = (-1)^s × 1.m × 2^(e - bias)
非规格数 (指数  = 0): value = (-1)^s × 0.m × 2^(1 - bias)
1
2

为什么需要非规格数——填补 0 和最小规格数之间的空白:

float 的正数范围(非规格化到规格化的过渡):
  ... 1.1754942107e-38  ← 最小规格数
       8.8167e-39       ← 非规格数(尾数全1)
       5.8775e-39
       2.9388e-39
       1.4694e-39       ← 最小非规格数(尾数只有最低位1)
       0.0              ← exponent=0, mantissa=0
1
2
3
4
5
6
7

没有非规格化的话,最小规格数之下就是突变为 0——这是一个巨大的精度黑洞。非规格化让浮点数渐进地趋近于零(gradual underflow)。

# 4.3 float的最小正值

#include <float.h>
#include <stdio.h>

int main(void) {
    printf("最小正规格数: %e\n", FLT_MIN);       /* 1.175494e-38 */
    printf("最小非规格数: %e\n", FLT_TRUE_MIN);  /* 1.401298e-45 */
    printf("float epsilon: %e\n", FLT_EPSILON);   /* 1.192093e-07 */
    return 0;
}
1
2
3
4
5
6
7
8
9

这三个常量的物理意义:

常量 含义 二进制表示
FLT_MIN 最小正规格数 exponent=1, mantissa=0
FLT_TRUE_MIN 最小正非规格数 exponent=0, mantissa=最低位1
FLT_EPSILON 1.0 和下一个可表示的 float 之间的差距 单位在末位(ULP)

# 5. 特殊值无穷大NaN

# 5.1 正负无穷的产生

当 exponent = 全1(float: 255, double: 2047),mantissa = 0 → 无穷大:

float inf_pos = 1.0f / 0.0f;    /* +inf */
float inf_neg = -1.0f / 0.0f;   /* -inf */
float inf_ovf = 1e38f * 1e38f;  /* 溢出 → +inf */

printf("%f\n", inf_pos);         /* inf */
printf("%f\n", -inf_pos);        /* -inf */
printf("isinf: %d\n", isinf(inf_pos));  /* 1 */
1
2
3
4
5
6
7

无穷不是错误——它是浮点运算的合法结果:

float x = 1.0f / 0.0f;  /* 不崩溃、不抛异常 → 结果是 inf */
float y = x + 100.0f;   /* inf + 100 = inf */
float z = x / x;        /* inf / inf = NaN (不定式) */
1
2
3

# 5.2 NaN的多种形式

当 exponent = 全1, mantissa ≠ 0 → NaN (Not a Number):

float nan1 = 0.0f / 0.0f;         /* 0/0 → NaN */
float nan2 = inf_pos - inf_pos;    /* inf - inf → NaN */
float nan3 = sqrtf(-1.0f);        /* sqrt(-1) → NaN */

printf("%f\n", nan1);              /* nan */
printf("isnan: %d\n", isnan(nan1)); /* 1 */
1
2
3
4
5
6

NaN 有两种:

类型 mantissa 特征 行为
Quiet NaN (qNaN) 最高尾数位=1 传播——运算继续,不触发异常
Signaling NaN (sNaN) 最高尾数位=0, 其他≠0 触发浮点异常

C 语言的 0.0/0.0 产生 qNaN。

# 5.3 NaN传染性与陷阱

float x = 0.0f / 0.0f;    /* NaN */
float y = x + 1.0f;       /* NaN + 1 = NaN */
float z = x * 100.0f;     /* NaN * 100 = NaN */

/* NaN 和任何值比较都返回 false——包括和 NaN 自己! */
printf("%d\n", x == x);    /* 0(false)! */
printf("%d\n", x != x);    /* 1(true)! */
printf("%d\n", x >  0);    /* 0 */
printf("%d\n", x <= 0);    /* 0 */
1
2
3
4
5
6
7
8
9

这是最容易踩的坑之一:不能用 if (x == x) 来"检测 x 是否是 NaN"——即便 x 是 NaN,x == x 返回假,但它也可能因为别的原因返回假。应该用 isnan(x)。

NaN 的传播性意味着:一个 NaN 进入计算图→所有依赖它的结果都变成 NaN→排查时难以追踪源头。

# 6. 十进制转二进制丢失

# 6.1 0.1的二进制困境

疑惑:为什么 0.1 在 float 中不精确?

论证——手工转 0.1 为二进制:

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.2——循环!

0.1₁₀ = 0.0001100110011001100110011...₂  (无限循环)
1
2
3
4
5
6
7
8

float 只有 23 位尾数,所以只能截断到 23 位(再加上隐含 1 就是 24 位有效值):

实际存储:  0.0001100110011001100110011₂  (24位)
完整值:    0.000110011001100110011001100110011...₂ (无限)

存储值 < 真实值 —— 因为被截断后向上的位被丢弃
1
2
3
4

这就是 0.1f 实际值约等于 0.100000001490116... 的原因——24位有效值,扔掉了第25位及以后的。

# 6.2 0.1+0.2为何不等0.3

printf("%.17f\n", 0.1 + 0.2);   /* 0.30000000000000004 */
printf("%.17f\n", 0.3);         /* 0.29999999999999999 */

/* 0.1+0.2 != 0.3 → 结果是 true(不等于) */
1
2
3
4

为什么:

0.1 ≈ 0.100000000000000005551...
0.2 ≈ 0.200000000000000011102...
0.3 ≈ 0.299999999999999988897...

0.1 + 0.2 ≈ 0.300000000000000044408...  ← 40 个零后有个 4
1
2
3
4
5

0.1 + 0.2 的舍入方向是"向上",0.3 的舍入方向是"向下"——两者不在 IEEE 754 的同一个"桶"里。转换为 double 后它们的二进制表示不同。

# 6.3 累加的大数吃小数

回到第 1 章的金融对账——这是浮点累加中最常见的问题:

float big   = 100.0f;
float small = 0.000001f;

float result = big + small;
printf("%.10f\n", result);  /* 100.000000...——small 被吃掉了! */
1
2
3
4
5

为什么被吃掉——100.0f 和 0.000001f 在 float 中的指数差了 7(~128 vs ~0)。两个浮点数相加时,尾数需要对齐到同一指数——0.000001f 的尾数被右移 23 位后变成了 0。

对齐过程:
  big   = 1.100100... × 2^6
  small = 1.000011... × 2^-20

  对齐指数: small 的尾数右移 26 位 → 全零!
  big + small = big (small 被完全丢失)
1
2
3
4
5
6

这就是 Kahan 求和算法存在的理由——通过增加一个补偿项来跟踪丢失的低位。

# 7. 浮点比较正确姿势

# 7.1 绝对误差比较

#include <math.h>
#include <float.h>

/* 小数值安全的比较——两个数都不大时,绝对误差够用 */
int float_eq_abs(float a, float b, float epsilon) {
    return fabsf(a - b) < epsilon;
}

float_eq_abs(0.1f + 0.2f, 0.3f, 1e-6f);  /* ✅ 返回真 */
float_eq_abs(0.1 + 0.2, 0.3, 1e-15);     /* ✅ double 的 epsilon 更小 */
1
2
3
4
5
6
7
8
9
10

# 7.2 相对误差与ULP

疑惑:绝对误差比较对大数有效吗?

论证——不有效:

/* 1e8 附近的 float 间距已经是 ~8.0 了 */
/* 这时用 epsilon=1e-6 做比较毫无意义——8.0 的差异都无法区分 */
1
2

相对误差比较:

int float_eq_rel(float a, float b, float rel_eps) {
    if (a == b) return 1;  /* 处理 a==b==0 的 corner case */
    float max_val = fmaxf(fabsf(a), fabsf(b));
    return fabsf(a - b) <= max_val * rel_eps;
}
1
2
3
4
5

ULP 比较(最精确):

#include <math.h>

/* 检查两个 float 是否在 N 个 ULP 以内 */
int float_nearly_eq(float a, float b, int max_ulps) {
    /* 转成整数——相邻的整数差值即为 ULP 数 */
    int32_t ia, ib;
    memcpy(&ia, &a, sizeof(ia));
    memcpy(&ib, &b, sizeof(ib));

    /* 处理 NaN */
    if ((ia < 0) != (ib < 0)) {
        return a == b;  /* 用 == 处理 ±0 和 NaN */
    }

    return abs(ia - ib) <= max_ulps;
}

/* 判断 a 和 b 是否在 4 个 ULP 以内 */
float_nearly_eq(0.1f + 0.2f, 0.3f, 4);  /* ✅ 返回真 */
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

# 7.3 直接将float当map键

/* ❌ 浮点数作为 map 的键——微小舍入误差导致"查不到" */
/* 一个从计算得到的 0.3,和字面量 0.3,在 double 中是不同的 bit */
1
2

正确做法——量化到指定精度后再当键:

#include <math.h>

double quantize(double x, double step) {
    return round(x / step) * step;
}

/* 量化到 0.01 的倍数后用作键——所有"同一个价格"的浮点值收敛到同一个 bit */
double key = quantize(0.1 + 0.2, 0.01);  /* 0.30 */
1
2
3
4
5
6
7
8

# 8. 字节序与类型双关

# 8.1 大小端与浮点布局

float f = 1.0f;         /* IEEE 754: 0x3F800000 */
unsigned char *bytes = (unsigned char *)&f;

/* 小端系统(x86/ARM):低地址存低字节 */
printf("%02X %02X %02X %02X\n",
       bytes[0], bytes[1], bytes[2], bytes[3]);
/* 00 00 80 3F    ← 小端 */

/* 大端系统(网络/PPC):低地址存高字节 */
/* 3F 80 00 00    ← 大端 */
1
2
3
4
5
6
7
8
9
10

# 8.2 union类型双关

/* ✅ 用 union 做类型双关——严格符合 C 标准 */
typedef union {
    float    f;
    uint32_t u;
} FloatBits;

FloatBits fb;
fb.f = -6.625f;
printf("0x%08X\n", fb.u);  /* 0xC0D40000 */

/* 反向——从整数构造浮点 */
fb.u = 0x3F800000;
printf("%f\n", fb.f);      /* 1.000000 */
1
2
3
4
5
6
7
8
9
10
11
12
13

注意:*(uint32_t *)&f 这种指针方式——在某些编译器上违反 strict aliasing 规则(可能导致 UB)。union 是标准安全的方式。

# 8.3 浮点转十六进制打印

#include <stdio.h>

void print_float_bits(float f) {
    uint32_t bits;
    memcpy(&bits, &f, sizeof(bits));

    printf("%.6f → 0x%08X\n", f, bits);
    printf("  sign: %d\n",    (bits >> 31) & 1);
    printf("  exp:  %d (%d)\n", (bits >> 23) & 0xFF,
           ((bits >> 23) & 0xFF) - 127);
    printf("  mant: 0x%06X\n", bits & 0x7FFFFF);
}

print_float_bits(1.0f);
/* 1.000000 → 0x3F800000
   sign: 0, exp: 127 (0), mant: 0x000000 */

print_float_bits(-6.625f);
/* -6.625000 → 0xC0D40000
   sign: 1, exp: 129 (2), mant: 0x540000 */
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

# 9. 浮点的舍入模式

# 9.1 四种舍入方向

IEEE 754 定义了四种舍入模式(可通过 fesetround 改变):

模式 方向 用途
最近偶数(默认) 向最近的数舍入,平局时向偶数 通用计算——最小统计偏差
向零 截断(truncate) C 语言的类型转换
向正无穷 向上(ceil) 区间算术的上界
向负无穷 向下(floor) 区间算术的下界
#include <fenv.h>
#include <stdio.h>

#pragma STDC FENV_ACCESS ON

fesetround(FE_TONEAREST);  printf("1.5→%d\n", (int)rint(1.5));  /* 2——最近偶数 */
fesetround(FE_DOWNWARD);   printf("1.5→%d\n", (int)rint(1.5));  /* 1——向下 */
fesetround(FE_UPWARD);     printf("1.5→%d\n", (int)rint(1.5));  /* 2——向上 */
fesetround(FE_TOWARDZERO); printf("1.5→%d\n", (int)rint(1.5));  /* 1——截断 */
1
2
3
4
5
6
7
8
9

# 9.2 银行家舍入原理

默认的"最近偶数舍入"(也叫银行家舍入)——被舍入的值恰好在两个数的正中间时,选择偶数那个:

1.5 → 2 (2 是偶数)
2.5 → 2 (不是 3!——最近的偶数是 2)
3.5 → 4
4.5 → 4
1
2
3
4

为什么选偶数——抵消统计偏差。如果永远向上舍入,大量数据的舍入误差会系统性偏高。偶数舍入让一半情况向上、一半情况向下——期望误差为零。

# 9.3 舍入导致的灾难

Patriot 导弹事故(1991年海湾战争):

弹道计算将 0.1 秒的累加时间误差与导弹的飞行速度相乘——经过 100 小时运行后,约 0.34 秒的时间误差导致 600 米的定位误差,无法拦截飞毛腿导弹。

根因:0.1 在二进制中无法精确表示(无限循环),舍入误差每 0.1 秒累积一次——100 小时 = 360 万次累积 ≈ 不可忽略的偏移。

# 10. 综合案例串讲

# 10.1 案例真相揭晓

回到第 1 章的金融对账,六个疑问逐条作答:

疑问 答案
① float 的 bit 分配? 第 3.1:1 位符号 + 8 位指数 + 23 位尾数,偏置 127
② 0.01 被截断成什么样? 第 6.1:十进制 0.01 在二进制中是无限循环 → float 截到 24 位有效值
③ 为什么累加产生误差? 第 6.3:每次累加都引入 ~2^-28 的舍入误差,1 万次后线性放大
④ float vs double 精度? 第 3.2:double 52 位尾数 vs float 23 位——多 29 位有效值
⑤ 安全浮点比较? 第 7:绝对误差/相对误差/ULP 三种方案——永远不用 ==
⑥ 大端小端怎么办? 第 8:union 类型双关 + htonl/ntohl 网络字节序转换

金融级安全 rewrite——用整数代替浮点:

/* 黄金法则:金融计算永远不用浮点——用"分"作为最小单位 */
typedef int64_t Money;   /* 单位:分(cent) */

Money money_from_yuan(double yuan) {
    return (Money)llround(yuan * 100.0);  /* 一次性转为整数 */
}

void add_commission_fixed(Money *total, Money amount) {
    *total += amount;     /* 整数加法——绝对精确 */
}

int reconcile_fixed(Money total, Money db_total) {
    printf("累加器: %lld 分, 数据库: %lld 分\n",
           (long long)total, (long long)db_total);
    return total == db_total ? 0 : -1;  /* ✅ 整数 == 永远精确 */
}

int main(void) {
    Money total = 0;
    Money cent  = money_from_yuan(0.01);  /* 1 分 */
    Money big   = money_from_yuan(100.00); /* 10000 分 */

    for (int i = 0; i < 10000; i++) {
        total += cent;                   /* 整数累加——无精度丢失 */
    }
    total += big;

    Money expected = 10000 * cent + big;  /* 期望:20000 分 */
    int ret = reconcile_fixed(total, expected);
    printf("对账: %s\n", ret == 0 ? "✓ 平" : "✗ 不平");
    return 0;
}
/* 输出:累加器: 20000 分, 数据库: 20000 分 → ✓ 平 */
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

为什么这样可行:

  • 支付系统的最小粒度是"分"——所有金额都是分的整数倍
  • 整数的加法和乘法是精确的——没有舍入、没有近似
  • 只在"显示给人看"时除以 100 转为元——显示精度不参与计算

# 10.2 浮点运算安全方针

浮点安全决策树:

你的数据是?
│
├─ 金额、数量、计数器?
│     └─ 用整数(分/厘/最小粒度)
│
├─ 物理量(速度、加速度、温度)?
│     └─ 可以用 float/double
│           └─ 但比较用 epsilon 或 ULP,不要用 ==
│
├─ 需要累加大量数据?
│     └─ 用 double 而非 float(52 vs 23 位尾数)
│           └─ 考虑 Kahan 补偿求和
│
├─ 只能浮点?
│     └─ 默认 double(多 29 位尾数,几乎白送)
│           └─ 只在内存带宽敏感(GPU/嵌入式)时用 float
│
└─ 跨平台传输?
      └─ 转为整数(htonl)传输,收到后再转回浮点
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

# 10.3 设计哲学回扣

哲学 1:有限位宽 = 有限精度——浮点数是稀疏的近似值集合

32 位只能表示 2^32 个不同的 bit 模式——减去 NaN、inf、负数的编码——大约 20 亿个不同的"数值"。但实数轴是连续的、无限的。浮点数不是实数——它们是在实数轴上分布不均匀的 20 亿个采样点。越靠近零的点越密集,越大的点越稀疏。

哲学 2:二进制和十进制不可通约——0.1 的悲剧是硬件必然

0.1 在十进制中是 1/10——干净的小数。但在二进制中它是无限循环小数——就像 1/3 在十进制中是 0.3333...。这不是 IEEE 754 设计缺陷——而是十进制和二进制之间不可通约的数学必然。所有十进制小数转换成二进制都可能产生近似。

哲学 3:不要用浮点表示精确值——金钱、数量、索引

金融计算用浮点是业界最古老的反模式之一——但至今仍然常见。浮点的设计目标是"覆盖极大和极小的范围"——不是精确表示特定值。对于需要精确等值比较的场景——整数是唯一正确的选择。

哲学 4:NaN 的不可等性是最安静的地雷

x == x 是编程中最基本的恒等式——但 NaN 打破了这个恒等式。这导致了一个反直觉的结果:你无法通过 if (x != x) 来可靠地检测"变量是否损坏"——因为 NaN 会让这个条件为真,但这不是"损坏"的唯一原因。NaN 是浮点运算的暗物质——你看不到它,但它存在,且一旦出现就污染整个计算图。

# 10.4 速查表

操作 代码 说明
查看 bits memcpy(&u, &f, 4) float → uint32_t
查看 bits memcpy(&ll, &d, 8) double → uint64_t
检测 NaN isnan(x) 不要用 x != x
检测 inf isinf(x)
安全比较 fabs(a-b) < epsilon 绝对误差
安全比较 fabs(a-b) <= max*eps 相对误差
安全比较 abs(ia-ib) <= 4 ULP 比较
舍入到整数 llround(x) 最近偶数舍入
改变舍入模式 fesetround(FE_DOWNWARD) 需 #pragma STDC FENV_ACCESS ON
浮点最小值 FLT_MIN / DBL_MIN 最小正规格数
浮点 epsilon FLT_EPSILON 1.0 与下一个数的差距
类型双关 union {float f; uint32_t u;} 标准安全方式

下一篇:浮点数让我们看到了"二进制表示连续量"的必然限制,下一步进入 09.数组与指针的纠葛——把 int arr[5] 和 int *p = arr 从内存布局和汇编级别彻底分离,解开数组名不等同于指针的经典误会。

上次更新: 2026/06/11, 08:54:53
补码与位运算原理
数组与指针的纠葛

← 补码与位运算原理 数组与指针的纠葛→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式