编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

  • Cpp入门到精通

    • README
    • 入门教程

    • 综合案例

    • 专栏博客

    • 开发技巧

      • 信号崩溃快速排查
      • ASan内存三件套
        • 1. 案例引入:两类内存 bug
          • 1.1 主线一:偶发 UAF
          • 1.2 主线二:越界 1 字节
          • 1.3 为什么 gdb 不够用
          • 1.4 本篇要回答什么
        • 2. 内存错误九宫格
          • 2.1 堆越界读写
          • 2.2 栈越界读写
          • 2.3 全局区越界
          • 2.4 use-after-free
          • 2.5 use-after-return
          • 2.6 use-after-scope
          • 2.7 double-free 与无效 free
          • 2.8 内存泄漏
          • 2.9 未初始化读取
        • 3. Sanitizer 全家桶
          • 3.1 三件套定位
          • 3.2 编译与链接姿势
          • 3.3 互斥与组合规则
          • 3.4 与 valgrind 之争
        • 4. ASan 原理拆解
          • 4.1 影子内存模型
          • 4.2 编译器插桩流程
          • 4.3 红区与隔离区
          • 4.4 quarantine 缓存原理
          • 4.5 性能与内存代价
        • 5. ASan 报告解读
          • 5.1 报告结构总览
          • 5.2 三栈一图法则
          • 5.3 shadow bytes 解读
          • 5.4 常见错误类型
          • 5.5 误报与漏报识别
        • 6. ASan 配置进阶
          • 6.1 ASAN_OPTIONS 详解
          • 6.2 黑名单与忽略
          • 6.3 自定义分配器适配
          • 6.4 与 fork/exec 的坑
          • 6.5 符号化与离线分析
        • 7. UBSan 未定义行为
          • 7.1 什么是 UB
          • 7.2 UBSan 检测清单
          • 7.3 报告解读
          • 7.4 trap 模式部署
          • 7.5 生产可开的子集
        • 8. TSan 数据竞争
          • 8.1 数据竞争定义
          • 8.2 happens-before 模型
          • 8.3 TSan 影子结构
          • 8.4 报告解读
          • 8.5 与原子操作配合
        • 9. LSan 内存泄漏
          • 9.1 泄漏判定标准
          • 9.2 直接泄漏与间接泄漏
          • 9.3 LSan 报告解读
          • 9.4 主动检测点
          • 9.5 LSan 局限
        • 10. 内存排查方法论
          • 10.1 立刻暴露原则
          • 10.2 三角验证法
          • 10.3 分层防御策略
          • 10.4 找到第一现场
          • 10.5 修复与回归套路
        • 11. 典型场景速查
          • 11.1 std::vector 失效迭代器
          • 11.2 dangling string_view
          • 11.3 lambda 捕获悬挂
          • 11.4 单例析构次序
          • 11.5 容器越界与下标
          • 11.6 自定义 new 的坑
          • 11.7 多线程数据竞争
        • 12. CI 集成与生产
          • 12.1 CI 流水线设计
          • 12.2 单测必跑 ASan
          • 12.3 fuzz 配合 ASan
          • 12.4 生产灰度的取舍
          • 12.5 HWASan 与 MTE
        • 13. 综合案例串讲
          • 13.1 案例真相揭晓
          • 13.2 一次内存 bug 全景
          • 13.3 设计哲学回扣
          • 13.4 ASan 速查表
          • 13.5 思考题
      • GDB十命令速查
      • CoreDump破案
      • perf火焰图实战
      • 迭代器失效陷阱
      • 智能指针选型
      • 异常安全RAII
      • 多线程锁选型
      • 编译期防御
  • Java入门精通

  • Go入门到精通

  • JavaScript入门

  • CodeX
  • Cpp入门到精通
  • 开发技巧
杨充
2026-06-15
目录

ASan内存三件套

# 第21章:ASan 内存三件套

# 目录介绍

  • 1. 案例引入:两类内存 bug
    • 1.1 主线一:偶发 UAF
    • 1.2 主线二:越界 1 字节
    • 1.3 为什么 gdb 不够用
    • 1.4 本篇要回答什么
  • 2. 内存错误九宫格
    • 2.1 堆越界读写
    • 2.2 栈越界读写
    • 2.3 全局区越界
    • 2.4 use-after-free
    • 2.5 use-after-return
    • 2.6 use-after-scope
    • 2.7 double-free 与无效 free
    • 2.8 内存泄漏
    • 2.9 未初始化读取
  • 3. Sanitizer 全家桶
    • 3.1 三件套定位
    • 3.2 编译与链接姿势
    • 3.3 互斥与组合规则
    • 3.4 与 valgrind 之争
  • 4. ASan 原理拆解
    • 4.1 影子内存模型
    • 4.2 编译器插桩流程
    • 4.3 红区与隔离区
    • 4.4 quarantine 缓存原理
    • 4.5 性能与内存代价
  • 5. ASan 报告解读
    • 5.1 报告结构总览
    • 5.2 三栈一图法则
    • 5.3 shadow bytes 解读
    • 5.4 常见错误类型
    • 5.5 误报与漏报识别
  • 6. ASan 配置进阶
    • 6.1 ASAN_OPTIONS 详解
    • 6.2 黑名单与忽略
    • 6.3 自定义分配器适配
    • 6.4 与 fork/exec 的坑
    • 6.5 符号化与离线分析
  • 7. UBSan 未定义行为
    • 7.1 什么是 UB
    • 7.2 UBSan 检测清单
    • 7.3 报告解读
    • 7.4 trap 模式部署
    • 7.5 生产可开的子集
  • 8. TSan 数据竞争
    • 8.1 数据竞争定义
    • 8.2 happens-before 模型
    • 8.3 TSan 影子结构
    • 8.4 报告解读
    • 8.5 与原子操作配合
  • 9. LSan 内存泄漏
    • 9.1 泄漏判定标准
    • 9.2 直接泄漏与间接泄漏
    • 9.3 LSan 报告解读
    • 9.4 主动检测点
    • 9.5 LSan 局限
  • 10. 内存排查方法论
    • 10.1 立刻暴露原则
    • 10.2 三角验证法
    • 10.3 分层防御策略
    • 10.4 找到第一现场
    • 10.5 修复与回归套路
  • 11. 典型场景速查
    • 11.1 std::vector 失效迭代器
    • 11.2 dangling string_view
    • 11.3 lambda 捕获悬挂
    • 11.4 单例析构次序
    • 11.5 容器越界与下标
    • 11.6 自定义 new 的坑
    • 11.7 多线程数据竞争
  • 12. CI 集成与生产
    • 12.1 CI 流水线设计
    • 12.2 单测必跑 ASan
    • 12.3 fuzz 配合 ASan
    • 12.4 生产灰度的取舍
    • 12.5 HWASan 与 MTE
  • 13. 综合案例串讲
    • 13.1 案例真相揭晓
    • 13.2 一次内存 bug 全景
    • 13.3 设计哲学回扣
    • 13.4 ASan 速查表
    • 13.5 思考题

# 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
}
1
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
1
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] 越界
}
1
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
1
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;
1
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");  // 经典
}
1
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
}
1
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
1
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
}
1
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
1
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
1
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 字节泄漏
1
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];            // 读垃圾值
1
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)         (数据竞争)        │
                  │                                │
                  └────────────────────────────────┘
1
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 让回溯更可靠
1
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 ..
1
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      ─ 可以
1
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
1
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 字节       平台相关常量
1
2
3

x86_64 Linux 的内存布局(默认 SHADOW_OFFSET = 0x7fff8000):

高地址
┌─────────────────────────────┐
│  HighMem (用户堆/栈/库)       │  ← 真实程序内存
├─────────────────────────────┤
│  HighShadow                 │  ← 高内存的影子
├─────────────────────────────┤
│  Bad / ShadowGap            │  ← 故意保留的非法区
├─────────────────────────────┤
│  LowShadow                  │  ← 低内存的影子
├─────────────────────────────┤
│  LowMem                     │  ← 低内存
└─────────────────────────────┘
低地址
1
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, ...);
}
1
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 前后插入红区
                                          │ 在每个全局变量前后插入红区
1
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);                   // 放入隔离区
1
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
            ──────  ──────────  ──────
             不可读     可访问     不可读
1
2
3
4
5

隔离区(Quarantine):free 后内存不立刻归还分配器,而是放进一个 FIFO 队列:

free(p) → quarantine_queue.push(p)         shadow 标 0xFD
         │
         │ 队列总大小限制 256MB(默认)
         │ 满了之后 FIFO 真正 free 最早的
         ▼
         真正 free 给 malloc 池
1
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
1
2
3
4
5

# 4.4 quarantine 缓存原理

很多人疑惑:"quarantine_size_mb=256 是什么意思?是 256MB 全部都用来放 freed 块吗?"——答案是累计 freed 块的"原始大小"达到 256MB 后开始 FIFO 出队。

当前 quarantine 总大小 256MB(达上限)
┌──────────────────────────────────────────┐
│  最早 freed 的 8MB 块  ← 出队,真正 free │
│  ...                                      │
│  ...                                      │
│  最近 freed 的 1MB 块  ← 入队             │
└──────────────────────────────────────────┘
1
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 倍:

  1. 每次访存多 5-10 条指令(shadow 查表 + 比较)
  2. shadow 区与真实区的访问交错,缓存命中率下降
  3. malloc 路径多了红区设置和 shadow 标记

为什么内存翻倍:

  1. 1/8 的 shadow(很小)
  2. 每块堆分配多两段红区(默认 32 字节/块)
  3. quarantine 占 256MB(默认)
  4. 所有全局变量周围加红区

心法: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
                                ^^
                          访问的就是这个字节
1
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 的"          │   "现在哪里又访问了"
    │    什么类型创建的"             │                              │
    └──────────────── 时间轴 ─────────────────────────────────────►
1
2
3
4
5

读取顺序心法:

  1. 先看 C(当前栈):知道"我在哪行访问了非法内存"
  2. 再看 D(释放栈):知道"是谁先把它干掉了"——这就是 bug site
  3. 最后看 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
                                ^^
1
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
1
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"
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
1
2
3
4
5

# 6.2 黑名单与忽略

某些第三方库 ASan 报告噪音很大但你又不想动它(如:用了 placement new 的对象池、自实现的 hashmap)——用黑名单忽略:

# blacklist.txt
src:third_party/legacy_pool.cpp
fun:LegacyPool::*
src:.*/protobuf/.*
1
2
3
4

编译时:

g++ -fsanitize=address -fsanitize-blacklist=blacklist.txt ...
1

抑制运行时报告(不重编):

# suppressions.txt
leak:third_party::LegacyHash
race:LegacyLogger::write
1
2
3
LSAN_OPTIONS=suppressions=suppressions.txt ./a.out
TSAN_OPTIONS=suppressions=suppressions.txt ./a.out
1
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
    }
};
1
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
1

这个 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 在符号服务器
1
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
1
2
3
4
5
6
7

关键经验:所有上线版本都要保留对应的 .debug 包,否则线上报告里只有地址完全没法分析。

# 7. UBSan 未定义行为

ASan 抓的是"内存层面的非法访问"。但 C++ 还有一类更阴险的 bug——语言层面的未定义行为(UB)——代码看似没访问错地址,但语言标准说这种写法行为不确定,编译器可能做任何事。

# 7.1 什么是 UB

UB(Undefined Behavior)是 C++ 标准里特意不规定行为的代码模式。原因有二:

  1. 历史 ABI 兼容:不同硬件对溢出/对齐/带符号位移的处理不同,标准妥协
  2. 优化空间:编译器假定 UB 不会发生,可以做激进优化

UB 的危险不在 UB 本身,而在编译器假定 UB 不发生时做的优化:

// 你写的
int x = INT_MAX;
if (x + 1 < x) printf("overflow\n");   // 想检测溢出

// 编译器认为:signed overflow 是 UB,不可能发生
// 所以 x+1 < x 永远 false,整个 if 被消除!
// 结果:你的"溢出检测"完全没用
1
2
3
4
5
6
7

这种 bug 在调试构建(O0)下能跑、Release(O2)下消失——最难调试的一类。

# 7.2 UBSan 检测清单

clang++ -fsanitize=undefined -g a.cpp -o a.out
1

完整子检查(按重要性排序):

子检查 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
1
2
3
4
5

# 7.3 报告解读

// ub.cpp
#include <climits>
int main() {
    int x = INT_MAX;
    int y = x + 1;
    return y;
}
1
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'
1
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
1
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'
1
2

# 7.4 trap 模式部署

UBSan 默认是报告 + 继续——不影响行为,只输出错误。但生产里你可能想"UB 一旦发生立即崩"——用 -fsanitize-trap:

# 触发 UB 时执行 ud2 指令(SIGILL)
clang++ -fsanitize=undefined -fsanitize-trap=undefined a.cpp
1
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
1
2

价值:额外抓住 30%+ 的 UB bug,几乎不影响性能——比关掉所有 sanitizer 划算太多。

# 8. TSan 数据竞争

多线程是 C++ 程序员心中的痛——bug 复现率几乎为 0,崩溃时栈还跨了多个线程,谁都不知道是谁先动了谁。TSan 是目前最好用的 race 检测工具。

# 8.1 数据竞争定义

C++ 标准对 data race 的定义(极其精确):

两个或多个线程访问同一内存位置,至少一个是写,没有 happens-before 关系——即没有用原子操作、互斥锁、条件变量、或线程创建/join 来同步——则行为是 UB。

注意三个关键点:

  1. 必须有写:两个线程都只读,不构成 race
  2. 必须没有同步:用了锁或原子就没事
  3. 同一内存位置:哪怕只是读相邻 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
}
1
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
1
2
3
4
5
6

如果两个写操作之间没有任何 HB 边,就是 race。

# 8.3 TSan 影子结构

TSan 的 shadow 不是 1:1 字节映射,而是每 8 字节真实内存对应 32 字节 shadow——存最近 N 个访问者的元组:

shadow cell(8 字节真实内存的"访问历史"):
┌──────────────────────┬──────────────────────┐
│  访问 1(线程,时钟)  │  访问 2(线程,时钟)  │  ...
└──────────────────────┴──────────────────────┘
1
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();
}
1
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
1
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;              // ✓
1
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);
    }
};
1
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)

从根集出发,递归追踪所有指针(按机器字扫描),能到达的内存视为"还活着"。
扫不到的堆块 = 泄漏。
1
2
3
4
5
6
7
┌─ 全局/静态 ─┐
│  ptr_a ────┼───→ Object A ──→ Object B
└────────────┘                       │
                                     ↓
┌─ 栈/寄存器 ─┐                  Object C  ← 还活着,不报
│  ptr_x ────┼───→ Object X
└────────────┘

         Object Y ← 没有任何指针指向它 = 泄漏
1
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 整条都泄漏
1
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).
1
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).
1
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();
}
1
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 仍可达
1
2

LSan 看到 v 还活着——它的存储一直可达。这是逻辑 bug 而不是泄漏。

2) 缓存堆积也不是泄漏

std::unordered_map<int, BigObject> cache;
// 缓存只插入不淘汰,越来越大,但 LSan 报告里它"还活着"
1
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(硬件辅助)+ 监控告警              │
└────────────────────────────────────────────────────────┘
1
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 的心法:

  1. ASan 有 D 栈(释放栈)→ bug site 在 D 栈某一层
  2. ASan 没有 D 栈(如越界)→ bug site 通常在分配点周围
  3. 多线程崩溃 → 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(...);
}
1
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);
    }
});
1
2
3
4
5
6
7
8
9
10
11

C. 架构级(资深做法):

把"会话生命周期"抽出独立的 Owner,所有人通过 ID 访问而非裸指针。Owner 死则 ID 失效,所有访问统一通过查表来判断对象是否还在。

回归测试:

  1. 路径覆盖:原崩溃路径再走一遍——单测 + ASan
  2. 机制覆盖:用 ASan 证明同类 bug 都不存在
  3. 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
1
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
}
1
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
}
1
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
1
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
1
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); }
};
1
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;                 // ❌ 非原子写
}
1
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
1
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:长跑场景                 │
│                                  │
└──────────────────────────────────┘
1
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()
1
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")
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;
}
1
2
3
4
5
clang++ -fsanitize=address,undefined,fuzzer -g fuzz_target.cpp parse.cpp -o fuzzer
./fuzzer corpus/ -max_total_time=3600    # 跑 1 小时
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 是否匹配
1
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
1
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
1
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
1
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"
1
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 必跑
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

# 13.5 思考题

  1. 为什么 ASan 的 quarantine_size_mb 默认是 256MB?把它调到 4GB 会有什么影响?调到 16MB 呢?

  2. 一段代码用了 placement new 在自管理内存池上构造对象。ASan 报了 heap-buffer-overflow,但你检查代码逻辑没问题。怎么判断是 ASan 误报、还是池适配代码的问题?

  3. 你的代码在 Linux 上 ASan + UBSan 全 clean,但部署到 macOS 上偶发崩溃。可能的原因有哪些?应该怎么定位?

  4. ASan 默认 detect_stack_use_after_return=0。开了之后慢 2 倍,但能抓更多 bug。如何在你的项目里做这个 trade-off?

  5. 一段代码崩在 std::string 内部的 operator[]。ASan 报告显示 heap-use-after-free,但 D 栈完全在标准库里,你看不懂。下一步怎么办?

  6. UBSan trap 模式生产开,触发 UB 时崩得没有任何报告,只有 SIGILL。你怎么把这个崩溃定位回源码行?

  7. 多线程下,TSan 报了一堆 race。但你检查发现都在原子操作上。可能是为什么?怎么验证?

  8. 一个 30 万行的老项目,从来没开过 ASan。一开就报几百个错误。怎么逐步推进而不被淹没?

  9. ASan 的 shadow memory 占用 1/8 真实内存。如果你的进程已经 RSS 32GB,ASan 后是不是就需要 36GB?为什么实际可能远不止?

  10. 假设你是某团队的代码质量负责人。你会怎么把"内存安全"系统化嵌入团队工程实践?


ASan 不是魔法,是把"未来才会暴露的 bug"提前到现在。 早 5 秒发现的 bug 和晚 5 天发现的 bug,从来不是同一种成本。

下一篇:本篇讲了"如何让内存 bug 在第一现场暴露",下一步进入 03.GDB高级调试——把"现场已经被抓住后如何把 bug 钉死"这一面讲透。配套阅读:01.信号崩溃快速排查(崩溃定位的姊妹篇)、01.进程地址空间布局(理解 shadow memory 必备的内存模型基础)。

上次更新: 2026/06/16, 18:05:07
信号崩溃快速排查
GDB十命令速查

← 信号崩溃快速排查 GDB十命令速查→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式