非侵入探针:ASan/TSan/UBSan 如何让 bug 在发作之前显形

作者:

先做一个实验。

// 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

多跑几次:200000200000198743200000……偶尔少一点,多数完美。没有崩溃,没有报错,没有任何可见异常。多数人跑完这个实验的结论是:”基本没问题,丢几个计数而已,真在意就加个锁。”

但按 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 恰恰是靠大规模扰动系统来工作的。它们有效,不是因为”不动”。

那为什么它们不像断点那样反噬?因为”扰动了什么”从来就不是关键。关键是下面这个判据。


二、核心判据:检测所依赖的变量,必须不是检测所扰动的变量

把任何一个观测手段拆成两半:

  1. 扰动项:它改变了被测系统的什么。
  2. 判定项:它宣布”有 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

一个影子字节的取值:

影子值含义
0x008 字节全部可访问
0x010x07仅前 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。于是任何越界–哪怕只多写一个字节–落在的必然是毒区,与”物理上踩到了哪个邻居”无关。栈帧和全局变量同样有红区(0xf10xf3/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 17strcpy 被 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–地盘边界、越界起点、变量名,一行全给。普通构建里这是一次安静的栈污染;探针构建里它是判决书。
  • 栈一样有红区(0xf10xf3)。顺带两个需要显式打开的开关:-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 的诚实边界

两条必须说清:

  1. TSan 依赖执行覆盖,不依赖后果。它报告的是”本次运行中实际发生的无序冲突访问对”。某段代码没被执行到,它无话可说。所以 TSan 的正确用法是配合全量测试、流量回放、压测,让代码尽量多地跑起来–而不是跑一次就下”无竞争”的结论。
  2. 误报与漏报都源于”逻辑边的可见性”。未经插桩的第三方库(汇编、手写原子、绕过 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 当作”溢出检查”:若 xINT_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/MSanUBSan(trap)
开销2~15 倍约 1% 量级
报告完整符号栈仅崩溃位置
位置CI / 验证构建可进生产

最后一点分工:UBSan 是 ASan 的盲区补位者。有符号溢出、空指针、移位这些值层面的 UB 不产生内存布局异常,ASan 看不见;两者叠加(-fsanitize=address,undefined 是合法组合)才是一张接近完整的内存安全网。


六、家族全景:一个项目该养几个构建

把五件工具摊开,各自守一段检测域:

工具检测域开销(慢/内存)生产可用互斥关系编译器支持
ASanUAF、堆/栈/全局越界、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:症状指纹 → 探针速查表

(家族划分详见海森堡篇第三节)

症状指纹家族首选探针报告关键词
多线程、单步永不复现、加日志变好一(时序)TSandata race / lock-order-inversion
release 才崩、读到 0xCD/0xDD 模式垃圾二(初始化)ASan(UAF/越界)、MSan(未初始化读)heap-use-after-free / use-of-uninitialized-value
-O2 才崩、-O0 不崩、崩溃栈无辜三(优化/UB)UBSansigned integer overflow / shift exponent / misaligned
崩溃栈每次漂移、换机器/构建号就变四(布局)ASanheap-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 主场)。

相关文章

评论

一条对“非侵入探针:ASan/TSan/UBSan 如何让 bug 在发作之前显形”的回复

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注