编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • 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语言入门精通

  • Cpp入门到精通

    • README
    • 入门教程

    • 综合案例

    • 专栏博客

      • README
      • 进程地址空间布局
      • 对象内存布局原理
      • 引用与指针本质
      • this指针与成员函数
      • 虚函数表深度剖析
      • 多重继承内存模型
      • 内存对齐与缓存行
      • 内存分配器演进史
      • 五大值类别详解
      • 右值引用与移动语义
      • 完美转发与引用折叠
      • 类型推导三大规则
      • 类型转换与隐式构造
      • const与volatile真相
      • RTTI与dynamic_cast
      • 类型擦除技术原理
      • 模板实例化机制
      • 模板特化与偏特化
      • SFINAE与enable_if
      • 可变参数模板原理
      • constexpr编译期计算
      • Concepts深度剖析
      • 元编程模板技巧
      • Modules模块化设计
      • RAII的设计哲学
      • 对象构造与析构
      • 拷贝与移动控制
      • unique_ptr原理剖析
      • shared_ptr底层剖析
      • weak_ptr与this增强
      • 五种存储期管理
      • vector扩容真相
      • deque分段连续设计
      • list与forward_list
      • 关联容器红黑树
      • 哈希容器深度剖析
      • 迭代器五大类别
      • STL算法设计哲学
      • Allocator分配器机制
      • C++内存模型基石
      • 六大内存序详解
      • atomic原子操作原理
      • mutex与条件变量
      • thread与jthread机制
      • 异步编程future家族
      • 无锁数据结构设计
      • 协程coroutine原理
      • 翻译单元与预处理
      • 编译期符号生成
      • 链接器工作原理
      • ODR规则与陷阱
      • 动态库与符号可见性
      • C++ ABI兼容性
      • LTO与PGO优化
      • 异常机制底层原理
      • Ranges革命与管道
      • format与print体系
      • UB未定义行为图鉴
        • 1. 案例引入
          • 1.1 有符号溢出——循环永远不会结束还是直接跳过?
          • 1.2 strict aliasing——两个不同类型的指针指向同一块内存→编译器优化掉了你的写入
          • 1.3 七个待解疑问
        • 2. 架构概览
          • 2.1 UB 的三级分类
          • 2.2 UB 不是随机结果
        • 3. 有符号整数溢出——最经典的 UB
          • 3.1 标准规定——有符号溢出 = UB、无符号溢出 = 回绕
          • 3.2 编译器如何利用这个 UB——把循环「优化」成无限循环或直接删除
          • 3.3 在 -O2 下失效的溢出检查——if (a + b < a) 被优化为 if (false)
        • 4. 严格别名(strict aliasing)——类型的幽灵边界
          • 4.1 规则——只能通过「合法类型」的指针访问对象
          • 4.2 违反 strict aliasing 的经典案例——float 和 int 的 reinterpret_cast 访问
          • 4.3 编译器优化——两个不同类型的指针被假设为不重叠→读写重排
          • 4.4 合法绕过——char / std::byte / std::bit_cast / memcpy
        • 5. 生命周期外的访问——use-after-free 与 use-after-scope
          • 5.1 返回局部变量的引用——栈上的幽灵
          • 5.2 use-after-free——delete 后的指针仍然是原来的地址但内存已回收
          • 5.3 在构造/析构期间调用虚函数——vptr 逐步切换的窗口
        • 6. 空指针解引用——看似简单、实则狡猾
          • 6.1 nullptr 解引用 = UB——但编译器可能把「后面的代码」也删了
          • 6.2 this == nullptr 调用非虚成员函数——这个常见的「技巧」是 UB
        • 7. 多线程中的 data race——未定义的双写
          • 7.1 什么是 data race——两个线程同时访问、至少一个写、没有同步
          • 7.2 data race 的后果——不仅是「读到错误值」——包括撕裂读、编译器重排
        • 8. 编译器如何利用 UB——把不可能变成优化机会
          • 8.1 编译器基于「UB 不会发生」的假设做优化——删除对 UB 路径的检查
          • 8.2 真实案例——Linux 内核的 NULL pointer 检查被 GCC 优化掉了
          • 8.3 编译器的时间旅行——UB 让后续代码「不可能」被执行
        • 9. UB 的检测与防御
          • 9.1 UBSan——GCC/Clang 的运行时 UB 检测器
          • 9.2 AddressSanitizer (ASan)——检测 use-after-free、缓冲区溢出
          • 9.3 ThreadSanitizer (TSan)——检测 data race
          • 9.4 -Wnull-dereference / -Wstrict-aliasing——编译期静态分析
          • 9.5 为什么 sanitizer 不能在生产环境跑——2-3× 慢、内存 2-3×
        • 10. 综合案例串讲
          • 10.1 案例真相揭晓
          • 10.2 UB 的破坏力——从静默数据损坏到编译器删除关键检查
          • 10.3 设计哲学回扣
          • 10.4 速查表合集
      • C++设计哲学回望
      • 写作模板
    • 开发技巧

  • Java入门精通

  • Go入门到精通

  • JavaScript入门

  • CodeX
  • Cpp入门到精通
  • 专栏博客
杨充
2026-06-06
目录

UB未定义行为图鉴

# 59.UB未定义行为图鉴

# 目录介绍

  • 1. 案例引入
    • 1.1 有符号溢出——循环永远不会结束还是直接跳过?
    • 1.2 strict aliasing——两个不同类型的指针指向同一块内存→编译器优化掉了你的写入
    • 1.3 七个待解疑问
  • 2. 架构概览
    • 2.1 UB 的三级分类
    • 2.2 UB 不是随机结果
  • 3. 有符号整数溢出——最经典的 UB
    • 3.1 标准规定——有符号溢出 = UB、无符号溢出 = 回绕
    • 3.2 编译器如何利用这个 UB——把循环「优化」成无限循环或直接删除
    • 3.3 在 -O2 下失效的溢出检查——if (a + b < a) 被优化为 if (false)
  • 4. 严格别名(strict aliasing)——类型的幽灵边界
    • 4.1 规则——只能通过「合法类型」的指针访问对象
    • 4.2 违反 strict aliasing 的经典案例——float 和 int 的 reinterpret_cast 访问
    • 4.3 编译器优化——两个不同类型的指针被假设为不重叠→读写重排
    • 4.4 合法绕过——char* / std::byte* / std::bit_cast / memcpy
  • 5. 生命周期外的访问——use-after-free 与 use-after-scope
    • 5.1 返回局部变量的引用——栈上的幽灵
    • 5.2 use-after-free——delete 后的指针仍然是原来的地址但内存已回收
    • 5.3 在构造/析构期间调用虚函数——vptr 逐步切换的窗口
  • 6. 空指针解引用——看似简单、实则狡猾
    • 6.1 nullptr 解引用 = UB——但编译器可能把「后面的代码」也删了
    • 6.2 this == nullptr 调用非虚成员函数——这个常见的「技巧」是 UB
  • 7. 多线程中的 data race——未定义的双写
    • 7.1 什么是 data race——两个线程同时访问、至少一个写、没有同步
    • 7.2 data race 的后果——不仅是「读到错误值」——包括撕裂读、编译器重排
  • 8. 编译器如何利用 UB——把不可能变成优化机会
    • 8.1 编译器基于「UB 不会发生」的假设做优化——删除对 UB 路径的检查
    • 8.2 真实案例——Linux 内核的 NULL pointer 检查被 GCC 优化掉了
    • 8.3 编译器的时间旅行——UB 让后续代码「不可能」被执行
  • 9. UB 的检测与防御
    • 9.1 UBSan——GCC/Clang 的运行时 UB 检测器
    • 9.2 AddressSanitizer (ASan)——检测 use-after-free、缓冲区溢出
    • 9.3 ThreadSanitizer (TSan)——检测 data race
    • 9.4 -Wnull-dereference / -Wstrict-aliasing——编译期静态分析
    • 9.5 为什么 sanitizer 不能在生产环境跑——2-3× 慢、内存 2-3×
  • 10. 综合案例串讲
    • 10.1 案例真相揭晓
    • 10.2 UB 的破坏力——从静默数据损坏到编译器删除关键检查
    • 10.3 设计哲学回扣
    • 10.4 速查表合集

# 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 → 循环终止?还是编译器优化成死循环?
1
2
3
4
5
6

两种编译结果——同一个 UB、截然不同的行为:

-O0(无优化):frame_count INT_MAX → INT_MIN → &lt; 0 → 循环终止
              → 游戏退出主循环——表现为 freeze

-O2(有优化):编译器推理——
  「frame_count 是 int——有符号溢出是 UB——UB 不可能发生」
  「既然 UB 不可能——frame_count 永远不会溢出」
  「既然永远不会溢出——frame_count++ 永远不会让 frame_count 变负」
  「frame_count >= 0 永远为 true」
  → 编译器把 while 优化成死循环——或直接优化掉退出条件
1
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
1
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 章
1
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 最危险——编译器可能做任何事——包括「时间旅行」
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

# 2.2 UB 不是随机结果

常见的误解:「UB 意味着结果不确定——可能对、可能错」

UB 的真正含义:
  「编译器可以假设 UB 永远不会发生——并基于这个假设优化代码」
  → 这不是「随机结果」——这是「编译器做了不符合你预期的优化」

为什么编译器能这么做:
  标准说「有符号溢出是 UB」→ 编译器把「不会溢出」作为推理前提
  → 任何基于「可能溢出」的代码——在编译器看来是多死码
  → 编译器名正言顺地删掉这些代码——因为标准允许
1
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
1
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 假设)
1
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;
}
1
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 —— 编译器假设不同类别的指针不重叠——可以做激进优化
1
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 的内存
1
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);
1
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 的位表示!
1
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 下——编译器可能内联——可能不崩——更难排查
1
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 指向同一地址
1
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 的构造调用 */ }
};
1
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 不可能发生」
}
1
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
1
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」
1
2
3
4
5
6

# 7.2 data race 的后果——不仅是「读到错误值」——包括撕裂读、编译器重排

data race 的可能后果:
① 撕裂读——写入 8 字节(在 32 位平台上可能需要两次指令)——读到一半新一半旧
② 编译器重排——data race 让编译器假设「变量不被其他线程修改」——
   把循环中的重复读优化为一次读——修改永远不会被看到
③ 时间旅行——UB 让编译器做一些不符合直觉的时间序重排
1
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 → 删除检查代码
1
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
1
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——编译器假设它永远不执行——删掉前面的检查
1
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 检查时需要额外标志
1
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× 内存
1
2
3
4

# 9.3 ThreadSanitizer (TSan)——检测 data race

g++ -fsanitize=thread main.cpp -o app

# 检测:data race——任何非同步的并发访问
# 开销:5-15× 慢、5-10× 内存
1
2
3
4

# 9.4 -Wnull-dereference / -Wstrict-aliasing——编译期静态分析

g++ -Wnull-dereference -Wstrict-aliasing=2 main.cpp
# 编译期警告——零运行时开销——覆盖有限的场景
1
2

# 9.5 为什么 sanitizer 不能在生产环境跑——2-3× 慢、内存 2-3×

ASan:影子内存技术——每个 8 字节需要 1 字节的「影子」标记——额外内存 1/8
UBSan:每个可能 UB 的算术操作——插入运行时检查
TSan:每个内存访问——插入检查——开销最大

结论:sanitizer 是测试/调试工具——不适合生产——在 CI 中跑 sanitizer 构建是推荐实践
1
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 的本质:
  不是「程序做了错误的事」
  是「程序和编译器做了不同的假设——严重不一致——行为不可预测」
1
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 篇专栏贯穿的核心设计理念。

上次更新: 2026/06/10, 11:13:41
format与print体系
C++设计哲学回望

← format与print体系 C++设计哲学回望→

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