ASan内存三件套
# 第21章:ASan 内存三件套
# 目录介绍
- 1. 案例引入:两类内存 bug
- 2. 内存错误九宫格
- 3. Sanitizer 全家桶
- 4. ASan 原理拆解
- 5. ASan 报告解读
- 6. ASan 配置进阶
- 7. UBSan 未定义行为
- 8. TSan 数据竞争
- 9. LSan 内存泄漏
- 10. 内存排查方法论
- 11. 典型场景速查
- 12. CI 集成与生产
- 13. 综合案例串讲
# 1. 案例引入:两类内存 bug
上一篇讲"信号崩溃怎么定位"——能拿到栈、能看到信号,但很多内存 bug 不在崩溃现场被发现。它们埋伏在堆上,运行 100 次崩 1 次,崩在远离 bug 的地方。本篇用两条主线讲 ASan 怎么把这种 bug "在第一现场拍住"。
# 1.1 主线一:偶发 UAF
某后台服务,每天凌晨偶发一次崩溃,core dump 总是停在不同位置:有时在 std::string 析构、有时在 malloc_consolidate、有时甚至在毫不相干的 RPC 回调里。日志里能找到的唯一规律是——每次崩溃前都触发了一次会话清理。
代码大致长这样:
// session_manager.cpp
struct Session {
std::string user_id;
Connection* conn;
Buffer* buf; // 业务缓冲
};
void cleanup(Session* s) {
delete s->buf;
delete s; // ← s 也释放了
}
void on_response(Session* s, const Response& r) {
s->buf->append(r.body); // ⚠️ 异步回调,s 可能已 cleanup
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
现象:
- 测试环境(单连接、QPS=10):跑一周不崩
- 生产环境(QPS 5 万、清理周期 10 分钟):每天崩 1~3 次,栈各不相同
- gdb 看 core:
s->buf有时是0x0,有时是合法地址(指向别的对象)
这是典型的 UAF + 内存复用——delete s 后这块内存可能被其他 malloc 拿走,再访问 s->buf 读到的是新对象的字节,可能崩在任何一行。core 抓到的位置和 bug 产生的位置完全脱节。
ASan 一上场:
==12345==ERROR: AddressSanitizer: heap-use-after-free on 0x603000004018
READ of size 8 at 0x603000004018 thread T7
#0 0x4a... in on_response(Session*, Response const&) session.cpp:42
#1 0x4b... in RpcClient::on_recv(...) rpc.cpp:115
freed by thread T3 here: ← ⭐ 释放点
#0 0x7f... in operator delete(void*)
#1 0x4c... in cleanup(Session*) session.cpp:18
previously allocated by thread T0 here: ← ⭐ 分配点
#0 0x7f... in operator new(unsigned long)
#1 0x4d... in SessionManager::create() manager.cpp:23
2
3
4
5
6
7
8
9
10
11
12
三个栈一次给齐——当前访问点、释放点、分配点。15 分钟从"凌晨偶发崩溃"定位到"第 18 行 delete 后第 42 行还在用"。
# 1.2 主线二:越界 1 字节
另一个项目,单元测试 100% 通过,但客户反馈 "输入特定 UTF-8 字符串时偶尔显示乱码"。用 gdb 复现不出来——程序不崩,行为只是"偶尔不对"。
最小化复现案例:
// utf8_decode.cpp
#include <cstring>
#include <iostream>
void decode(const char* src, size_t n) {
char* buf = new char[n]; // ① 分配 n 字节
std::memcpy(buf, src, n);
// 处理逻辑(简化)
for (size_t i = 0; i <= n; ++i) { // ⚠️ <= 而不是 <
if (buf[i] == '\0') break; // ② 越界读 1 字节
}
std::cout << buf << "\n";
delete[] buf;
}
int main() {
decode("hello", 5); // n=5,buf[5] 越界
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
普通编译、普通运行——绝大多数情况下都看不出问题:buf[5] 那个字节恰好是堆元数据或下一个块的内容,读到啥都不会立刻崩。bug 沉睡在那里,直到某天它读到的字节恰好是 \0 或者某个会让上层逻辑分叉的值——表现为"诡异的 1 字节差异"。
ASan 视角:
==45623==ERROR: AddressSanitizer: heap-buffer-overflow on 0x602000000015
READ of size 1 at 0x602000000015 thread T0
#0 0x10... in decode(char const*, unsigned long) utf8_decode.cpp:9
#1 0x10... in main utf8_decode.cpp:18
0x602000000015 is located 0 bytes to the right of 5-byte region
[0x602000000010,0x602000000015) allocated by thread T0 here:
#0 0x10... in operator new[](unsigned long)
#1 0x10... in decode(char const*, unsigned long) utf8_decode.cpp:5
2
3
4
5
6
7
8
9
关键三句话:
- 错误类型:
heap-buffer-overflow(堆越界) - 位置:
utf8_decode.cpp:9,正是buf[i] - 偏移:合法区
[0x10,0x15)共 5 字节,访问的是0x15——右侧 0 字节处,越了恰好 1 字节
ASan 把"非崩溃 bug"变成了"100% 复现的崩溃"。这就是它的核心价值——从概率 bug 到必然 bug。
# 1.3 为什么 gdb 不够用
| 维度 | gdb / lldb | ASan |
|---|---|---|
| 触发条件 | 必须先崩 | 触发非法访问就报,不需要崩 |
| 信息粒度 | 崩溃那一刻的寄存器/栈 | 当前点 + 释放点 + 分配点 三栈齐发 |
| UAF 能查吗 | 看到的是"被复用后的字节",常常误导 | shadow 标记 freed,立刻命中 |
| 1 字节越界 | 几乎查不到 | 红区直接拍到 |
| 能不能找到没崩的 bug | 不能 | 能(只要被触发) |
| 启动成本 | 0 | 重编(2-5x 慢、2-3x 内存) |
| 适用阶段 | 生产事后 | 开发 + CI |
结论:事后 gdb 看的是"症状",运行时 ASan 看的是"病灶"。两者不是替代关系,而是互补——开发期 ASan 拦截,生产期 gdb 兜底。
# 1.4 本篇要回答什么
| 层次 | 你将学到 |
|---|---|
| 错误层 | 9 类内存错误,每一类的代码形态与触发条件 |
| 工具层 | ASan / UBSan / TSan / LSan 各管什么、何时用 |
| 原理层 | shadow memory、红区、quarantine、影子映射的字节级原理 |
| 报告层 | 怎么读三栈、shadow bytes、为什么这些字节是这个值 |
| 方法层 | "立刻暴露 → 三角验证 → 第一现场修复"的方法论 |
| 工程层 | CI 集成、fuzz 配合、生产取舍、HWASan/MTE 等下一代方案 |
📌 本篇定位:上一篇讲的是"崩溃后怎么定位",本篇讲的是**"怎么让内存 bug 在第一现场暴露,根本不给它沉睡到生产环境的机会"**。读完本篇,再看任何 C++ 内存类 bug,都能立刻回答:"用哪个 Sanitizer、它会报什么、报告里哪几行是根因"。
# 2. 内存错误九宫格
把 C++ 内存错误穷举成 9 类,每一类对应不同的 Sanitizer 检测能力。先把分类记牢,再看工具就能"对号入座"。
# 2.1 堆越界读写
int* p = new int[10];
p[10] = 1; // heap-buffer-overflow
int x = p[-1]; // heap-buffer-underflow(罕见)
delete[] p;
2
3
4
底层原理:new int[10] 通过 malloc 分配 40 字节合法区,紧邻的内存属于其他对象(或堆元数据)。越界写会破坏邻居或 chunk header,写到 chunk header 通常导致下次 free 时 SIGABRT。
ASan 检测:在每块堆分配的前后插入 redzone(红区,默认 16 字节),任何对红区的访问立刻报 heap-buffer-overflow。
# 2.2 栈越界读写
void f() {
int a[10];
a[10] = 1; // stack-buffer-overflow
char buf[16];
std::strcpy(buf, "this is way too long for buf"); // 经典
}
2
3
4
5
6
底层原理:栈帧是连续的,越界会覆盖保存的寄存器值、返回地址、调用方的局部变量。strcpy 把返回地址踩成乱码,函数返回时 ret 跳到非法地址 → SIGSEGV/SIGILL。
ASan 检测:在每个栈对象前后插红区,函数入口 poison 红区,函数返回前 unpoison。
# 2.3 全局区越界
int g_arr[10];
void f() {
g_arr[10] = 1; // global-buffer-overflow
}
const char STR[] = "hello";
void bad() {
char c = STR[10]; // 同样是 global-buffer-overflow
}
2
3
4
5
6
7
8
9
底层原理:全局/静态变量在 .data/.bss/.rodata,链接器决定布局,越界一般打到下一个全局变量。
ASan 检测:在每个全局变量前后插红区,链接时调整布局。
# 2.4 use-after-free
int* p = new int(42);
delete p;
*p = 100; // heap-use-after-free
2
3
底层原理:delete 把内存还给堆分配器(glibc ptmalloc / tcmalloc / jemalloc),分配器可能立刻把它给下一次 new——你的悬挂指针访问的是别人的数据。最危险的是它经常不立刻崩——bug 沉睡,直到某次内存复用产生不一致。
ASan 检测:free 时不立刻归还,先放进 quarantine 队列(默认 256MB),shadow 标 0xFD(freed 标记),任何访问立刻报 UAF。
# 2.5 use-after-return
int* dangling() {
int x = 42;
return &x; // 返回栈对象地址
}
int main() {
int* p = dangling();
*p = 1; // stack-use-after-return
}
2
3
4
5
6
7
8
底层原理:x 在 dangling() 的栈帧上,函数返回栈帧立刻失效,p 成为悬挂指针。访问它读到的是后续函数压栈的数据——通常不立刻崩,但行为完全不可预测。
ASan 检测:需要 ASAN_OPTIONS=detect_stack_use_after_return=1。原理是把栈对象搬到"假栈"(fake stack)——堆上的代理区——返回后 poison 这块代理区。代价较大,默认关闭。
# 2.6 use-after-scope
int* p;
{
int x = 42;
p = &x;
} // x 出作用域
*p = 100; // stack-use-after-scope
2
3
4
5
6
底层原理:x 的栈空间在大括号结束时还在物理上存在(栈顶没动),但生命周期已经结束——下次新对象会复用这块空间。这是更隐蔽的"短命栈对象"bug。
ASan 检测:编译时加 -fsanitize-address-use-after-scope(GCC ≥ 7、Clang ≥ 6)。LLVM 在每个变量的作用域开始/结束时分别 unpoison/poison。
# 2.7 double-free 与无效 free
int* p = new int(1);
delete p;
delete p; // double-free
int x;
delete &x; // 释放栈对象 → invalid-free
int* q = new int[10];
delete q; // 用 delete 而非 delete[] → 也是 invalid-free
2
3
4
5
6
7
8
9
底层原理:堆分配器维护空闲链表,对已 free 的块再次 free,会破坏链表结构。glibc 的 double free or corruption (fasttop) 就是这条路径。
ASan 检测:每次 free 都查 shadow,若该块已是 freed 状态,立刻报 attempting double-free。
# 2.8 内存泄漏
void f() {
int* p = new int[1000];
// 忘记 delete[] p
} // 1000*4 字节泄漏
2
3
4
底层原理:进程退出时,OS 会回收所有内存——所以泄漏在短生命周期程序里"不可见"。但长生命周期服务(数月不重启)会被慢慢吃光内存,最终 OOM。
LSan 检测:进程退出时遍历所有堆块,扫描根集(栈、全局、寄存器),把"无任何指针指向"的块判为泄漏。LSan 是 ASan 的内置子模块,也可单独使用。
# 2.9 未初始化读取
int x;
if (x == 42) ... // 读未初始化的 x
int* p = new int[10]; // new[] 不初始化 POD
std::cout << p[0]; // 读垃圾值
2
3
4
5
底层原理:栈/堆分配的内存内容是上一次写入的残余值。读未初始化变量是 UB,编译器在 -O2 下可能做激进优化,行为不可预测。
MSan 检测:MemorySanitizer 专门做这件事,但它要求所有依赖库都用 MSan 重编(包括 libc++),落地成本极高。日常用 valgrind --tool=memcheck 或 UBSan 的部分检测 替代。
九宫格速查:
| # | 错误类型 | 信号/症状 | 工具 | 编译选项 |
|---|---|---|---|---|
| 1 | heap-buffer-overflow | SIGSEGV / 静默 | ASan | -fsanitize=address |
| 2 | stack-buffer-overflow | SIGSEGV / SIGILL | ASan | 同上 |
| 3 | global-buffer-overflow | SIGSEGV / 静默 | ASan | 同上 |
| 4 | use-after-free | SIGSEGV / 静默 | ASan | 同上 |
| 5 | use-after-return | 静默 | ASan | + detect_stack_use_after_return=1 |
| 6 | use-after-scope | 静默 | ASan | + -fsanitize-address-use-after-scope |
| 7 | double-free | SIGABRT | ASan | -fsanitize=address |
| 8 | memory leak | 静默 | LSan | -fsanitize=leak 或 ASan 内置 |
| 9 | uninitialized read | UB | MSan/valgrind | -fsanitize=memory(重编依赖) |
# 3. Sanitizer 全家桶
# 3.1 三件套定位
ASan / UBSan / TSan 是日常用得最多的三件套,加上 LSan / MSan 一共五个,定位各不相同:
┌──────────── 内存类 ────────────┐
│ │
│ ASan LSan │
│ (越界/UAF) (泄漏) │
│ │
│ MSan │
│ (未初始化) │
└────────────────────────────────┘
┌──────────── 行为类 ────────────┐
│ │
│ UBSan TSan │
│ (UB) (数据竞争) │
│ │
└────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| 工具 | 全名 | 检测什么 | 速度代价 | 内存代价 |
|---|---|---|---|---|
| ASan | AddressSanitizer | 越界/UAF/double-free | 2-5x | 2-3x |
| LSan | LeakSanitizer | 内存泄漏 | ~0(仅退出时扫) | ~0 |
| MSan | MemorySanitizer | 未初始化读 | 3x | 2-3x |
| UBSan | UBSanitizer | 未定义行为 | 5-20% | 极小 |
| TSan | ThreadSanitizer | 数据竞争 | 5-15x | 5-10x |
口诀:
排查崩溃用 ASan, 想抓 UB 用 UBSan, 多线程 race 上 TSan, 长服务漏内存查 LSan。
# 3.2 编译与链接姿势
最小工作集:
# ASan + UBSan(最常用组合)
g++ -fsanitize=address,undefined -fno-omit-frame-pointer -g -O1 a.cpp -o a.out
# 注意四件事:
# 1. 编译和链接都要加 -fsanitize=...,否则链接报错
# 2. -g 必须有,否则报告里只有地址没有行号
# 3. -O1 是个甜点:保留内联栈又不过度优化
# 4. -fno-omit-frame-pointer 让回溯更可靠
2
3
4
5
6
7
8
clang 与 gcc 差异:
| 工具 | clang 支持 | gcc 支持 | 备注 |
|---|---|---|---|
| ASan | 全平台 | Linux/macOS | clang 实现更完整 |
| LSan | x86_64 Linux | x86_64 Linux | macOS 上 LSan 不支持 |
| TSan | x86_64 Linux | x86_64 Linux | 仅 64 位 |
| MSan | clang only | ✗ | gcc 不支持 |
| UBSan | 全平台 | Linux/macOS | 大部分一致 |
经验:开发首选 clang,能用上更多 sanitizer 子检查;CI 双跑 clang+gcc,避免漏检。
CMake 集成:
option(ENABLE_ASAN "Enable AddressSanitizer" OFF)
if(ENABLE_ASAN)
add_compile_options(-fsanitize=address,undefined
-fno-omit-frame-pointer -g -O1)
add_link_options(-fsanitize=address,undefined)
endif()
# CI 调用:cmake -DENABLE_ASAN=ON ..
2
3
4
5
6
7
8
# 3.3 互斥与组合规则
不能同时开:
ASan ❌ TSan ─ shadow memory 模型冲突
ASan ❌ MSan ─ 同样冲突
TSan ❌ MSan ─ 同样冲突
ASan ✓ UBSan ─ 完美组合(最常用)
ASan ✓ LSan ─ ASan 自带 LSan
TSan ✓ UBSan ─ 可以
2
3
4
5
6
为什么 ASan/TSan/MSan 互斥:三者都通过给每段真实内存分配一段"影子内存"工作,但每个都按自己的语义解读 shadow 字节——同一块影子内存不可能既表示"是否可访问"又表示"被哪个线程读过"。
实战组合方案:
# 方案 1:日常开发(最常用)
-fsanitize=address,undefined
# 方案 2:多线程专项
-fsanitize=thread,undefined
# 方案 3:fuzz 测试
-fsanitize=address,undefined,fuzzer
# 方案 4:泄漏专项(轻量)
-fsanitize=leak
# 方案 5:MSan 专项(需要重编依赖)
-fsanitize=memory -fsanitize-memory-track-origins=2
2
3
4
5
6
7
8
9
10
11
12
13
14
# 3.4 与 valgrind 之争
老前辈 valgrind(memcheck/helgrind/drd)依然在某些场景里有不可替代的位置:
| 维度 | Sanitizer | valgrind |
|---|---|---|
| 是否需要重编 | 是 | 否(pure binary) |
| 速度 | 2-15x | 10-50x |
| 未初始化读 | 难(要 MSan 重编依赖) | 强(memcheck 默认) |
| UAF / 越界 | 强 | 强 |
| 多线程 race | TSan 强 | helgrind/drd 较弱 |
| 三方闭源库 | 难(要重编) | 直接跑 |
| ARM/RISC-V | 部分支持 | 全平台 |
选择心法:
- 有源码:首选 Sanitizer(更快、报告更精准)
- 闭源 / 系统库 / 紧急排查无法重编:valgrind
- 未初始化读:valgrind memcheck(不需要重编依赖)
- 嵌入式/特殊架构:valgrind 兼容性更好
禁忌:valgrind 二进制下跑过的程序,不要直接当性能基准——它会让程序慢 10-50 倍,有些 race 因此被掩盖。
# 4. ASan 原理拆解
理解 ASan 不是"看一眼黑魔法就过去"。只有理解了 shadow memory,才能解释"为什么 ASan 没抓到我这个 bug"。
# 4.1 影子内存模型
ASan 的核心思想——为每 8 字节真实内存分配 1 字节影子内存,记录这 8 字节的可访问性。
真实地址 → 影子地址:shadow = (addr >> 3) + SHADOW_OFFSET
───── ─────────────
一字节代表 8 字节 平台相关常量
2
3
x86_64 Linux 的内存布局(默认 SHADOW_OFFSET = 0x7fff8000):
高地址
┌─────────────────────────────┐
│ HighMem (用户堆/栈/库) │ ← 真实程序内存
├─────────────────────────────┤
│ HighShadow │ ← 高内存的影子
├─────────────────────────────┤
│ Bad / ShadowGap │ ← 故意保留的非法区
├─────────────────────────────┤
│ LowShadow │ ← 低内存的影子
├─────────────────────────────┤
│ LowMem │ ← 低内存
└─────────────────────────────┘
低地址
2
3
4
5
6
7
8
9
10
11
12
13
shadow 字节编码(共 256 种值,但只有少数有意义):
| shadow 值 | 含义 | 触发场景 |
|---|---|---|
0x00 | 全 8 字节都可访问 | 正常的 malloc 区域 |
0x01 ~ 0x07 | 前 N 字节可访问 | 不是 8 对齐结尾的合法区 |
0xFA | 堆左红区(heap left redzone) | malloc 块前的红区 |
0xFB | 堆右红区(heap right redzone) | malloc 块后的红区 |
0xFD | freed heap region | 已 free 的内存 |
0xF1 | stack left redzone | 栈对象左红区 |
0xF2 | stack mid redzone | 栈对象之间 |
0xF3 | stack right redzone | 栈对象右红区 |
0xF5 | stack-use-after-return | UAR |
0xF8 | stack-use-after-scope | UAS |
0xF9 | global redzone | 全局变量红区 |
判断算法(每次访问注入的代码逻辑):
// 访问 *(char*)addr
shadow = (addr >> 3) + SHADOW_OFFSET;
k = *shadow;
if (k != 0) {
last_accessible_byte = addr & 7; // 取低 3 位
if (last_accessible_byte >= k)
ReportError(addr, ...);
}
2
3
4
5
6
7
8
例子:访问 addr = 0x60400000_dff0,shadow 字节是 0xFA ⇒ 完全在红区 ⇒ 立刻报错。
# 4.2 编译器插桩流程
ASan 不是运行时库——它是编译器 + 运行时库的组合:
源码 → Clang/GCC 前端 → IR → AddressSanitizer Pass → 插桩后 IR → 目标码
│
│ 在每个 load/store 前注入:
│ 1. 计算 shadow 地址
│ 2. 取 shadow 字节
│ 3. 比较 + 跳转报错
│
│ 在每个 alloca 前后插入红区
│ 在每个全局变量前后插入红区
2
3
4
5
6
7
8
9
最终每条 *p 大约变成 5-10 条额外指令——这就是 ASan 慢 2-5 倍的根本原因。
链接时:libasan.so(runtime)替换 malloc/free/new/delete,所有分配都"加红区 + 标 shadow"。
// 你写的代码
int* p = new int[10];
p[5] = 1;
delete[] p;
// ASan 看到的等价行为
int* p = __asan_aligned_malloc(40 + 16 + 16); // 多分配两端的红区
__asan_poison(p - 16, 16, 0xFA); // 左红区
__asan_poison(p + 40, 16, 0xFB); // 右红区
__asan_unpoison(p, 40); // 真实区可访问
__asan_check_load8(&p[5]); // 注入的检查
p[5] = 1;
__asan_check_free(p); // 检查是否是合法 malloc 出的
__asan_poison(p - 16, 72, 0xFD); // 标 freed
quarantine_push(p - 16, 72); // 放入隔离区
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 4.3 红区与隔离区
红区(Redzone):每个 malloc/alloca/global 对象前后都加一段"故意污染"的字节。默认 16 字节,可调(ASAN_OPTIONS=redzone=32)。
正常布局: | malloc(40) |
ASan 布局: | 红区16 | malloc(40) | 红区16 |
0xFA 0x00 0xFB
────── ────────── ──────
不可读 可访问 不可读
2
3
4
5
隔离区(Quarantine):free 后内存不立刻归还分配器,而是放进一个 FIFO 队列:
free(p) → quarantine_queue.push(p) shadow 标 0xFD
│
│ 队列总大小限制 256MB(默认)
│ 满了之后 FIFO 真正 free 最早的
▼
真正 free 给 malloc 池
2
3
4
5
6
为什么需要 quarantine:如果 free 立刻归还,你的 UAF 访问读到的可能是新对象的合法字节——shadow 已经被新对象重置成 0x00,ASan 抓不到。quarantine 让 freed 状态保持一段时间,UAF 才能在第一现场暴露。
调优(生产里偶尔需要):
# 内存紧张时减小 quarantine
ASAN_OPTIONS=quarantine_size_mb=64
# 大 malloc 测试时增加
ASAN_OPTIONS=quarantine_size_mb=1024
2
3
4
5
# 4.4 quarantine 缓存原理
很多人疑惑:"quarantine_size_mb=256 是什么意思?是 256MB 全部都用来放 freed 块吗?"——答案是累计 freed 块的"原始大小"达到 256MB 后开始 FIFO 出队。
当前 quarantine 总大小 256MB(达上限)
┌──────────────────────────────────────────┐
│ 最早 freed 的 8MB 块 ← 出队,真正 free │
│ ... │
│ ... │
│ 最近 freed 的 1MB 块 ← 入队 │
└──────────────────────────────────────────┘
2
3
4
5
6
7
实战影响:
- 如果你的 UAF 在 free 后立刻发生(比如 1ms 内)——quarantine 必抓
- 如果 UAF 发生在 free 后 10 分钟、期间累计 free 超过 256MB——shadow 已被新对象覆盖,ASan 可能漏报
这是 ASan 的物理上限。生产服务想要 100% 抓到 UAF 需要更大的 quarantine 或更激进的 hold 策略。
# 4.5 性能与内存代价
典型代价(实测数据,因程序而异):
| 指标 | 普通编译 | ASan | 增长 |
|---|---|---|---|
| 运行时间 | 1.0x | 2.0~5.0x | 慢 2-5 倍 |
| 内存占用 | 1.0x | 2.0~3.0x | 多 1-2 倍 |
| 二进制大小 | 1.0x | 1.5~2.0x | 大 50-100% |
为什么慢 2-5 倍:
- 每次访存多 5-10 条指令(shadow 查表 + 比较)
- shadow 区与真实区的访问交错,缓存命中率下降
- malloc 路径多了红区设置和 shadow 标记
为什么内存翻倍:
- 1/8 的 shadow(很小)
- 每块堆分配多两段红区(默认 32 字节/块)
- quarantine 占 256MB(默认)
- 所有全局变量周围加红区
心法:CI 上无脑开 ASan,生产上慎用——除非你能接受 2x 资源、2x 延迟。生产用 HWASan / MTE(见 12.5)。
# 5. ASan 报告解读
会读报告比会用工具更重要。一份 ASan 报告里有 5 块关键信息,每一块对应不同的根因方向。
# 5.1 报告结构总览
把主线一的报告拆开看:
==12345==ERROR: AddressSanitizer: heap-use-after-free on 0x603000004018 ← 块 A:错误类型 + 地址
READ of size 8 at 0x603000004018 thread T7 ← 块 B:访问方式
#0 0x4a... in on_response(...) session.cpp:42 ← 块 C:当前栈
#1 0x4b... in RpcClient::on_recv(...) rpc.cpp:115
freed by thread T3 here: ← 块 D:释放栈
#0 0x7f... in operator delete(void*)
#1 0x4c... in cleanup(Session*) session.cpp:18
previously allocated by thread T0 here: ← 块 E:分配栈
#0 0x7f... in operator new(unsigned long)
#1 0x4d... in SessionManager::create() manager.cpp:23
SUMMARY: AddressSanitizer: heap-use-after-free session.cpp:42
Shadow bytes around the buggy address: ← 块 F:shadow 视图(高级)
0x0c067fff8000: fa fa fd fd fd fd fa fa 00 00 00 00 00 00 00 00
0x0c067fff8010: fa fa fa fa[fd]fd fd fd fa fa 00 00 00 00 00 00
^^
访问的就是这个字节
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| 块 | 信息 | 用途 |
|---|---|---|
| A | 错误类型(heap-use-after-free / heap-buffer-overflow / ...) | 一眼定位错误大类 |
| B | 访问方式(READ/WRITE,多少字节) | 区分读越界/写越界 |
| C | 当前栈(崩溃发生的代码位置) | 找暴露点 |
| D | 释放栈(什么时候被 free 的,仅 UAF 有) | 找释放点 |
| E | 分配栈(什么时候被 alloc 的) | 找产生点 |
| F | shadow bytes 视图 | 高级调试时验证 ASan 的判断是否正确 |
# 5.2 三栈一图法则
ASan 报告最强的部分是 "三个栈一次给齐":当前点、释放点、分配点。一个手工 gdb 永远凑不齐的视角。
分配点 (E) ─────────────────► 释放点 (D) ─────────────────► 当前点 (C)
│ │ │
│ "这块内存是谁、什么时候、 │ "什么时候 free 的" │ "现在哪里又访问了"
│ 什么类型创建的" │ │
└──────────────── 时间轴 ─────────────────────────────────────►
2
3
4
5
读取顺序心法:
- 先看 C(当前栈):知道"我在哪行访问了非法内存"
- 再看 D(释放栈):知道"是谁先把它干掉了"——这就是 bug site
- 最后看 E(分配栈):知道"对象的身份",配合代码理解"它本应该活多久"
回到主线一:
- C 在
on_response:访问s->buf时 s 已死 - D 在
cleanup:第 18 行delete s干掉了 s - E 在
SessionManager::create:明确了 s 是 Session 对象
三栈一对,根因当场出来:异步回调晚于 cleanup 触发——经典的"对象生命周期管理 bug"。
# 5.3 shadow bytes 解读
报告底部的 shadow bytes 视图是 ASan 给你的"现场照片"——用来亲眼看到内存被标了什么。
=>0x0c067fff8010: fa fa fa fa[fd]fd fd fd fa fa 00 00 00 00 00 00
^^
2
每两个十六进制字符是一个 shadow 字节,每个 shadow 字节代表 8 字节真实内存。从左到右,地址递增 8 字节。
怎么读这一行:
| 位置 | shadow | 含义 | 真实地址 |
|---|---|---|---|
| -16~-9 | fa fa | 前面的红区 | addr-16 ~ addr-1 |
| -8~-1 | fa fa | 前面的红区 | 紧邻 |
| 0 | fd | 被访问的字节,已 freed | 当前 addr |
| +8~+24 | fd fd fd | 同一 freed 块的剩余部分 | 后续 |
| +32 | fa fa | 红区 | |
| +48 起 | 00 00 ... | 后面是合法可访问的 8 字节块 |
报告底部还会附一段图例:
Shadow byte legend (one shadow byte represents 8 application bytes):
Addressable: 00
Partially addressable: 01 02 03 04 05 06 07
Heap left redzone: fa
Freed heap region: fd
Stack left redzone: f1
Stack right redzone: f3
Stack after return: f5
Stack use after scope: f8
Global redzone: f9
Global init order: f6
Poisoned by user: f7
ASan internal: fe
Left alloca redzone: ca
Right alloca redzone: cb
2
3
4
5
6
7
8
9
10
11
12
13
14
15
把这段图例配合 shadow 视图,能逐字节复盘"ASan 看到的内存是什么样"——这是高级调试的必备技能。
# 5.4 常见错误类型
| ASan 报告标题 | 含义 | 第一刀 |
|---|---|---|
| heap-buffer-overflow | 堆越界(读/写) | 看分配大小、看下标 |
| heap-use-after-free | 已 free 内存被访问 | 比较 D 和 C 的栈 |
| stack-buffer-overflow | 栈越界 | 看局部数组大小 |
| stack-use-after-return | 返回后访问栈对象 | 看是不是返回了局部地址 |
| stack-use-after-scope | 出作用域后访问 | 看 {} 后还有没有引用 |
| global-buffer-overflow | 全局/常量越界 | 看下标和数组定义 |
| double-free | 同一指针 free 两次 | 看两次 free 路径 |
| invalid-free | free 了非堆指针/错配 new[]/delete | 看分配时用的 new 还是 new[] |
| memcpy-param-overlap | memcpy 源/目标重叠 | 用 memmove |
| negative-size-param | size 参数为负 | 检查 size_t 转换 |
| odr-violation | 同名符号多个定义不一致 | 检查 inline、static 用法 |
| container-overflow | 容器越界(已开启 annotation) | 看容器大小 |
# 5.5 误报与漏报识别
ASan 几乎不误报,但会漏报:
1) 误报场景(极少)
- 自定义内存池:你的 pool 用 mmap 拿一大块再切,ASan 不知道边界,导致越界进了同一个 pool 不报错。需要手动
__asan_poison_memory_region。 reinterpret_cast改大小后访问:int*强转long*访问 8 字节,原来只分配 4 字节——ASan 抓的是真实分配大小,逻辑正确但人会疑惑。
2) 漏报场景(要警惕)
- 延迟非常久的 UAF:超过 quarantine 容量(256MB)后,shadow 被覆盖
- 越界正好越到下一个合法块:红区只有 16 字节,越界 100 字节直接跳过红区进入下一块——ASan 看到的是合法访问
- 未初始化读:ASan 不管初值,分配出来 shadow 是 0x00,读垃圾值合法
- mmap 直接访问:mmap 不走 malloc,ASan 不知道这块内存的边界
3) 怎么验证 ASan 抓到的是真问题
ASan 误报极罕见,但第一反应别立刻"修了 ASan 报的位置"——先看:
- C 栈里访问的地址是不是真的指向了被 free / 越界 / 红区的内存
- D 栈里的 free 是不是确实在 C 之前发生
- 关键变量值(
p $arr[i]一类)能不能解释
如果能解释,就是真 bug;如果完全无法解释(极少见),考虑是不是自定义分配器没适配 ASan。
# 6. ASan 配置进阶
默认配置覆盖 80% 的场景。剩下 20% 需要调 ASAN_OPTIONS。
# 6.1 ASAN_OPTIONS 详解
环境变量传参,格式 key1=value1:key2=value2:
# 最常用配置
export ASAN_OPTIONS="\
detect_leaks=1:\
detect_stack_use_after_return=1:\
strict_string_checks=1:\
check_initialization_order=1:\
detect_invalid_pointer_pairs=2:\
abort_on_error=1:\
print_stats=0:\
halt_on_error=1"
2
3
4
5
6
7
8
9
10
关键开关:
| 选项 | 默认 | 用途 |
|---|---|---|
detect_leaks | 1(Linux) | 退出时扫描泄漏 |
detect_stack_use_after_return | 0 | UAR 检测(额外慢 2x,开了再 4-10x) |
detect_invalid_pointer_pairs | 0 | 比较两个指针时要求是同一对象 |
strict_string_checks | 0 | strlen/strcpy 严格检查 |
check_initialization_order | 0 | 全局变量构造顺序问题 |
abort_on_error | 0 | 报错时 abort 生 core(CI 必开) |
halt_on_error | 1 | 第一次报错就停(关掉可继续跑剩余测试) |
quarantine_size_mb | 256 | 隔离区大小 |
redzone | 16 | 红区字节数(堆) |
max_redzone | 2048 | 最大红区 |
malloc_context_size | 30 | 报告里栈的最大深度 |
log_path | stderr | 把报告写到文件 |
生产/CI 推荐配置:
# CI(严格 + 完整泄漏)
ASAN_OPTIONS=detect_leaks=1:abort_on_error=1:halt_on_error=1:strict_string_checks=1
# 长时间跑(聚合报告,不立刻退出)
ASAN_OPTIONS=halt_on_error=0:log_path=/var/log/asan:print_stats=1
2
3
4
5
# 6.2 黑名单与忽略
某些第三方库 ASan 报告噪音很大但你又不想动它(如:用了 placement new 的对象池、自实现的 hashmap)——用黑名单忽略:
# blacklist.txt
src:third_party/legacy_pool.cpp
fun:LegacyPool::*
src:.*/protobuf/.*
2
3
4
编译时:
g++ -fsanitize=address -fsanitize-blacklist=blacklist.txt ...
抑制运行时报告(不重编):
# suppressions.txt
leak:third_party::LegacyHash
race:LegacyLogger::write
2
3
LSAN_OPTIONS=suppressions=suppressions.txt ./a.out
TSAN_OPTIONS=suppressions=suppressions.txt ./a.out
2
注意:黑名单用得越多,ASan 价值越低——只用于"明确无法修复但已知风险可控"的场景。
# 6.3 自定义分配器适配
如果你写了内存池/对象池,ASan 默认看不到内部边界,会漏报。要主动告知 ASan 哪些字节"应该被红区保护":
#include <sanitizer/asan_interface.h>
class MemoryPool {
char* arena_;
size_t size_;
public:
void* allocate(size_t n) {
char* p = bump_pointer_;
bump_pointer_ += n + 16; // 留出红区
__asan_unpoison_memory_region(p, n); // 标可访问
__asan_poison_memory_region(p + n, 16); // 标红区
return p;
}
void deallocate(void* p, size_t n) {
__asan_poison_memory_region(p, n); // 标 freed
}
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
容器自带的 annotation:libstdc++/libc++ 的 std::vector 已经做了这件事——v.reserve(100) 但 v.size()==10 时,访问 v[50] 会被 ASan 抓到 container-overflow。
# 6.4 与 fork/exec 的坑
ASan 的 shadow memory 占地址空间巨大(高地址区段),跟 fork/exec 偶尔打架:
1) system() / popen():子进程继承 ASan,但 shell 命令里有特殊路径时可能解析失败。绕过方式:ASAN_OPTIONS=verify_asan_link_order=0。
2) setuid 程序:ASan 默认不允许 setuid 二进制运行(安全考虑)。需要 ASAN_OPTIONS=allow_user_segv_handler=1 或者干脆不在生产用 setuid + ASan。
3) 大量 fork():每次 fork 都会复制 shadow 区,进程数多时内存翻倍。考虑用 vfork 或 posix_spawn。
4) execve 后丢失 ASan 设置:环境变量保留,但程序自己要保证用 ASan 编译。
# 6.5 符号化与离线分析
报告里的栈是 raw 的:
#0 0x4a2b5e in main a.cpp:5
这个 0x4a2b5e 是怎么变成 main a.cpp:5 的?
编译时带 -g → 二进制里有 DWARF → ASan 运行时调用 llvm-symbolizer(clang)或 addr2line(gcc)把地址翻成行号。
生产场景:上报的报告只有地址,没有源码
把 raw 栈和带 debug 符号的二进制(或 split debug 文件)一起留档:
# 编译时分离调试符号
clang++ -g -fsanitize=address -O1 a.cpp -o a.out
objcopy --only-keep-debug a.out a.out.debug
strip --strip-debug a.out
objcopy --add-gnu-debuglink=a.out.debug a.out
# 部署 a.out,调试时 a.out.debug 在符号服务器
2
3
4
5
6
7
离线符号化 raw 报告:
# 报告里地址是 0x4a2b5e
echo "0x4a2b5e" | llvm-symbolizer -e a.out.debug
# main
# /home/user/a.cpp:5
# 或 ASAN_SYMBOLIZER_PATH 指定
ASAN_SYMBOLIZER_PATH=$(which llvm-symbolizer) ./a.out
2
3
4
5
6
7
关键经验:所有上线版本都要保留对应的 .debug 包,否则线上报告里只有地址完全没法分析。
# 7. UBSan 未定义行为
ASan 抓的是"内存层面的非法访问"。但 C++ 还有一类更阴险的 bug——语言层面的未定义行为(UB)——代码看似没访问错地址,但语言标准说这种写法行为不确定,编译器可能做任何事。
# 7.1 什么是 UB
UB(Undefined Behavior)是 C++ 标准里特意不规定行为的代码模式。原因有二:
- 历史 ABI 兼容:不同硬件对溢出/对齐/带符号位移的处理不同,标准妥协
- 优化空间:编译器假定 UB 不会发生,可以做激进优化
UB 的危险不在 UB 本身,而在编译器假定 UB 不发生时做的优化:
// 你写的
int x = INT_MAX;
if (x + 1 < x) printf("overflow\n"); // 想检测溢出
// 编译器认为:signed overflow 是 UB,不可能发生
// 所以 x+1 < x 永远 false,整个 if 被消除!
// 结果:你的"溢出检测"完全没用
2
3
4
5
6
7
这种 bug 在调试构建(O0)下能跑、Release(O2)下消失——最难调试的一类。
# 7.2 UBSan 检测清单
clang++ -fsanitize=undefined -g a.cpp -o a.out
完整子检查(按重要性排序):
| 子检查 | flag | 抓什么 |
|---|---|---|
| signed-integer-overflow | -fsanitize=signed-integer-overflow | INT_MAX + 1 |
| shift | -fsanitize=shift | 1 << 32、负数移位 |
| integer-divide-by-zero | -fsanitize=integer-divide-by-zero | x / 0 |
| null | -fsanitize=null | 解引用 nullptr |
| return | -fsanitize=return | 非 void 函数没 return |
| bool | -fsanitize=bool | bool 变量值不是 0/1 |
| enum | -fsanitize=enum | enum 值越界 |
| float-cast-overflow | -fsanitize=float-cast-overflow | float→int 溢出 |
| pointer-overflow | -fsanitize=pointer-overflow | 指针运算溢出 |
| alignment | -fsanitize=alignment | 未对齐访问 |
| object-size | -fsanitize=object-size | 编译期可推断的越界 |
| vptr | -fsanitize=vptr | 虚函数表损坏 / 错误的多态调用 |
| function | -fsanitize=function | 函数指针类型不匹配 |
| nonnull-attribute | -fsanitize=nonnull-attribute | __attribute__((nonnull)) 违反 |
最常用组合:
# 全开(性能损耗 5-15%)
-fsanitize=undefined
# 选择子集
-fsanitize=signed-integer-overflow,null,shift,bool,enum,vptr
2
3
4
5
# 7.3 报告解读
// ub.cpp
#include <climits>
int main() {
int x = INT_MAX;
int y = x + 1;
return y;
}
2
3
4
5
6
7
$ clang++ -fsanitize=undefined -g ub.cpp && ./a.out
ub.cpp:4:13: runtime error: signed integer overflow:
2147483647 + 1 cannot be represented in type 'int'
2
3
报告比 ASan 简洁——一行话讲清楚:
- 位置:
ub.cpp:4:13(行 + 列) - 类型:
signed integer overflow - 具体值:
2147483647 + 1
vptr 检查的报告稍微特殊:
class Base { public: virtual void f() {} };
class Derived : public Base {};
Base* b = new Derived;
delete b; // Base 没有虚析构 → 错误的析构调用 → vptr UB
2
3
4
5
runtime error: member call on address 0x... which does not point to an object of type 'Derived'
0x...: note: object is of type 'Base'
2
# 7.4 trap 模式部署
UBSan 默认是报告 + 继续——不影响行为,只输出错误。但生产里你可能想"UB 一旦发生立即崩"——用 -fsanitize-trap:
# 触发 UB 时执行 ud2 指令(SIGILL)
clang++ -fsanitize=undefined -fsanitize-trap=undefined a.cpp
2
特点:
- 几乎零开销(不带 runtime 库)
- 不输出报告(直接 SIGILL)
- 二进制变小(不含 UBSan runtime)
- 生产可用——崩了之后线下用普通 UBSan 重跑定位
很多大型 C++ 项目(Chromium、LLVM 自己)生产开 UBSan trap 模式抓 UB,崩溃之后从 core dump 反查代码行。
# 7.5 生产可开的子集
UBSan 全开有 5-15% 性能损耗,但部分子检查几乎零开销——生产可以放心开:
| 子检查 | 性能影响 | 生产可开 |
|---|---|---|
| signed-integer-overflow | 5-15% | 慎重,热点函数有影响 |
| null | <1% | ✓ 强烈推荐 |
| return | 0%(编译期) | ✓ |
| bool / enum | <1% | ✓ |
| vptr | 1-3% | ✓ |
| object-size | <1% | ✓ |
| alignment | <1% | ✓ |
| float-cast-overflow | 1-2% | ✓ |
| shift | <1% | ✓ |
生产推荐配置(trap 模式):
-fsanitize=null,return,bool,enum,vptr,object-size,alignment,shift
-fsanitize-trap=null,return,bool,enum,vptr,object-size,alignment,shift
2
价值:额外抓住 30%+ 的 UB bug,几乎不影响性能——比关掉所有 sanitizer 划算太多。
# 8. TSan 数据竞争
多线程是 C++ 程序员心中的痛——bug 复现率几乎为 0,崩溃时栈还跨了多个线程,谁都不知道是谁先动了谁。TSan 是目前最好用的 race 检测工具。
# 8.1 数据竞争定义
C++ 标准对 data race 的定义(极其精确):
两个或多个线程访问同一内存位置,至少一个是写,没有 happens-before 关系——即没有用原子操作、互斥锁、条件变量、或线程创建/join 来同步——则行为是 UB。
注意三个关键点:
- 必须有写:两个线程都只读,不构成 race
- 必须没有同步:用了锁或原子就没事
- 同一内存位置:哪怕只是读相邻 1 字节,硬件层面也是同一个 cache line,但语言层面不算 race
int counter = 0;
void worker() {
for (int i = 0; i < 1000000; ++i)
counter++; // ❌ data race
}
void worker_atomic() {
static std::atomic<int> a_counter{0};
for (int i = 0; i < 1000000; ++i)
a_counter++; // ✓ 无 race
}
2
3
4
5
6
7
8
9
10
11
12
# 8.2 happens-before 模型
理解 TSan 必须先理解 happens-before(HB) 关系:
- 同一线程内:代码顺序就是 HB(A 写在 B 前面,A HB B)
- 跨线程:必须通过同步原语建立 HB
- 锁:unlock(M) HB lock(M)
- 原子:release-store HB acquire-load 同一变量
- 线程创建:parent 创建子线程 HB 子线程开始
- join:子线程结束 HB parent join 返回
线程 1 线程 2
───── ─────
write x = 1 ┐
unlock(M) ────HB───→ lock(M) │ 线程 1 的 write x
read x │ HB 线程 2 的 read x
unlock(M) ┘ ⇒ 不构成 race
2
3
4
5
6
如果两个写操作之间没有任何 HB 边,就是 race。
# 8.3 TSan 影子结构
TSan 的 shadow 不是 1:1 字节映射,而是每 8 字节真实内存对应 32 字节 shadow——存最近 N 个访问者的元组:
shadow cell(8 字节真实内存的"访问历史"):
┌──────────────────────┬──────────────────────┐
│ 访问 1(线程,时钟) │ 访问 2(线程,时钟) │ ...
└──────────────────────┴──────────────────────┘
2
3
4
每次访问检查:当前线程的 vector clock 与历史访问的 clock 是否有 HB 边。无边 + 至少一写 = race。
代价:每个内存位置 4x shadow,速度 5-15x——比 ASan 重得多。
# 8.4 报告解读
// race.cpp
#include <thread>
int counter = 0;
int main() {
std::thread t1([]{ counter++; });
std::thread t2([]{ counter++; });
t1.join(); t2.join();
}
2
3
4
5
6
7
8
==WARNING: ThreadSanitizer: data race (pid=12345)
Read of size 4 at 0x... by thread T2:
#0 operator() race.cpp:5
Previous write of size 4 at 0x... by thread T1:
#0 operator() race.cpp:4
Location is global 'counter' of size 4 at 0x...
Thread T2 (created here):
#0 std::thread::thread<...> race.cpp:5
#1 main race.cpp:5
Thread T1 (created here):
#0 std::thread::thread<...> race.cpp:4
#1 main race.cpp:4
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
四块信息:
- 当前访问:T2 在 race.cpp:5 读
- 冲突访问:T1 在 race.cpp:4 写
- 位置:全局变量 counter
- 线程创建栈:T1/T2 是怎么创建的(帮你回到现场)
# 8.5 与原子操作配合
TSan 默认认识所有标准同步原语:
std::atomic<int> a; // ✓ TSan 识别
std::mutex m; // ✓
std::condition_variable cv; // ✓
pthread_mutex_t pm; // ✓
pthread_cond_t pc; // ✓
2
3
4
5
6
但自己实现的锁/同步TSan 不认识——会报很多假 race。需要手动 annotation:
#include <sanitizer/tsan_interface.h>
class MyLock {
std::atomic<int> state_;
public:
void lock() {
while (state_.exchange(1)) {}
__tsan_acquire(this); // 告诉 TSan:这里建立了 acquire 边
}
void unlock() {
__tsan_release(this); // 告诉 TSan:这里建立了 release 边
state_.store(0);
}
};
2
3
4
5
6
7
8
9
10
11
12
13
14
金律:能用标准库就用标准库。自定义锁哪怕逻辑写对了,TSan 也帮不了你——最终生产 race 都得你自己肉眼审。
# 9. LSan 内存泄漏
泄漏是 C++ 服务里最慢性的病——一周不重启,慢慢吃掉 RSS,最终 OOM。LSan 是 ASan 内置的小工具(也可独立 -fsanitize=leak),进程退出时扫一遍堆。
# 9.1 泄漏判定标准
LSan 怎么判断"这块内存泄漏了"?
算法核心:根集可达性扫描——类似 GC 的 mark-sweep:
根集:
- 所有线程的栈
- 所有线程的寄存器
- 全局变量(.bss + .data)
从根集出发,递归追踪所有指针(按机器字扫描),能到达的内存视为"还活着"。
扫不到的堆块 = 泄漏。
2
3
4
5
6
7
┌─ 全局/静态 ─┐
│ ptr_a ────┼───→ Object A ──→ Object B
└────────────┘ │
↓
┌─ 栈/寄存器 ─┐ Object C ← 还活着,不报
│ ptr_x ────┼───→ Object X
└────────────┘
Object Y ← 没有任何指针指向它 = 泄漏
2
3
4
5
6
7
8
9
# 9.2 直接泄漏与间接泄漏
LSan 把泄漏分成两类:
- direct leak(直接泄漏):根集扫不到,且自身被算入泄漏
- indirect leak(间接泄漏):被一个 direct leak 块引用,跟着死
struct Node {
Node* next;
int v;
};
Node* head = new Node{nullptr, 1}; // direct
head->next = new Node{nullptr, 2}; // indirect (被 head 引用)
head = nullptr; // 切断根集→ head 整条都泄漏
2
3
4
5
6
7
8
LSan 报告:
Direct leak of 16 byte(s) in 1 object(s) allocated from:
#0 operator new
#1 main leak.cpp:7
Indirect leak of 16 byte(s) in 1 object(s) allocated from:
#0 operator new
#1 main leak.cpp:8
SUMMARY: AddressSanitizer: 32 byte(s) leaked in 2 allocation(s).
2
3
4
5
6
7
8
9
修复时只需要修 direct leak——管住头,indirect 自然解决。
# 9.3 LSan 报告解读
g++ -fsanitize=address -g leak.cpp && ./a.out
# 进程退出时输出:
=================================================================
==12345==ERROR: LeakSanitizer: detected memory leaks
Direct leak of 4000 byte(s) in 1 object(s) allocated from:
#0 0x... in operator new[](unsigned long)
#1 0x... in main leak.cpp:6
SUMMARY: AddressSanitizer: 4000 byte(s) leaked in 1 allocation(s).
2
3
4
5
6
7
8
9
10
11
每条 leak 报告 = 一个分配栈。通常一个泄漏点对应一行 new——直接对照修。
多次同样的泄漏只报一次:LSan 按"分配栈相同"聚合。如果一个 bug 在循环里漏 1000 次,只报一条,但显示 4000 字节 in 1000 objects。
# 9.4 主动检测点
默认 LSan 在 atexit 时扫描——意味着只能检测进程结束时还在活的泄漏。但长生命周期服务想中途看一下:
#include <sanitizer/lsan_interface.h>
void check_leaks() {
int leaks = __lsan_do_recoverable_leak_check();
if (leaks) {
printf("Detected %d leaks\n", leaks);
// 不退出,继续运行
}
}
// 在长服务里定期调用
while (running) {
handle_requests();
if (cycle++ % 1000 == 0) check_leaks();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
__lsan_do_recoverable_leak_check 与 __lsan_do_leak_check 的区别:
_recoverable_:扫描,报告,返回继续运行- 默认那个:扫描,报告,
_exit
# 9.5 LSan 局限
LSan 不能解决所有"内存增长"问题。三个常见误解:
1) "内存一直涨" ≠ "内存泄漏"
std::vector<int> v;
while (true) v.push_back(rand()); // 不算泄漏,v 仍可达
2
LSan 看到 v 还活着——它的存储一直可达。这是逻辑 bug 而不是泄漏。
2) 缓存堆积也不是泄漏
std::unordered_map<int, BigObject> cache;
// 缓存只插入不淘汰,越来越大,但 LSan 报告里它"还活着"
2
LSan 不管"逻辑上不该再用",只管"指针上还能不能走到"。
3) glibc malloc 内部碎片:
free 了,但 glibc 不还给 OS(保留给下次 malloc 用)。看 RSS 没降,但其实没泄漏——这是分配器的策略,跟代码无关。
正确工具组合:
| 现象 | 工具 |
|---|---|
| 进程退出后还有未 free | LSan |
| 长服务 RSS 增长 | heaptrack / massif(看堆 profile) |
| 缓存逻辑性堆积 | 业务指标监控 + LRU |
| glibc 碎片 | MALLOC_TRIM_THRESHOLD_ 调优 |
# 10. 内存排查方法论
把第 4-9 章的工具能力,组装成一套可复用的方法论。
# 10.1 立刻暴露原则
"晚一秒崩,多一千倍调试成本"
C++ 内存 bug 最折磨人的特性是——它们经常不在第一现场暴露。一次 1 字节越界可能在 10 分钟后破坏到关键数据结构才崩;一次 UAF 可能在内存复用后半小时才出诡异行为。
立刻暴露的几道防线:
┌────────────────────────────────────────────────────────┐
│ 编译期:-Wall -Wextra -Werror -Wuninitialized │
│ clang-tidy / cppcheck 静态分析 │
├────────────────────────────────────────────────────────┤
│ 链接期:ODR 违反、未初始化全局检查 │
├────────────────────────────────────────────────────────┤
│ 运行期:ASan + UBSan(开发/CI) │
│ 守护页 + canary(OS/编译器层默认) │
├────────────────────────────────────────────────────────┤
│ 灰度期:UBSan trap 模式 + LSan + minidump 上报 │
├────────────────────────────────────────────────────────┤
│ 生产期:HWASan / MTE(硬件辅助)+ 监控告警 │
└────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
每一层拦住,bug 就不会进入下一层。层数越多越早,调试成本越低。
# 10.2 三角验证法
回到主线一的诊断过程。单一证据不可信——三条相互印证的证据才叫根因:
| 证据来源 | 给出的事实 |
|---|---|
| ASan | UAF on 0x603...,free 在 cleanup:18,alloc 在 manager:23 |
| gdb(事后 core) | s 指向 0x6030...,但内容已被复用为 string 的 SSO 数据 |
| 日志 | 崩溃前 0.3s,cleanup_idle_sessions 处理了 s.user_id |
三条证据都指向同一根因:异步回调线程晚于 cleanup 触发。
三角验证的金律:
- 证据相互矛盾 → 心智模型出错或多因复合 → 重新建立假设
- 证据相互印证 → 进入修复阶段
- 证据不全 → 补工具(如开 ASan、加日志、看 core)
# 10.3 分层防御策略
很多团队的误区:"开了 ASan 就不需要其他工具了"。错。Sanitizer 各有盲区,分层叠加才完整:
| 层 | 工具 | 抓什么 | 漏掉什么 |
|---|---|---|---|
| 编译期 | -Wall, clang-tidy | 编码规范、可疑模式 | 运行时数据相关 bug |
| 静态分析 | clang-static-analyzer, coverity | 路径敏感的 NPE/资源泄漏 | 跨翻译单元、运行时多态 |
| ASan | runtime | 越界/UAF/double-free | 未初始化、UB、race |
| UBSan | runtime | UB(溢出、null 等) | 内存类、race |
| MSan | runtime | 未初始化读 | 内存类、race |
| TSan | runtime | data race | 内存类、UB |
| LSan | atexit | 退出时未释放 | 长服务运行中堆积 |
| Fuzz | 输入空间 | 边界/未覆盖路径 | 多线程时序 |
最佳实践:每个 PR 至少跑过 ASan+UBSan,关键模块加 Fuzz,并发模块跑 TSan。
# 10.4 找到第一现场
"crash site is rarely the bug site"——崩的位置很少是 bug 的位置。
ASan 的最大价值就是把"暴露位置"和"产生位置"绑在一起报告。回到主线一:
| 术语 | 主线一 |
|---|---|
| Crash site | on_response:42 访问 s->buf(症状) |
| Bug site | cleanup:18 delete s 后没保护对象生命周期(根因) |
只看 crash site 修,会修出"加判空"这种治标不治本的方案。看到 bug site 才能修出"用 shared_ptr / weak_ptr"这种治本的方案。
判断 bug site 的心法:
- ASan 有 D 栈(释放栈)→ bug site 在 D 栈某一层
- ASan 没有 D 栈(如越界)→ bug site 通常在分配点周围
- 多线程崩溃 → bug site 可能完全不在崩溃线程
# 10.5 修复与回归套路
修复方案永远三层(参考主线一):
A. 紧急止血(一行):
void cleanup(Session* s) {
delete s->buf; s->buf = nullptr;
delete s;
}
void on_response(Session* s, ...) {
if (!s || !s->buf) return; // 防御
s->buf->append(...);
}
2
3
4
5
6
7
8
B. 智能指针(推荐):
struct Session {
std::unique_ptr<Buffer> buf;
};
// 异步回调用 weak_ptr 升级
std::weak_ptr<Session> weak_s;
on_callback([weak_s](const Response& r) {
if (auto s = weak_s.lock()) {
s->buf->append(r.body);
}
});
2
3
4
5
6
7
8
9
10
11
C. 架构级(资深做法):
把"会话生命周期"抽出独立的 Owner,所有人通过 ID 访问而非裸指针。Owner 死则 ID 失效,所有访问统一通过查表来判断对象是否还在。
回归测试:
- 路径覆盖:原崩溃路径再走一遍——单测 + ASan
- 机制覆盖:用 ASan 证明同类 bug 都不存在
- CI 用例:把这次 bug 永久封装成 test,未来回归立刻发现
心法六条:
- 先选工具,再开调试:内存类 → ASan,UB → UBSan,race → TSan,泄漏 → LSan
- 看完三栈再下结论:当前点 + 释放点 + 分配点
- 信 ASan 不是迷信:误报极少,先验证三栈再质疑工具
- 修 bug site 不修 crash site:治本而非治标
- 修复用智能指针:消灭一整类 UAF/泄漏
- 沉淀为 CI:每次 bug 一个永久 test
# 11. 典型场景速查
把方法论落到 7 个最高频场景。
# 11.1 std::vector 失效迭代器
std::vector<int> v = {1,2,3,4};
auto it = v.begin();
v.push_back(5); // ← 可能 reallocate
*it = 0; // ⚠️ heap-use-after-free
2
3
4
原理:push_back 触发扩容时,老的存储被 delete[]、新存储 new[],所有指向老存储的迭代器/指针/引用都失效。
ASan 报告:heap-use-after-free,分配栈在 vector::reserve,释放栈也在 vector::reserve(旧块被 delete)。
修法:
- 提前
reserve足够容量 - 不要在循环中保存迭代器跨修改操作
- 用 index 代替迭代器(
for (size_t i = 0; i < v.size(); ++i))
# 11.2 dangling string_view
std::string_view get_name() {
std::string s = load_name();
return s; // ❌ s 出作用域销毁,sv 悬挂
}
void use() {
auto sv = get_name();
std::cout << sv; // ⚠️ heap-use-after-free
}
2
3
4
5
6
7
8
9
原理:string_view 是非拥有的视图,仅持有指针 + 长度。被引用对象死了,view 就是悬挂指针。
ASan 报告:heap-use-after-free on s 的存储;分配栈在 string::string,释放栈在 string::~string。
修法:
- 函数返回
std::string不要返string_view - view 的生命周期严格短于被引用对象
- 用 clang 的
-Wdangling-gsl编译期检查
# 11.3 lambda 捕获悬挂
std::function<void()> make_callback() {
int local = 42;
return [&local]() { // ❌ 引用捕获栈对象
std::cout << local;
};
}
int main() {
auto cb = make_callback();
cb(); // ⚠️ stack-use-after-return
}
2
3
4
5
6
7
8
9
10
11
修法:
- 闭包跨生命周期 → 值捕获
[local]或显式[local = std::move(local)] - 引用捕获 only 用于 lambda 不逃逸的场景(如 STL 算法 inline 调用)
# 11.4 单例析构次序
// a.cpp
Logger g_logger;
// b.cpp
Database g_db; // 析构时调 g_logger.log(...)
// 但 g_logger 可能已析构 → UB
2
3
4
5
原理:跨 TU 的全局对象析构次序未定义(static destruction order fiasco)。
修法:
- Meyers Singleton:
static T& get() { static T t; return t; }——首次调用时构造,析构次序仍未定,但至少构造次序确定 std::call_once+ 永不析构(leak intentionally)- 显式
init()/shutdown()控制次序
UBSan 的 check_initialization_order 可以抓部分场景。
# 11.5 容器越界与下标
std::vector<int> v(10);
int x = v[10]; // 静默 UB(不抛异常)
int y = v.at(10); // 抛 std::out_of_range
2
3
坑:operator[] 不检查越界——-O2 下编译器可能假定不越界做激进优化。
修法:
- 调试构建用
_GLIBCXX_DEBUG(libstdc++)/_LIBCPP_DEBUG(libc++)开启容器边界检查 - 关键路径用
at() - ASan + container annotation 抓
container-overflow
# 11.6 自定义 new 的坑
class Pool {
public:
static void* operator new(size_t n) { return pool_.alloc(n); }
static void operator delete(void* p) { pool_.free(p); }
};
2
3
4
5
问题:
- ASan 不知道 pool 内部边界,越界进同一个 pool 不报
- LSan 把 pool 持有的内存视为"还活着"——内部对象的泄漏看不见
修法:
- pool 内调用
__asan_poison_memory_region/__asan_unpoison_memory_region标边界 - 给 pool 加调试模式直接调 malloc,开 ASan 时切换
# 11.7 多线程数据竞争
bool stop = false;
void worker() {
while (!stop) work(); // ❌ 非原子读
}
void main_thread() {
stop = true; // ❌ 非原子写
}
2
3
4
5
6
7
ASan 抓不到——TSan 才能:
WARNING: ThreadSanitizer: data race
Read of size 1 ... by thread T1:
#0 worker() loop.cpp:3
Previous write of size 1 by main thread:
#0 main_thread loop.cpp:7
2
3
4
5
修法:
std::atomic<bool> stop{false};——天然解决- 复杂结构用
std::mutex - 永远不要"我自己想了想觉得这里不会冲突"——工程上认知边界以外的 race 太多
# 12. CI 集成与生产
工具会用了,怎么把它系统化嵌入团队流程?
# 12.1 CI 流水线设计
推荐三阶段:
┌───────── PR / 提交触发 ─────────┐
│ │
│ Stage 1: 编译检查(< 2 min) │
│ - clang -Wall -Wextra -Werror │
│ - clang-tidy │
│ - clang-format │
│ │
│ Stage 2: 单元测试 ASan(< 5 min)│
│ - -fsanitize=address,undefined │
│ - 跑全部单测 │
│ │
│ Stage 3: 集成测试 + 多 sanitizer │
│ - ASan + UBSan:完整集成测试 │
│ - TSan:并发模块 │
│ - LSan:长跑场景 │
│ │
└──────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
不同 PR 跑不同子集——核心规则:没过 Stage 1+2 不能合并。
# 12.2 单测必跑 ASan
最便宜的高价值动作。CMake 例子:
if(BUILD_TESTING)
set(CMAKE_CXX_FLAGS_DEBUG
"${CMAKE_CXX_FLAGS_DEBUG} -fsanitize=address,undefined -fno-omit-frame-pointer")
set(CMAKE_LINKER_FLAGS_DEBUG
"${CMAKE_LINKER_FLAGS_DEBUG} -fsanitize=address,undefined")
endif()
2
3
4
5
6
ctest 配合:
add_test(NAME my_test COMMAND my_test_bin)
set_tests_properties(my_test PROPERTIES
ENVIRONMENT "ASAN_OPTIONS=detect_leaks=1:halt_on_error=1:abort_on_error=1")
2
3
经验:90% 的内存 bug 在单测开 ASan 后立刻暴露——这是性价比最高的投资。
# 12.3 fuzz 配合 ASan
Fuzz 是给 ASan 喂"超出人类想象"的输入。基本骨架:
// fuzz_target.cpp
extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
parse_input(data, size); // 你要测的函数
return 0;
}
2
3
4
5
clang++ -fsanitize=address,undefined,fuzzer -g fuzz_target.cpp parse.cpp -o fuzzer
./fuzzer corpus/ -max_total_time=3600 # 跑 1 小时
2
libFuzzer + ASan 是组合拳——fuzz 触发 bug、ASan 拍下来 + 三栈定位。Google OSS-Fuzz 跑 1000+ 开源项目都是这套。
不能 fuzz 的部分:纯计算(数学函数)、网络/IO 重的代码。能 fuzz 的部分:所有从外部读字节流的解析器——HTTP、protobuf、JSON、XML、图像格式、压缩格式。
# 12.4 生产灰度的取舍
ASan 不上生产是定论吗?不全是——分场景:
| 场景 | 生产开 ASan? | 替代方案 |
|---|---|---|
| 高 QPS 网关 | ✗(2x 资源不可接受) | UBSan trap 子集 + minidump |
| 离线批处理 | △(可考虑) | 开 ASan,每天跑一次发现历史问题 |
| 客户端 / 桌面 | △ | 灰度开少量用户 |
| 服务端长跑 | ✗ | LSan 定期扫 + 监控 RSS 趋势 |
折中方案:UBSan trap 模式(几乎零开销)生产开,崩了之后线下用完整 sanitizer 重跑定位。
# 12.5 HWASan 与 MTE
下一代方案,目标是"生产可用的 sanitizer":
HWAsan(Hardware-assisted ASan):用 ARM64 的 Top Byte Ignore(TBI)特性——指针高 8 位放 tag,每次访问比对 tag。开销 5-15%(vs ASan 200-500%)。
普通指针:0x0000FFFF12345678
HWASan: 0xAB00FFFF12345678 ← 0xAB 是 tag
访问时硬件忽略高 8 位,但 ASan 校对 tag 是否匹配
2
3
MTE(Memory Tagging Extension):ARMv8.5 硬件特性,每 16 字节内存自带 4 位 tag——硬件级越界/UAF 检测,开销 < 5%。Android 13+ 的 Pixel 系列已经默认开启。
| 方案 | 开销 | 平台 | 状态 |
|---|---|---|---|
| ASan | 200-500% | 全平台 | 成熟 |
| HWASan | 5-15% | ARM64 | 成熟 |
| MTE | <5% | ARMv8.5+ 硬件 | 普及中 |
未来五年:MTE 普及后,C++ 程序生产开 sanitizer 会成为标配。就像今天的 stack canary 一样无感。
# 13. 综合案例串讲
# 13.1 案例真相揭晓
回到第 1 节的两条主线,所有疑问现在能逐条作答:
主线一(偶发 UAF)的诊断链:
| 步骤 | 工具 | 证据 |
|---|---|---|
| 1. 现象描述 | dmesg / 日志 | 凌晨偶发崩溃,core 栈各不相同 |
| 2. 怀疑方向 | gdb 看 core | s->buf 内容像被复用过 → 怀疑 UAF |
| 3. 重编 ASan | 测试环境跑 | 立刻命中 heap-use-after-free |
| 4. 三栈定位 | ASan 报告 | C: on_response:42, D: cleanup:18, E: create:23 |
| 5. 根因 | 代码审查 | cleanup 后异步回调仍持有 s 裸指针 |
| 6. 修复 | unique_ptr + weak_ptr | 异步回调升级 weak → 对象死了拿 nullptr |
| 7. 回归 | 单测 + ASan + 高 QPS 压测 | 不再触发 |
最终一句话:
cleanup释放 Session 后,异步线程持有的裸指针成为悬挂指针。修复用weak_ptr让异步访问能感知对象生命周期。
主线二(越界 1 字节)的诊断链:
| 步骤 | 工具 | 证据 |
|---|---|---|
| 1. 现象 | 用户反馈 | 偶发显示乱码,无崩溃 |
| 2. 复现失败 | gdb / 日志 | 不崩,没有现场 |
| 3. ASan 重跑 | CI / 本地 | heap-buffer-overflow at decode:9 |
| 4. 报告解读 | shadow bytes | 越界 1 字节,分配 5 字节,访问 buf[5] |
| 5. 根因 | 代码审查 | for (i=0; i<=n; ++i) 应该是 i<n |
| 6. 修复 | 改 <= 为 < | 同时引入 -Wsign-compare 防类似 |
| 7. 回归 | fuzz + ASan | 跑了 1h 无报告 |
最终一句话:
循环上界写错(
<=vs<),导致越界读 1 字节。普通运行不崩,但读到的字节随机,触发上层逻辑分叉。
# 13.2 一次内存 bug 全景
把"一次内存 bug 从写下到修复"串成一棵知识树:
程序员: int* p = new int[10]; p[10] = 1;
│
├─ 编译期
│ ├─ -Wall -Wextra: 无告警(运行时下标无法静态分析)
│ └─ clang-tidy: bugprone-* 规则未触发
│
├─ 运行期 - 普通构建
│ ├─ 写 p[10] → 越过分配区
│ ├─ 写到的字节属于堆元数据/邻块
│ └─ 可能不立刻崩 → bug 沉睡
│
├─ 运行期 - ASan 构建
│ ├─ 编译器在 store 前注入:
│ │ shadow = (p+10) >> 3 + OFFSET
│ │ if (shadow != 0) report_error
│ ├─ p[10] 落在右红区 → shadow = 0xFB
│ ├─ 立刻 abort,输出报告
│ └─ 报告含:当前栈 + 分配栈 + shadow 视图
│
├─ 报告解读
│ ├─ "heap-buffer-overflow" → 类型
│ ├─ "WRITE of size 4" → 方式
│ ├─ "0 bytes to the right" → 偏移
│ └─ "allocated by..." → 分配点
│
└─ 修复 + 回归
├─ 修:边界 `<` 而非 `<=`
├─ 单测:构造 n=0/1/many 边界
├─ Fuzz:随机大小输入
└─ CI:所有路径必跑 ASan
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 13.3 设计哲学回扣
四条跨篇适用的设计哲学:
哲学 1:影子内存——看不见的元数据是质量的源泉
shadow memory 不在程序的逻辑视图里,但每次访存它都参与判断。这是一个普适的工程范式:真实数据 + 影子元数据 = 自我验证的系统。Java/Go 的 GC mark bit、Rust 的 borrow checker、TLA+ 的状态空间,本质都是同一思路。
哲学 2:立刻暴露——晚一秒崩,多一千倍调试成本
C++ 默认是"乐观执行"——错了也继续跑。Sanitizer 改成"悲观验证"——一旦不对立刻停。短期慢 2-5 倍,长期省下 100 倍调试时间。这是工程经济学最划算的投资之一。
哲学 3:三角验证——单一证据不可信
ASan 的三栈、TSan 的两线程访问、LSan 的根集扫描——共同点是不依赖单一观测。多源证据相互印证,才能锁定根因。一个证据可能误报、可能巧合,三个一致的证据才叫事实。
哲学 4:分层防御——没有银弹,只有组合
ASan 抓不到 UB,UBSan 抓不到 race,TSan 抓不到内存泄漏,LSan 抓不到逻辑错误。没有一个工具能解决所有问题——但分层组合可以覆盖 99%。聪明的工程师不是找银弹,而是设计层层叠加的防线。
# 13.4 ASan 速查表
工具选择:
内存越界 / UAF → ASan
未定义行为(UB) → UBSan
未初始化读 → MSan / valgrind
数据竞争 → TSan
内存泄漏 → LSan / heaptrack
长服务 RSS 增长 → heaptrack / massif
闭源 / 三方库 → valgrind
2
3
4
5
6
7
最常用编译选项:
# 开发默认
-fsanitize=address,undefined -fno-omit-frame-pointer -g -O1
# 生产灰度(trap 模式)
-fsanitize=null,return,bool,enum,vptr,object-size,alignment
-fsanitize-trap=null,return,bool,enum,vptr,object-size,alignment
# Fuzz
-fsanitize=address,undefined,fuzzer
# Race 专项
-fsanitize=thread,undefined
2
3
4
5
6
7
8
9
10
11
12
ASAN_OPTIONS 速查:
ASAN_OPTIONS="detect_leaks=1\
:detect_stack_use_after_return=1\
:strict_string_checks=1\
:abort_on_error=1\
:halt_on_error=1\
:malloc_context_size=20\
:log_path=/var/log/asan"
2
3
4
5
6
7
报告快速识别表:
| 报告头 | 第一查 |
|---|---|
| heap-buffer-overflow | 看分配大小、看下标变量值 |
| heap-use-after-free | 比 D 栈和 C 栈,找释放点漏的引用 |
| stack-buffer-overflow | 看局部数组大小 |
| stack-use-after-return | 看是否返回了局部地址 |
| stack-use-after-scope | 看 {} 块结束后是否仍有引用 |
| global-buffer-overflow | 看全局数组定义和下标 |
| double-free | 看两次 free 路径 |
| invalid-free | 检查是不是栈对象 / new vs new[] 错配 |
| LeakSanitizer | 看分配栈的 new,判断生命周期管理 |
60 秒诊断流程:
# 1. 重编 ASan
clang++ -fsanitize=address,undefined -fno-omit-frame-pointer -g -O1 *.cpp -o app
# 2. 运行
ASAN_OPTIONS=detect_leaks=1:abort_on_error=1 ./app
# 3. 看报告三栈
# C 栈 → 暴露点
# D 栈 → 释放点(UAF)/ 分配点(越界)
# E 栈 → 分配点
# 4. 对照代码:找根因
# UAF:D 栈 + C 栈之间为什么没有同步
# 越界:分配大小 vs 访问下标
# 5. 修复 + CI 回归
# 单测 + ASan 必跑
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 13.5 思考题
为什么 ASan 的
quarantine_size_mb默认是 256MB?把它调到 4GB 会有什么影响?调到 16MB 呢?一段代码用了
placement new在自管理内存池上构造对象。ASan 报了heap-buffer-overflow,但你检查代码逻辑没问题。怎么判断是 ASan 误报、还是池适配代码的问题?你的代码在 Linux 上 ASan + UBSan 全 clean,但部署到 macOS 上偶发崩溃。可能的原因有哪些?应该怎么定位?
ASan 默认
detect_stack_use_after_return=0。开了之后慢 2 倍,但能抓更多 bug。如何在你的项目里做这个 trade-off?一段代码崩在
std::string内部的operator[]。ASan 报告显示heap-use-after-free,但 D 栈完全在标准库里,你看不懂。下一步怎么办?UBSan trap 模式生产开,触发 UB 时崩得没有任何报告,只有 SIGILL。你怎么把这个崩溃定位回源码行?
多线程下,TSan 报了一堆 race。但你检查发现都在原子操作上。可能是为什么?怎么验证?
一个 30 万行的老项目,从来没开过 ASan。一开就报几百个错误。怎么逐步推进而不被淹没?
ASan 的 shadow memory 占用 1/8 真实内存。如果你的进程已经 RSS 32GB,ASan 后是不是就需要 36GB?为什么实际可能远不止?
假设你是某团队的代码质量负责人。你会怎么把"内存安全"系统化嵌入团队工程实践?
ASan 不是魔法,是把"未来才会暴露的 bug"提前到现在。 早 5 秒发现的 bug 和晚 5 天发现的 bug,从来不是同一种成本。
下一篇:本篇讲了"如何让内存 bug 在第一现场暴露",下一步进入 03.GDB高级调试——把"现场已经被抓住后如何把 bug 钉死"这一面讲透。配套阅读:01.信号崩溃快速排查(崩溃定位的姊妹篇)、01.进程地址空间布局(理解 shadow memory 必备的内存模型基础)。