AddressSanitizer / UBSan
更新时间:2026-09-24。本文是
languages/c/主题专家层文档。崩溃排查主线里的 AddressSanitizer (ASan) 原理 讲了"怎么看报错",本篇往编译器和运行时内部再钻一层:ASan UBSan 这对组合究竟靠什么机制,把内存错误和未定义行为(Undefined Behavior,简称 UB)从"线上玄学崩溃"变成"测试阶段首次越界即精确报错"?文中所有数字都来自上游 sanitizer 文档与常见 x86-64 + gcc/clang 实测量级,具体到你的工作集会有出入,但数量级稳定。
本文要回答的问题
- ASan 的影子内存是什么?"8:1 映射"凭什么能判断任意一次访问是否合法?
- 堆越界、use-after-free、double-free 分别是被哪个机制抓到的?ASan 又抓不住什么?
- UBSan 检查哪些 UB,开销为什么比 ASan 小得多?
- LSan、TSan 和它们怎么组合?ASan UBSan 在 CI 里的构建矩阵应该怎么搭?
- 既然这么好用,为什么 release 包默认不带 sanitizer?
一、先看代价:不用 sanitizer 时,你在失去什么
一个堆越界写,程序可能当场没事,因为踩的是同 arena 里另一块空闲内存;三小时后另一个线程 malloc 拿到那块内存,链表元数据早已损坏,于是崩在一个与 bug 现场毫无关系的地方。这类"延迟爆炸"的问题,靠 core dump 验尸极难追溯——你看到的是第二现场。排查工具链里 Valgrind 的原理(动态二进制插桩,不用重编译但慢 10-50 倍)和 double free 为什么会 crash(ptmalloc 链表不变量)讲的都是这个困境的侧面。
ASan UBSan 的思路完全不同:编译器在每次内存访问前插入检查代码,运行时在错误发生的那一条指令上直接 abort。你拿到的是第一现场——错误访问的地址、访问栈、(堆对象的)分配栈和释放栈。代价是 2 倍左右的运行时开销和数倍的内存占用,所以它属于测试与 CI 工具,而不是线上配置。
二、ASan 机制:影子内存、redzone 与隔离队列
ASan 的运行时把整个虚拟地址空间一分为二:应用程序正常使用的内存,以及一块与之对应的影子内存(shadow memory)。映射规则是经典的 8:1——应用内存每 8 字节对应影子内存里 1 字节,该字节记录这 8 个字节的可访问状态:
- 值为
0:8 字节全部可访问; - 值为
k(1-7):前k字节可访问,后面8-k字节不可访问(这就是为什么 8 字节粒度也能发现 1 字节溢出); - 值为负数(如
-1、-2等):整 8 字节不可访问,不同负值区分堆 redzone、栈 redzone、已释放内存等。
x86-64 上映射通过一条移位加偏移的地址变换完成,编译插桩后的一次访问检查只有几条指令:算出影子地址 → 读影子字节 → 与访问长度比较 → 违规则进入报告函数。

光有影子内存还不够,另外两个机制补齐检测面:
- redzone(红色禁区):每个栈对象、堆块、全局变量周围都插入至少 16-32 字节(可配到更大)的毒区。于是"多读 1 字节"立刻踩毒,而不是踩到隔壁合法对象。
- 隔离队列(quarantine):
free()并不立刻把内存归还分配器,而是先放入一个 FIFO 隔离队列,整块内存被标记为"已释放毒区",直到队列体积超阈值才真正回收。这保证了典型的 use-after-free(释放后很快又访问)必然撞毒——如果立刻复用,UAF 反而检测不到。
这也解释了 ASan 内存放大的两个来源:redzone 让每个对象变胖,隔离队列让释放的内存延迟归还。对大量小对象、大量 malloc/free 的程序,驻留内存涨到 3-5 倍并不奇怪。
栈对象还有两个值得知道的进阶开关。ASAN_OPTIONS=detect_stack_use_after_return=1(编译时配合 -fsanitize-address-use-after-return=runtime)会在函数返回后把整个栈帧对应的影子内存毒化,专门抓"返回局部变量地址到外面继续用"的错误,代价是这类栈帧改由一块假栈(fake stack)分配,内存开销进一步上升。-fsanitize-address-use-after-scope 则在变量离开作用域的一刻就毒化它,能抓到"同一函数后半段误引用已失效变量"——这两类错误普通 redzone 都无能为力,因为访问发生时栈内存本身仍合法地属于当前线程。
三、能抓什么、抓不到什么,以及开销数字
| 检测器 | 能抓的典型错误 | 抓不到 / 弱项 | 典型运行时开销 | 内存开销 |
|---|---|---|---|---|
| ASan | 堆/栈/全局缓冲区越界、use-after-free、use-after-return(可选)、double-free、new[]/delete 不配对 | 未初始化内存读取(交给 Valgrind/MSan)、任意类型的 UB、内存泄漏(默认交给 LSan) | 约 2x(CPU) | 约 2-5x RSS |
| UBSan | 有符号整数溢出、空指针/野指针解引用、越界枚举值、对齐违规(misaligned pointer)、非法移位、整数转换截断、bool 存非 0/1、函数类型不匹配、[[noreturn]] 返回、vptr(C++,需 RTTI) | 内存越界、UAF(它不跟踪对象生命周期);无符号溢出不是 UB,默认不管 | 多数检查 < 1.2x;开 recovery/全检查约 1.5x 内 | 几乎可忽略 |
| LSan | 进程退出前仍不可达的堆分配(泄漏),打印分配栈 | 仍被指针引用但逻辑上已无用的内存;长生命周期服务需触发检测点 | 极低(只在退出时做一次标记-扫描) | 小 |
| TSan | 数据竞争(缺同步的并发读写),原理见 ThreadSanitizer | 与 ASan 不能同时开(运行时冲突,需分构建) | 约 5-15x | 约 5-10x |
几个值得记住的边界:ASan 的越界检测精度受 redzone 宽度限制——如果溢出跨度恰好大到越过 redzone 落到下一个对象(实践中极少见,可加配增大 redzone);栈上数组的检查依赖编译器能识别其边界;而 UAF 检测窗口受隔离队列大小影响,队列越小、free 到再次访问之间穿插的分配越多,越可能漏。
四、动手:一个含堆越界与 UAF 的最小 demo
// demo.c
#include <stdlib.h>
#include <string.h>
int main(void) {
char *p = malloc(8);
char *q = malloc(8);
p[8] = 'x'; // (1) 堆越界写 1 字节,踩中 p 的 redzone
free(q);
*q = 'y'; // (2) use-after-free:q 还在隔离队列里
free(p);
free(p); // (3) double free
return 0;
}编译命令(clang 或 gcc 均可,-g 是栈符号化的必要条件,-O1 让代码更接近真实构建):
gcc -fsanitize=address,undefined -g -O1 -fno-omit-frame-pointer demo.c -o demo
./demoASan 会在第一个错误(越界写 p[8])处立即停下,关键输出结构如下:
==12345==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x602000000018
WRITE of size 1 at 0x602000000018 thread T0
#0 0x... in main demo.c:9 <- 访问栈:错误就在这一行
0x602000000018 is located 0 bytes to the right of 8-byte region [...]
allocated by thread T0 here:
#0 0x... in malloc
#1 0x... in main demo.c:6 <- 分配栈:这块内存在这里分配
SUMMARY: AddressSanitizer: heap-buffer-overflow demo.c:9 in main逐行读:错误类型(heap-buffer-overflow,越界;UAF 时是 heap-use-after-free,double free 时是 attempting double-free)→ 操作性质(READ/WRITE)与大小 → 访问栈 → 出错地址相对对象的位置("right of 8-byte region"即越界 0 字节、刚好踩线)→ 该对象的分配栈(UAF 还会额外给出 free 栈)。注释掉 (1) 再跑,会依次在 (2)、(3) 处报出另外两类错误,建议亲手跑一遍观察三种报告的差异。
五、UBSan:低开销的"语言规则警察"
ASan 管"这次内存访问合不合法",UBSan 管"这个操作在 C 语言标准下是不是 UB"。UB 的危险在于编译器可以假设它不发生并据此优化——有符号溢出后循环边界被改写、错位访问被假定不发生而删掉对齐处理,详见本站 UB 与编译器优化。UBSan 在这些操作点插入检查,把"编译器悄悄利用"变成"运行时明确报告"。
常用编译选项:
| 选项 | 作用 | 备注 |
|---|---|---|
-fsanitize=address | 开启 ASan(同时默认链接 LSan,Linux 上) | 与 TSan 互斥 |
-fsanitize=undefined | 开启 UBSan 一组核心检查(溢出/对齐/移位/枚举/转换等) | 不含 alignment 在部分平台的限制,可用子项精确控制 |
-fsanitize=undefined -fno-sanitize-recover=all | 首个 UB 即 abort | 默认是 recovery(打印后继续),CI 里建议关掉 recovery |
-fsanitize=leak | 单独开 LSan | 已被 address 默认包含,单独用于轻量泄漏构建 |
-fsanitize-recover=all | 报告后继续执行 | 适合本地一次性收集全部 UB |
-fno-omit-frame-pointer | 保留帧指针 | 让调用栈展开更快更准 |
-fsanitize-blacklist=file.txt | 屏蔽指定函数的插桩 | 处理第三方/已知噪声 |
UBSan 开销小的原因是它没有影子内存、没有地址变换,多数检查就是一条比较加条件跳转,分支预测器几乎总能猜对。代价是它只在具体违规发生时报警——代码路径没跑到,UB 依然潜伏。所以 UBSan 的价值要靠测试覆盖率和 fuzz 来放大,这正是下一节构建矩阵的意义。
六、LSan 与 CI 构建矩阵:把三种检测器排进流水线
LSan(LeakSanitizer)在 Linux 上已随 ASan 自动启用:进程退出时,它对堆做一次类似 GC 的标记-扫描——从全局变量、寄存器、栈出发标记所有"可达"分配,剩下的不可达块就是泄漏,逐个打印分配栈。在长期运行的服务里,你也可以在健康检查点调用 __lsan_do_recoverable_leak_check() 主动触发。深入实践见下一篇 内存泄漏检测。
工程上,真正起作用的是 CI 矩阵而不是单个构建:

解读这张矩阵:ASan 与 TSan 运行时冲突不能同构建,所以拆成两个 job;UBSan 单独构建是因为它开销低,可以跑完整的长测试集而 ASan job 只跑核心用例;fuzz job 与 模糊测试与输入解析器 对接,把新生成的语料先过 sanitizer——fuzz 负责"逼出路径",sanitizer 负责"认出错误",两者是互相放大的关系。矩阵里每个 job 都应设 ASAN_OPTIONS=abort_on_error=1:halt_on_error=1:detect_leaks=1,让非零退出码稳定地阻断流水线。
七、为什么 release 不带,以及常见坑
release 不带 sanitizer 是三个硬约束的叠加:约 2x 的 CPU 税对线上吞吐直接是成本;2-5x 的内存税改变容量规划;插桩运行时还会让二进制体积显著增大,并与自定义内存分配器、静态链接、某些 JIT/信号处理方式冲突。sanitizer 的定位是让 bug 在到达 release 之前暴露,而不是在线上兜底。
| 常见坑 | 表现 | 处理 |
|---|---|---|
没加 -g | 栈轨迹只有地址没有行号 | 编译加 -g,或用 符号分离 保留调试符号 |
| release 也想带 | 内存翻倍、OOM | 仅 debug/CI/fuzz 构建开启 |
| 自定义 malloc / 静态链接 | ASan 拦截失效或启动即崩 | ASan 必须最先加载;用系统分配器或实现拦截接口 |
| UBSan 默认 recovery | CI 输出报错但退出码 0 | 加 -fno-sanitize-recover=all |
| 隔离队列太小漏 UAF | 偶发检测不到 | 调大 ASAN_OPTIONS=quarantine_size_mb=... |
| 泄漏误报(指针被混淆加密) | LSan 误判正常分配泄漏 | 用 __lsan_ignore_object 或 suppression 文件 |
这一篇在知识体系里的位置
- 崩溃排查主线的姊妹篇:ASan UBSan 报错阅读法、Valgrind(不用重编译,抓未初始化读)、ThreadSanitizer(数据竞争)。
- 上游机制:malloc 内部原理 是理解 redzone 与隔离队列为何能挂进分配器的基础;UB 与编译器优化 解释 UBSan 报警的那些操作为什么危险。
- 下游实践:模糊测试与输入解析器 与 sanitizer 互为放大器;内存泄漏检测 展开 LSan 的工程细节。
总结
ASan UBSan 是测试侧性价比最高的动态防线:ASan 靠 8:1 影子内存、redzone 和隔离队列在越界/UAF/double-free 的第一条指令上抓现行(约 2x CPU、2-5x 内存),UBSan 以极低开销把有符号溢出、错位、非法枚举等未定义行为变成显式报错,LSan 在退出时扫出泄漏;正确用法是在 CI 中与 TSan、fuzz 组成并行构建矩阵、带 -g 并关闭 recovery——让 bug 死在合并之前,而不是带着 2x 开销进 release。