楼层: 首页/ 软件技术/ C 语言/ 未定义行为与 Sanitizers
14

未定义行为与 Sanitizers

Undefined Behavior · ASan · UBSan · TSan · Valgrind

这一章讲 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 不是"运行到那一行才出问题",而是"从编译器看到它的那一刻起,你的整个程序就失去了语义保障"。这跟"除以零会崩溃"是两件完全不同的事——后者只是其中一个后果,而且是最"善良"的那个。

/* 三个经典的、都"能编译能跑"的 UB */ // ① 有符号整数溢出:不是"变成负数"这么简单,而是整个循环变成死循环 int sum(int n) { int s = 0; for (int i = 0; i <= n; i++) // 若 n == INT_MAX,i++ 溢出就是 UB s += i; // 编译器可假设 i 不会溢出 -> 循环永远不结束 return s; } // ② 序列点违规:一行里对同一变量读又写,求值顺序未定义 int i = 0; i = i++ + 1; // C11 起这是明确 UB(C23 也是) printf("%d %d\n", i, i++); // 同样 UB:参数求值顺序未指定 // ③ 严格别名违反:用不兼容类型去访问同一块内存 float f = 1.0f; int bits = *(int *)&f; // 违反严格别名规则 // 正确写法(C11 起有标准做法): int bits2; memcpy(&bits2, &f, sizeof bits2);
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-freefree(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 的标配,所有提交都必须通过。

五种 sanitizer:它们的分工是互补的,不是替代关系
名字编译开关专抓什么开销与支持
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 和 TSan 不能塞进同一个二进制

ASan、MSan、TSan 这三个都要接管内存布局(ASan 需要 shadow memory、TSan 需要记录访问历史),它们互相冲突,同时开会链接失败或运行时直接报错。能组合的常见搭配只有一个:-fsanitize=address,undefined(ASan + UBSan,官方支持,推荐日常就用这个)。TSan 需要单独出一个构建配置,在 CI 里单开一个 job。

ASan 实战:把内存错误钉在具体某一行

怎么用:关键在于编译参数——除了 -fsanitize=address,还需要 -g(行号)和 -fno-omit-frame-pointer(准确栈回溯),优化级别建议 -O1。

# 推荐的日常开发/测试参数(GCC 与 Clang 通用) gcc -std=c17 -Wall -Wextra -Wpedantic -g -O1 \ -fsanitize=address,undefined -fno-sanitize-recover=all \ -fno-omit-frame-pointer demo.c -o demo ./demo # 各参数的用途: # -g 保留调试信息,报告里才有 "demo.c:9" 这样的行号 # -O1 保留部分优化又不至于把变量优化没(别用 -O3) # -fno-omit-frame-pointer 强制保留帧指针,栈回溯才准确(-O2 默认会省掉) # -fno-sanitize-recover=all UBSan 报错就直接退出(默认只打印然后继续,CI 会漏检)

一段会被 ASan 抓住的代码,以及它给出的报告

/* demo.c */ #include <stdlib.h> #include <stdio.h> static void fill(int *buf) { for (int i = 0; i <= 4; i++) /* 越界!应该是 i < 4 */ buf[i] = i; } int main(void) { int *buf = malloc(4 * sizeof(int)); fill(buf); printf("%d\n", buf[0]); free(buf); return 0; } /* ── 运行结果(ASan 报告,节选) ── ==41281==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x602000000018 WRITE of size 4 at 0x602000000018 thread T0 ← ① 什么操作(写 4 字节) #0 0x... in fill /home/me/demo.c:9 ← ② 谁干的:fill 函数第 9 行 #1 0x... in main /home/me/demo.c:14 ← 从 main 第 14 行调进来 0x602000000018 is located 0 bytes after 16-byte region ← ③ 越界位置:这块内存的尾巴后面 allocated by thread T0 here: #1 0x... in main /home/me/demo.c:13 ← ④ 这块内存是 main 第 13 行 malloc 的 SUMMARY: AddressSanitizer: heap-buffer-overflow /home/me/demo.c:9 in fill */

读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:不改代码就能调整检测行为

# 把"栈上返回后的使用"也抓出来(开销略高,排查疑难问题时开) ASAN_OPTIONS=detect_stack_use_after_return=1 ./demo # 一次跑完所有错误,不要第一个错误就退出(批量清理历史遗留问题时有用) ASAN_OPTIONS=halt_on_error=0 ./demo # 泄漏检测(默认开启)。想关掉: ASAN_OPTIONS=detect_leaks=0 ./demo # 报告里只有地址没有行号?说明符号化工具没找到: # sudo apt install llvm 然后: ASAN_SYMBOLIZER_PATH=$(which llvm-symbolizer) ./demo
坑:开了 ASan 反而启动就报错(高熵 ASLR 冲突)

在较新的内核发行版(如 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。

# 最简用法(只用 UBSan,开销最小) gcc -std=c17 -Wall -Wextra -g -O1 -fsanitize=undefined -fno-sanitize-recover=all ub.c -o ub # 典型报告(每条都精确到行列,一眼定位) # ub.c:12:15: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int' # ub.c:20:5: runtime error: shift exponent 32 is too large for 32-bit type 'int' # ub.c:27:12: runtime error: null pointer passed as argument 1, which is declared to never be null # 想只查某一类?用子选项(逗号分隔) gcc -fsanitize=signed-integer-overflow,integer-divide-by-zero,shift ub.c -o ub # 想看全部可用检查项: gcc -fsanitize=undefined -fsanitize-help 2>&1 | head -40 # Clang 用 -fsanitize=undefined --help
坑:UBSan 默认"报错但不失败",CI 里会静默放过

默认行为是 -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 能在一次运行里就把竞争的两个访问点同时报出来。

# 编译(注意 -pthread) gcc -std=c17 -Wall -g -O1 -fsanitize=thread -pthread race.c -o race ./race # 典型报告:一次给出"两个访问点",这就是它最值钱的地方 ================== WARNING: ThreadSanitizer: data race (pid=12345) Write of size 4 at 0x... by thread T1: ← 访问点 1(T1 线程在写) #0 inc /home/me/race.c:7 Previous read of size 4 at 0x... by main thread: ← 访问点 2(主线程在读) #0 main /home/me/race.c:15 Location is global 'counter' at 0x... ← 争的就是这个变量 ================== # 修法(按推荐度): # ① 能用原子就用原子:C11 <stdatomic.h> 的 atomic_int + atomic_fetch_add # ② 需要保护一组操作时用 pthread_mutex_t # ③ 实在不需要同步(只读或线程私有)就消除共享
坑:TSan 的三条使用禁忌

① 不能和 ASan 同开(链接会失败或运行时报冲突),TSan 单独出一个构建配置;② 不要在 Valgrind 下跑 TSan 二进制(两个工具都要接管内存与指令,互相打架,用 Valgrind 的 --tool=helgrind 才是对应做法);③ TSan 内存开销大(5~10 倍),跑全量集成测试前先确认机器内存够,否则你会以为是程序泄漏,其实是工具在吃内存。另外,TSan 只能报告"它实际观察到的"竞争——没报不代表没有,测试覆盖不到的代码路径它也无能为力。

Valgrind:没有源码也能用的老牌体检工具

是什么:Valgrind 是一个"虚拟 CPU"——它把程序放在自己的模拟器里执行,因此能观察到每一次内存读写。最常用的工具是 Memcheck。

为什么现在还要学它:因为它有一个 Sanitizer 完全无法替代的优势:不需要重新编译。拿到一个已经编译好的第三方二进制、或者客户现场那份没带源码的程序,你依然能跑 valgrind ./程序 得到一份内存错误报告。另外,未初始化内存的检测它是强项——正好是 ASan 的盲区。

Valgrind Memcheck 与 Sanitizers 的取舍:不是新旧交替,是场景互补
维度Valgrind MemcheckSanitizers(ASan/UBSan/TSan)
是否需要重新编译不需要(有 -g 才有行号)必须,要加 -fsanitize=...
运行速度慢 10~50 倍ASan 约 2 倍、TSan 5~15 倍
未初始化内存读取能检测(强项)ASan 不能;MSan 能(但要 Clang + 全链路插桩)
越界 / 泄漏能检测,准确率高ASan 也强,且快得多
数据竞争要换工具:--tool=helgrind 或 drdTSan,体验更好
适合的场景没有源码的二进制、补充确认"未初始化"类问题日常开发与 CI 的主力
# 安装 sudo apt install -y valgrind # Debian / Ubuntu sudo dnf install -y valgrind # RHEL 系(需 EPEL) # 用法:参数放在 valgrind 后、程序前(-- 是分隔符) valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes \ --error-exitcode=1 ./app # 关键参数: # --leak-check=full 详细列出每一处泄漏的调用栈 # --show-leak-kinds=all 把 definitely/indirectly/possibly lost 全列出来 # --track-origins=yes 追踪"未初始化值"的来源(很慢但极有用) # --error-exitcode=1 发现错误就以非 0 退出 -- CI 里靠它判断成败 # 输出里只看两类关键词(别被一屏等号线吓住): # Invalid read/write of size N -- 越界读写 # definitely lost / Conditional jump depends on uninitialised value
坑:在 Valgrind 下跑带 ASan 的程序

两个工具都在接管内存和指令流,一起用会得到满屏的假报告,或者直接崩在 Valgrind 自己的逻辑里。正确姿势是:要用 Valgrind,就用"普通构建"(带 -g 但不带任何 -fsanitize);要用 Sanitizer,就别套 Valgrind。这也是为什么建议项目里保留两个构建配置:一个 asan、一个 plain。顺便说一句,Valgrind 不属于你日常该跑的工具——它慢到足以破坏交互体验,把它留给"确认未初始化问题"和"分析没有源码的二进制"这两个场景。

接进项目与 CI:让它自动替你干活

怎么用:Sanitizer 的价值取决于"它是否每次提交都跑"。有三种接法,从轻到重。

① 最轻:不改 CMakeLists,直接用命令行传参数(配合第 13 章的 CMake 项目)

# 单开一个 build 目录,用 RelWithDebInfo(有优化 + 有调试信息) cmake -S . -B build/asan -DCMAKE_BUILD_TYPE=RelWithDebInfo \ -DCMAKE_C_FLAGS="-fsanitize=address,undefined -fno-sanitize-recover=all -fno-omit-frame-pointer" \ -DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address,undefined" cmake --build build/asan -j$(nproc) ctest --test-dir build/asan --output-on-failure

② 推荐:写进 CMakePresets.json,团队里所有人一条命令得到同样的配置

{ "name": "asan", "inherits": "dev", "binaryDir": "build/asan", "cacheVariables": { "CMAKE_BUILD_TYPE": "RelWithDebInfo", "CMAKE_C_FLAGS": "-fsanitize=address,undefined -fno-sanitize-recover=all -fno-omit-frame-pointer", "CMAKE_EXE_LINKER_FLAGS": "-fsanitize=address,undefined" } } # 用法:cmake --preset asan && cmake --build --preset asan && ctest --preset asan

③ CI:两个 job,一个 ASan+UBSan,一个 TSan(必须分开,不能合并)

# 以 GitHub Actions 为例,核心就是两条构建命令 # job: asan → gcc -fsanitize=address,undefined -fno-sanitize-recover=all 跑全部单测 # job: tsan → gcc -fsanitize=thread -pthread 跑并发相关测试 # 两个都设成"必须通过",UB 才算真的被挡住 # 顺带一提:Sanitizer 是"运行时"检测,还能再配一层"静态"检查 gcc -fanalyzer -c demo.c # GCC 10+ 自带的静态分析器,能提前看出部分空指针/资源泄漏 clang --analyze demo.c # Clang 静态分析器 clang-tidy demo.c -- -std=c17 # 结合 compile_commands.json 做规则检查
坑:用带 sanitizer 的构建去跑性能测试

ASan 会让程序慢 2 倍、TSan 慢十几倍,拿它们的耗时去做性能对比、或者去判断"某个优化是否有效",结论一律无效。同样地,不要用 sanitizer 构建做压测或上生产——它们的用途是"抓 bug",不是"跑得快"。正确分工:sanitizer 构建跑测试(抓 bug),普通 Release 构建跑性能测试(看数据)。

坑:看到 sanitizer 报告就"反正不影响功能"先跳过

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。