UB未定义行为图鉴
# 59.UB未定义行为图鉴
# 目录介绍
- 1. 案例引入
- 2. 架构概览
- 3. 有符号整数溢出——最经典的 UB
- 4. 严格别名(strict aliasing)——类型的幽灵边界
- 5. 生命周期外的访问——use-after-free 与 use-after-scope
- 6. 空指针解引用——看似简单、实则狡猾
- 7. 多线程中的 data race——未定义的双写
- 8. 编译器如何利用 UB——把不可能变成优化机会
- 9. UB 的检测与防御
- 10. 综合案例串讲
# 1. 案例引入
# 1.1 有符号溢出——循环永远不会结束还是直接跳过?
某游戏引擎的帧计数器在连续运行 23 天后——游戏突然 freeze:
// ====== 事故代码 V1:有符号溢出——时间炸弹 ======
int frame_count = 0;
while (frame_count >= 0) { // ① 期望:frame_count 永远递增、永远 ≥ 0
process_frame();
frame_count++; // ② 23 天后——INT_MAX → INT_MIN(溢出!)
} // ③ INT_MIN < 0 → 循环终止?还是编译器优化成死循环?
2
3
4
5
6
两种编译结果——同一个 UB、截然不同的行为:
-O0(无优化):frame_count INT_MAX → INT_MIN → < 0 → 循环终止
→ 游戏退出主循环——表现为 freeze
-O2(有优化):编译器推理——
「frame_count 是 int——有符号溢出是 UB——UB 不可能发生」
「既然 UB 不可能——frame_count 永远不会溢出」
「既然永远不会溢出——frame_count++ 永远不会让 frame_count 变负」
「frame_count >= 0 永远为 true」
→ 编译器把 while 优化成死循环——或直接优化掉退出条件
2
3
4
5
6
7
8
9
# 1.2 strict aliasing——两个不同类型的指针指向同一块内存→编译器优化掉了你的写入
// ====== 事故代码 V2:strict aliasing 违例 ======
int process_buffer() {
float f = 3.14f;
int* p = reinterpret_cast<int*>(&f); // ① 把 float 当 int 看——UB
*p = 42; // ② 通过 int* 写入 float
return static_cast<int>(f); // ③ 编译器(-O2):f 没有被 int 写入!
// 因为 strict aliasing 规则——int* 不能合法修改 float
// → 编译器假设 f 还是 3.14——返回 3
}
// process_buffer() 在 -O2 下返回 3——不是 42!
// 编译器的推理:float f 只能被 float* 或 char* 修改——int* 不是合法类型
// → *p = 42 不影响 f——f 仍然是 3.14
2
3
4
5
6
7
8
9
10
11
12
13
# 1.3 七个待解疑问
① UB 到底是什么?分类有哪些?实现定义行为算 UB 吗? → 第 2 章
② 有符号溢出为什么是 UB?编译器怎么利用它做「优化」? → 第 3 章
③ strict aliasing 是什么?reinterpret_cast 为什么是 UB 之源? → 第 4 章
④ use-after-free 和返回局部引用是什么 UB?编译器能检测吗? → 第 5 章
⑤ 空指针解引用一定 SIGSEGV 吗?编译器能因此删除后续代码吗? → 第 6 章
⑥ data race 和正常的原子操作之间有什么区别? → 第 7 章
⑦ UBSan / ASan / TSan 分别检测什么?能覆盖所有 UB 吗? → 第 9 章
2
3
4
5
6
7
# 2. 架构概览
# 2.1 UB 的三级分类
① 未定义行为 (UB — Undefined Behavior):
标准没有规定任何行为——编译器有权做任何事
编译器不报错、运行时可能崩、可能不崩、可能删你的检查代码
例子:有符号溢出、strict aliasing 违例、use-after-free、data race
② 未指定行为 (Unspecified Behavior):
标准规定了有限几种可能行为——实现在其中选一个——不需要文档记录
例子:函数参数求值顺序(C++17 之前)、new 的内存对齐
③ 实现定义行为 (Implementation-Defined Behavior):
标准要求各实现选择一种行为——并且必须文档记录
例子:sizeof(int)、char 是有符号还是无符号、位域的对齐方向
→ 实现定义行为最安全——编译器文档明确说明了行为
→ 未指定行为次安全——行为在有限集合中——但不能依赖
→ UB 最危险——编译器可能做任何事——包括「时间旅行」
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 2.2 UB 不是随机结果
常见的误解:「UB 意味着结果不确定——可能对、可能错」
UB 的真正含义:
「编译器可以假设 UB 永远不会发生——并基于这个假设优化代码」
→ 这不是「随机结果」——这是「编译器做了不符合你预期的优化」
为什么编译器能这么做:
标准说「有符号溢出是 UB」→ 编译器把「不会溢出」作为推理前提
→ 任何基于「可能溢出」的代码——在编译器看来是多死码
→ 编译器名正言顺地删掉这些代码——因为标准允许
2
3
4
5
6
7
8
9
10
# 3. 有符号整数溢出——最经典的 UB
# 3.1 标准规定——有符号溢出 = UB、无符号溢出 = 回绕
int a = INT_MAX; // 2147483647
a++; // UB — 有符号溢出——编译器可以假设这不发生
unsigned b = UINT_MAX; // 4294967295
b++; // ✅ 合法 — 无符号溢出 = 回绕到 0
2
3
4
5
为什么标准做这个区分:有符号溢出的硬件行为在不同 CPU 上不一致——有的回绕、有的饱和、有的抛异常。标准选择「不规定」——编译器可以自由优化。
# 3.2 编译器如何利用这个 UB——把循环「优化」成无限循环或直接删除
案例 1.1 的细节展开:
for (int i = 0; i <= n; i++) { ... }
// 编译器推理:i 是 int——不会溢出(溢出了是 UB——不可能)
// → i 从 0 开始——永远递增——同时 i 永远不会超过 INT_MAX
// → 如果 n == INT_MAX——这个循环必须永远执行(因为 i 永远 ≤ n)
// → 编译器:把这个循环优化成死循环
// 副作用:在 n == INT_MAX 时运行时间 → ∞
// 和不发生的情况对比:
for (unsigned i = 0; i <= n; i++) { ... }
// 无符号溢出 = 合法回绕 → 循环在 n 轮后终止(n 可能为 UINT_MAX 时是死循环——
// 但这是合法的——不依赖 UB 假设)
2
3
4
5
6
7
8
9
10
11
# 3.3 在 -O2 下失效的溢出检查——if (a + b < a) 被优化为 if (false)
// ❌ 看似合理的溢出检测——在有符号上被 -O2 删掉了
bool safe_add(int a, int b) {
if (a + b < a) // 检查溢出?——编译器:有符号溢出是 UB——不可能发生
return false; // → 这段代码被优化掉——永远 reach 不了
return true;
}
// -O2 优化的等价代码:
bool safe_add(int a, int b) {
return true; // 编译器:a+b 不可能溢出——< a 永远为 false
}
// ✅ 正确的溢出检测——在相加之前检查——不触发溢出
bool safe_add(int a, int b) {
if (a > 0 && b > INT_MAX - a) return false;
if (a < 0 && b < INT_MIN - a) return false;
return true;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 4. 严格别名(strict aliasing)——类型的幽灵边界
# 4.1 规则——只能通过「合法类型」的指针访问对象
strict aliasing 规则(简化):
一个对象只能通过以下类型的指针/引用访问:
① 对象的「动态类型」(创建时的类型)本身
② const/volatile/unsigned/signed 限定符的变体
③ char / unsigned char / std::byte(万能类型——可以读任何对象的字节)
④ 基类类型(通过基类指针访问派生类)
违反规则 → UB —— 编译器假设不同类别的指针不重叠——可以做激进优化
2
3
4
5
6
7
8
# 4.2 违反 strict aliasing 的经典案例——float 和 int 的 reinterpret_cast 访问
案例 1.2 的完整分析——为什么 reinterpret_cast 是 UB 之源:
float f = 3.14f;
int* p = reinterpret_cast<int*>(&f); // ① 类型双关 (type punning) — UB
*p = 42; // ② 通过 int* 写入 float 的内存
2
3
合法的替代方案:
// ✅ 方法 1 — std::bit_cast (C++20)
float f = 3.14f;
int i = std::bit_cast<int>(f); // 把 float 的位表示当 int 看——合法——编译器内联
// ✅ 方法 2 — memcpy(编译器理解为合法)
float f = 3.14f;
int i;
std::memcpy(&i, &f, sizeof(i)); // 合法的 type punning——编译器优化为直接赋值
// ✅ 方法 3 — 用 unsigned char* 逐字节读(万能类型)
unsigned char* bytes = reinterpret_cast<unsigned char*>(&f);
int i = bytes[0] | (bytes[1] << 8) | (bytes[2] << 16) | (bytes[3] << 24);
2
3
4
5
6
7
8
9
10
11
12
# 4.3 编译器优化——两个不同类型的指针被假设为不重叠→读写重排
int foo(float* fp, int* ip) {
*fp = 1.0f; // ① 写入 float
*ip = 2; // ② 写入 int — 编译器假设 ip 和 fp 不重叠
return *fp; // ③ 读 float — 编译器:fp 没被 ip 修改 → 返回 1
}
// 在 -O2 下——即使 fp 和 ip 指向同一块内存——函数也返回 1——不是 2 的位表示!
2
3
4
5
6
# 4.4 合法绕过——char* / std::byte* / std::bit_cast / memcpy
已在 4.2 展开——核心:永远不要用 reinterpret_cast 在两个不相关的类型之间做指针转换——用 memcpy 或 std::bit_cast。
# 5. 生命周期外的访问——use-after-free 与 use-after-scope
# 5.1 返回局部变量的引用——栈上的幽灵
const int& get_value() {
int local = 42;
return local; // ❌ 返回局部变量的引用——函数返回后 local 被析构
}
// get_value() 返回一个悬挂引用——访问它 = UB
// 在 -O2 下——编译器可能内联——可能不崩——更难排查
2
3
4
5
6
# 5.2 use-after-free——delete 后的指针仍然是原来的地址但内存已回收
auto* p = new int(42);
delete p;
// p 仍然是 0x12345678——但内存已还给分配器
*p = 100; // ❌ UB — 写入已释放的内存 — ASan 检测得到——但 -O2 可能静默通过
// 更隐蔽的场景——分配器复用了同一地址
auto* q = new int(99); // q 恰好 = 0x12345678 —— 和旧 p 相同地址
*p = 100; // 现在 *q 也被改成 100——因为 p 和 q 指向同一地址
2
3
4
5
6
7
8
# 5.3 在构造/析构期间调用虚函数——vptr 逐步切换的窗口
第 26 篇已有详细展开——这里从 UB 角度补充:
struct Base {
Base() { virtual_init(); } // ❌ 构造中调虚函数——此时 vptr 指向 Base 的 vtable
virtual void virtual_init() = 0;
};
struct Derived : Base {
void virtual_init() override { /* 永远不会被 Base 的构造调用 */ }
};
2
3
4
5
6
7
# 6. 空指针解引用——看似简单、实则狡猾
# 6.1 nullptr 解引用 = UB——但编译器可能把「后面的代码」也删了
void process(int* p) {
int value = *p; // ① 解引用——如果 p == nullptr → UB
if (p == nullptr) { // ② 后续检查——编译器:既然 ① 已经解引了——p 不可能是 null
return; // → 这段代码被优化掉——因为编译器假设 UB 不会发生
}
do_work(value);
}
// -O2 优化后等价于:
void process(int* p) {
int value = *p;
do_work(value); // 空指针检查被删除——因为「UB 不可能发生」
}
2
3
4
5
6
7
8
9
10
11
12
13
# 6.2 this == nullptr 调用非虚成员函数——这个常见的「技巧」是 UB
struct Widget {
void do_something() {
if (this == nullptr) return; // ❌ 这段代码本身就是 UB
// 因为在一个 null 对象上调非静态成员函数——this 是 null——UB
}
};
Widget* w = nullptr;
w->do_something(); // ❌ UB — this == nullptr
2
3
4
5
6
7
8
9
# 7. 多线程中的 data race——未定义的双写
# 7.1 什么是 data race——两个线程同时访问、至少一个写、没有同步
int counter = 0;
// 线程 A 线程 B
counter++; counter++;
// ❌ data race — 两个线程同时写——没有 atomic、没有 mutex
// 这不是「可能读到错误值」——这是「整个程序的 UB」
2
3
4
5
6
# 7.2 data race 的后果——不仅是「读到错误值」——包括撕裂读、编译器重排
data race 的可能后果:
① 撕裂读——写入 8 字节(在 32 位平台上可能需要两次指令)——读到一半新一半旧
② 编译器重排——data race 让编译器假设「变量不被其他线程修改」——
把循环中的重复读优化为一次读——修改永远不会被看到
③ 时间旅行——UB 让编译器做一些不符合直觉的时间序重排
2
3
4
5
# 8. 编译器如何利用 UB——把不可能变成优化机会
# 8.1 编译器基于「UB 不会发生」的假设做优化——删除对 UB 路径的检查
前文所有案例的共同模式——编译器把「对 UB 的防御代码」优化掉:
// 源版——程序员想防御溢出
int* p = new int[n];
if (n < 0) return nullptr; // ① 负长度检查
// -O2 优化后——编译器推理:
// new int[n] —— 如果 n < 0 → UB
// UB 不可能发生 —— n >= 0
// → ① 的检查永远为 false → 删除检查代码
2
3
4
5
6
7
8
# 8.2 真实案例——Linux 内核的 NULL pointer 检查被 GCC 优化掉了
// 2009 年 Linux 内核的真实 bug
struct sock *sk = tun->sk;
if (!tun) return; // NULL 检查
// tun 在上一行已经被解引用了 (tun->sk)
// → 编译器:tun 不是 NULL(因为已经解引了——NULL 解引 = UB——不可能)
// → 删除 if (!tun) 检查
// → 攻击者利用这个「被优化掉的检查」——触发内核 NULL pointer dereference
2
3
4
5
6
7
# 8.3 编译器的时间旅行——UB 让后续代码「不可能」被执行
void time_travel(int* p) {
*p = 42;
if (p == nullptr) { // ① 编译器:*p 已经执行——p 不可能是 null
std::println("p is null"); // ② 这段代码被「时间旅行」删除
*p = 0; // ③ 同上
}
}
// 编译器的推理从「未来的 UB」回溯到「现在的假设」——
// 因为后面的代码可能导致 UB——编译器假设它永远不执行——删掉前面的检查
2
3
4
5
6
7
8
9
# 9. UB 的检测与防御
# 9.1 UBSan——GCC/Clang 的运行时 UB 检测器
# 编译时加 UBSan
g++ -fsanitize=undefined main.cpp -o app
# 检测的 UB 类型:
# 有符号溢出 (-fsanitize=signed-integer-overflow)
# 不正确的指针运算 (-fsanitize=pointer-overflow)
# nullptr 解引用 (-fsanitize=null)
# 除零 (-fsanitize=integer-divide-by-zero)
# misaligned 指针 (-fsanitize=alignment)
# strict aliasing 检查时需要额外标志
2
3
4
5
6
7
8
9
10
# 9.2 AddressSanitizer (ASan)——检测 use-after-free、缓冲区溢出
g++ -fsanitize=address main.cpp -o app
# 检测:use-after-free、heap-buffer-overflow、stack-buffer-overflow、memory leaks
# 开销:2-3× 慢、2-3× 内存
2
3
4
# 9.3 ThreadSanitizer (TSan)——检测 data race
g++ -fsanitize=thread main.cpp -o app
# 检测:data race——任何非同步的并发访问
# 开销:5-15× 慢、5-10× 内存
2
3
4
# 9.4 -Wnull-dereference / -Wstrict-aliasing——编译期静态分析
g++ -Wnull-dereference -Wstrict-aliasing=2 main.cpp
# 编译期警告——零运行时开销——覆盖有限的场景
2
# 9.5 为什么 sanitizer 不能在生产环境跑——2-3× 慢、内存 2-3×
ASan:影子内存技术——每个 8 字节需要 1 字节的「影子」标记——额外内存 1/8
UBSan:每个可能 UB 的算术操作——插入运行时检查
TSan:每个内存访问——插入检查——开销最大
结论:sanitizer 是测试/调试工具——不适合生产——在 CI 中跑 sanitizer 构建是推荐实践
2
3
4
5
# 10. 综合案例串讲
# 10.1 案例真相揭晓
| # | 疑问 | 答案 |
|---|---|---|
| ① | UB 分类? | 第 2 章:未定义/未指定/实现定义——UB 最危险、编译器可做任何假设 |
| ② | 有符号溢出? | 第 3 章:UB ——编译器可能删除溢出检查、优化循环为死循环 |
| ③ | strict aliasing? | 第 4 章:只能用合法类型指针——绕过用 memcpy/bit_cast/unsigned char* |
| ④ | 生命周期 UB? | 第 5 章:返回局部引用、use-after-free、构造/析构中调虚函数 |
| ⑤ | 空指针? | 第 6 章:解引用后编译器可删除后续 null 检查——this==nullptr 是 UB |
| ⑥ | data race? | 第 7 章:data race = 整个程序的 UB——不仅是读到错误值 |
| ⑦ | 检测工具? | 第 9 章:UBSan/ASan/TSan 覆盖三类主要 UB——不适合生产 |
案例①修复——有符号溢出:用 unsigned 做计数器(溢出=合法回绕)——或提前检查 INT_MAX - count。
案例②修复——strict aliasing:用 std::bit_cast<float, int>(C++20)或 memcpy——避免 reinterpret_cast 做类型双关。
# 10.2 UB 的破坏力——从静默数据损坏到编译器删除关键检查
UB 不是你写了「错代码」——是你告诉了编译器「我永远不会做某件事」——
然后编译器相信了你——在你不做这件事的假设上做了优化——
你在运行期做了这件事——编译器已经把反应机制优化掉了。
UB 的本质:
不是「程序做了错误的事」
是「程序和编译器做了不同的假设——严重不一致——行为不可预测」
2
3
4
5
6
7
# 10.3 设计哲学回扣
哲学 1:UB 是 C++ 性能的代价——把「不可能」编码为标准让编译器大胆优化
标准说「有符号溢出是 UB」——不是委员会不知道该怎么做——是委员会选择不给编译器约束——让编译器假设它不会发生——然后用这个假设做优化。每一个 UB 都是编译器优化的「许可牌」。 这和 Rust 的设计形成两极——Rust 消灭了所有的 UB(在 safe Rust 中)——但也牺牲了某些优化自由度。
哲学 2:UB 不是 bug——是「你发给编译器的自由宣言」
当你写 int x = a + b; ——你隐含地告诉编译器:「我保证 a+b 不会溢出」。编译器相信了你——基于这个保证做了优化。如果你食言了——编译器不会惩罚你——它只是按照你的保证继续执行——但结果是基于「a+b 不溢出」的逻辑——和你的预期完全不一致。UB 不是编译器在「作恶」——是你在「给编译器传递了错误的保证」。
哲学 3:UB 的危险等级越高——它和「正常运行」的代码距离越近
if (a + b < a) 在 -O0 下正常工作——在 -O2 下被优化掉。同样的源代码——不同的优化级别——不同的行为。UB 的最恐怖性质:它在 DEBUG 模式下不暴露、在 RELEASE 模式下产生完全不同的行为。 而 sanitizer 的代价又让它不适合生产——这就是 UB 为什么是 C++ 最隐蔽的敌人。
哲学 4:检测 UB 的最佳时机是昨天——每推迟一天就多一天的风险积累
Sanitizer 在 CI 中跑——把 UB 在进入生产之前捕获。-Wstrict-aliasing 在编译期给出警告。UB 检测不是「优化性能」——是「防止优化器误解你的意图」。 在 C++ 中——你的代码不是直接翻译成机器指令——是被优化器「解读」后再翻译成机器指令。UB 就是「优化器误解了你的代码」的根源。
# 10.4 速查表合集
UB 分级速查:
| 级别 | 示例 | 检测工具 |
|---|---|---|
| 核心语言 UB | 有符号溢出、strict aliasing、use-after-free、空指针解引用 | UBSan / ASan |
| 库 UB | 在已析构的容器上操作、越过 end 的迭代器解引用 | ASan / libstdc++ debug mode |
| 并发 UB | data race、在已 join 的线程上操作 | TSan |
| 实现定义 | sizeof(int)、char 的符号 | 文档——不检测 |
UB 防御工具链:
| 工具 | 检测范围 | 开销 | 适用 |
|---|---|---|---|
-Wnull-dereference -Wstrict-aliasing | 编译期静态分析 | 零 | CI 编译 |
| UBSan | 有符号溢出、对齐、nullptr | 1.5-2× | CI 测试 |
| ASan | use-after-free、缓冲区溢出 | 2-3× | CI 测试 |
| TSan | data race | 5-15× | 专门 data race 测试 |
-fno-strict-aliasing | 关闭 strict aliasing | 轻微性能下降 | 遗留代码 |
合法 type punning 速查:
| 方法 | C++ 版本 | 说明 |
|---|---|---|
std::bit_cast | C++20 | 最推荐——编译期内联 |
memcpy | 所有版本 | 编译器优化为直接赋值 |
unsigned char* 逐字节读 | 所有版本 | 万能类型——合法 |
reinterpret_cast | ❌ | 不合法——不要用 |
本篇小结:UB 是 C++ 性能的代价——标准用「不规定」给编译器最大的优化自由。每种 UB 都是编译器可以做激进优化的「许可牌」——有符号溢出让循环可以被优化掉、strict aliasing 让不同类型的指针被假设为不重叠、空指针解引用让后续的 null 检查被删除。UBSan/ASan/TSan 是检测 UB 的三把武器——在 CI 中跑 sanitizer 构建是防御 UB 的最有效手段。理解 UB 不是为了「避免崩溃」——是为了「避免编译器基于错误假设做优化」。
下一篇:59 篇——UB 图鉴——C++ 最深的地狱——完全照亮。下一篇进入终章 60.C++设计哲学回望——零开销抽象、不为不用的付费、值语义 vs 引用语义——回顾这本 60 篇专栏贯穿的核心设计理念。