先做一个实验。
// race_counter.cpp - 一个"看起来没问题"的程序
#include <cstdio>
#include <thread>
long counter = 0; // 普通变量:无锁,无原子
void worker() {
for (int i = 0; i < 100000; ++i)
counter++; // 两个线程同时读-改-写同一地址
}
int main() {
std::thread a(worker), b(worker);
a.join(); b.join();
std::printf("counter = %ld\n", counter); // 期望 200000
}
g++ -O1 -g race_counter.cpp -pthread -o rc
./rc
多跑几次:200000,200000,198743,200000……偶尔少一点,多数完美。没有崩溃,没有报错,没有任何可见异常。多数人跑完这个实验的结论是:”基本没问题,丢几个计数而已,真在意就加个锁。”
但按 C++ 语言标准,从两个线程第一次同时执行 counter++ 的那一微秒起,这个程序就处于未定义行为(UB)之中–两个线程无同步地访问同一内存、且至少一个是写,这叫数据竞争(data race),标准对它的裁决只有一句:存在数据竞争的程序没有语义。”在 x86 上恰好只丢几次计数”是硬件的恩赐,不是程序的属性。换一颗内存模型更弱的 ARM 核心、换一个把读操作提到循环外的编译器版本,同一段代码可以从”永远只差那么几次”变成”系统性丢一半计数”,再变成偶发崩溃。程序有没有病,和你有没有看见症状,是两个独立的问题。
现在换一条命令跑同一个程序:
g++ -O1 -g -fsanitize=thread race_counter.cpp -pthread -o rc_tsan
./rc_tsan
输出(节选):
==================
WARNING: ThreadSanitizer: data race (pid=31234)
Write of size 8 at 0x55f3... by thread T2:
#0 worker() race_counter.cpp:7
Previous write of size 8 at 0x55f3... by thread T1:
#0 worker() race_counter.cpp:7
Location is global 'counter' of size 8 in race_counter.cpp:4
Thread T2 (running) created by main thread at:
#0 pthread_create
#1 main race_counter.cpp:13
==================
counter = 200000 照常打印,程序”一切正常”,但 TSan 已经把判决书写好了:第 7 行,两个线程,写同一块内存,中间没有任何同步关系。它没有等 bug 发作,它直接证明了 bug 存在。
这篇文章要讲的就是这件事背后的道理:ASan、TSan、UBSan 这类工具(统称 sanitizer,消毒器)为什么能做到”不惊动系统就拿到证据”?它们到底往你的程序里塞了什么机关?以及一个更根本、却几乎没人说破的问题–“非侵入”这三个字,到底是什么意思。
一、先校正一个流行的说法:sanitizer 并不”不改变时序”
在海森堡 bug 那一篇里,我说过一句话:”断点改变时序,探针不改。”作为入门级的判断,这句话够用;但严格说,它是错的,而且错得有价值。
逐个检查 sanitizer 的”扰动清单”:
- ASan 改变内存布局。每次堆分配周围都加红区(redzone),释放后的块不立即复用而是进隔离区。分配地址、对象间距、堆的增长轨迹,与普通构建完全不同。
- TSan 改变时序。5~15 倍的减速,线程交错方式天翻地覆。如果 bug 依赖某个特定的交错窗口,TSan 构建下那个窗口要么更容易撞上,要么彻底消失。
- UBSan 也在改。额外的检查指令改变了代码大小、内联决策和指令缓存的行为。
也就是说,sanitizer 恰恰是靠大规模扰动系统来工作的。它们有效,不是因为”不动”。
那为什么它们不像断点那样反噬?因为”扰动了什么”从来就不是关键。关键是下面这个判据。
二、核心判据:检测所依赖的变量,必须不是检测所扰动的变量
把任何一个观测手段拆成两半:
- 扰动项:它改变了被测系统的什么。
- 判定项:它宣布”有 bug”时,依据的是系统的什么。
用这个框架把常见手段过一遍:
- 断点。扰动项:冻结线程(时序)。判定项:你在场时撞见了那个时序窗口。判定的依据,恰好是被扰动的变量–你一在场,窗口就没了。反噬。这就是海森堡 bug 越调越远的机制。
- printf。扰动项:时序 + 堆分配序列(布局)。判定项:撞见窗口或落点。同样反噬。
- guard page 方案(老牌工具 Electric Fence 的思路):把每个分配单独放在一页的末尾、紧贴一页不可访问内存。扰动项:布局剧变、慢上百倍。判定项:越界恰好跨过页边界踩进保护页。越界一步、落点还在同页内?漏报。判定依赖”物理落点”,而落点正是被扰动项。靠运气的检测。
- ASan。扰动项:布局(红区 + 隔离区)。判定项:访问发生的那一刻,检查这个地址在影子内存里的毒化标记。它问的不是”你物理上踩到了哪个邻居”,而是”你踩的是不是这个对象自己的地盘”。布局变没变,不进入这个判定。判定 ≠ 扰动。扰动只是背景噪声,检测成立。
- TSan。扰动项:速度(时序)。判定项:两次访问之间在逻辑上是否存在 happens-before 偏序。物理上怎么交错、跑得多慢,不进入判定。成立。
- core dump。扰动项:无(系统已死)。判定项:尸体快照。平凡成立。
- rr/TTD 录制。录制期扰动最小(写 trace);判定在回放期做,而回放期的时序已冻结在 trace 里,你的观察不再能改写它。成立。
收成一张表:
| 手段 | 扰动项 | 判定项 | 判定依赖扰动项? |
|---|---|---|---|
| 断点/单步 | 时序 | 撞见时序窗口 | 是–越调越远 |
| printf | 时序、布局 | 撞见窗口/落点 | 是–越看越偏 |
| guard page | 布局 | 越界恰好跨页 | 是–漏报看运气 |
| ASan | 布局 | 影子毒化标记(逻辑边界) | 否 |
| TSan | 速度/时序 | happens-before 偏序(逻辑关系) | 否 |
| UBSan | 指令布局 | 类型系统/标准条款断言 | 否 |
| core dump | 无 | 快照 | 否(平凡成立) |
| rr/TTD | 写 trace(仅录制期) | 固化时序的回放 | 否(录制后解耦) |
一句话收拢成全文的地基:
“非侵入”不是”不扰动系统”,而是”检测所依据的断言,不依赖被扰动的那个变量”。
再往深一层看这张表:断点和 guard page 问的都是物理问题(”刚才物理上发生了什么巧合”),sanitizer 问的都是逻辑问题(”这次访问是否违反了程序应有的约束”)。物理巧合不可复现,逻辑断言每次执行都成立。这解释了开头的实验:TSan 不需要等那个”后果显形”的物理事件,因为”两次无序的冲突访问”是一个每次运行都在场的逻辑事实。
后面三节分别拆 ASan、TSan、UBSan 的判定项是怎么造出来的。看懂了机关,你才知道报告的每一行意味着什么、什么时候该信、什么时候该怀疑。
三、ASan:把”踩到了谁”换成”踩没踩进毒区”
影子内存:给每个字节配一份户口
ASan 的判定项,建立在一片影子内存(shadow memory)上:应用内存的每 8 个字节,对应影子区里 1 个字节,记录这 8 个字节的”户口状态”。x86-64 Linux 上的换算公式:
shadow_addr = (app_addr >> 3) + 0x7fff8000
一个影子字节的取值:
| 影子值 | 含义 |
|---|---|
0x00 | 8 字节全部可访问 |
0x01–0x07 | 仅前 k 字节可访问(对象尾部) |
0xf1/0xf2/0xf3 | 栈红区(左/中/右) |
0xf9 | 全局变量红区 |
0xfa/0xfb | 堆红区(左/右) |
0xfd | 已释放(隔离区)–use-after-free 的标记 |
编译器在每次内存访问(load/store)前插入几条指令,逻辑上等价于:
shadow = (addr >> 3) + 0x7fff8000;
if (*shadow != 0) // 命中毒区或部分可访问
__asan_report_load8(addr); // 慢路径:收栈、出报告、终止
// ……随后是原本的访问
注意这三条的性质:纯用户态、无系统调用、无锁、不冻结任何线程。命中毒区才进慢路径,不命中就是白走几条指令。这就是”查表即走”的物理形态–检查本身是确定性的,不构成对时序的干预。
红区与隔离区:让两类 bug 从”看运气”变成”必然”
红区:每次堆分配,ASan 在对象前后各垫一段红区(几十字节量级),全部标成 0xfa/0xfb。于是任何越界–哪怕只多写一个字节–落在的必然是毒区,与”物理上踩到了哪个邻居”无关。栈帧和全局变量同样有红区(0xf1–0xf3/0xf9)。
隔离区(quarantine):free/delete 发生时,ASan 不把内存还给分配器,而是标成 0xfd 塞进隔离区(默认总量 256MB,LIFO 逐出)。于是悬垂指针的任何读写,命中的必然是”已释放”毒标记,而不是被复用后的新数据。
把这两步连起来看 ASan 与 guard page 的本质区别:guard page 的判定是”越界恰好跨页”–物理落点问题;ASan 的判定是”这个地址是否在这个对象声明的边界内”–逻辑边界问题。前者问物理,后者问逻辑。这就是第二节判据在 ASan 上的具体兑现:红区确实扰动了布局,但判定读的是影子户口,不是物理邻居。
例 1:堆上 off-by-one
// asan_offby1.cpp
#include <cstdio>
int main() {
int* scores = new int[10];
for (int i = 0; i <= 10; ++i) // <=:多写一个
scores[i] = i;
std::printf("done\n");
delete[] scores;
}
g++ -O1 -g -fsanitize=address asan_offby1.cpp -o ao
./ao
=================================================================
==12345==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x602000000048
WRITE of size 4 at 0x602000000048 thread T0
#0 main asan_offby1.cpp:5
#1 __libc_start_main
#2 _start
0x602000000048 is located 0 bytes to the right of 40-byte region [0x602000000020,0x602000000048)
allocated by thread T0 here:
#0 operator new[](unsigned long)
#1 main asan_offby1.cpp:3
SUMMARY: AddressSanitizer: heap-buffer-overflow asan_offby1.cpp:5 in main
逐行读这份报告:
WRITE of size 4 ... #0 main asan_offby1.cpp:5:违规访问点,第 5 行,写 4 字节。0 bytes to the right of 40-byte region [0x...,0x602000000048):字节级精度。40 字节 =10 * sizeof(int),你写的正是这个区域右边第 0 字节–off-by-one 直接量出。guard page 对这种”同页内越界一步”是无能为力的。allocated by thread T0 here: ... asan_offby1.cpp:3:这块内存的出生证明。越界类错误给两栈(访问点 + 分配点);下面的 use-after-free 给三栈(多一个释放点)。
普通构建里,这个程序毫无症状:scores[10] 大概率落在分配器的填充字节或相邻对象里,安静地写坏一个没人看的数据。ASan 构建里,它在写下去的那条指令上当场被抓–不是后果显形,是行为违规。
例 2:栈溢出与 use-after-scope
// asan_stack.cpp
#include <cstdio>
#include <cstring>
void greet(const char* who) {
char buf[16];
std::strcpy(buf, who); // 无界拷贝:16 字节栈缓冲
std::puts(buf);
}
int main() {
greet("0123456789ABCDEF"); // 16 字符 + '\0' = 17 字节
}
==12346==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffc...
WRITE of size 17 at 0x7ffc... thread T0
#0 strcpy (interceptor)
#1 greet(char const*) asan_stack.cpp:5
#2 main asan_stack.cpp:10
Address 0x7ffc... is located in stack of thread T0 at offset 48 in frame
#0 greet(char const*) asan_stack.cpp:4
This frame has 1 object(s):
[32, 48) 'buf' <== Memory access at offset 48 overflows this variable
三个细节值得停下看:
WRITE of size 17:strcpy被 ASan 的拦截器(interceptor)接管,16 个字符加结尾\0共 17 字节一次判定,而不是逐字节试探。常用 libc 函数(memcpy/strcpy/strlen 一族)都有拦截器,检测粒度更粗、报告更完整。[32, 48) 'buf' <== Memory access at offset 48 overflows this variable:报告直接点名变量。buf在栈帧里的地盘是[32, 48)这 16 字节,你的写发生在 offset 48–地盘边界、越界起点、变量名,一行全给。普通构建里这是一次安静的栈污染;探针构建里它是判决书。- 栈一样有红区(
0xf1–0xf3)。顺带两个需要显式打开的开关:-fsanitize-address-use-after-scope抓”变量出作用域后仍被使用”(离开作用域的变量被标成0xf8);栈 use-after-return 需要运行时选项detect_stack_use_after_return=1,因为返回后的栈帧物理上还在,必须用”假栈”机制把语义补上。
use-after-free:三栈判决书
对 UAF,ASan 一次给全三栈:悬垂访问点、释放点、分配点。海森堡篇第五节的 release-only UAF 案例里有完整展示,此处不重复。这里只强调隔离区的价值:没有隔离区,释放的内存立刻被下一个 new 复用,悬垂读到的可能是”合法的新数据”,永远不报;有了隔离区,释放块保持 0xfd 毒化,悬垂读必然命中标记。代价是内存占用变大(这也是 ASan 构建 2~3 倍内存的来源之一:影子 1/8 + 红区 + 隔离区)。
开销与用法要点
- 约 2 倍减速、2~3 倍内存。减速来源:每次访存前的影子检查指令;内存来源:影子 + 红区 + 隔离区。
- 优化等级用
-O1:-O0把访问拆碎、又慢又噪;-O2可能把”本应违规的访问”优化掉。 - MSVC(VS 2019 16.9+):
cl /fsanitize=address ...,Windows 一侧可用。 - ASan 与 TSan/MSan 互斥(影子内存的地址映射冲突),一个进程只能开一家。
四、TSan:竞态的定义里没有”崩溃”两个字
先把”数据竞争”的定义摆正
C++ 内存模型给的定义:两个线程访问同一内存位置,至少一个是写,且这两个访问之间不存在 happens-before 偏序关系。注意这个定义的两个特点:第一,它没有提”崩溃”、”错乱”或任何后果;第二,它是一个逻辑关系的判定,不是物理时间的判定。两次访问发生在什么时刻、相隔多远,都不在定义里;定义只问:它们之间有没有”逻辑上的先后保证”。
happens-before 边从哪来?从同步原语来:线程创建/汇合(create/join)、互斥锁的解锁→加锁、原子操作、信号量。T1 unlock(m) 之后 T2 lock(m) 拿到同一把锁,则 T1 在锁内做的一切”happens-before” T2 锁后做的一切。
这套语言并非并发领域原创–它就是 Lamport 1978 年那篇《Time, Clocks, and the Ordering of Events in a Distributed System》里的偏序,分布式系统定序的基石。共享内存的多线程程序,在逻辑上就是一个分布式系统:物理时钟不可信,能信的只有因果关系。这个视角值得单独展开,这里先埋一句。
向量时钟:把”谁见过谁”变成可计算的量
TSan 给每个线程维护一个向量时钟(长度 = 线程数,第 i 格记录”我见过线程 i 的第几个事件”),每个 8 字节内存配若干条访问历史槽(记录最近几次访问的线程号与逻辑时刻)。同步原语执行时合并时钟:unlock 发布自己的时钟到锁上,lock 把锁上的时钟并入自己。于是”有没有 happens-before 边”变成一次向量比较。
用一个最小例子走一遍(T1、T2 两线程):
无同步:
T1 时钟 (1,0),写 x -> 历史槽记录 [T1@1]
T2 时钟 (0,1),写 x -> 查历史槽:上次是 T1@1,
但我的时钟里"没见过"T1 的第 1 个事件
-> 两访问无偏序 -> 数据竞争,报告
有一把锁:
T1 时钟 (1,0),写 x,unlock(m)(把 (1,0) 发布到 m)
T2 lock(m)(并入 (1,0) -> T2 时钟变 (1,1)),写 x
-> 查历史槽:上次是 T1@1,
我的时钟已见过 T1@1 -> 有偏序 -> 合法
整个判定过程里没有出现”纳秒”、”窗口”、”交错”这些物理词汇。这就是 TSan 的判定项:纯逻辑的偏序检查。它被 5~15 倍减速剧烈扰动的是物理时序,而物理时序不在判定式里–第二节判据的又一次兑现。
例 3(回收开头的实验):永不崩溃的竞争
开篇的 race_counter.cpp 里,程序每次运行都在”完美结果”或”丢几次计数”之间摇摆,永远不会崩。TSan 每次运行都稳定报告:
WARNING: ThreadSanitizer: data race
Write of size 8 ... by thread T2: #0 worker() race_counter.cpp:7
Previous write of size 8 ... by thread T1: #0 worker() race_counter.cpp:7
Location is global 'counter' ...
Thread T2 created by main thread at: ...
报告里有两次冲突访问的完整栈、内存的归属、两个线程的出生地。要读懂的重点是:“Previous write by thread T1″不是说 T1 先写完 T2 再写这种物理顺序,而是说在无偏序关系下这两次访问互相”看不见”对方。硬件层面 x86 对齐 8 字节写是原子的,所以后果温和;但语言层面这是 UB,编译器有权基于”无竞争”假设做优化–比如把 counter 的读取提升到循环外,让其中一个线程的十万次自增全部丢失。同一份源码,症状谱系从”看不出”到”丢一半”,全看编译器和硬件心情。硬件恰好无害,不等于语言定义良好。
例 4:同一个结构,后果谱系从”永不可见”到”偶发崩溃”
// race_lazyinit.cpp
#include <cstdio>
#include <thread>
struct Widget {
int id;
double ratio;
Widget() : id(42), ratio(3.14) {}
};
Widget* instance = nullptr;
Widget* get() {
if (!instance) { // 检查
instance = new Widget(); // 使用:检查与使用之间有时间窗
}
return instance;
}
void user() {
for (int i = 0; i < 1000; ++i) {
Widget* w = get();
if (w->id != 42) std::printf("corrupt!\n");
}
}
int main() {
std::thread a(user), b(user);
a.join(); b.join();
}
这段代码在多数机器上”永远正确”。但它的竞争结构(TOCTOU:check-then-act 不原子)能展开出完整的后果谱系:
- 最温和:两个线程都通过检查,各自构造一个 Widget,一个泄漏。无任何可见症状。
- 中等:在允许重排的平台或激进优化的编译器下,
instance = new Widget()的”分配-构造-发布”三步可能被观察者看到”先发布、后构造完成”,读到半成品对象。 - 最严重:与后续的
delete、跨线程销毁组合,演变成 use-after-free,偶发崩溃,海森堡式难调。
传统调试只能等到谱系最右端那个物理事件发生才开始工作;TSan 在谱系最左端就给出结构判决。这就是”证明存在”与”等待发生”的区别–海森堡篇方法论转向四(复现触发条件而非复现 bug)的工程化兑现。
TSan 的诚实边界
两条必须说清:
- TSan 依赖执行覆盖,不依赖后果。它报告的是”本次运行中实际发生的无序冲突访问对”。某段代码没被执行到,它无话可说。所以 TSan 的正确用法是配合全量测试、流量回放、压测,让代码尽量多地跑起来–而不是跑一次就下”无竞争”的结论。
- 误报与漏报都源于”逻辑边的可见性”。未经插桩的第三方库(汇编、手写原子、绕过 pthread 的自定义同步原语)不产生向量时钟事件,TSan 会误报(真同步但看不见边)或漏报(真竞争但边被误建)。自定义同步需要显式的 happens-before 注解(如
ANNOTATE_HAPPENS_BEFORE/AFTER);确认为良性的报告用 suppression 文件压制,不要用关闭 TSan 来”解决”。
五、UBSan:把”编译器的假设”翻译成显式断言
机制:在”假设被违反”的那一步插一条检查
Debug/Release 差异那篇讲过优化差异类 bug 的根源:C/C++ 标准把大量行为划为未定义,而优化器有权假设你的程序不包含 UB,并基于这个假设做变换。-O2 下的循环向量化、外提、死代码删除,全都踩在这条假设上。
UBSan 做的事情,从机制上说极其朴素:在”假设即将被违反”的那条指令前,插入一条检查;违反即报告。它不像 ASan/TSan 那样建影子内存,它就是散布在代码里的几百条断言,每条对应标准里的一条 UB 条款:
- 检查项是”纯静态可判”的(常量场景编译警告就能拦截),插桩主要针对运行期才知道值的场景;
- 常用集合:有符号溢出、移位越界、除零、空指针、错误对齐、枚举/bool 非法值、数组下标越界(
-fsanitize=bounds)、多态类型错误转换(vptr)。
判定项依然是逻辑的:”这次移位的指数是否 ≥ 位宽”、”这个加法是否在 int 的表示范围内”。标准条款写死的断言,与你程序跑多快、内存怎么排,毫无关系。
例 5:一个会”凭空消失”的溢出检查
老式 C 代码常用 x + 1 < x 当作”溢出检查”:若 x 是 INT_MAX,x + 1 回绕成 INT_MIN,这时 x + 1 < x 成立,函数返回真。
// ub_fold.cpp
#include <cstdio>
bool wraps(int x) {
return x + 1 < x; // "老式溢出检查":若 x+1 回绕变小则返回真
}
int main() {
std::printf("%d\n", wraps(2147483647)); // INT_MAX
}
三组对照(前两组为本文写作时的实测):
# MSVC:/Od 与 /O2 均输出 1
cl /Od ub_fold.cpp && ub_fold # 1:硬件按补码回绕,INT_MAX+1 == INT_MIN < INT_MAX,"检查成功"
cl /O2 ub_fold.cpp && ub_fold # 1:这个版本没有折叠它,检查"依然好用"
# clang / gcc:-O2 下输出 0
clang++ -O2 ub_fold.cpp -o f2 && ./f2
# 0:编译器推理--x+1<x 只能在有符号溢出时为真,而溢出是 UB,
# 因此假设它不发生 -> 表达式恒假 -> 函数体被折叠成 return false
# UBSan 在溢出发生的那一刻拦截
clang++ -O1 -g -fsanitize=undefined ub_fold.cpp -o fub && ./fub
# ub_fold.cpp:4:18: runtime error: signed integer overflow:
# 2147483647 + 1 cannot be represented in type 'int'
clang 的优化器(InstCombine)有一条明确的变换规则:带 nsw 标记的 x + 1 < x 恒为 false。这不是编译器的 bug,是它在行使标准赋予的权利–假设你的程序无 UB,并基于假设化简代码。
请注意前两组的对照有多别扭:MSVC 上这个检查”一直好用”,换到 clang/gcc 的 -O2 它就被合法地删没了。三份编译器、同一个源文件、三种答案,全部符合标准。这正是家族三(优化差异)最直观的演示:你测的是你的编译器,你发布的是用户的平台–而”用回绕检查溢出”这类代码的生命线,全系在编译器的宽容上。UBSan 的价值在此显形:它不关心各家优化器怎么处置这段代码,它只在”违反标准的那次运算”发生时报告。报告后默认继续执行(recover 模式,便于一轮跑完收集全部违规);加 -fno-sanitize-recover=undefined 可让它当场中止。
例 6:移位越界–硬件在替你打掩护
// ub_shift.cpp
#include <cstdio>
int main() {
int bits = 32; // 运行期才知道的移位数
std::printf("%d\n", 1 << bits); // x86 硬件把移位量按 32 取模
}
g++ -O1 -g -fsanitize=undefined ub_shift.cpp -o ub && ./ub
# ub_shift.cpp:6:26: runtime error: shift exponent 32 is too large for 32-bit type 'int'
对 32 位 int 移 32 位是 UB,但 x86 的 shl 指令默默把移位量取模,于是 1 << 32 打印 1,”看起来没事”。这是 UB 最阴险的形态:硬件恰好宽容,程序表面健康。而在 -O2 下,编译器有权基于”移位指数必小于位宽”做循环变换和向量化–假设一旦被运行期的 bits=32 击穿,下游一切变换的合理性失去根基。
开销与生产可用性:UBSan 是家族里唯一能进生产的
核心整数检查的运行时开销约在 1%~2% 量级(vptr 等重检查贵得多,按需开启)。更关键的是 trap 模式:-fsanitize-trap=undefined 让违规直接触发 CPU 陷阱指令(不链接运行时库、不格式化报告),开销进一步压低。Android 与 Fuchsia 都把部分 UBSan 检查以 trap 模式开进了正式版系统,靠崩溃上报管道收集违规点。这与 ASan/TSan 形成鲜明分工:
| ASan/TSan/MSan | UBSan(trap) | |
|---|---|---|
| 开销 | 2~15 倍 | 约 1% 量级 |
| 报告 | 完整符号栈 | 仅崩溃位置 |
| 位置 | CI / 验证构建 | 可进生产 |
最后一点分工:UBSan 是 ASan 的盲区补位者。有符号溢出、空指针、移位这些值层面的 UB 不产生内存布局异常,ASan 看不见;两者叠加(-fsanitize=address,undefined 是合法组合)才是一张接近完整的内存安全网。
六、家族全景:一个项目该养几个构建
把五件工具摊开,各自守一段检测域:
| 工具 | 检测域 | 开销(慢/内存) | 生产可用 | 互斥关系 | 编译器支持 |
|---|---|---|---|---|---|
| ASan | UAF、堆/栈/全局越界、double-free、(含 LSan)泄漏 | ~2 倍 / 2~3 倍 | 否 | 与 TSan、MSan 互斥 | gcc、clang、MSVC(VS2019+) |
| MSan | 未初始化读 | ~3 倍 / ~3 倍 | 否 | 与 ASan、TSan 互斥;需全量插桩 | 仅 clang |
| TSan | 数据竞争、锁序反转(潜在死锁) | 5~15 倍 / 5~10 倍 | 否 | 与 ASan、MSan 互斥 | gcc、clang(MSVC 无) |
| UBSan | 值与类型层面的 UB | ~1%~2%(trap 更低) | 可(trap) | 可与 ASan 叠加 | gcc、clang |
| LSan | 泄漏(进程退出时检测) | 近乎免费 | 可 | ASan 默认包含 | gcc、clang |
(HWASan 用硬件地址标签把影子内存降到 1/16,代价是 UAF 检测概率化,仅 ARM 平台,此处不展开。)
MSan 的”全量插桩”值得单独解释,它是影子内存机制的另一个推论:MSan 给每个字节配一个”是否已初始化”的影子位,程序读到毒位即报。但任何一段未插桩的代码(比如静态链接的第三方库)写内存时,不会顺手清掉影子位–写完的内存”看起来”仍是未初始化,反之,未插桩代码写入的垃圾也因影子未标毒而”洗白”。所以 MSan 构建必须连所有依赖一起插桩(含自编译的 libc++),工程门槛是五件工具里最高的。
工程结论也顺理成章:一个健康的 C++ 项目至少维护三个验证构建–ASan 构建跑内存、UBSan 构建跑值、TSan 构建跑并发(可选第四个:fuzz 构建,见第八节)。它们不能合并(互斥),也不该合并(各自 2~15 倍的开销叠起来没人跑得起)。
七、盲区与误报:探针的诚实边界
工具有边界,用的人得知道边界在哪,否则”全绿”会变成一种新的自欺。
ASan 的盲区:
- 未初始化读(MSan 的地盘)、数据竞争(TSan 的地盘)、纯逻辑错误(谁的地盘都不是)。
- 对象内越界:结构体里
char buf[16]溢出写到相邻成员int x,整块结构体都是”合法地盘”,影子不报。这是最常见的”ASan 明明开着为什么没抓到”。缓解:把热缓冲区单独堆分配,或使用实验性的字段填充选项。 - 容器内越界:
std::vector写到[size, capacity)区间,内存合法、逻辑违规。libc++(及较新 libstdc++)内置注解可查,需-fsanitize=address配合对应标准库并开启注解。 - 自定义分配器:绕过
malloc的内存池对 ASan 不透明,需要手动调用__asan_poison_memory_region一族接口登记毒区,否则池内越界全部漏报。
TSan 的盲区与误报:见第四节末尾–依赖执行覆盖;未插桩库与自定义同步导致误报/漏报;suppression 只压制报告,不消除竞争。
MSan 的门槛:全量插桩做不到,结果就不可信(见第六节)。
UBSan 的边界:默认集合不含无符号回绕(那是良定义行为,-fsanitize=unsigned-integer-overflow 可显式开启当 lint 用);vptr 检查需要 RTTI 且开销大,常单独权衡。
还有一条总纲,对五件工具都成立:“没报警”的确切含义是”在我能检查的断言范围内,本次执行没有违规”,仅此而已。探针是存在性证明工具,不是 absence 证明工具。把这句话贴在 CI 的通过报告旁边,能省掉很多事后追悔。
八、落地:把探针嵌入 CI 与生产
单次手工跑 sanitizer 只是体验;让它们持续工作才是工程。一份可直接抄的清单:
1. 构建矩阵(CI 上常驻三个 + 可选一个)
# ASan + UBSan(可叠加)
g++ -O1 -g -fsanitize=address,undefined -fno-omit-frame-pointer app.cpp -o app_asan
# TSan(独立构建,与 ASan 互斥)
g++ -O1 -g -fsanitize=thread -fno-omit-frame-pointer app.cpp -pthread -o app_tsan
-fno-omit-frame-pointer 让栈回溯在二进制里不依赖帧指针猜测,报告质量立竿见影。夜跑全量测试套件 + 真实流量回放;PR 只跑增量用例。
2. 关键运行时选项
ASAN_OPTIONS=detect_leaks=1:halt_on_error=0:abort_on_error=1:fast_unwind_on_malloc=0
TSAN_OPTIONS=halt_on_error=0:suppressions=tsan.supp
UBSAN_OPTIONS=print_stacktrace=1
halt_on_error=0 + abort_on_error=1 的组合:一轮跑完收集全部违规(不是第一个就停),但退出码非零让 CI 判红。fast_unwind_on_malloc=0 让分配栈更完整。
3. 与 fuzzing 的化学反应
探针解决”违规如何显形”,fuzzing 解决”如何让违规被触发”。-fsanitize=fuzzer,address 组合下,覆盖率引导的模糊测试不断生成新输入,每一个触发内存违规的输入都在违规指令处当场爆炸、附完整输入。两者合一才是完整的显形机–OSS-Fuzz 用这套组合在开源世界累计挖出数以万计的内存与 UB 缺陷,绝大多数在发作前就被拦在 CI。
4. 生产侧的分工
- UBSan trap 模式随正式版上车(第五节),崩溃上报管道即违规收集管道。
- LSan 几乎免费,例行跑一次退出检测即可。
- ASan/TSan 不进生产,除非你有影子流量集群专门承接验证构建。
5. 探针抓不到时的接力棒
当 bug 只在真实生产负载、特定数据下触发,验证构建的测试套件覆盖不到执行路径时,探针让位:改用录制–Windows 走 WinDbg TTD,Linux 走 rr,录一次真实失败再离线回放(另见《逆向调试原理与应用》,暂未发布);或者等尸体,取 core 离线分析(另见《转储文件实战全解》,暂未发布)。三件武器的分工就一句:探针查结构,录制还时序,尸检看现场。
6. 一个与 AI 时代有关的收口
这套东西的价值正在被动抬升。AI 生成的代码有一个系统性特征:看起来正确的比例极高,而”看起来正确”恰恰意味着结构性的内存违规、越界、UB 更容易混过人眼审查(AI 写的 bug,长得很像正确的代码)。当生成代码的吞吐量上去,”逐行人审”作为唯一质量闸门的成本曲线必然崩掉,而探针是唯一能在同一成本量级上扩展的结构校验器–它不读意图,只验断言,恰好是 AI 代码最需要的那种审稿人。五年前 sanitizer 是高级技巧,今天它是基础设施。
九、收尾:从”撞见 bug”到”证明 bug 存在”
回头看这篇文章走过的路。
开头的实验里,一个数据竞争”安静地正确”了三年。传统调试对它的姿态是等待:等某个物理后果–一次崩溃、一次错乱–显形,然后从后果倒查。这个姿态的困境,海森堡篇已经论证过:你的观察本身会改写结果,越努力越远。而 sanitizer 换了一个根本不同的姿态:不等待物理事件,直接验证逻辑断言。
三件武器在这个框架下各就各位:
- core dump 是尸检–等物理事件(死亡)发生,然后解剖现场;
- rr/TTD 是录像–把一次物理运行的时序固化,事后反复重演;
- sanitizer 是布进系统里的监控探针–不等事件,只验约束。
它们共享同一个判据,也就是第二节那句地基:检测所依据的断言,不依赖被扰动的那个变量。断点之所以反噬,是因为它的判定变量(时序窗口)恰是它的扰动项;探针之所以成立,是因为它把判定从”物理巧合”(撞见窗口、恰好跨页、踩对邻居)改写成了”逻辑违规”(踩进毒区、偏序缺失、违反标准条款)。物理巧合需要运气,逻辑断言每次执行都在场。
最后一层,回到认识论。这个系列反复说的一句话是:调试是与一个会因你而变的系统对话,你读到的永远是观测方式下的投影。sanitizer 没有取消这句话–TSan 构建下的系统照样不是原生系统,ASan 构建下的堆照样不是原生堆。它做的是更聪明的一步:既然任何观测都有投影,那就设计一种投影不变的量。无论时序怎么被拖慢、布局怎么被重排,”这次访问是否在对象边界内”、”这两次访问是否无序”,答案不变。工程上这叫确定性检测,认识论上这叫在依赖的变量与扰动的变量之间筑一道墙。
下一层已经若隐若现:TSan 判定并发正确性时用的 happens-before 偏序,正是 Lamport 为分布式系统发明的定序语言–共享内存的多线程程序,逻辑上就是一个分布式系统;而”没有特权观测者、每个观测者都有自己的时钟与因果锥”,还有更大的版图可展开。那是另一篇文章的事。
眼下可以带走的是三句话:
程序有没有病,和你有没有看见症状,是两个独立的问题。
探针不治疗疾病,它把”带病运行”从侥幸变成一份可读的报告。
非侵入不是不打扰,而是让证据不依赖你打扰过的那个东西。
附录 A:症状指纹 → 探针速查表
(家族划分详见海森堡篇第三节)
| 症状指纹 | 家族 | 首选探针 | 报告关键词 |
|---|---|---|---|
| 多线程、单步永不复现、加日志变好 | 一(时序) | TSan | data race / lock-order-inversion |
release 才崩、读到 0xCD/0xDD 模式垃圾 | 二(初始化) | ASan(UAF/越界)、MSan(未初始化读) | heap-use-after-free / use-of-uninitialized-value |
-O2 才崩、-O0 不崩、崩溃栈无辜 | 三(优化/UB) | UBSan | signed integer overflow / shift exponent / misaligned |
| 崩溃栈每次漂移、换机器/构建号就变 | 四(布局) | ASan | heap-buffer-overflow / stack-buffer-overflow / global-buffer-overflow |
| 怀疑泄漏 | – | LSan(含于 ASan) | detected memory leaks |
| 探针全绿但线上仍偶发 | – | 放弃探针,转录制/尸检 | TTD / rr / core dump |
附录 B:编译选项与运行时选项速查
# ASan + UBSan(叠加合法)
g++ -O1 -g -fsanitize=address,undefined -fno-omit-frame-pointer app.cpp -o app_au
# TSan(独立构建)
g++ -O1 -g -fsanitize=thread -pthread -fno-omit-frame-pointer app.cpp -o app_t
# MSan(需全量插桩,含依赖;仅 clang)
clang++ -O1 -g -fsanitize=memory -fno-omit-frame-pointer app.cpp -o app_m
# UBSan 生产 trap 模式(不链运行时库)
g++ -O2 -fsanitize=undefined -fsanitize-trap=undefined app.cpp -o app_prod
# fuzz 构建(覆盖率引导 + ASan)
clang++ -O1 -g -fsanitize=fuzzer,address app.cpp -o app_fuzz
# 常用运行时选项
ASAN_OPTIONS="detect_leaks=1:halt_on_error=0:abort_on_error=1:fast_unwind_on_malloc=0"
TSAN_OPTIONS="halt_on_error=0:suppressions=tsan.supp"
UBSAN_OPTIONS="print_stacktrace=1"
# MSVC(VS 2019 16.9+)
cl /EHsc /O1 /Zi /fsanitize=address app.cpp
注意:MSVC 只支持 ASan;TSan/MSan 请用 gcc/clang(Linux/macOS 主场)。
相关文章
- 你越想看清它,它就越不存在:海森堡 bug 与调试的观察者效应–本文的枢纽前篇:三件武器与”解耦测量与系统”的方法论
- AI 写的 bug,长得很像正确的代码:一场调试认识论的重写–为什么 AI 时代探针从技巧变基础设施
- Visual Studio 中 Debug 模式和 Release 模式有什么区别–家族三(优化差异)的机制前传

发表回复