第 14 章 · 未定义行为与陷阱

全课的终章,讲 C 最危险的词:UB(未定义行为)。它最阴的特质是不报错:标准不再约束编译器,编译器按「它永远不会发生」来优化代码,于是一行 UB 的实际后果可能是「正常工作」(最糟,雷传给了未来)、「读到垃圾」、「崩」、或「编译器优化出物理学都不遵守的结果」。本章把全课散落的 UB 案卷归总立案(前文各章的伏笔在此收网),补上严格别名和对齐两个大案,最后给出防御工具链。原文小节编号错成 13.x,一并修正。

14.1 UB 的本质:编译器拿到了「免死金牌」

先建立一个准确的模型。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 的代价是隐形的。

14.2 全课 UB 案卷汇编

前面各章的伏笔,在此全部立案:

案由出处后果
解引用 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 从「玄学」变成「可检测的工程问题」。

14.3 缓冲区溢出:从 bug 到 CVE

溢出值得单独一节,因为它不只是程序 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 抬高利用门槛,操作系统课讲过)。

14.4 严格别名与对齐:两个高级案由

严格别名(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。

14.5 防御工具链:把 UB 从玄学变成工单

六层防御,按投入产出排序:

  1. 警告开满: -Wall -Wextra -Wpedantic,关键项目 -Werror。很多 UB 编译器其实看得见,只是你不看警告。
  2. Sanitizer:开发/测试期 -fsanitize=address,undefined(ASan 抓内存案、UBSan 抓溢出移位),报错带案发现场调用栈,第 9 章的案型几乎全部秒破。
  3. 静态分析:clang --analyze、cppcheck,提交前扫一遍。
  4. 初始化纪律:指针声明即初始化,free 后置 NULL(第 9 章)。
  5. 边界显式化:函数参数带长度,别指望 '\0' 和调用者的自觉。
  6. 代码评审盯 UB 模式:把 14.2 的清单当 checklist,「这个强转合法吗」「这个减法会溢出吗」当口头禅。

Rust 语言的立场是把这六层里的 2、3、6 全部搬进编译器(所有权 + 借用检查在编译期消灭悬空、别名、数据竞争),代价是学习曲线和与现有 C 生态的摩擦。理解 UB 的本质后,你会对 Rust 的设计动机有最深的共情:它试图在语言层面终结本章的整个案卷。

常见错误

  • 「在我机器上跑对了」当通过标准。UB 的定义就是「换个编译器/优化级别/平台就可能变」;CI 里换 -O0 和 -O2 各跑一遍、加 sanitizer,是最低配的验证。
  • 用未定义行为省性能。有符号溢出回绕、强制别名读这类「聪明写法」,省下的性能往往为零(优化器会做等价合法变换),埋下的雷是万级的。合法优先。
  • UBSan 报了不修。「只是个警告」心态在 UB 面前最致命,它今天不炸,是编译器还没想到那么做。

练习

  1. 动手:编译 14.1 的 a + 1 > a(-O2 与 -O0),用 objdump 看两次输出的差异,亲眼看「优化器删除防御代码」的现场。
  2. 动手:写一个「栈溢出改返回地址」的演示(不用真实攻击载荷,只把邻居数组写满 A 字符观察返回地址被污染后的 segfault),配合 gdb 的 info frame 看 saved rip 变化。
  3. 手算:char buf[9] = "abcdefgh"; 对 buf + 1 强转 double* 解引用,判断是否对齐违规(算偏移);改用 memcpy 读 8 字节到 double,为什么这个永远合法。
  4. 思考:把 14.2 的案卷按「编译期能查出 / 运行时工具能查出 / 只能靠纪律」分三类,数一数各有多少。这个分布解释了「为什么 Rust 选择在编译期重设计所有权」,也解释了为什么 C 的历史包袱如此难卸载。