Skip to content

Sanitizer 门禁:让 UB 与内存错误在运行期当场现形 ​

引言:警告抓不到的,让运行期插桩来抓 ​

前两章咱们攒下两个让人不踏实的事实:第 9 章看到编译器警告是 best-effort,-Wuninitialized 在条件分支里有明显盲区,没报警告绝不等于没 UB。第 10 章更狠,一段“靠有符号溢出回绕来检测溢出”的代码,gcc 连 -O0 都假定不溢出、把检查整段删掉,clang -O0 才老实回绕,咱们拿“测过”当保证完全靠不住。这两件事指向同一个缺口:静态分析(警告)和编译器优化都治不了运行期才暴露的 UB 和内存错误。

这一章请上专门填这个缺口的工具 sanitizer。它的思路跟警告完全不同:警告是编译期扫一眼代码“猜”咱们有没有写错,sanitizer 则是让编译器在每个内存访问、每个算术操作前后都插一段检查指令(这叫“插桩”),程序跑起来时一旦真踩到越界、释放后访问、溢出这些雷,当场报给咱们看,还能配着 -g 把出错位置精确到源码的第几行第几列。咱们这一章把两个最常用的,抓未定义行为的 UBSan(-fsanitize=undefined)和抓内存错误的 ASan(-fsanitize=address),挨个真跑,让您直观看到它比警告强在哪、以及它的代价是什么。

sanitizer 怎么工作:编译期插桩 + shadow memory ​

咱们先建立直觉:sanitizer 不改代码的逻辑,它做的是在编译时往生成的机器码里额外塞检查指令。拿 ASan 来说,它还会在内存里维护一张“影子表”(shadow memory):把程序里每 8 个字节的内存,映射到 1 个字节的“状态”,记录这 8 字节当前是不是可以合法访问;并且在每个栈变量、每个堆分配的前后埋一圈“禁区”(redzone)。于是当咱们写出 a[8] = 42 而 a 只有 4 个元素时,那条写指令落在了 a 后面的 redzone 里,shadow memory 那一格标记着“这是禁区”,插桩的检查一看就炸。等下您会在 ASan 的报错里亲眼看到这圈 redzone(输出里的 f1/f2/f3 就是它的标记)。

这套机制的代价咱们也先说在前面:插桩和 shadow memory 会让程序变慢、占更多内存。所以待会儿咱们会真测一下开销,并且强调 sanitizer 是调试和 CI 用的,不该进发布构建。

UBSan:把“未定义行为”变成运行期错误 ​

咱们先看 UBSan。它盯的是“这一步运算本身是不是踩了 UB”。咱们把第 10 章那个 INT_MAX + 100 的有符号溢出再拿出来,这次不是看编译器怎么“假定不溢出”把它删掉,而是看 UBSan 怎么在运行期把它当场抓住:

C
#include <limits.h>
#include <stdio.h>

int main(void) {
    volatile int x = INT_MAX;
    int y = x + 100; /* 有符号溢出,UB */
    printf("%d\n", y);
    return 0;
}
终端
$ gcc -std=c11 -O1 -g -fsanitize=undefined ub.c -o ub && ./ub
ub.c:6:9: runtime error: signed integer overflow: 2147483647 + 100 cannot be represented in type 'int'
-2147483549

咱们注意它报的是什么:runtime error: signed integer overflow: 2147483647 + 100 cannot be represented in type 'int',精确到 ub.c:6:9(第 6 行第 9 列),这正是第 10 章那段 gcc 默默删掉、咱们完全查不出来的溢出,UBSan 一运行就把它按在原地。这就是它和静态警告的根本区别:警告是“觉得这段代码可能有问题”,UBSan 是“程序刚跑到这一步,确实溢出了”(依据就是 ISO/IEC 9899 §6.5 第 5 段,有符号整数溢出是 UB)。

再看一个 UBSan 抓的、警告基本不会理咱们的 UB,移位越界。把 1 左移 32 位(int 只有 32 位,移位大于等于位宽是 UB,ISO/IEC 9899 §6.5.7 第 3 段):

C
#include <stdio.h>

int main(void) {
    volatile int n = 32;
    int y = 1 << n; /* 移位 >= int 宽度,UB */
    printf("%d\n", y);
    return 0;
}
终端
$ gcc -std=c11 -O1 -g -fsanitize=undefined shift.c -o sh && ./sh
shift.c:5:15: runtime error: shift exponent 32 is too large for 32-bit type 'int'
1

shift exponent 32 is too large for 32-bit type 'int',移位的“指数”(右操作数)超了位宽,又是精确到行列。这种移位 bug 在手写位运算、组包解包的代码里特别常见,编译器一个字都不会提醒咱们,UBSan 一抓一个准。

这里有个细节值得咱们停一下:上面两个例子里,UBSan 报完 runtime error 后,程序并没有停,printf 照样跑出了 -2147483549 和 1。这是因为 UBSan 默认是“可恢复(recover)”模式,发现一处 UB 就报一行,然后继续往下跑,把一路上所有的 UB 都报出来。这在咱们想“一次性看全这个程序到底踩了多少 UB”时很方便;但如果您希望它踩到第一个就立刻 abort(比如 CI 里),加一个 -fno-sanitize-recover=all 就会让它一报即停。

UBSan 默认那组(-fsanitize=undefined)还覆盖了不少别的:空指针解引用、对齐违规、整数转换截断、除零、返回值未初始化等等。咱们不用记全,记住“凡是涉及‘这一步运算/访问本身合不合法’的 UB,UBSan 大概率管”就行。

ASan:内存错误的照妖镜 ​

咱们再看 ASan,它管的是“这次内存访问有没有越界、是不是访问了已经释放的内存”。这是 C 程序里最阴险的一类 bug,它们在很多时候“碰巧能跑”,等程序规模一大、负载一变,突然就在某个客户机器上段错误,而咱们本地怎么都复现不了。ASan 把这类问题的复现门槛打到了“只要跑一遍”。

咱们先看一个栈上的缓冲区越界:数组 a 只有 4 个元素,咱们偏要写 a[8]:

C
#include <stdio.h>

int main(void) {
    int a[4] = {0};
    volatile int i = 8;
    a[i] = 42; /* 越界写 */
    printf("%d\n", a[0]);
    return 0;
}
终端
$ gcc -std=c11 -O1 -g -fsanitize=address oob.c -o oob && ./oob
=================================================================
==187827==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x... at pc 0x...
WRITE of size 4 at 0x... thread T0
    #0 0x... in main /tmp/cj/ch10/oob.c:6
...
  This frame has 2 object(s):
    [48, 52) 'i' (line 5)
    [64, 80) 'a' (line 4) <== Memory access at offset 96 overflows this variable
SUMMARY: AddressSanitizer: stack-buffer-overflow /tmp/cj/ch10/oob.c:6 in main

(地址和 BuildId 每次运行因 ASLR 不同,这里省略成 0x...;关键信息是稳定不变的。)咱们看 ASan 的报错,信息量比 UBSan 还大:它不光告诉咱们这是个 stack-buffer-overflow、是个“写 4 字节(WRITE of size 4)”、发生在 oob.c:6,还直接点名是哪个变量被越界——'a' (line 4) <== Memory access at offset 96 overflows this variable,说的是“第 4 行定义的数组 a,被第 6 行偏移到 96 的那次访问撑爆了”。这种“越界发生在哪一行、受害者是哪个变量”级别的定位,靠 printf 调试法可能要查半天。最底下那串 f1 f1 ... f2 ... f3 就是前面说的 shadow memory 里的 redzone 标记,越界踩中的那一格被方括号标了出来。

最惊艳的是 ASan 对 use-after-free 的定位。free 之后再读这块内存,是 C 里最难抓的 bug 之一(因为那块内存经常已经被改作他用,表现完全不可预测)。咱们看 ASan 怎么收拾它:

C
#include <stdio.h>
#include <stdlib.h>

int main(void) {
    int* p = malloc(sizeof(int));
    *p = 42;
    free(p);
    printf("%d\n", *p); /* use-after-free */
    return 0;
}
终端
$ gcc -std=c11 -O1 -g -fsanitize=address uaf.c -o uaf && ./uaf
==187834==ERROR: AddressSanitizer: heap-use-after-free on address 0x... thread T0
READ of size 4 at 0x... thread T0
    #0 0x... in main /tmp/cj/ch10/uaf.c:8        ← 在这里读
freed by thread T0 here:
    #0 ... in free (...libasan...)
    #1 ... in main /tmp/cj/ch10/uaf.c:7          ← 在这里 free 的
previously allocated by thread T0 here:
    #0 ... in malloc (...libasan...)
    #1 ... in main /tmp/cj/ch10/uaf.c:5          ← 在这里 malloc 的
SUMMARY: AddressSanitizer: heap-use-after-free /tmp/cj/ch10/uaf.c:8 in main

ASan 给出了三段栈:错误发生在 uaf.c:8(那次非法的读),这块内存是在 uaf.c:7 被 free 的,而它最初是在 uaf.c:5 被 malloc 出来的。一个 use-after-free 的完整生命周期,分配、释放、非法访问,三个位置一次性全摆给咱们。这种 bug 用任何传统调试手段都得掉一层皮,ASan 一跑就交底。

这里要特别强调那条和第 10 章呼应的点:上面所有“精确到源码行”的定位,全靠 -g。咱们要是去掉 -g,ASan 只能给出一堆内存地址,main 那一帧变成裸地址(clang 版的 ASan 尤其明显,它需要配套的 symbolizer 才能把地址翻译回源码行)。所以跑 sanitizer 的标配是 -fsanitize=... -g,-g 提供地图,sanitizer 标出炸点,两者一合才能指路。

代价、退出码,以及怎么把它用对 ​

sanitizer 这么好,为什么不永远开着?因为有代价。咱们拿一个 2000 万次循环访问数组的程序,对比“不插桩”和“插 ASan/UBSan”的运行时间:

终端
$ gcc -O2 cost.c -o cost_plain && gcc -O2 -fsanitize=address cost.c -o cost_asan
$ for b in cost_plain cost_asan; do printf "%s " $b; { time ./$b >/dev/null; } 2>&1; done
cost_plain  0.072 s
cost_asan   0.120 s

这个例子里 ASan 大约慢了 1.7 倍(从 0.072 秒到 0.120 秒),UBSan 约 1.4 倍,内存访问越密集、越随机的代码,ASan 的开销越明显,经典说法是“约 2 倍速度 + 数倍的内存占用(shadow memory)”。所以 sanitizer 是调试和 CI 的工具,不是发布构建的配置:咱们在本地调试、在 CI 流水线里跑 sanitizer 构建来当质量门,但最终交付给用户的二进制是关掉 sanitizer、带正常 -O2 的。

至于 CI 里咱们怎么把它当门用,靠的是退出码:sanitizer 抓到错误时,进程会以非 0 退出码结束:

终端
$ ./uaf >/dev/null 2>&1; echo "退出码=$?"
退出码=1
$ ./cost_plain >/dev/null 2>&1; echo "退出码=$?"
退出码=0

抓到 UAF 退出码是 1,干净程序是 0。CI 里只要“跑一遍 sanitizer 构建,退出码非 0 就判失败”,就把 sanitizer 变成了一道硬门,这正是第 17 章咱们要在 GitHub Actions 里落的配置。注意 ASan 和 UBSan 默认对“非 0 退出码”的处理不同:ASan 默认一抓到就 abort(进程立刻终止),UBSan 默认是 recover(报完继续跑、最后才退出),所以想让 UBSan 也“一抓即停、退出码非 0”,要配 -fno-sanitize-recover=all,否则它可能一路报着错、最后还是正常退出。

咱们把用法收拢成一套可以直接抄的:调试和 CI 时用 -O1 -g -fsanitize=address,undefined(-O1 是平衡点,-O0 太慢、-O2 有时会把 UBSan 要检测的模式优化掉,-O1 保留可调试性又不至于太慢)。想抓内存泄漏再加 -fsanitize=leak,多线程数据竞争用 -fsanitize=thread(TSan),读未初始化内存用 -fsanitize=memory(MSan,注意它和 ASan 不能同开)。address 和 undefined 用逗号组合是最常见的一对,覆盖了绝大多数“C 程序最容易踩的雷”。

小结 ​

sanitizer 是静态警告和编译器优化都管不了的那部分 bug 的运行期兜底。它靠编译期插桩,在每个内存访问和算术操作前后塞检查指令,ASan 还用 shadow memory 给每个变量前后埋 redzone,程序一旦真踩到雷就当场报,配着 -g 能把出错位置精确到源码行列。UBSan(-fsanitize=undefined)抓运算本身的 UB:有符号溢出(§6.5 第 5 段)它能精确报到 ub.c:6:9、移位越界(§6.5.7 第 3 段)它也抓,而且默认是 recover 模式(报完继续跑)。ASan(-fsanitize=address)抓内存错误:栈越界它会点名是哪个变量被撑爆、偏移多少;use-after-free 它给出“访问 / free / malloc”三段栈,把一块内存的完整生命周期摆给咱们,这种 bug 靠传统调试能掉一层皮。代价是运行变慢约 2 倍、内存占用上涨(咱们真测过 0.072 秒变 0.120 秒),所以它是调试和 CI 的工具、不进发布构建。用法上记一套:-O1 -g -fsanitize=address,undefined,抓到错误进程非 0 退出(ASan 默认 abort、UBSan 默认 recover 要靠 -fno-sanitize-recover=all 才一抓即停),CI 里拿这个非 0 退出码当硬门,这就是第 17 章 GitHub Actions 要落的质量门。

到这一章,开发环境这条线上的“编译器能帮咱们抓错”就讲到头了:第 9 章警告(best-effort 静态)、第 10 章 -O/-std/-g(旋钮与 UB)、这一章 sanitizer(运行期兜底)。下一章咱们换个方向,从“单文件敲 gcc”走向“管理多文件、多目标的构建”,先从最基础的 make 讲起。

参考资源 ​

  • ISO/IEC 9899:2011 §6.5 第 5 段(表达式求值的 UB,有符号溢出的标准依据)、§6.5.7 第 3 段(移位操作的 UB,移位指数大于等于位宽未定义)
  • GCC / Clang 手册:-fsanitize=(address / undefined / leak / thread / memory)、-fno-sanitize-recover=、ASAN_OPTIONS / UBSAN_OPTIONS 环境变量
  • AddressSanitizer Wiki(shadow memory、redzone 机制的原理说明)、UndefinedBehaviorSanitizer 文档(-fsanitize=undefined 覆盖的检查项清单)
  • 第 10 章:标准与优化(-O 让 UB 现形、-g 提供 sanitizer 报错所需的源码映射)
  • 第 17 章:GitHub Actions(把 sanitizer 构建变成 CI 硬门的真实写法)

87bb5f2 · 87bb5f2 · 2026-09-21