全课的终章,讲 C 最危险的词:UB(未定义行为)。它最阴的特质是不报错:标准不再约束编译器,编译器按「它永远不会发生」来优化代码,于是一行 UB 的实际后果可能是「正常工作」(最糟,雷传给了未来)、「读到垃圾」、「崩」、或「编译器优化出物理学都不遵守的结果」。本章把全课散落的 UB 案卷归总立案(前文各章的伏笔在此收网),补上严格别名和对齐两个大案,最后给出防御工具链。原文小节编号错成 13.x,一并修正。
先建立一个准确的模型。CPU 层面没有 UB,指令照常执行;UB 是语言标准的约定:「标准不定义这个行为,编译器爱怎么处理怎么处理」。而现代优化器的处理方式统一而极端:假设它不发生,并利用这个假设。两个经典展示:
int x;
if (x) do_something(); // 未初始化读:UB
// 编译器推理:「x 未初始化?不会发生。所以 if 的条件恒为假(或恒为真)」→ 整支分支被删
int overflow_check(int a) {
return a + 1 > a; // 有符号溢出 UB(计组课第 2 章讲过)
// gcc -O2 的真实输出: return 1(常数!)
// 因为「溢出不会发生」⇒「a+1 > a 恒真」,优化器直接替换
}
第二个例子是真实事故原型:无数「防御性检查」(包括 Linux 内核里的检查)被这个优化无声吞掉。UB 让你的防御代码失效,而不让你知道,这才是它对 C 的最大伤害。Java/Rust 的哲学是把这类情况定义成「受控报错」,C 的哲学是「不定义」:两者都有代价,但 C 的代价是隐形的。
前面各章的伏笔,在此全部立案:
| 案由 | 出处 | 后果 |
|---|---|---|
| 解引用 NULL | 第 1 章 | 多数平台段错误,但不保证 |
| 未初始化指针/变量 | 第 1、9 章 | 编译器按上节方式「推理」 |
| 越界读/写(含 one-past-the-end 之外) | 第 3 章 | 脏数据、堆损坏,或被优化删除 |
| 越界指针运算本身 | 第 3 章 | 不解引用也算 UB |
| 不同数组的指针相减/比较 | 第 3 章 | 无定义 |
| 返回/使用悬空指针 | 第 2、9、10 章 | use-after-free |
| double free / free 非 malloc 内存 | 第 9 章 | 堆元数据损坏 |
| 写字符串字面量 | 第 6 章 | 只读段,段错误 |
| 丢弃 const 再写 | 第 8 章 | 优化器看不见你的写入 |
| 违反 restrict/别名 | 第 8 章 | 「写了 2 读到 1」 |
| 移位数 ≥ 位宽 | 第 3 章(计组) | 结果不确定 |
| 有符号溢出/移位负数 | 计组第 2 章 | 防御代码被优化吞掉 |
这张表的价值不在背诵(用到时查),而在它们共享同一个底层模型:都是「标准放弃了定义」,所以表现都可以是「无声地错」。把这张表和 14.4 的工具链配套使用,才能把 UB 从「玄学」变成「可检测的工程问题」。
溢出值得单独一节,因为它不只是程序 bug,是安全漏洞的头号产地:
char buf[8];
strcpy(buf, "attacker-controlled-100-chars"); // 写穿 8 字节
写穿的部分落到哪,取决于 buf 的邻居:在栈上,可能覆盖保存的寄存器和返回地址(计组第 10 章的栈帧图,ret 弹出一个被篡改的地址 = 跳去攻击者指定的代码);在堆上,可能覆盖堆元数据或相邻对象的函数指针(第 12 章的函数指针成员此刻变成攻击面)。从 1988 年 Morris 蠕虫到今天,这条攻击路径的核心机制没变过。防御分层:代码层(snprintf/fgets 带长度上限;gets 已从 C11 删除,见到就是事故)、工具层(ASan 开发期全量抓)、系统层(OS 的栈不可执行 NX、ASLR 抬高利用门槛,操作系统课讲过)。
严格别名(strict aliasing):同一块内存,只能用「兼容类型」的指针访问(char* 除外,它天生有权看任何字节)。违例:
float f = 1.0f;
int bits = *(int *)&f; // UB:int* 别名 float
合法的类型双关走 memcpy(编译器能优化成零开销)或 union:
uint32_t bits;
memcpy(&bits, &f, sizeof bits); // 合法且不慢
为什么这么严?回忆第 8 章:别名假设直接影响优化器敢不敢重排/向量化。放开别名规则,大量优化(寄存器缓存、SIMD)就做不了;所以标准宁可禁止这种「取巧」。计组课第 10 章读到的「指令和源码对不上」,不少就是严格别名在背后促成的。
对齐:解引用要求地址满足对齐(int 4 字节、double 8 字节)。x86 不对齐只慢不崩(硬件兜底),ARM/RISC-V 严格模式直接 SIGBUS。缓冲区按字段强转指针时最容易踩(第 6 章的字符串 buf + 1 就是典型),需要不对齐字节访问,永远用 char*(对齐要求 1);需要大对齐缓冲,用 aligned_alloc 或 _Alignas。
六层防御,按投入产出排序:
-Wall -Wextra -Wpedantic,关键项目 -Werror。很多 UB 编译器其实看得见,只是你不看警告。-fsanitize=address,undefined(ASan 抓内存案、UBSan 抓溢出移位),报错带案发现场调用栈,第 9 章的案型几乎全部秒破。Rust 语言的立场是把这六层里的 2、3、6 全部搬进编译器(所有权 + 借用检查在编译期消灭悬空、别名、数据竞争),代价是学习曲线和与现有 C 生态的摩擦。理解 UB 的本质后,你会对 Rust 的设计动机有最深的共情:它试图在语言层面终结本章的整个案卷。
a + 1 > a(-O2 与 -O0),用 objdump 看两次输出的差异,亲眼看「优化器删除防御代码」的现场。char buf[9] = "abcdefgh"; 对 buf + 1 强转 double* 解引用,判断是否对齐违规(算偏移);改用 memcpy 读 8 字节到 double,为什么这个永远合法。