未定义行为与防御
# 19.未定义行为与防御
UB/实现定义/未指定三种行为分类、有符号整数溢出
INT_MAX + 1从-O0正常到-O2死循环的编译器优化真相、空指针解引用后编译器删除后续 null 检查、strict aliasing规则下char*是唯一合法别名、越界访问/悬空指针/双重 free/栈溢出分类速查、UBSan + ASan + TSan 三圣器编译参数与输出解读、CI 集成最佳实践
# 目录
# 1. 案例引入
# 1.1 编译器变异
某计费系统的超时检测函数跑了 5 年没问题。升级 CI 的 GCC 从 7.5 到 12.2 后,测试环境偶发性死循环——进程 CPU 飙到 100%,但日志显示不应该走进的分支:
// billing_timeout.c —— 计费超时检测
#include <stdint.h>
#include <stdio.h>
static int g_counter = 0;
int is_timeout(int elapsed_ms) {
// 设计意图: 防止 elapsed_ms 被意外传入负值
// (因为上游有个 bug 偶尔传负数)
if (elapsed_ms < 0) {
g_counter++;
return 0; // 负数 → 不超时
}
// 正常逻辑
if (elapsed_ms > 30000) {
return 1; // >30秒 → 超时
}
return 0;
}
int main() {
int val = 0;
for (int i = 0; i < 10; i++) {
val += 400; // 每次加 400 毫秒
// ... 大量业务代码 ...
if (is_timeout(val)) {
printf("timeout at val=%d\n", val);
break;
}
}
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
现象(GCC 7.5 -O2):
# 正常输出: 循环 10 次,不会超时
(程序正常退出)
2
现象(GCC 12.2 -O2):
# 程序卡死!top 显示 100% CPU
# 用 gdb attach 进去:
(gdb) bt
#0 main () at billing_timeout.c:20
(gdb) p i
$1 = 9
(gdb) p val
$2 = 3600
(gdb) p is_timeout(val)
$3 = 1 # ← 在 gdb 里调用返回 1,但程序死循环
2
3
4
5
6
7
8
9
10
诡异:val = 3600,3600 > 30000 是 false,但 is_timeout 永远返回 0——程序跳不出 for 循环,一直 i++,但 i 是 int,极端情况下会溢出……真正的元凶不是这个。
用 gcc -S 看 -O2 生成的汇编:
is_timeout:
; ⚠️ 注意:整个 (elapsed_ms < 0) 的 if 分支不存在!
cmp edi, 30000 ; 直接比较 elapsed_ms > 30000
setg al ; al = (elapsed_ms > 30000)
movzx eax, al
ret
2
3
4
5
6
if (elapsed_ms < 0) 的分支被编译器完全删除了!
# 1.2 顺藤摸到根因
追查:
假设 1:是不是 GCC 12 的 bug?—— 提交给 GCC Bugzilla,回复 "not a bug:
valis always positive in your loop, soelapsed_ms < 0is provably dead code"。假设 2:编译器怎么知道
val永远为正?—— 看main中的for循环:val = 0+ 每次+= 400—— 在有符号整数中,从不溢出。编译器可以证明val永远 ≥ 0。假设 3:那上游偶尔传负数呢?—— 编译器不是"运行时检查",是"编译期推理"。它看到
is_timeout的所有调用点中elapsed_ms都 ≥ 0 → 所有elapsed_ms < 0的路径都是"死代码" → 删除。假设 4:GCC 7.5 为什么没删?—— 因为越老的编译器,越少做这种"跨函数的推导传播"(IPA, Inter-Procedural Analysis)。GCC 12 的 IPA 更强大了。
假设 5:这不是 "upstream bug" 吗?为什么编译器背锅?—— 因为从 C 标准的角度看,这个程序的所有调用点都满足"
elapsed_ms永远 ≥ 0"。编译器基于此推理删掉了"永远走不到"的路径——它没做错。
但现实中上游确实会传负数。 这就是"编译器的静态推理"与"程序的运行时现实"的冲突——编译器在标准层面是正确的(因为标准只承诺了"调用点给的值"),但现实中的 "bug" 会绕过这个推理。
这个事故藏着至少 8 个原理点:
① 什么是未定义行为?为什么编译器可以"为所欲为"? → 第 2/3 章
② 有符号整数溢出是 UB,编译器怎么利用这一点做优化? → 第 4 章
③ 空指针解引用后,为什么后续 null 检查会被删除? → 第 5 章
④ strict aliasing 规则是什么?为什么 `char*` 是万能的? → 第 6 章
⑤ UBSan/ASan/TSan 各检测什么?怎么用? → 第 7 章
⑥ 怎么在 CI 里集成 Sanitizer? → 第 7.4 节
⑦ "实现定义"和"未指定"的区别? → 第 3 章
⑧ 哪些"看起来能跑"的代码其实是 UB? → 第 8 章
2
3
4
5
6
7
8
# 1.3 我们要回答什么
这个"编译器升级后死循环"的事故,表面上看是"GCC 太激进了",实质是:C 标准把有符号整数溢出定为 UB,而编译器基于"UB 不会发生"的假设做优化——当 UB 在运行时确实发生时,一切后果由程序员承担。
本篇路线:
架构总图 (第 2 章)
↓
UB 三级分类 (第 3 章) ─→ 解开"UB/实现定义/未指定"
↓
整数溢出陷阱 (第 4 章) ─→ 解开"INT_MAX+1 为什么能让安全代码消失"
↓
空指针解引用 (第 5 章) ─→ 解开"用了空指针后,检查也被删了"
↓
strict aliasing (第 6 章) ─→ 解开"为什么不能把 int* 转 float*"
↓
Sanitizer 三件套 (第 7 章) ─→ 武器库: UBSan + ASan + TSan
↓
综合案例 (第 8 章) ─→ UB 杀伤力排行榜 + 速查卡
2
3
4
5
6
7
8
9
10
11
12
13
📌 本篇定位:这是 C 语言专栏的"安全收尾篇"。前 18 篇讲了 C 的语法、编译、链接、内存、IO——它们合在一起构成了 C 的"正确使用"路径。本篇告诉你"错误使用"的后果:UB 不是"会崩",是"编译器假设它不会发生,然后基于这个假设做优化——你的代码在优化后被改写了"。理解 UB,才算真正理解了"为什么 C 既强大又危险"。
# 2. 架构概览
# 2.1 三种行为
C 标准定义了程序行为的三个等级:
┌─────────────────────────────────────────────────────────────────┐
│ 程序行为的三个等级 │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Well-defined (定义良好) │ │
│ │ 标准规定: 必须这样 │ │
│ │ 例: int x = 42; │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Implementation-defined (实现定义) │ │
│ │ 标准规定: 编译器必须在几种可能中选择,且必须文档化 │ │
│ │ 例: sizeof(int) = 4 或 8 (由编译器决定,但必须明确) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Unspecified (未指定) │ │
│ │ 标准规定: 编译器在几种可能中选,不需要文档化 │ │
│ │ 例: f(a(), b()) —— a() 和 b() 的求值顺序不确定 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Undefined Behavior (UB, 未定义行为) │ │
│ │ 标准规定: 什么都没说——编译器可以做任何事 │ │
│ │ 例: INT_MAX + 1, *NULL, free(p)后再次free(p) │ │
│ │ 后果: 可能崩、可能不崩、可能删代码、可能格式化硬盘 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
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
UB ≠ "会崩溃"——UB = "编译器不再受标准约束"。它可以:
- 生成让程序崩溃的代码(你以为的)
- 生成看起来"正常"的代码(最危险的——你以为修好了)
- 删除你的安全检查代码(第 4/5 章的核心)
- 把你调到另一个完全不相关的函数(LLVM 对 UB 的"毒化"传播)
# 2.2 UB 是编译器优化的燃料
疑惑:为什么 C 标准要留下这么多 UB?
论证——不是标准委员会疏忽,而是故意的:
性能优先的设计哲学:如果标准说"有符号整数溢出必须回绕(wrap)",那编译器每次做加法前都要插入溢出检查——性能损失大概 5~10%。UB 允许编译器假设"不会溢出",从而生成最快的代码。
编译器优化的链式依赖:
假设: "有符号整数溢出不会发生"
→ 可以假设 i + 1 > i (当 i ≥ 0)
→ 循环 i < N 必定终止
→ i * 常数 / 常数 = i (代数化简)
→ 安全检查 if (i + 1 <= i) 永远不会触发 → 删除
2
3
4
5
- 跨平台的可承载性:有符号整数溢出在 x86 上回绕,在 ARM 上可能饱和,在 DSP 上可能触发异常。标准说 UB → 每种 CPU 可以用最快的方式实现,编译器不需要插入"让它回绕"的成本。
结论:UB 不是 C 的 bug,是 C 的 feature——它是用"程序员的责任"换来的"编译器的优化自由"。问题在于,当这个责任没被兑现时,后果是灾难性的。
# 3. UB 三级分类
# 3.1 未定义行为
UB 是 C 标准的"法外之地"——标准对此没有任何要求,编译器可以做任何事:
// UB 示例集锦
// 1. 有符号整数溢出
int x = INT_MAX;
int y = x + 1; // UB! 编译器可以假设 x+1 > x
// 2. 空指针解引用
int* p = NULL;
*p = 42; // UB! 编译器可以假设 p 非空
// 3. 数组越界
int arr[10];
arr[10] = 42; // UB! 可以覆盖栈上任何东西
// 4. 使用已释放的内存 (use-after-free)
int* p = malloc(sizeof(*p));
free(p);
*p = 42; // UB! 那块内存可能已被复用
// 5. 双重 free
free(p); free(p); // UB! 可能破坏分配器内部结构
// 6. 违反 strict aliasing
float f = 3.14f;
int* p = (int*)&f;
int x = *p; // UB! 不能用 int* 读 float
// 7. 有符号整数除零 / 取模
int x = 42 / 0; // UB!
// 8. 有符号整数移位超过位宽
int x = 1 << 31; // UB! (如果 int 是 32 位, 1<<31 溢出)
int y = 1 << 40; // UB! 移位 >= 位宽
// 9. 返回局部变量的地址
int* f() { int x = 42; return &x; } // UB! 返回后栈帧被回收
// 10. 没有返回值的非 void 函数
int g() { /* 没有 return */ } // UB! (仅在调用方使用返回值时)
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
37
38
39
UB 的致命特征——时间回溯:一旦 UB 发生,编译器不仅当前行为未定义,UB 发生之前的代码也可能被重写。这就是为什么"先 UB 再检查"反过来会被删除检查(第 5 章)。
# 3.2 实现定义行为
实现定义行为是 UB 的"温良版本"——编译器必须在几种可能性中选一个,并且必须文档化:
// 实现定义行为示例
// 1. 类型大小
printf("%zu\n", sizeof(int)); // 4 (大多数平台) 或 2 (某些嵌入式)
printf("%zu\n", sizeof(long)); // 8 (LP64, Linux) 或 4 (LLP64, Windows)
// 2. char 的符号
char c = 0xFF;
printf("%d\n", c); // -1 (char=signed, 大多数) 或 255 (char=unsigned, ARM)
// 3. 右移的符号扩展
int x = -8;
int y = x >> 1; // -4 (算术右移, 大多数) 或 2147483644 (逻辑右移)
// 4. NULL 的内部表示
printf("%p\n", (void*)0); // 0x0 (大多数) 或 其他 (某些平台)
// 5. bit-field 的存储顺序
struct { int a:1; int b:1; } s;
// a 在低位还是高位?——实现定义
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
实现定义的"温和"之处:编译器选择某个行为后,同一编译器的同一版本行为一致。你可以依赖它——只是不能跨平台依赖。
# 3.3 未指定行为
未指定行为比"实现定义"更弱——标准提供了多种可能,编译器不需要文档化选择的是哪一种:
// 未指定行为示例
// 1. 函数参数的求值顺序
f(a(), b()); // a() 和 b() 谁先调用?——未指定!
// 如果 a 和 b 有共享状态 → 结果不确定
// 2. 子表达式的求值顺序 (C17 之前)
int r = a() + b(); // a() 和 b() 谁先求值?——未指定!
int s = arr[i++]; // i++ 何时发生?——未指定!
// 3. 结构体成员的对齐 padding
struct { char a; int b; } s;
printf("%zu\n", offsetof(typeof(s), b)); // 未指定 (通常 4, 但不必)
// 4. 同一个表达式中对同一变量多次写入
int i = 0;
arr[i] = i++; // UB! (多次修改+读取同一变量, 无序列点)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 3.4 关系图谱
┌─────────────────────────────────────────────────┐
│ │
│ 定义良好 实现定义 未指定 UB │
│ (defined) (impl-def) (unspecified) │
│ │ │ │ │ │
│ └────────────┴───────────┴───────────┘ │
│ │ │
│ C 标准有明确文本 │
│ │
│ UB: 标准完全沉默 —— 编译器为所欲为 │
│ │
└─────────────────────────────────────────────────┘
面试高频面试陷阱: "sizeof(int) 是 4"
→ 错误! sizeof(int) 是实现定义行为,不是 UB,但它可以是 2/4/8
"i = i++ 是未指定行为"
→ 错误! i = i++ 是 UB (两个序列点之间多次修改同一对象)
"有符号整数溢出行为是未指定的"
→ 错误! 是 UB
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
一图区分:
| 例子 | 分类 | 编译器必须? | 可移植? |
|---|---|---|---|
int x = 42; | 定义良好 | 按标准生成代码 | ✅ |
sizeof(int) = 4 | 实现定义 | 文档化其选择 | ❌ |
f(a(), b()) 求值顺序 | 未指定 | 随意 | ⚠️ |
INT_MAX + 1 | UB | 做任何事 | 💀 |
# 4. 整数溢出优化陷阱
# 4.1 INT_MAX + 1 的三个面孔
同一行 int y = x + 1;,在不同优化级别下可能有三种完全不同的行为:
#include <limits.h>
#include <stdio.h>
int main() {
int x = INT_MAX;
int y = x + 1; // UB!
printf("x=%d, y=%d\n", x, y);
return 0;
}
2
3
4
5
6
7
8
9
GCC -O0: x=2147483647, y=-2147483648 ← 回绕 (wraparound)
GCC -O2: x=2147483647, y=-2147483648 ← 恰好也回绕 (但没保证!)
Clang -O2: x=2147483647, y=2147483648** → 可能被优化成饱和或任意值
2
3
问题不在于"到底输出了什么"——UB 意味着没有正确答案。编译器可以:
- 删掉这行代码(假设它不可达)
- 把
y替换为任意常量 - 把后续用到
y的代码全部优化掉
# 4.2 溢出UB优化
案例 1:循环终止条件的推理
// 这个循环在 -O0 下正常退出(遍历一半 int 范围)
// 在 -O2 下变成死循环!
int count_down(int n) {
for (int i = n; i <= n + 10; i++) {
do_work(i);
}
}
2
3
4
5
6
7
编译器推理:
已知: n 是有符号 int
已知: 有符号整数溢出 = UB → 编译器可假设"不会溢出"
假设: i 从 n 开始,每次 i++,i 最终会超过 n+10
但是: 如果 n = INT_MAX - 5,那 i 增加 15 次后会溢出
结论: 如果溢出会发生 → UB,而编译器的假设是"UB 不会发生"
→ 所以 n 一定不是 INT_MAX - 5
→ 所以循环一定终止 … 不,等等,这只是说明 UB 的假设链
实际上更简单的推理:
如果 i 永远不会溢出到 "比 n+10 更小" → 循环必定前进
但 i 每次 +1 → 如果 n 接近 INT_MAX → i 会溢出
编译器假设 UB 不发生 → 则 i 在 n 到 n+10 之间绝不溢出
→ 循环终止条件是 i > n+10 → 因为 i 不断增大且不溢出 → 终究满足
→ 循环必然终止 → 没问题
2
3
4
5
6
7
8
9
10
11
12
13
14
但这个死循环案例是更微妙的:
// 当 n = INT_MAX 时:
int n = INT_MAX;
for (int i = n; i <= n + 5; i++) // n+5 溢出 = UB
;
2
3
4
编译器的推理链条:
1. n + 5 如果溢出 → UB
2. 编译器假设 UB 不出现 → n + 5 不溢出 → n 不是 INT_MAX - 4 到 INT_MAX
3. i = n 到 i = n+5 不溢出 → i 永远 ≤ n+5
4. 但 i 每次 +1 → 最终 i > n+5 → 循环终止
实际上的问题: 如果 n 确实是 INT_MAX:
编译器已经假定了 n 不是 INT_MAX (否则 UB)
→ 基于这个假定优化
→ 但实际上 n 就是 INT_MAX → 优化基于错误假设 → 后果不可预测
2
3
4
5
6
7
8
9
# 4.3 安全检删
第 1 章案例的最简复现:
// test.c
int is_timeout(int elapsed_ms) {
if (elapsed_ms < 0) return 0; // ← "安全检查"
if (elapsed_ms > 30000) return 1;
return 0;
}
int main() {
int val = 0;
for (int i = 0; i < 10; i++) {
val += 400;
is_timeout(val); // val 始终 ≥ 0
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
$ gcc -O2 -S test.c -o test.s
is_timeout 的汇编(-O2):
is_timeout:
cmp edi, 30000 ; 直接比较 >30000
setg al
movzx eax, al
ret
; 整个 if (elapsed_ms < 0) 分支消失了!
2
3
4
5
6
优化过程(GCC 内部 pass 链):
Pass 1: Inline
→ is_timeout 被内联进 main 的循环
Pass 2: VRP (Value Range Propagation, 值范围传播)
→ 分析 val 的值范围:
val = 0 (初始)
val = 400, 800, 1200, ..., 3600 (循环体)
→ 结论: val ∈ [0, 3600]
→ 传入 is_timeout 的参数 elapsed_ms ∈ [0, 3600]
→ 所以 elapsed_ms < 0 是 dead code (死代码)
Pass 3: DCE (Dead Code Elimination, 死代码消除)
→ 删除 if (elapsed_ms < 0) return 0; 整个分支
→ 删除 g_counter++ (没有其他代码读它)
2
3
4
5
6
7
8
9
10
11
12
13
14
这是 UB 吗?——严格来说,这段代码本身没有 UB。问题在于:编译器做了跨过程传播(IPA)后基于调用点信息优化了被调用函数。这在标准层面是合法的(is_timeout 本身没 UB,但在 main 的视角下所有调用点 val≥0),在工程层面却删除了"防御性检查"。
# 4.4 防御: -fwrapv 与无符号回绕
# 方案 A: 让有符号整数溢出定义为回绕 (wrap)
$ gcc -fwrapv -O2 test.c -o test
# 有符号溢出现在有明确定义: 和 unsigned 一样绕回
# 但这也会阻止合法优化
# 方案 B: 用无符号整数 (unsigned 溢出是定义良好的回绕)
unsigned int n = UINT_MAX;
n = n + 1; // 定义良好! n = 0
# 方案 C: 编译器内置溢出检查
int x = INT_MAX;
if (__builtin_add_overflow(x, 1, &x)) {
// 处理溢出
}
2
3
4
5
6
7
8
9
10
11
12
13
14
选择指南:
- 计费/金融:永远用无符号或有检查的有符号
- 性能优先的安全无关代码:
-fwrapv或接受 UB(通过 Sanitizer 检测)
# 5. 空指针连锁
# 5.1 解引用那一刻就已经 UB
疑惑:空指针解引用是 UB——但如果硬件碰巧"没崩"(如 MMU 保护范围外),后续代码会怎样?
论证——编译器对空指针解引用的反应比硬件更恐怖:
// 1. 解引用本身可能不崩(如果 0 地址碰巧被映射)
int* p = NULL;
int x = *p; // UB! 但可能不崩
// 2. 但编译器可以做更激进的事:
2
3
4
5
因为标准规定空指针解引用是 UB,编译器可以假设"任何被解引用的指针都不是 NULL"。
# 5.2 null检查证据
int deref_then_check(int* p) {
int val = *p; // (1) 解引用
if (p == NULL) { // (2) 空指针检查
return 0;
}
return val;
}
2
3
4
5
6
7
$ gcc -O2 -S test.c
deref_then_check:
mov eax, DWORD PTR [rdi] ; 直接解引用!
ret ; 直接返回!
; p == NULL 的检查和分支——全部消失!
2
3
4
编译器的推理链条:
1. 第 (1) 行: *p → 编译器假设 p ≠ NULL
(否则就是 UB,既然 UB 不发生,p 一定非空)
2. 第 (2) 行: if (p == NULL)
→ 既然 p 非空 → p == NULL 恒为假
→ 删除整个 if 分支 → 永远走 return val
2
3
4
5
6
这就是 UB 的"时间回溯"效应:*p 在 if (p == NULL) 之前执行,但 UB 一旦发生,编译器不仅会改变 UB 当时的行为,还会反过来删除之前或之后的代码。因为标准说:一旦发生了 UB,整个程序的行为都是未定义的——编译器有机会重排或删除任意代码。
# 5.3 Linux 内核的经典 null-check 删除事故
2009 年 Linux 内核的一个真实 CVE(CVE-2009-1897):
// 内核代码 (简化版)
static int tun_chr_poll(struct file *file, poll_table *wait) {
struct tun_file *tfile = file->private_data;
struct sock *sk = tfile->sk; // (1) 用 tfile
unsigned int mask = 0;
if (!tfile) // (2) 检查 tfile 是否 NULL
return -EBADFD;
// ... 使用 tfile 的更多代码 ...
}
2
3
4
5
6
7
8
9
10
11
GCC 4.4 把这行 if (!tfile) 优化掉了——因为上一行 tfile->sk 已经被编译器看作"tfile 非空"的证据。但实际上 tfile 在某些内核路径下确实可以是 NULL。
修复方案:
// 方案 A: 先检查再用 (但可能被优化掉——编译器的 CSE 可能把它们合并)
if (!tfile) return -EBADFD;
struct sock *sk = tfile->sk;
// 方案 B: 用 OPTIMIZER_HIDE_VAR 阻止编译器推理
#define OPTIMIZER_HIDE_VAR(var) \
__asm__ ("" : "=r"(var) : "0"(var))
if (!tfile) return -EBADFD;
OPTIMIZER_HIDE_VAR(tfile);
struct sock *sk = tfile->sk; // 编译器不知道 tfile ≠ NULL
// 方案 C: 用 Linux 内核的 READ_ONCE / barrier()
#include <linux/compiler.h>
if (!tfile) return;
barrier();
struct sock *sk = tfile->sk;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 5.4 先检查后使用
// ✅ 永远正确的空指针防御模式:
void process(int* p) {
if (p == NULL) return; // 先检查
*p = 42; // 再用
}
// ❌ 有 UB 风险的模式:
void bad_process(int* p) {
*p = 42; // 先用了
if (p == NULL) return; // 再检查 → 检查会被删!
}
// ⚠️ 看似安全但真可能被优化掉的模式:
void tricky(int* p) {
if (p) do_setup(); // 检查1: p 非空则...
int x = *p; // 用 p
if (p) do_cleanup(); // 检查2: 可能被优化删除!
// 因为 p 在两次检查之间被解引用了 → 编译器假设 p 非空
// → 第二个检查被优化掉
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 6. strict aliasing 规则
# 6.1 别名约束
strict aliasing 规则的核心:两个不同类型的指针不能指向同一块内存(有少数例外)。
// ❌ 违反 strict aliasing
float f = 3.14f;
int* p = (int*)&f;
int x = *p; // UB! 通过 int* 读取 float 对象
// 编译器的合法推理:
// 写入 f (float 类型) 时 → 不会影响任何 int 类型的变量
// 读取 x (int 类型) 时 → 不需要考虑 float 变量的值
// → 可以把 f 和 x 放在不同的寄存器,独立优化
2
3
4
5
6
7
8
9
为什么要这个规则——它是很多优化的基础:
void add_one(float* fp, int* ip) {
*fp += 1.0f; // 写 float
*ip += 1; // 写 int
// 编译器假设 fp 和 ip 不指向同一块内存
// → 这两个写入可以重排,甚至合并
// → 不需要每次写之前重新读
}
2
3
4
5
6
7
# 6.2 char万能别名
标准明确豁免了 char*:
// ✅ 合法: char* 可以读取任何类型的内存
float f = 3.14f;
char* p = (char*)&f;
p[0] = 0x42; // 合法! char* 是"万能别名"
// ✅ 合法: unsigned char* 也可以
unsigned char* q = (unsigned char*)&f;
// ✅ 合法: 相同类型的指针当然可以
float* r = &f;
*r = 2.71f;
// ❌ 非法: 通过 signed char* 读 int (标准只豁免 char* 和 unsigned char*)
// 但 GCC/Clang 通常也接受 signed char*
2
3
4
5
6
7
8
9
10
11
12
13
14
为什么需要这个豁免——memcpy 的内部实现就是通过 char* 逐字节拷贝:
// memcpy 的典型实现:
void* memcpy(void* dst, const void* src, size_t n) {
char* d = (char*)dst;
const char* s = (const char*)src;
while (n--) *d++ = *s++;
return dst;
}
// 如果没有 char* 豁免,memcpy 本身就是 UB!
2
3
4
5
6
7
8
# 6.3 违反 strict aliasing 的静默错误
// 经典陷阱: 通过类型双关 (type punning) 判断浮点符号位
int is_negative(float f) {
int* ip = (int*)&f; // ❌ UB: strict aliasing 违反
return (*ip >> 31) & 1; // 读取 int
}
// 测试:
float val = -3.14f;
printf("%d\n", is_negative(val));
2
3
4
5
6
7
8
9
-O0 输出: 1 (看起来正确——因为 -O0 没有基于 strict aliasing 做优化)
-O2 输出: 0 (错误! 编译器重排了 float 和 int 的读写,
因为"它们不可能是同一块内存")
2
3
正确的做法:
// 方法 1: memcpy (最可移植, 编译器会优化掉 memcpy 调用)
int is_negative_fixed(float f) {
int i;
memcpy(&i, &f, sizeof(i)); // ✅ 合法: memcpy 内部是 char*
return (i >> 31) & 1;
}
// 方法 2: union (C 标准允许, C++ 不允许)
union { float f; int i; } u;
u.f = val;
return (u.i >> 31) & 1; // C: ✅ C++: UB
// 方法 3: signbit() 宏 (最标准)
#include <math.h>
return signbit(f); // C99+
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 6.4 防御: memcpy / union / -fno-strict-aliasing
# 关闭 strict aliasing (治标)
$ gcc -fno-strict-aliasing -O2 test.c
# 代价: 损失大量的指针别名分析优化
# 只对特定文件关闭
# my_legacy_type_pun.c:
#pragma GCC optimize("-fno-strict-aliasing")
2
3
4
5
6
7
最佳实践:
- 永远通过
memcpy做类型双关(零运行时开销,编译器优化后会消除) - 如果必须用指针强转,只在
char*上做 - 避免在同一个编译单元中 mix 大量
void*和不同类型的cast
# 7. Sanitizer 三件套实战
# 7.1 UBSan
UBSan (Undefined Behavior Sanitizer) 在运行时检测 C 标准定义的未定义行为:
$ gcc -fsanitize=undefined -g -O2 test.c -o test
$ ./test
# 或指定具体的 UB 类别:
$ gcc -fsanitize=integer -g test.c -o test # 只看整数相关 UB
$ gcc -fsanitize=null -g test.c -o test # 只看空指针
$ gcc -fsanitize=alignment -g test.c -o test # 只看对齐违规
2
3
4
5
6
UBSan 能检测的 UB 类别:
// 1. 有符号整数溢出
int x = INT_MAX; x += 1;
// → runtime error: signed integer overflow: 2147483647 + 1
// 2. 除零
int x = 42; x /= 0;
// → runtime error: division by zero
// 3. 非法指针类型转换 (alignment mismatches)
char buf[8];
int* p = (int*)&buf[1]; // buf[1] 不是 4 字节对齐
*p = 42;
// → runtime error: store to misaligned address
// 4. 超出范围的移位
int x = 1 << 31; // 32-bit int → UB (溢出)
// → runtime error: left shift of 1 by 31 places cannot be represented
// 5. 类型转换溢出
int x = 300;
char c = x; // -fsanitize=implicit-conversion
// (300 不能放入 signed char)
// 6. 违反 strict aliasing (配合 -fsanitize=undefined)
float f = 3.14f;
int* ip = (int*)&f;
printf("%d\n", *ip);
// (部分编译器/版本支持检测)
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
# 7.2 ASan
ASan 已经在第 18.7.4 节详细介绍过原理。这里聚焦实操:
# 编译时加 ASan
$ gcc -fsanitize=address -g -O2 test.c -o test
# 运行时配置
$ ASAN_OPTIONS=detect_leaks=1:abort_on_error=1 ./test
2
3
4
5
ASan 能检测的内存错误:
// 1. 堆缓冲区溢出
int* p = malloc(100 * sizeof(int));
p[100] = 42; // ← ASan 立刻报错! 写出 RedZone
// 2. 栈缓冲区溢出
char buf[16];
buf[16] = 'x'; // ← ASan 检测! 写到栈上 RedZone
// 3. Use-after-free
free(p);
*p = 42; // ← ASan 检测! 内存已被释放
// 4. Use-after-return
int* get_ptr() {
int x = 42;
return &x; // 需要 ASAN_OPTIONS=detect_stack_use_after_return=1
}
// 5. Double free
free(p); free(p); // ← ASan 检测!
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 7.3 TSan
TSan (Thread Sanitizer) 检测多线程程序中的数据竞争:
$ gcc -fsanitize=thread -g -O2 test.c -o test -lpthread
$ ./test
2
// TSan 检测的数据竞争
int shared = 0;
void* thread1(void* arg) {
shared++; // 写
return NULL;
}
void* thread2(void* arg) {
shared++; // 写 (无同步!) → 数据竞争
return NULL;
}
// TSan 输出:
// WARNING: ThreadSanitizer: data race
// Write of size 4 at 0x... by thread T1
// Previous write of size 4 at 0x... by thread T2
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
TSan 的开销:~5-10 倍 slowdown,~5-10 倍内存。仅测试用。
# 7.4 CI集成
# Makefile 中集成 Sanitizer
SANITIZERS ?= address,undefined
ifeq ($(SANITIZERS),all)
CFLAGS += -fsanitize=address,undefined,thread -fno-omit-frame-pointer
else ifeq ($(SANITIZERS),ubsan)
CFLAGS += -fsanitize=undefined
else ifeq ($(SANITIZERS),asan)
CFLAGS += -fsanitize=address
endif
test_ubsan:
$(MAKE) SANITIZERS=ubsan CFLAGS_EXTRA=-g -O1
./test_suite
test_asan:
$(MAKE) SANITIZERS=asan CFLAGS_EXTRA=-g -O1
./test_suite
test_tsan:
$(MAKE) SANITIZERS=thread CFLAGS_EXTRA=-g -O1
./test_thread_suite
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
CI 集成策略:
# .gitlab-ci.yml
sanitize:
stage: test
parallel:
matrix:
- SANITIZER: [ubsan, asan, tsan]
script:
- make test_$SANITIZER
artifacts:
when: on_failure
paths:
- sanitizer_logs/
2
3
4
5
6
7
8
9
10
11
12
注意事项:
- ASan 和 TSan 不能同时启用(互斥)
- Sanitizer 版本不要跑在生产环境(性能和内存开销)
- 使用
-fno-omit-frame-pointer得到更好的栈回溯 - Sanitizer 发现的每个问题都必须修复——不要加 suppress
# 8. 综合案例串讲
# 8.1 案例真相揭晓
回到第 1 章计费系统死循环的八个疑问,逐条作答:
| 疑问 | 答案 |
|---|---|
| ① UB 是什么?编译器能做什么? | 第 2/3 章:标准完全沉默——编译器可以删代码/改值/不崩 |
| ② 有符号溢出是 UB,怎么被利用? | 第 4.2:编译器假设"溢出不会发生"→删除依赖此假设可达的代码 |
| ③ 空指针解引用后 null 检查被删? | 第 5.2:解引用那一刻编译器假设 p≠NULL → 后续检查恒假→删除 |
| ④ strict aliasing 为什么 char* 例外? | 第 6 章:否则 memcpy 本身就是 UB;类型双关必须通过 memcpy 或 union(C) |
| ⑤ UBSan 检测什么? | 第 7.1:有符号溢出/除零/非法移位/类型对齐/越界转换 |
| ⑥ ASan 检测什么? | 第 7.2:堆/栈越界、use-after-free、double free、内存泄漏 |
| ⑦ TSan 检测什么? | 第 7.3:多线程数据竞争、死锁 |
| ⑧ 怎么在 CI 中集成? | 第 7.4:matrix 并行跑 ubsan/asan/tsan,禁止 suppress |
第 1 章案例的完整根因链条:
main:
val = 0 → val += 400 × 10 → val ∈ [0, 3600]
GCC VRP pass:
所有调用 is_timeout(val) 的参数 val ≥ 0
→ is_timeout 内部 if (elapsed_ms < 0) 不可达
→ DCE: 删除 if 分支
结果:
is_timeout 只剩下 if (elapsed_ms > 30000)
→ 但 val ≤ 3600 < 30000 → 永远返回 0
循环:
for (int i = 0; i < 10; i++) ...
但! i 和 val 的 i 关系不直接——循环依赖的是 i<10
但编译器的优化可能把循环边界改成"当 val > 30000"
→ 如果 val 永远 ≤ 3600 → 条件永远不满足
→ gcc 7.5 的行为: 循环 10 次 OK
→ gcc 12.2 的行为: 更激进的推导 + 可能把循环改成 do-while 或死循环
(当 is_timeout 被优化成永远返回 0, 而 break 条件 is_timeout(val)==1 永远为假)
实际上程序的 UB 点是: 循环永远不停 → i 最终溢出 → UB (有符号整数溢出)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
防御措施:
-fsanitize=undefined编译跑测试- 不要信任编译器的值范围推理来"保护你的防御代码"
- 防御代码放在调用方而非被调用方
- 使用
-fno-strict-overflow或-fwrapv如果性能不是绝对优先
# 8.2 UB 杀伤力排行榜
| 排名 | UB 类型 | 危险程度 | 检测难度 | 典型后果 |
|---|---|---|---|---|
| 🔴 1 | Use-after-free | ⭐⭐⭐⭐⭐ | 极难 (偶发) | 静默数据损坏、安全漏洞 |
| 🔴 2 | 缓冲区溢出 | ⭐⭐⭐⭐⭐ | 中 (ASan) | 栈破坏、代码注入 |
| 🔴 3 | 有符号整数溢出 | ⭐⭐⭐⭐ | 中 (UBSan) | 安全代码被删 |
| 🔴 4 | 空指针解引用 | ⭐⭐⭐⭐ | 中 | 检查代码被删 |
| 🟡 5 | Double free | ⭐⭐⭐ | 中 (ASan) | 堆损坏 |
| 🟡 6 | Strict aliasing 违反 | ⭐⭐⭐ | 难 | 值被"忘记" |
| 🟡 7 | 移位超出位宽 | ⭐⭐ | 低 (UBSan) | 结果不确定 |
| 🟢 8 | 函数缺少返回值 | ⭐⭐ | 低 | 垃圾返回值 |
# 8.3 面试高频问题清单
1. 什么是 UB?和实现定义/未指定行为有什么区别?
UB = 标准什么都没说,编译器可以做任何事(包括删除代码、输出错误值、不崩溃但运行错误)。实现定义 = 编译器必须从几种可能中选一种并文档化(如 sizeof(int))。未指定 = 编译器在几种可能中随意选,不需要文档化(如函数参数求值顺序)。详见第 3 章。
2. 有符号整数溢出为什么是 UB?这和编译器优化有什么关系?
C 标准将溢出定为 UB 是为了给编译器优化自由。编译器可以假设"不会溢出"→ 推导出
i+1 > i(当 i≥0)、循环必定终止、安全检查永远不触发 → 然后删除这些"冗余"代码。详见第 4.2 节。
3. 为什么先用了空指针再检查 null,检查会被删除?
解引用
*p那一刻,编译器已经假设p ≠ NULL(否则就是 UB)。因此后续的if (p == NULL)恒为假 → 整个 if 分支被优化删除。这是 UB 的"时间回溯"效应。详见第 5.2 节。
4. strict aliasing 规则是什么?为什么 char* 是例外?
不同类型的指针不能指向同一块内存(编译器假设它们不别名,独立优化)。
char*是唯一豁免——因为memcpy内部通过char*逐字节拷贝,没有这个豁免 memcpy 本身就是 UB。详见第 6 章。
5. UBSan/ASan/TSan 各检测什么?能不能一起用?
UBSan:整数溢出、除零、非法移位、对齐违规等。ASan:堆/栈越界、use-after-free、double free。TSan:多线程数据竞争。ASan 和 TSan 不能同时启用(互斥),UBSan 可以与 ASan 一起用。详见第 7 章。
6. -fwrapv 和 -fno-strict-aliasing 分别是做什么的?
-fwrapv:让有符号整数溢出定义为回绕(像无符号一样),防止编译器基于"不会溢出"做优化。-fno-strict-aliasing:关闭 strict aliasing 规则,允许通过不同类型指针访问同一块内存。
7. 为什么" -O0 正常、-O2 崩"是 UB 的标志?
因为 UB 的行为不固定——
-O0可能碰巧表现为"程序员期望的行为",-O2编译器做了更激进的假设和优化,暴露了 UB 的真实后果。任何优化级别敏感的问题,首先怀疑 UB。
8. 什么是 UB 的"时间回溯"效应?
一旦 UB 在某处发生,整个程序的"之前"和"之后"都是未定义的——编译器可以重排、删除 UB 前后的任意代码。这就是为什么先解引用空指针再检查 null 时,检查会被删除——因为 UB 发生后,编译器不再受时序约束。
9. CI 中怎么集成 Sanitizer?
UBSan 写入每个 CI 构建的测试步骤。ASan 写入功能测试。TSan 写入并发测试。使用 matrix 策略并行跑,每个问题都必须修复不得 suppress。详见第 7.4 节。
10. 有符号溢出和编译器删除循环终止条件的关系?
如果
for (int i = n; i <= n+10; i++),编译器假设 i 从 n 到 n+10 不会溢出 → 从而推断出循环必定在有限步终止。即便运行时 n = INT_MAX 导致溢出,编译器优化的代码已经不是"你的循环"了——它可能是死循环,也可能直接跳过。详见第 4 章。
# 8.4 防御型 C 编码速查卡
永远别写的代码:
// ❌ 有符号整数溢出
int x = INT_MAX; x += 1;
// ❌ 先解引用再检查空指针
*p = 42; if (p == NULL) return;
// ❌ strict aliasing 违反
float f = 3.14; int x = *(int*)&f;
// ❌ Use-after-free / double free / 越界
free(p); *p = 42;
// ❌ 返回局部变量地址
int* f() { int x; return &x; }
// ❌ 无符号回绕检查用有符号写法
if (x + y > INT_MAX) // 溢出发生在 if 之前!
// ❌ 移位超出位宽
int x = 1 << (sizeof(int) * 8);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
正确的替代写法:
// ✅ 有符号溢出检查
int x, y;
if (__builtin_add_overflow(x, y, &result)) { ... }
// ✅ 先检查再用
if (!p) return; *p = 42;
// ✅ memcpy 做类型双关
float f = 3.14; int i; memcpy(&i, &f, sizeof(i));
// ✅ 释放后立即置 NULL
free(p); p = NULL; // 虽然不防 double free,但防 use-after-free 误用
// ✅ 动态分配返回大对象
char* f() { char* p = malloc(N); return p; } // 调用者负责 free
// ✅ 无符号溢出检查
if (UINT_MAX - y < x) { /* 溢出 */ }
else { z = x + y; }
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Sanitizer CI 集成模板:
# 每个 CI build 都跑
gcc -fsanitize=undefined -g -O1 -o test test.c && ./test
gcc -fsanitize=address -g -O1 -o test test.c && ./test
# 并发测试单独跑
gcc -fsanitize=thread -g -O1 -o test test.c && ./test
2
3
4
5
6
下一篇:20.GDB调试底层原理 —— 我们已经知道"写了 UB 代码会怎样被编译器'惩罚'",下一步进入调试:
ptrace系统调用怎么让 GDB 接管另一个进程?断点指令int3是怎么插入的?watchpoint用了什么硬件机制?core dump 文件里到底存了什么?