未定义行为与 Sanitizers
这一章讲 C 语言最"反直觉"的一件事:有些代码,标准根本没规定它该干什么——编译器可以任意处理,包括把你自己写的安全检查优化掉。这就是未定义行为(UB)。它让"我本地明明能跑"变成一句毫无意义的话。好消息是:有三个开关,能把 90% 的 UB 在测试阶段就当场抓出来。这一章教你打开它们。
UB 是什么:标准说了"这事我不负责"
是什么:C 标准把程序行为分成几类:定义良好的(well-defined)、实现定义的(implementation-defined)、未指定的(unspecified)、以及未定义行为(Undefined Behavior, UB)。UB 的含义是:标准对这段代码不施加任何要求——编译器可以做出任何它想做的事,包括让程序崩溃、静默算错、或者干脆把这段代码删掉。
为什么它比"报错"可怕得多:因为它不报错。程序可能今天正常、明天换台机器就错;可能 Debug 版正常、Release 版出错;可能加一句 printf 就好了、删掉又坏。而这一切的根源,是编译器在优化时会"假设 UB 永远不发生"。
理为什么编译器敢删掉你的空指针检查
看这段"看起来无懈可击"的代码:
int deref(int *p) { if (p == NULL) return 0; return *p; }
标准规定 解引用空指针是 UB,因此编译器可以推断:"如果执行到 return *p,那么 p 一定不是 NULL"。于是它认为 p == NULL 永远为假,把整个 if 分支删掉——这就是著名的 "null check removed by compiler"。
结论:UB 不是"运行到那一行才出问题",而是"从编译器看到它的那一刻起,你的整个程序就失去了语义保障"。这跟"除以零会崩溃"是两件完全不同的事——后者只是其中一个后果,而且是最"善良"的那个。
| 类别 | 典型写法 | 后果 | 谁帮你抓 |
|---|---|---|---|
| 未初始化变量 | int x; printf("%d", x); | 读到随机值;换机器就变 | Valgrind、MSan(ASan 抓不到) |
| 缓冲区越界 | int a[5]; a[10] = 1; | 踩坏相邻变量的值 | ASan |
| 有符号整数溢出 | INT_MAX + 1 | 循环被优化成死循环 | UBSan(signed-integer-overflow) |
| 除零 / 取模零 | int r = 10 / 0; | SIGFPE 直接崩 | UBSan(integer-divide-by-zero) |
| 空指针解引用 | int *p = NULL; *p = 1; | 段错误,或判空被优化掉 | UBSan(null)、ASan |
| 移位超位宽 | 1 << 32(int 只有 32 位) | 结果随平台变 | UBSan(shift) |
| use-after-free | free(p); p[0] = 1; | 堆损坏,可被利用攻击 | ASan |
| 返回局部变量地址 | return &local; | 栈数据被覆盖,读到垃圾 | ASan(use-after-scope)、-Wreturn-local-addr |
| 求值顺序 / 序列点 | i = i++ + 1; | 结果因编译器而异 | GCC -Wsequence-point(UBSan 不查这个) |
| 严格别名违反 | *(int *)&f | 优化后算出错误结果 | -Wstrict-aliasing(不太可靠);改用 memcpy 或 union |
| 修改字符串字面量 | char *s = "x"; s[0] = 'y'; | 段错误(字面量在只读段) | 直接崩溃;ASan 也会报 |
| 数据竞争 | 两个线程无锁写同一变量 | 结果不可预期(C11 起明确是 UB) | TSan |
| memcmp 比较含 padding 的结构体 | memcmp(&a, &b, sizeof a) | 明明相等却判不等 | Valgrind / MSan(padding 字节是"未初始化"的) |
Sanitizers:给程序装上"违规探测器"
是什么:Sanitizer(消毒剂)是编译器内置的一类动态检测工具——你在编译时加一个开关,编译器就往生成的代码里插入检查逻辑,程序一旦踩到 UB 就当场打印出"哪一行、做了什么、涉事内存是谁分配的",然后退出。
为什么它是最划算的投资:因为它把"可能三个月后在生产上崩一次"变成了"今天写测试时就崩给你看"。加一个编译开关的成本,换回的是确定性的错误定位。Google、Chromium、LLVM 这些项目把 ASan/UBSan 当作 CI 的标配,所有提交都必须通过。
| 名字 | 编译开关 | 专抓什么 | 开销与支持 |
|---|---|---|---|
| ASan | -fsanitize=address | 堆/栈/全局的缓冲区越界、use-after-free、double free、内存泄漏(内置 LSan)。 | 约 2x 时间、2~3x 内存;GCC 与 Clang 都支持。日常主力。 |
| UBSan | -fsanitize=undefined | 整数溢出、除零、非法移位、空指针、对齐错误、非法类型转换等"语义级 UB"。 | 开销极小(通常 5~20%),GCC/Clang 都支持,可以常年开着。 |
| TSan | -fsanitize=thread | 多线程数据竞争(读写同一变量且无同步)。 | 5~15x 时间、5~10x 内存;GCC/Clang 在 x86-64 / aarch64 上支持。 |
| MSan | -fsanitize=memory | 未初始化内存的读取(ASan 查不到的那一类)。 | 仅 Clang,且要求 libc++ 等依赖也全部插桩,用起来最挑环境。 |
| HWASan | -fsanitize=hwaddress | 和 ASan 类似,但依靠 aarch64 的硬件标记,内存开销小得多。 | 仅 Clang、仅 aarch64(ARM 服务器 / 安卓)。 |
ASan、MSan、TSan 这三个都要接管内存布局(ASan 需要 shadow memory、TSan 需要记录访问历史),它们互相冲突,同时开会链接失败或运行时直接报错。能组合的常见搭配只有一个:-fsanitize=address,undefined(ASan + UBSan,官方支持,推荐日常就用这个)。TSan 需要单独出一个构建配置,在 CI 里单开一个 job。
ASan 实战:把内存错误钉在具体某一行
怎么用:关键在于编译参数——除了 -fsanitize=address,还需要 -g(行号)和 -fno-omit-frame-pointer(准确栈回溯),优化级别建议 -O1。
一段会被 ASan 抓住的代码,以及它给出的报告
读ASan 报告的固定四段式,照这个顺序读
① 什么错:heap-buffer-overflow / use-after-free / stack-buffer-overflow / double-free / LeakSanitizer: detected memory leaks。先认这一类,就知道问题的性质。
② 谁干的:紧跟着的 #0 ... in 函数名 (文件:行号)。这一行就是你要去改的那一行,往下的 #1 #2 是调用链。
③ 哪块内存:越界会告诉你"在这块 16 字节区域之后 0 字节",use-after-free 会告诉你"这块内存已经被释放"。
④ 谁分配的:allocated by ... 那段告诉你这块内存从哪来。UB 的两个源头通常都在这里——"分配的人"和"用的人"对不上,就是 bug 所在。
ASAN_OPTIONS:不改代码就能调整检测行为
在较新的内核发行版(如 Ubuntu 24.04)上,你可能遇到 ASan 程序什么都还没干就打印一大段 Shadow memory range interleaves with an existing memory mapping 然后退出。这不是你的代码有问题,而是内核的 ASLR 随机位数(vm.mmap_rnd_bits=32)与 ASan 预留的 shadow memory 区间冲突。缓解方式:临时 sudo sysctl vm.mmap_rnd_bits=28(setarch -R ./demo 关掉随机化也可以)。知道这一条,能省下你半天"怀疑人生"的时间。
UBSan 实战:开销量小、收益极高,建议常年开着
是什么:UBSan 专门抓"能编译过但语义非法"的操作——整数溢出、除零、非法移位、空指针解引用、对齐错误等。它不检测内存越界(那是 ASan 的活),而是检测"这一行本身就不该这么写"。
为什么值得单独强调:因为它的开销小到几乎可以常开(不像 ASan 那样翻倍内存)。很多团队的做法是:开发/测试构建一律带 -fsanitize=undefined -fno-sanitize-recover=all。
默认行为是 -fsanitize-recover——打印一行 runtime error,然后若无其事地继续执行,进程退出码仍然是 0。如果你的 CI 只看退出码,那么所有 UB 都会被漏掉。必须加 -fno-sanitize-recover=all(让它直接 abort),CI 才会红。另一个相关选项是 -fsanitize-trap=undefined(把检查变成一条 trap 指令,性能更好,也一定会崩)。一句话:光开 UBSan 不够,必须同时让它"失败"。
TSan 实战:把"偶尔出错"的并发 bug 抓出来
是什么:ThreadSanitizer 通过记录每个内存访问的线程与同步关系,检测数据竞争——两个线程访问同一内存,其中至少一个是写,且之间没有同步。数据竞争从 C11 起就是明确的 UB。
为什么必须有它:因为并发 bug 的可怕之处在于"测试环境几乎不复现、生产上一天崩一次"。靠加日志碰运气效率极低,而 TSan 能在一次运行里就把竞争的两个访问点同时报出来。
① 不能和 ASan 同开(链接会失败或运行时报冲突),TSan 单独出一个构建配置;② 不要在 Valgrind 下跑 TSan 二进制(两个工具都要接管内存与指令,互相打架,用 Valgrind 的 --tool=helgrind 才是对应做法);③ TSan 内存开销大(5~10 倍),跑全量集成测试前先确认机器内存够,否则你会以为是程序泄漏,其实是工具在吃内存。另外,TSan 只能报告"它实际观察到的"竞争——没报不代表没有,测试覆盖不到的代码路径它也无能为力。
Valgrind:没有源码也能用的老牌体检工具
是什么:Valgrind 是一个"虚拟 CPU"——它把程序放在自己的模拟器里执行,因此能观察到每一次内存读写。最常用的工具是 Memcheck。
为什么现在还要学它:因为它有一个 Sanitizer 完全无法替代的优势:不需要重新编译。拿到一个已经编译好的第三方二进制、或者客户现场那份没带源码的程序,你依然能跑 valgrind ./程序 得到一份内存错误报告。另外,未初始化内存的检测它是强项——正好是 ASan 的盲区。
| 维度 | Valgrind Memcheck | Sanitizers(ASan/UBSan/TSan) |
|---|---|---|
| 是否需要重新编译 | 不需要(有 -g 才有行号) | 必须,要加 -fsanitize=... |
| 运行速度 | 慢 10~50 倍 | ASan 约 2 倍、TSan 5~15 倍 |
| 未初始化内存读取 | 能检测(强项) | ASan 不能;MSan 能(但要 Clang + 全链路插桩) |
| 越界 / 泄漏 | 能检测,准确率高 | ASan 也强,且快得多 |
| 数据竞争 | 要换工具:--tool=helgrind 或 drd | TSan,体验更好 |
| 适合的场景 | 没有源码的二进制、补充确认"未初始化"类问题 | 日常开发与 CI 的主力 |
两个工具都在接管内存和指令流,一起用会得到满屏的假报告,或者直接崩在 Valgrind 自己的逻辑里。正确姿势是:要用 Valgrind,就用"普通构建"(带 -g 但不带任何 -fsanitize);要用 Sanitizer,就别套 Valgrind。这也是为什么建议项目里保留两个构建配置:一个 asan、一个 plain。顺便说一句,Valgrind 不属于你日常该跑的工具——它慢到足以破坏交互体验,把它留给"确认未初始化问题"和"分析没有源码的二进制"这两个场景。
接进项目与 CI:让它自动替你干活
怎么用:Sanitizer 的价值取决于"它是否每次提交都跑"。有三种接法,从轻到重。
① 最轻:不改 CMakeLists,直接用命令行传参数(配合第 13 章的 CMake 项目)
② 推荐:写进 CMakePresets.json,团队里所有人一条命令得到同样的配置
③ CI:两个 job,一个 ASan+UBSan,一个 TSan(必须分开,不能合并)
ASan 会让程序慢 2 倍、TSan 慢十几倍,拿它们的耗时去做性能对比、或者去判断"某个优化是否有效",结论一律无效。同样地,不要用 sanitizer 构建做压测或上生产——它们的用途是"抓 bug",不是"跑得快"。正确分工:sanitizer 构建跑测试(抓 bug),普通 Release 构建跑性能测试(看数据)。
UB 的残酷之处在于:它今天"不影响功能",是因为编译器今天选择了对你有利的行为;明天换个编译器版本、加一个无关的改动,它就会以完全不同的面目出现——数据错乱、偶发崩溃、只在 Release 下复现。相反,sanitizer 抓到的每一条都是"标准层面的确定性错误",修它的成本通常只有几分钟(改边界、加初始化、换 memcpy),而放过它的成本可能是线上排查好几天。这一章最值钱的一句话就是:把 sanitizer 报告的每一条当成必修的 bug,而不是噪声。
① UB 的本质是"编译器可以假设它不发生",所以它最典型的表现不是崩溃,而是你的判空被优化掉、循环变成死循环、结果随编译选项变化。别用"本机能跑"当证据。
② 记住三个开关的分工:ASan 抓内存越界/释放后使用(2x 开销)、UBSan 抓语义级 UB(几乎免费,建议常开)、TSan 抓数据竞争(要单独构建);ASan 抓不到"未初始化读取",那是 Valgrind/MSan 的活。
③ 两个必踩的配置坑:UBSan 不加 -fno-sanitize-recover=all 就等于没开;高熵 ASLR 的系统上 ASan 可能起不来,改 vm.mmap_rnd_bits 即可。日常参数直接抄:-g -O1 -fsanitize=address,undefined -fno-sanitize-recover=all -fno-omit-frame-pointer。