IEEE754浮点本质
# 08.IEEE754浮点本质
# 目录介绍
- 1. 案例引入
- 2. 架构概览
- 3. IEEE754三段布局
- 4. 规格化与非规格化
- 5. 特殊值无穷大NaN
- 6. 十进制转二进制丢失
- 7. 浮点比较正确姿势
- 8. 字节序与类型双关
- 9. 浮点的舍入模式
- 10. 综合案例串讲
# 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;
}
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 下通过)
但这不是根本原因——把 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!
2
3
0.01 在 float 中根本无法精确表示——它被存储为一个极接近 0.01 但不是 0.01 的二进制浮点数。累加 1 万次后,这个微小的近似误差被放大到约 0.002 元。
根因链条:
- 十进制小数
0.01在二进制中是无限循环小数(就像 1/3 在十进制中是 0.3333...) float只有 23 位尾数,只能近似存储——产生约 2^-28 的相对误差- 1 万次累加将这个相对误差线性放大——最终偏差达到可观测的量级
- 加上 100 元的大额后,大数和小数混合计算加剧了舍入误差
这段代码暴露了 6 个关于浮点数的深度问题:
① float 的 32 位 bit 是怎么分配的?符号/指数/尾数各占多少? → 第 3 章
② 0.01 转成二进制后,被截断成什么样了? → 第 6 章
③ 为什么累加会产生误差?大数加小数有什么问题? → 第 6.3
④ 为什么 float 换成 double 暂时能行?精度差多少? → 第 3.2
⑤ 怎样才是安全的浮点比较?== 从来不应该用吗? → 第 7 章
⑥ 浮点数的大端小端是怎么决定的——网络传输怎么办? → 第 8 章
2
3
4
5
6
# 1.3 我们要回答什么
这个案例就是本篇的主线。我们从 IEEE 754 的三个段(符号、指数、尾数)开始,推导为什么 0.1 无法精确表示,再深入浮点比较的正确姿势,最后用"分"代替"元"彻底解决金融对账问题。
编码全景图 (第 2 章)
↓
三段布局拆解 (第 3 章) ─→ 解开①
↓
规格化与特殊值 (第 4-5 章)
↓
精度丢失推导 (第 6 章) ─→ 解开②~③
↓
浮点比较姿势 (第 7 章) ─→ 解开④~⑤
↓
字节序与舍入 (第 8-9 章) ─→ 解开⑥
↓
综合案例 (第 10 章) ─→ 金融级安全 rewrite
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)
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 */
2
3
4
5
6
7
8
9
10
二进制表示:
-6.625 的 float 编码:
1 10000001 10101000000000000000000
↑ ↑ ↑
符 指数 尾数
号 (129) (0x540000 = 1.10101 - 1 = 0.10101)
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 */
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)
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 ✅
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
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 冲突了!
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)
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
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;
}
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 */
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 (不定式) */
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 */
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 */
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...₂ (无限循环)
2
3
4
5
6
7
8
float 只有 23 位尾数,所以只能截断到 23 位(再加上隐含 1 就是 24 位有效值):
实际存储: 0.0001100110011001100110011₂ (24位)
完整值: 0.000110011001100110011001100110011...₂ (无限)
存储值 < 真实值 —— 因为被截断后向上的位被丢弃
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(不等于) */
2
3
4
为什么:
0.1 ≈ 0.100000000000000005551...
0.2 ≈ 0.200000000000000011102...
0.3 ≈ 0.299999999999999988897...
0.1 + 0.2 ≈ 0.300000000000000044408... ← 40 个零后有个 4
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 被吃掉了! */
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 被完全丢失)
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 更小 */
2
3
4
5
6
7
8
9
10
# 7.2 相对误差与ULP
疑惑:绝对误差比较对大数有效吗?
论证——不有效:
/* 1e8 附近的 float 间距已经是 ~8.0 了 */
/* 这时用 epsilon=1e-6 做比较毫无意义——8.0 的差异都无法区分 */
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;
}
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); /* ✅ 返回真 */
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 */
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 */
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 ← 大端 */
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 */
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 */
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——截断 */
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
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 分 → ✓ 平 */
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)传输,收到后再转回浮点
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从内存布局和汇编级别彻底分离,解开数组名不等同于指针的经典误会。