From 108df9decc4c011754895ed2f389ba5b35d91e25 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Thu, 25 Jun 2026 20:30:02 +0800 Subject: [PATCH 1/2] feat: migrate some passages of *sans --- .gitignore | 1 + README.md | 2 +- documents/en/vol8-domains/networking/index.md | 28 - .../10-asan-family-and-memory-safety.md | 398 ++++++++++++++ .../11-memory-safety-asan-valgrind.md | 486 ++++++++++++++++++ ...2-sanitizer-toolchain-and-memory-safety.md | 332 ++++++++++++ documents/vol6-performance/index.md | 3 + documents/vol8-domains/networking/.pages | 1 - documents/vol8-domains/networking/index.md | 23 - scripts/tags.py | 3 + 10 files changed, 1224 insertions(+), 53 deletions(-) delete mode 100644 documents/en/vol8-domains/networking/index.md create mode 100644 documents/vol6-performance/10-asan-family-and-memory-safety.md create mode 100644 documents/vol6-performance/11-memory-safety-asan-valgrind.md create mode 100644 documents/vol6-performance/12-sanitizer-toolchain-and-memory-safety.md delete mode 100644 documents/vol8-domains/networking/.pages delete mode 100644 documents/vol8-domains/networking/index.md diff --git a/.gitignore b/.gitignore index 09da07364..0551cdc74 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # AI agent infra — 公开(AGENTS.md / CLAUDE.md / .claude 资产库),仅以下保持私有 .claude/settings.local.json .claude/cache/ +.claude/drafts/ .claude/issues/ .claude/prompts/produce-vol5-labs.md .claude/prompts/vol5-code-verification.md diff --git a/README.md b/README.md index a38d959ad..9f3b7da59 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ --- -![English Coverage](https://img.shields.io/badge/en_coverage-100%25-green.svg) 492/492 docs translated +![English Coverage](https://img.shields.io/badge/en_coverage-99%25-green.svg) 491/494 docs translated ## 这是什么项目 diff --git a/documents/en/vol8-domains/networking/index.md b/documents/en/vol8-domains/networking/index.md deleted file mode 100644 index 4df8303ec..000000000 --- a/documents/en/vol8-domains/networking/index.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -title: Network Programming -description: Socket, HTTP, asynchronous I/O, WebSocket, RPC -platform: host -tags: -- cpp-modern -- host -- intermediate -translation: - source: documents/vol8-domains/networking/index.md - source_hash: 061eff1fa2f15c315ac1026800957c3f310ee80500e97f3297e0f59c2dde6a1d - translated_at: '2026-05-26T12:23:40.447955+00:00' - engine: anthropic - token_count: 62 ---- -# Network Programming - -> Status: Planned - -## Overview - -This subdomain covers C++ network programming, including Socket fundamentals, HTTP, asynchronous I/O, WebSocket, and RPC. - -Thirteen articles are expected. - -## Chapter Navigation - -> Content is being written, stay tuned. diff --git a/documents/vol6-performance/10-asan-family-and-memory-safety.md b/documents/vol6-performance/10-asan-family-and-memory-safety.md new file mode 100644 index 000000000..9c149752c --- /dev/null +++ b/documents/vol6-performance/10-asan-family-and-memory-safety.md @@ -0,0 +1,398 @@ +--- +title: "ASan 工具家族与内存安全:shadow memory、Heartbleed 与 sanitizer 选型" +description: "从 Heartbleed 说起,拆解 AddressSanitizer 的 shadow memory 三件套,实测 OOB/UAF/全局越界与 UBSan,理清 ASan/LSan/MSan/TSan/UBSan 五兄弟的职责与互斥关系" +chapter: 6 +order: 10 +platform: host +difficulty: advanced +cpp_standard: [11, 14, 17, 20] +reading_time_minutes: 22 +prerequisites: + - "动态内存管理(new/delete 与智能指针)" + - "并发程序调试技巧(ThreadSanitizer)" +related: + - "动态内存管理(new/delete 与智能指针)" + - "并发程序调试技巧(ThreadSanitizer)" + - "C 语言动态内存管理(malloc/free 与 valgrind)" +tags: + - host + - cpp-modern + - advanced + - 内存安全 + - 调试 + - 内存管理 +--- + +# ASan 工具家族与内存安全:shadow memory、Heartbleed 与 sanitizer 选型 + +> PS: 这部分内容是笔者读大学期间迁移的笔记,仅经过有限的搜索验证,如果发现存在技术断言不严谨的问题甚至时错误,欢迎报告 Issue! 或者是直接的修复PR! + +写过一段时间 C/C++,你大概率被这几类问题反复折磨过:数组多读了一位、释放完的指针被别人又用了一次、同一个 `delete` 调了两遍。这些错误有个共同的恶心之处——它们是**未定义行为**(大名鼎鼎的Undefined Behavior),不是"一定会崩",而是"在 Debug 构建里跑得好好的,到了 Release 或者换了台机器就随机爆炸"。更糟的是,崩的地方往往离真正出错的代码十万八千里,堆栈指向的可能是某个无辜的库函数。 + +为什么会这样?因为这类 bug 破坏的是内存管理器自己的元数据,等到下一次 `malloc`/`free` 走到那个被踩坏的位置才发作。本卷前面讲性能,这一篇我们换到另一个维度:怎么在 bug 还没酿成线上事故之前,就用工具把它揪出来。主角是 AddressSanitizer(ASan)和它背后那一整套 sanitizer 工具家族。 + +先别急着把 ASan 当成一个"加个 flag 就完事"的小工具。它背后的设计——shadow memory、编译期插桩——其实是过去十几年 C/C++ 内存安全领域最重要的工程进展之一,而且它最初被发明出来,就是为了堵住一个让整个互联网心惊肉跳的洞。我们从那个洞讲起。 + +## 一切的起点:Heartbleed 与 buffer over-read + +2014 年 4 月,CVE-2014-0160 被披露,代号 Heartbleed。这是 OpenSSL 实现里一个听起来人畜无害的特性——TLS 心跳扩展(heartbeat)——里藏的洞。协议很简单:客户端发来一段任意数据,告诉服务器"这段数据有 N 字节,请原样读回来",用来验证连接还活着。 + +漏洞在于,服务器**信任了客户端报上来的长度 N,但没有校验 N 是否真的不超过自己手里那段数据的实际长度**。于是攻击者只要把 N 报得很大(比如 64KB),服务器就会从自己进程内存里"读回" 64KB 给攻击者。读到的可能是别的会话的 TLS 私钥、用户密码、session token——进程内存里挨着那块缓冲区的一切,统统泄漏。 + +这个 bug 的本质不是越界**写**,而是越界**读**(buffer over-read / over-read)。写越界好歹会破坏数据、容易暴露;读越界安静得多,进程自己不会崩,数据就这么悄无声息地流出去了。ASan 当年被反复拎出来讲,正是因为它属于少数能**稳定检测 over-read**的工具——只要那段越界内存碰到了 ASan 埋下的 redzone,读一下就立刻报错。 + +我们用几十行 Modern C++ 复刻一个 Heartbleed 形状的 bug,然后让 ASan 抓现行。这是本篇的王牌演示,后面要反复用到。 + +```cpp +// oob_read.cpp —— 复刻 Heartbleed 形状的越界读 +// Platform: host Standard: C++20 +// 编译: g++ -std=c++20 -O1 -fsanitize=address -g oob_read.cpp -o oob_read +#include +#include +#include + +// 心跳回显:客户端说"还给我 n 字节"。服务器照办,但不校验 n 上界。 +std::string read_back(const std::array& buf, int n) +{ + return std::string(buf.data(), n); // n 可能远大于 8 +} + +int main() +{ + std::array buf{'H', 'i', '!', 0, 0, 0, 0, 0}; + // 只授权了 8 字节,却要求"读回" 64 字节 —— 经典 over-read + auto leaked = read_back(buf, 64); + std::printf("读到 %zu 字节: %.8s...\n", leaked.size(), leaked.c_str()); +} +``` + +不带 ASan 编译运行,这段代码大概率"看起来正常":`std::string` 的构造函数老老实实按你给的长度从 `buf.data()` 起拷贝 64 字节,把栈上后面那些不相干的字节全读走了,程序不会崩。这正是 over-read 可怕的地方。 + +加 `-fsanitize=address` 再跑,画风突变。用本机 GCC 16.1.1 跑出来: + +```text +================================================================= +==37023==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x72175e1f0028 at pc 0x761760d29ac2 ... +READ of size 64 at 0x72175e1f0028 thread T0 + #0 0x... in memcpy (/usr/lib/libasan.so.8+0x129ac1) + ... + #6 0x... in read_back[abi:cxx11](std::array const&, int) oob_read.cpp:11 + #7 0x... in main oob_read.cpp:18 + ... + + This frame has 2 object(s): + [32, 40) 'buf' (line 16) + [64, 96) 'leaked' (line 18) <== Memory access at offset 40 partially underflows this variable +SUMMARY: AddressSanitizer: stack-buffer-overflow oob_read.cpp:11 in read_back +``` + +注意两个细节。第一,报错类型是 `stack-buffer-overflow`,发生在 `read_back` 第 11 行——也就是 `return std::string(buf.data(), n);` 那一行,精确到源码位置,这正是为什么编译时必须带 `-g`。第二,ASan 甚至告诉我们栈帧里有两个对象,`buf` 占 `[32, 40)`、`leaked` 占 `[64, 96)`,越界读的位置(offset 40)正好落在两者之间。这种级别的现场信息,是 ASan 区别于"加个断言慢慢找"的根本所在。 + +## 所以,ASan 到底做了什么才能这样的呢? + +### 第一件:编译期插桩(CTI) + +ASan 不是事后分析的 profiler,而是**编译期就改写你的代码**。当你加 `-fsanitize=address`,编译器(GCC 或 Clang 都行)会在每一次内存访问——每一个 `*p`、每一次数组下标、每一次 `memcpy`——前后插入额外的检查指令。这种技术叫**编译期插桩**(compile-time instrumentation,CTI),也叫静态插桩。 + +这里先验证一下它真的"动了你的代码"。把上面的 `oob_read.cpp` 不带 ASan 编译一份,带 ASan 编译一份,对比两者的**代码段(.text)大小**——也就是真正塞进二进制里的机器指令: + +```text +普通构建 .text: 2792 字节 +ASan 构建 .text: 5736 字节 (+105%) +``` + +(本机 GCC 16.1.1,`g++ -std=c++20 -O1 -g`,用 `size` 看 `.text` 段。) 多出来的那一倍,就是编译器在每次内存访问前后塞进去的检查指令。注意这里有个坑:**别拿整个二进制文件的大小来比**——ASan 的运行时库 `libasan.so.8` 是**动态链接**的(`ldd` 能看到它),并没有被打进可执行文件,所以整个文件大小其实只涨了 5% 左右;真正反映插桩量的是 `.text` 代码段,那才是翻倍增长的地方。代价是体积变大、运行变慢——但比起它能抓到的 bug,这点开销在开发阶段几乎可以忽略。CTI 是**编译期**决定的事,所以你必须**编译时**就带上 `-fsanitize=address`,而且**链接时也要带**。如果你只编译主程序时加了、链接某个第三方 `.a` 库时没加,那库内部的内存访问就没被插桩,ASan 对那部分代码就是瞎的。完整流程是: + +```bash +g++ -std=c++20 -O1 -fsanitize=address -g -c a.cpp -o a.o # 编译带 +g++ -std=c++20 -O1 -fsanitize=address -g main.cpp a.o -o app # 链接也带 +``` + +`-fsanitize=address` 在编译和链接两个阶段都要出现,缺一个就白搭。 + +### 第二件:shadow memory(影子内存) + +光有插桩还不够。插桩插入的检查代码,需要一个"账本"来回答"这个地址现在到底能不能访问"。这个账本就是 **shadow memory**(影子内存)。 + +核心思想是一句很优雅的设计:**用 1 个字节的影子内存,记录 8 个字节的实际内存的可访问状态**。也就是说,ASan 把整个进程地址空间按 8 字节一组映射到一片连续的影子区,比例是 1:8。这样检查一个地址是否合法,只需要算出它对应的影子字节、读出来看一眼就行,不用维护什么复杂的哈希表。 + +影子字节的取值,ASan 的报告末尾会直接打印出来。我们看真实输出里的图例: + +```text +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 mid redzone: f2 +``` + +这就是 1:8 映射的全部语义。`00` 表示这 8 字节都可以访问;`01`–`07` 表示只有前几个字节合法(比如 `03` 表示前 3 字节能访问、后 5 字节不能,用于对齐尾部的部分可访问区域);`fa` 是堆分配周围的 redzone——ASan 在你 `new` 出来的每块内存四周都偷偷塞了一圈"禁入区",你一旦读到 `fa`,就是堆越界;`fd` 是已经 `free` 掉的内存,你一碰就是 use-after-free;`f1`/`f2` 是栈对象的 redzone。 + +回头看上面那段报错的 shadow dump: + +```text +=>0x72175e1f0000: f1 f1 f1 f1 00[f2]f2 f2 00 00 00 00 f3 f3 f3 f3 +``` + +`00` 是 `buf` 本体(8 字节,1 个影子字节),紧跟着的 `[f2]` 就是栈对象之间的 mid redzone。我们越界读的地址恰好落在这个 `f2` 上——ASan 一眼就看出来了。这是 shadow memory 机制能精确到字节级的原因。 + +### 第三件:运行时库 + quarantine + +光有插桩和影子区还不够,还得有人**填这个账本**。`new`/`delete`、`malloc`/`free` 这些函数,ASan 运行时库(`libasan`)会把它们整个换掉——换成自己的版本。每分配一块内存,运行时就给它在影子区里画上 redzone;每释放一块,就把对应影子区标记成 `fd`。 + +这里还有个关键设计叫 **quarantine(隔离区)**。`free` 掉的内存,ASan 不会立刻归还给系统重新分配,而是先扔进一个隔离队列里晾着。为什么?因为 use-after-free 这种 bug,如果你 `free` 完马上又分配出去给别人用了,那块内存的影子状态就变回 `00` 了,后面误读就抓不到了。隔离一段时间,保证"已释放"的状态能被后续的误访问撞上。 + +不过 quarantine 不是无限的——队列有上限,满了之后旧的已释放内存会按 FIFO 被真正回收。所以 ASan 对 use-after-free 的检测也不是 100%——如果隔离窗口已经滑过去了、内存已经被重新分配,那次误读就抓不到。但配合足够的测试覆盖,绝大多数 UAF 都能被逮住。 + +### 代价:2-4 倍开销,为什么还是值得 + +三件套加起来,ASan 的典型开销是 **2-4 倍的运行时间减速、3-5 倍的内存开销**(影子区占 1/8,加上 redzone 和 quarantine)。听起来不少,但这是跟谁比的问题。 + +传统的内存检测工具 Valgrind(Memcheck)用的是**动态二进制插桩**(DBI)——它不重新编译你的程序,而是在运行时把每一条机器指令翻译成自己的一套中间表示、逐条分析再执行。精度高、不用重新编译,但代价是 20-50 倍的减速。跑一个原本 1 秒的测试,Valgrind 要等半分钟,很多时候根本没法纳入日常 CI。 + +ASan 把分析成本**前移到了编译期**(CTI),运行时只做查表,所以能把开销压到 2-4 倍。这个量级意味着你可以在开发和 CI 里**常驻**开着 ASan 跑完整测试套件,而不是偶尔想起来才手动跑一次 Valgrind。这是 ASan 相对 Valgrind 最根本的优势——不是更准,而是**用得起**。 + +::: warning 本机没装 Valgrind +这篇的所有 ASan/UBSan 输出都是本机真跑出来的(GCC 16.1.1 / Clang 22,WSL2)。Valgrind 在本机环境里没装(`which valgrind` → not found),所以本篇不贴 Valgrind 实测输出。需要 Valgrind 的同学在 Debian/Ubuntu 上 `apt install valgrind` 即可,用法见 vol1 的 [C 语言动态内存管理](../vol1-fundamentals/c_tutorials/14-dynamic-memory.md) 一文的 valgrind 段。两条命令的本质区别记牢:**ASan 是编译期 `-fsanitize=address`(CTI),Valgrind 是运行时 `valgrind ./prog`(DBI)**。 +::: + +## 工具家族:五个 sanitizer 各管一类 + +ASan 其实只是一个家族的成员。这套工具最早是 Google 的工程师作为 GCC 和 Clang 的补丁实现的,后来成了主流编译器的标配。家族里一共五个成员,每个盯着一类特定的错误: + +| 工具 | 编译开关 | 抓什么 | 典型开销 | +|------|---------|--------|---------| +| **ASan**(AddressSanitizer) | `-fsanitize=address` | 越界读写、use-after-free、double-free、栈/全局越界 | 2-4x 减速 | +| **LSan**(LeakSanitizer) | `-fsanitize=leak` | 内存泄漏(程序退出时未释放的堆内存) | 几乎零开销 | +| **MSan**(MemorySanitizer) | `-fsanitize=memory` | 读未初始化的内存(use of uninitialized value) | ~3x 减速 | +| **TSan**(ThreadSanitizer) | `-fsanitize=thread` | 数据竞争(data race)、死锁 | 5-15x 减速 | +| **UBSan**(UndefinedBehaviorSanitizer) | `-fsanitize=undefined` | 未定义行为(有符号溢出、空指针解引用、位移越界等) | 可配置,多数子项开销很小 | + +五兄弟里,ASan 是主力,日常开发几乎必备;LSan 默认会随 ASan 一起启用(GCC/Clang 在支持的环境下);MSan 只在 Clang 上完整可用、且必须**全程序**都编译成 MSan 版本(连 libc 都要 MSan 版,否则误报满天飞);TSan 专门盯并发,我们在 vol5 的 [并发程序调试技巧](../vol5-concurrency/ch08-debug-testing-perf/01-debugging-concurrency.md) 里专门讲过;UBSan 是个"补刀手",开销小、可以和别的组合。 + +### ASan 和 TSan 互斥:一条铁律 + +这五个工具不是随便组合的。最重要的约束是:**ASan 和 TSan 不能同时启用**。ASan 要用自己的影子内存布局,TSan 也要用自己的,两套机制会打架。编译器会在编译期直接拒绝你: + +```text +$ g++ -std=c++20 -fsanitize=address,thread -g conflict.cpp -o conflict +cc1plus: error: '-fsanitize=thread' is incompatible with '-fsanitize=address' +``` + +报错直白。这条约束的工程后果是:一个项目的 CI 里,内存错误检测和数据竞争检测得分**两套独立的构建**——一套开 ASan、一套开 TSan,各自跑一遍测试。vol5 的 TSan 篇专门讲过这个"双构建"实践,这里我们记住结论就好。 + +至于 MSan,它跟 ASan/TSan 都不兼容(它要求所有代码都"干净"地走自己的未初始化追踪),而且只支持 Clang,所以实际项目里用得最少。LSan 和 UBSan 是两个"百搭"——LSan 几乎零开销可以常驻,UBSan 的多数子项也能跟 ASan 一起开。 + +## 实测:ASan 抓三类典型错误 + +光讲原理不过瘾。我们把 C++ 里最容易翻车的三类内存错误各写一个,让 ASan 一个个抓出来。下面三段都是本机真跑出来的。 + +### 堆 use-after-free + +智能指针能挡住大部分 UAF,但只要项目里还有裸指针、还有 C 风格 API,这个洞就堵不完。看一个最小例子——把一个 `unique_ptr` 释放后,还拿着它先前吐出来的裸指针去读: + +```cpp +// uaf.cpp —— use-after-free +// Platform: host Standard: C++20 +// 编译: g++ -std=c++20 -O1 -fsanitize=address -g uaf.cpp -o uaf +#include +#include + +int main() +{ + auto p = std::make_unique(42); + int* raw = p.get(); // 拿到裸指针 + p.reset(); // 这里释放 —— raw 立刻变成悬空指针 + std::printf("悬空指针读到的值: %d\n", *raw); // use-after-free +} +``` + +带 ASan 跑: + +```text +================================================================= +==37082==ERROR: AddressSanitizer: heap-use-after-free on address 0x7a948abe0010 ... +READ of size 4 at 0x7a948abe0010 thread T0 + #0 0x... in main uaf.cpp:12 + +0x7a948abe0010 is located 0 bytes inside of 4-byte region [0x7a948abe0010,0x7a948abe0014) +freed by thread T0 here: + #0 0x... in operator delete(void*, unsigned long) (/usr/lib/libasan.so.8+0x12e4c1) + ... + #4 0x... in main uaf.cpp:11 + +previously allocated by thread T0 here: + #0 0x... in operator new(unsigned long) (/usr/lib/libasan.so.8+0x12d341) + ... + #2 0x... in main uaf.cpp:9 + +SUMMARY: AddressSanitizer: heap-use-after-free uaf.cpp:12 in main +``` + +这份报告是 ASan 最有价值的地方。它不仅告诉你"第 12 行那次读是 use-after-free",还同时给出**这条内存的两段历史**:在 `uaf.cpp:9` 由 `make_unique` 分配、在 `uaf.cpp:11` 由 `reset` 释放。盯着这两行,bug 的因果链就完整了——这正是 quarantine + redzone 机制的价值:已释放的内存被标记成 `fd` 而不是立刻回收,所以后续误读能撞上。 + +shadow dump 里那个 `[fd]` 就是铁证: + +```text +=>0x7a948abe0000: fa fa[fd]fa fa fa fa fa ... +``` + +`fd` = freed heap region。这就是 ASan "账本"的功劳。 + +### 全局缓冲区越界 + +全局/静态变量同样有 redzone 保护。一个全局数组越界访问,ASan 照抓不误: + +```cpp +// global_oob.cpp —— 全局数组越界 +// 编译: g++ -std=c++20 -O1 -fsanitize=address -g global_oob.cpp -o global_oob +#include +int g[4] = {1, 2, 3, 4}; +int main() { std::printf("g[5] = %d\n", g[5]); } +``` + +```text +==38356==ERROR: AddressSanitizer: global-buffer-overflow on address 0x63ca65acd074 ... +SUMMARY: AddressSanitizer: global-buffer-overflow global_oob.cpp:5 in main +``` + +报错类型明确标成 `global-buffer-overflow`。ASan 把栈、堆、全局三类区域用不同的 redzone 编码区分开(`f1`/`f2` 栈、`fa` 堆、`f9` 全局),所以你能一眼看出越界发生在哪类存储上。 + +::: warning 关于「Clang 11 才支持全局 OOB」这个说法 +有些老资料会说"ASan 检测全局变量越界需要 Clang 11 以上"。这个说法的历史背景是:早期 ASan 对全局变量的 redzone 支持不完整,Clang 11 引入了 ODR 指示器(`-fsanitize-address-use-odr-indicator`)等改进才把全局检测做扎实。但**今天**——GCC 8.3+ / Clang 主流版本——对全局越界的检测都是默认开、开箱即用的,本机 GCC 16.1.1 上面这个例子就是默认配置一把抓到的。所以这条"版本门槛"对现在的工具链已经过时,别被老资料带歪。 +::: + +### 泄漏:LSan 在退出时收尾 + +最后看内存泄漏。LSan 的工作方式和前几个不一样——它不在运行中报错,而是在 `main` 返回、程序即将退出时,扫描所有还"活着"的堆分配,把没人引用的、没有对应释放的全标出来。我们写一个故意泄漏的最小例子: + +```cpp +// leak.cpp —— 故意泄漏 +// Platform: host Standard: C++20 +// 编译: g++ -std=c++20 -O1 -fsanitize=address -g leak.cpp -o leak +#include +#include +int main() +{ + int* p = (int*)std::malloc(sizeof(int) * 4); // 拿了堆内存 + p[0] = 42; + std::printf("ptr = %p\n", (void*)p); // 让指针逃逸,防止整段被优化器删掉 + // 没有 free,程序退出时 p 指向的内存泄漏 +} +``` + +带 ASan 跑(GCC 16.1.1 / WSL2,LSan 默认随 ASan 启用): + +```text +ptr = 0x730c4cbe0010 +================================================================= +==364484==ERROR: LeakSanitizer: detected memory leaks + +Direct leak of 16 byte(s) in 1 object(s) allocated from: + #0 0x... in malloc (/usr/lib/libasan.so.8+0x12c161) + #1 0x... in main leak.cpp:8 + ... + +SUMMARY: AddressSanitizer: 16 byte(s) leaked in 1 allocation(s). +``` + +注意:报告是在 `main` 返回**之后**才打出来的——这正是 LSan "退出时收尾"的工作方式。vol1 的 [动态内存管理](../vol1-fundamentals/ch12/02-new-delete.md) 也给过一个等价示例,可对照。 + +::: warning LSan 的"静默退出"陷阱 +LSan 在主流 Linux 环境(GCC 16.1.1 / Clang 22)下默认随 ASan 启用,上面这个例子本机能稳定抓到。但要小心一个真实陷阱:**泄漏只有在进程正常退出时才会被扫描**。如果你的程序是被 `SIGKILL` 干掉的、或调了 `_exit` 绕过 `atexit` 钩子、或在某些容器/沙箱里 LSan 的退出钩子没跑起来,泄漏报告就会**静默消失**——程序看起来"没报错",但其实是 LSan 压根没机会扫描。 + +排查办法:确认进程是正常 return 退出的;需要时显式 `ASAN_OPTIONS=detect_leaks=1` 强制开;长期运行的服务(根本不退出)用不了 LSan 这种"退出时收尾"的模型,得改用 Valgrind massif 或堆采样。别假设 LSan "没报就是没漏"。 +::: + +## UBSan:把"未定义行为"从沉默变成报错 + +讲完 ASan 家族的主力,我们看补刀手 UBSan。C/C++ 有个让人血压拉满的特性:**未定义行为(UB)**。编译器对 UB 的态度是"既然标准没规定会发生什么,我就当它不会发生,放心优化"。后果是,有符号整数溢出、位移越界、空指针解引用这些错误,程序**经常看起来跑得好好的**——直到某天开了 `-O2`、换了编译器版本,优化器基于"这段不会溢出"的假设做了激进变换,程序突然算出离谱的结果。 + +UBSan 的思路是:在每个可能产生 UB 的操作旁边插一个运行时检查,一旦真的发生 UB,立刻打印 `runtime error: ...` 报告(默认不中止程序,可以配置成中止)。开销很小,很多子项可以和 ASan 一起常驻。 + +看一个最小例子,一次塞进去三种经典 UB: + +```cpp +// ubsan.cpp —— UBSan 捕获未定义行为 +// Platform: host Standard: C++20 +// 编译: g++ -std=c++20 -O1 -fsanitize=undefined -g ubsan.cpp -o ubsan +#include +#include + +int main() +{ + int arr[4]{1, 2, 3, 4}; + int idx = 10; + std::printf("越界下标 arr[10] = %d\n", arr[idx]); // 下标越界 + + int max = std::numeric_limits::max(); + std::printf("有符号溢出: %d\n", max + 1); // 有符号整数溢出 + + int shift = 32; + std::printf("左移 32 位: %d\n", 1 << shift); // 位移量 >= 位宽 +} +``` + +带 UBSan 跑: + +```text +ubsan.cpp:11:55: runtime error: index 10 out of bounds for type 'int [4]' +ubsan.cpp:11:16: runtime error: load of address 0x7ffe8a0525c8 with insufficient space for an object of type 'int' +ubsan.cpp:14:16: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int' +ubsan.cpp:17:42: runtime error: shift exponent 32 is too large for 32-bit type 'int' +``` + +三个 UB 全被抓到,精确到 `文件:行号:列号`。UBSan 覆盖的 UB 清单很长,常见的包括: + +- **算术类**:有符号整数溢出/下溢、除以零; +- **位移类**:位移量为负或大于等于位宽、左移把符号位改掉; +- **内存/指针类**:空指针解引用、对齐错误的内存访问、对象大小不匹配(通过错误类型的指针访问); +- **数组类**:下标越界(`-fsanitize=bounds`,这个和 ASan 的越界检测有重叠但侧重不同——ASan 看 redzone,UBSan 看编译期已知的数组尺寸)。 + +UBSan 的开销取决于你开了哪些子项。`-fsanitize=undefined` 是一组默认子项的集合,多数都很轻;真正贵的是 `-fsanitize=integer`(无符号溢出也算,开销大、误报多,生产慎用)。日常建议:`-fsanitize=undefined` 跟 ASan 一起开,成本低、收益高。 + +## 选型:面对一个内存 bug,该上哪个工具 + +到这里五兄弟都亮相过了。问题来了——当你真坐到一个诡异的 bug 面前,该按什么顺序选工具?我们按"症状"来定位: + +- **一跑就崩 / 段错误 / 偶发崩溃**:先开 ASan 跑复现测试。越界、UAF、double-free 这三类是段错误最常见的原因,ASan 一把抓。 +- **结果偶发性错误 / 跨函数的诡异值**:怀疑 UAF 或数据竞争。先 ASan 排除 UAF,ASan 没报再单独构建一份 TSan 版本查数据竞争(别忘了两者互斥,不能同时开)。 +- **算出来的数离谱 / `-O2` 下行为变了**:几乎可以锁定 UB,直接 UBSan。 +- **读了"看起来正常"的垃圾值、行为依赖未初始化**:MSan(注意只 Clang、要全程序编译)。 +- **进程吃内存越来越多 / 怀疑泄漏**:LSan(退出时报告),或长期运行的服务用 Valgrind massif / 堆采样的方式。 + +一个工程化的实践是:**CI 里常驻两套构建**——`ASan+UBSan` 一套、`TSan` 一套,每次提交都跑。开销可以接受(ASan+UBSan 在 2-4 倍量级),换来的是把"上线后偶发崩溃"这类最贵的 bug,在还没出门的时候摁住。 + +::: warning ASan 不是银弹 +ASan 很强,但它有几个绕不开的限制,必须心里有数。 + +第一,**它只能抓"实际执行到"的路径**。CTI 是运行时检测,代码没跑到就不会触发检查。如果你的测试覆盖率不够、某条越界路径从来没被触发过,ASan 抓不到——这正是为什么 ASan 要配合好的测试用例、甚至 fuzzing(模糊测试)一起用,fuzzing 负责把罕见路径跑出来,ASan 负责在这些路径上一旦出错就报。 + +第二,**只抓内存类错误**。逻辑错误(算错了)、并发错误(数据竞争)、整数溢出这种 UB,ASan 不管——后者归 UBSan,前者归 TSan。别指望一个 flag 解决所有问题。 + +第三,**生产环境别开**。2-4 倍减速和额外内存,在生产负载下是灾难。ASan/UBSan/TSan 都是**开发/测试/CI 阶段**的工具,发布构建里一定要去掉这些 flag。 + +第四,**它有假阳性边界**。某些自定义的栈展开机制(`swapcontext`、`vfork`)会让 ASan 的影子区判断出错,报假阳性。报告里那句 `HINT: this may be a false positive if your program uses some custom stack unwind mechanism` 就是在提醒这个。 +::: + +## 小结 + +我们这一篇从 Heartbleed 那个让全世界心惊的 over-read 洞出发,把 ASan 工具家族拆了个底朝天。几个关键结论收一下: + +- **ASan 三件套**:编译期插桩(CTI)改写每次内存访问、shadow memory 用 1:8 影子字节记录每 8 字节内存的可访问状态、运行时库替换 `new`/`delete` 并用 quarantine 隔离已释放内存。开销 2-4 倍减速,远低于 Valgrind 的 20-50 倍,所以能在 CI 里常驻。 +- **shadow memory 的编码**:`00` 可访问、`01`-`07` 部分可访问、`fa` 堆 redzone、`fd` 已释放、`f1`/`f2` 栈 redzone、`f9` 全局 redzone。ASan 报告末尾的 shadow dump 就是这套编码的直观呈现。 +- **工具家族**:ASan(越界/UAF)、LSan(泄漏)、MSan(未初始化读,Clang only)、TSan(数据竞争)、UBSan(UB)。**ASan 和 TSan 互斥**,CI 里分两套构建。 +- **UBSan 是补刀手**:开销小、可和 ASan 共存,把有符号溢出、位移越界、空指针解引用这些"沉默的 UB"变成显式 `runtime error`。 +- **选型口诀**:段错误先 ASan,偶发错误 ASan 排 UAF 再 TSan 查竞争,算术离谱上 UBSan,内存增长查 LSan。生产环境一律关闭。 + +真正的内存安全不是靠工具抓出来的,是靠 RAII、智能指针、`std::span`、范围 `for` 这些**从语法层面就让你写不出越界和悬空**的手段守住的——那些是 vol1 和 vol3 的主题。ASan 这套工具的价值在于:在你还没把所有裸指针都替换掉、在第三方 C 库还没被现代封装包住的过渡期,它是那道"最后一道防线",让那些潜伏的内存 bug 在开发阶段就显形,而不是拖到线上凌晨三点炸给你看。 + +## 参考资源 + +- [AddressSanitizer · google/sanitizers Wiki](https://github.com/google/sanitizers/wiki/AddressSanitizer) —— ASan 官方说明,shadow memory 机制与 1:8 映射、2x 开销的权威出处 +- [Clang: AddressSanitizer](https://clang.llvm.org/docs/AddressSanitizer.html) —— Clang 侧 ASan 文档,含 `-fsanitize-address-use-odr-indicator` 等全局检测演进 +- [Clang: ThreadSanitizer](https://clang.llvm.org/docs/ThreadSanitizer.html) —— TSan 文档,ASan↔TSan 互斥的出处(详见 vol5 并发调试篇) +- [Clang: UndefinedBehaviorSanitizer](https://clang.llvm.org/docs/UndefinedBehaviorSanitizer.html) —— UBSan 各子项清单与开销 +- [Valgrind User Manual](https://valgrind.org/docs/manual/manual.html) —— DBI 方法与 Memcheck/Helgrind,20-50x 开销的对照 diff --git a/documents/vol6-performance/11-memory-safety-asan-valgrind.md b/documents/vol6-performance/11-memory-safety-asan-valgrind.md new file mode 100644 index 000000000..92cc6708e --- /dev/null +++ b/documents/vol6-performance/11-memory-safety-asan-valgrind.md @@ -0,0 +1,486 @@ +--- +title: Valgrind 与 ASan 对照:JIT 解释 vs 编译期插桩 +description: 把 Valgrind 五件套(memcheck/callgrind/cachegrind/helgrind+drd/massif)的职责拆开,用 ASan 真实编译运行六类经典内存错误,讲透「动态二进制翻译」与「编译期影子内存插桩」两条路线的本质差别 +chapter: 6 +order: 11 +platform: host +difficulty: advanced +cpp_standard: + - 11 + - 14 + - 17 + - 20 +tags: + - host + - cpp-modern + - advanced + - 内存安全 + - 调试 + - 内存管理 +reading_time_minutes: 28 +prerequisites: + - 动态内存管理(new/delete 与智能指针) + - C 语言动态内存(malloc/free 与 valgrind 速览) +related: + - ASan 工具家族与内存安全:shadow memory、Heartbleed 与 sanitizer 选型 + - 并发程序调试技巧(TSan / Helgrind 深入) + - 动态内存管理 +--- + +# Valgrind 与 ASan 对照:JIT 解释 vs 编译期插桩 + +> PS: 这部分内容由笔者大学期间的笔记迁移而来,关键结论均已用本机 GCC 16.1.1 + valgrind 3.25.1 实编实跑核对过;若仍有疏漏,欢迎 Issue 或 PR。 + +先说一个我们大概都干过的事:一段 C++ 代码本地跑得好好的,一上线就偶发崩溃,或者内存 RSS 一路涨到被 OOM Killer 收掉。你回头去翻代码,`new` 配 `delete` 看着都对,越界也就差那么一两个字节——光读代码根本读不出问题。这种 bug,肉眼 debug 是没希望的,必须靠工具去「看见」内存的每一次访问。 + +这篇我们要做的,是把抓内存错误的工具按「实现路线」分成两大派,把它们拆开跑一遍。一派是 **Valgrind**——老牌的、在程序外面套一层「虚拟 CPU」去解释执行的 JIT 方案;另一派是 **AddressSanitizer(ASan)**——编译期就把检查代码插进你程序里、靠「影子内存」记账的方案。源头的旧笔记只讲了 Valgrind,对 ASan 一字未提,但这恰恰是现在工程里更常用的那条路。这篇就把这个缺口补上,并把两条路线摆在一起对照。 + +## 一、两类内存错误,和「为什么读代码读不出来」 + +在动手用工具之前,先把要抓的「敌人」分清楚。内存错误大致两类,抓它们的难度天差地别: + +**第一类:确定性的越界 / use-after-free / double-free。** 这类错误的特征是「访问了一块不该访问的地址」。它危险,但相对好抓——只要工具能标记「哪块内存是合法的、哪块不是」,越界那一刻就能当场报出来。`char buf[8]; buf[8] = 'x';` 这种 off-by-one、`free(p); return *p;` 这种悬垂指针,都属于这一类。 + +**第二类:未初始化读取 / 内存泄漏。** 这类更阴险。未初始化读取是「地址合法、但值是垃圾」——程序不会崩,只是悄悄算错;内存泄漏是「地址一直合法、只是永远不归还」——程序也不崩,只是 RSS 慢慢涨。这俩你不能靠「合法地址表」抓,得靠另一套机制:Valgrind 给每个字节维护「这个值是不是已经初始化过」的标记,ASan 的泄漏检测(LSan)则在程序退出时扫一遍堆,看还有没有「分配了但没人指着」的块。 + +读代码读不出来的根本原因,是这两类错误都**取决于运行时的内存状态**,而不取决于代码的字面写法。你光看 `*p`,根本不知道这一刻 `p` 指的内存是活的还是死的、是初始化过的还是垃圾。这正是为什么我们需要工具去「记录」每一次分配、每一次释放、每一次读写——把运行时的内存状态变成一份事后可查的账本。 + +那「记录」这件事,Valgrind 和 ASan 是两条完全不同的实现路线。先把结论摆前面,后面再逐个拆。 + +| 维度 | Valgrind(memcheck) | AddressSanitizer | +|------|---------------------|------------------| +| 怎么记 | 动态二进制翻译:运行时把每条机器指令翻译成带检查的版本 | 编译期插桩:编译时就在每次访存前后插检查代码 | +| 要不要重新编译 | **不用**,拿现成二进制就能跑 | **要**,必须用 `-fsanitize=address` 重新编 | +| 运行时开销 | 慢 20~50 倍,内存 2 倍以上(官方原话) | 慢约 2 倍,内存约 3 倍 | +| 平台 | Linux/macOS(FreeBSD/Solaris),x86/ARM 等 | GCC/Clang/MSVC 全平台,含 Windows | +| 谁来抓未初始化读取 | memcheck 原生能抓(V-bit) | ASan **抓不了**,得另上 `-fsanitize=memory`(MSan,Clang 专属) | +| 抓栈上越界 | 能(但要 `-tool=memcheck` 全套) | 默认抓栈/全局红区,`detect_stack_use_after_return` 抓栈返回后访问 | + +这张表你先有个印象。接下来我们从「源头的痛点」开始,先看 Valgrind 这条路是怎么走通的。 + +## 二、Valgrind:套一层「虚拟 CPU」去 JIT 解释你的程序 + +### 2.1 它到底在干什么 + +Valgrind 本质上是一个**动态二进制翻译(dynamic binary translation, DBT)框架**。它不是个普通的检测库,而是把你的程序整个塞进一个「虚拟 CPU」里跑。你敲 `valgrind ./myprog`,真实发生的事是:Valgrind 拦截你的每一条机器指令,把它**即时翻译**成「干原来的活 + 顺带记录内存状态」的一串新指令,然后才执行。所以你的程序不是直接在 CPU 上跑的,而是在 Valgrind 的核心(core)里被「解释」着跑。 + +这就是它那句著名的副作用的来源——**慢 20 到 50 倍**,内存占用翻倍以上。Valgrind 官方手册原话就写着: + +> Programs running under Valgrind run significantly more slowly, and use much more memory -- e.g. more than twice as much as normal under the Memcheck tool. + +换算一下:一个跑 1 秒的程序,塞进 memcheck 可能要跑半分钟。所以 Valgrind 不是给你做日常开发时挂着的,是给你「这程序真的有内存 bug,我专门拿一段时间来揪它」用的。 + +这个 JIT 解释的架构有个巨大的好处,也是 Valgrind 至今没被淘汰的根本原因:**不用重新编译**。你手头有一个十年前的、连源码都找不全的二进制,怀疑它泄漏——`valgrind ./老古董` 一敲就能跑。ASan 做不到这点,ASan 必须从源码重新编一遍。这是两条路线最硬的差别。 + +### 2.2 五件套:一个框架,五个工具 + +Valgrind 的精髓是「框架 + 工具」。core 负责翻译和调度,具体「记什么、报什么」交给可插拔的 tool。`--tool=` 选哪个,就是选哪副「检查眼镜」。让我们瞧瞧,可以看到手册里列的核心工具有这些: + +**Memcheck**——内存错误检测器,Valgrind 的默认工具,也是绝大多数人说的「用 Valgrind 查内存」时实际用的那个。它抓的全集是(引自手册 4.1):访问不该访问的内存(堆块越界、栈顶越界、释放后访问)、使用未初始化的值、错误的释放(double-free、`malloc` 配 `delete` 这类不匹配)、`memcpy` 源目的重叠、传给分配函数「可疑」的负数 size、`realloc` 传 0、对齐值不是 2 的幂、以及内存泄漏。一句话:memcheck 把 C/C++ 程序里最常见的内存错误几乎一网打尽。 + +**Callgrind**——调用图 + 缓存/分支预测 profiler。它不需要你在编译时加特殊选项(但推荐 `-g`),运行结束时把分析数据写进一个文件,再用 `callgrind_annotate` 转成人能读的格式。定位「哪个函数被调了多少次、调用关系长啥样」用。 + +**Cachegrind**——缓存 profiler。它模拟 CPU 的 I1/D1/L2 缓存,精确指出程序里 cache miss 和命中的位置,能给你每行代码、每个函数、每个模块产生了多少次 miss、多少条指令。想压缓存性能用它。 + +**Helgrind 和 DRD**——这俩都是**线程错误检测器**,抓数据竞争、锁顺序不一致、POSIX 线程 API 误用。源笔记把 Helgrind 写成「仍然处于实验阶段」,这个说法**早就过时了**——2026 年的官方手册里 Helgrind 和 DRD 都是正式列出的稳定工具,各有独立的章节(手册第 7、8 章),不是实验功能。顺带提一句:源笔记只提了 Helgrind,**漏了 DRD**——它俩目的相同(抓线程 bug)但算法不同,DRD 通常更快、对某些场景(比如大量小对象、Boost.Thread、OpenMP)支持更好。线程错误这块我在卷五的[并发程序调试技巧](../vol5-concurrency/ch08-debug-testing-perf/01-debugging-concurrency.md)里专门讲过 TSan/Helgrind 的实战,这篇不重复,记住「线程类 bug 找 helgrind/drd、或更现代的 TSan」就行。 + +**Massif**——堆 profiler。测程序在堆上到底吃了多少内存,给你堆块、堆管理结构、栈的增长曲线。想给程序「瘦身」、找 RSS 大户用它。 + +> **一个容易被忽略的分工点**:memcheck 抓「对错」(这块内存能不能访问、有没有初始化),callgrind/cachegrind/massif 抓「快慢/多少」(性能和用量)。新人常把它们混为一谈,以为 Valgrind 就是查内存泄漏的——其实那只是 memcheck 一个工具的活。性能分析那几个工具(callgrind/cachegrind/massif)和 ASan 完全不在一个赛道,ASan 不碰性能 profiling。 + +### 2.3 memcheck 的双表原理:A-bit 与 V-bit + +memcheck 凭什么能抓住那么多种内存错误?关键就在它维护了两张覆盖整个进程地址空间的「影子表」。手册 4.5 节讲得很清楚: + +**Valid-Address 表(A-bit)。** 进程地址空间的每一个字节,都对应 1 个 bit,记录「这个地址当前能不能被读写」。malloc 出来一块、A-bit 就把那几个字节标记成「有效」;free 掉、标记翻回「无效」。当指令要去读写某个字节时,先查它的 A-bit——如果显示无效,就是非法访问,memcheck 当场报错。这层抓住了:越界、use-after-free、访问未分配区域。 + +**Valid-Value 表(V-bit)。** 进程地址空间的每一个字节,对应 8 个 bit;CPU 的每个寄存器也对应一个 bit 向量。它们记录「这个值是不是已经被初始化过了」。malloc 出来的内存,V-bit 全是「未初始化」;一旦有指令往里写了确定的值,对应字节的 V-bit 翻成「已初始化」。关键设计是:**V-bit 会跟着值「传播」**——你把一个未初始化的值从内存读进寄存器,V-bit 也跟着搬到寄存器里;你拿它做运算,结果的 V-bit 也是「未初始化」。但 memcheck 不会一读到未初始化值就报,它只在「这个值被拿去影响程序输出、或被用来生成地址」的那一刻才报。这个延迟是有意为之的——避免满屏误报。 + +把两张表合起来看就明白了:A-bit 管「地址合不合法」,V-bit 管「值干不干净」。前者抓越界/UAF,后者抓未初始化读取。double-free 和 alloc-dealloc 不匹配则靠 memcheck 自己维护的「这块内存是用什么分配器申请的」账本去比对。 + +这套「每字节都记账」的机制,代价就是前面说的内存翻倍——A-bit 和 V-bit 本身就要占地方。 + +## 三、ASan:编译期插桩 + 影子内存 + +### 3.1 思路完全反过来 + +ASan 的实现路线和 Valgrind 正好反过来。它**不**在程序外面套虚拟 CPU,而是**在编译的时候**就把检查代码插进你的程序里。你加 `-fsanitize=address`,编译器就会在你每一次读/写内存的前后,插一小段代码:这段代码会查一张「影子内存(shadow memory)」表,判断这次访问合不合法,不合法就报错并 abort。 + +所以 ASan 的检查是「程序自己查自己」,而不是「外面的虚拟 CPU 替它查」。这就解释了两条路线开销的巨大差距:ASan 只在被插桩的那几次访存上多花几条指令,没有「翻译整条指令流」的成本,所以**只慢约 2 倍**(Valgrind 是 20~50 倍);代价是必须重新编译,且检查只覆盖被插桩的代码——动态加载的、没带 ASan 编译的第三方 .so,它管不到(Valgrind 能,因为它在指令层全盘拦截)。 + +### 3.2 影子内存:8 字节 → 1 字节的编码 + +ASan 的核心机制是影子内存(shadow memory 的完整拆解见本卷[ASan 工具家族](./10-asan-family-and-memory-safety.md),那里还讲了它当年怎么堵 Heartbleed 这种越界读漏洞)。它把进程的整个地址空间按 8 字节一组映射到一张影子表里——每 8 个应用字节对应 1 个影子字节。那个影子字节的值有明确含义,我直接把本机跑出来的图例贴给你(后面那段输出是真实的): + +```text +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 mid redzone: f2 + Stack right redzone: f3 + Stack after return: f5 + Stack use after scope: f8 + Global redzone: f9 + Global init order: f6 + Poisoned by user: f7 + Container overflow: fc + Array cookie: ac + Intra object redzone: bb + ASan internal: fe + Left alloca redzone: ca + Right alloca redzone: cb +``` + +翻译一下这套编码的精妙之处: + +- 影子字节是 `00`:这 8 字节全部可访问; +- 是 `01`~`07`:只有前 N 字节可访问,剩下的是越界红区——这正是 ASan 抓 off-by-one 的原理,它在每个堆块、栈帧、全局变量周围都铺了一圈「红区」,红区的影子字节标记成 `fa`/`f9` 等,你一踩进红区,插桩代码查影子字节发现不是「可访问」,立刻报错; +- 是 `fd`:这块内存已经 free 了——再访问就是 use-after-free,当场抓住。 + +也就是说,ASan 不是像 memcheck 那样「逐字节记账地址合法性」,而是「在合法区域外围铺红区,用红区来界定边界」。这套机制对越界和 UAF 极其有效,但**它没有 V-bit**——所以 ASan 抓不了未初始化读取。这块缺口得用 MSan(MemorySanitizer,`-fsanitize=memory`)补,而 MSan 只有 Clang 实现,**GCC 到 16.1.1 都不支持 `-fsanitize=memory`**(本机实测 `unrecognized argument`)。这是 ASan 路线相对 memcheck 的一个真实短板。 + +> **踩坑预警**:ASan 和别的 sanitizer 是「一次只能开一类」的关系。`-fsanitize=address` 和 `-fsanitize=thread`(TSan)**不能同时开**——它们对影子内存的布局假设不同,混用会直接报错或行为异常。所以抓内存错误时开 ASan,抓并发数据竞争时单独开 TSan,别想着「一把梭」。线程错误该怎么查,见[卷五的并发调试篇](../vol5-concurrency/ch08-debug-testing-perf/01-debugging-concurrency.md)。 + +## 四、上手跑一跑:六类经典错误,ASan 真实输出 + +光讲原理不过瘾。我们把源笔记里那六类「全是截图、没源码」的经典错误,全部用真代码写出来,用 `g++ -std=c++20 -O0 -g -fsanitize=address,undefined` 在本机(GCC 16.1.1)编译运行。下面每一段输出都是我**真跑出来的**,不是手编的。 + +先把六类错误装进同一个程序: + +```cpp +// cases.cpp — 六类经典内存错误,逐个用 ASan 复现 +// 编译: g++ -std=c++20 -O0 -g -fsanitize=address,undefined cases.cpp -o cases +// 运行: ./cases <1..6> 不传参则只跑内存泄漏 +#include +#include + +// 1. 使用未初始化内存(ASan 抓不到,要 MSan) +int case_uninit() { + int* p = (int*)malloc(sizeof(int)); // 内容是垃圾 + int v = *p; // 读到垃圾值,但地址合法 + free(p); + return v; +} + +// 2. use-after-free +int case_uaf() { + int* p = (int*)malloc(sizeof(int)); + *p = 42; + free(p); + return *p; // 读已释放内存 +} + +// 3. 堆缓冲区越界(尾部读写) +int case_oob() { + int* a = (int*)malloc(4 * sizeof(int)); // 只有 a[0..3] + a[4] = 99; // 第 5 个元素越界 + int r = a[4]; + free(a); + return r; +} + +// 4. 内存泄漏(忘记 free) +void case_leak() { + int* p = (int*)malloc(sizeof(int)); + *p = 7; // 故意不 free +} + +// 5. malloc 配 delete(分配/释放不匹配) +void case_mismatch() { + int* p = (int*)malloc(sizeof(int)); + *p = 5; + delete p; // malloc 该配 free +} + +// 6. 双重释放 +void case_double_free() { + int* p = (int*)malloc(sizeof(int)); + free(p); + free(p); // 第二次 free +} + +int main(int argc, char** argv) { + if (argc < 2) { case_leak(); puts("done: leak only"); return 0; } + switch (atoi(argv[1])) { + case 1: printf("uninit=%d\n", case_uninit()); break; + case 2: printf("uaf=%d\n", case_uaf()); break; + case 3: printf("oob=%d\n", case_oob()); break; + case 4: case_leak(); puts("done leak"); break; + case 5: case_mismatch(); puts("done mismatch"); break; + case 6: case_double_free(); puts("done double-free"); break; + default: puts("usage: ./cases [1..6]"); break; + } + return 0; +} +``` + +编译这行你记一下,后面每个 case 都用它:`g++ -std=c++20 -O0 -g -fsanitize=address,undefined cases.cpp -o cases`。`-g` 是为了让 ASan 报告里带行号,`-O0` 是别让优化把我们的越界访问优化掉(高优化级别下,`a[4]` 这种「写了立刻读」可能被折叠,ASan 仍能抓,但调试时 `-O0` 最干净)。 + +### 4.1 用未初始化内存 —— ASan 的盲区 + +先跑 case 1,看 ASan 的反应: + +```text +$ ./cases 1 +uninit=-1094795586 +``` + +**ASan 一声不吭**,程序正常返回了一个垃圾值(`-1094795586`)。这就是前面说的短板:这块内存地址是合法的(malloc 来的),ASan 的影子内存里它标成「可访问」,没有 V-bit 去判断「这个值有没有被初始化过」。这块错误 memcheck 能抓(靠 V-bit),ASan 抓不到——要抓得换 MSan(`-fsanitize=memory`,Clang 专属)。这是两条路线一个**实质性的能力差异**,不是谁更强,是各管一摊。 + +### 4.2 use-after-free —— 红区当场咬住 + +跑 case 2: + +```text +$ ./cases 2 +================================================================= +==44083==ERROR: AddressSanitizer: heap-use-after-free on address 0x799329de0010 ... +READ of size 4 at 0x799329de0010 thread T0 + #0 ... in case_uaf() /tmp/asand/cases.cpp:20 + #1 ... in main /tmp/asand/cases.cpp:56 + ... + +0x799329de0010 is located 0 bytes inside of 4-byte region [0x799329de0010,0x799329de0014) +freed by thread T0 here: + #0 ... in free ... + #1 ... in case_uaf() /tmp/asand/cases.cpp:19 + ... + +previously allocated by thread T0 here: + #0 ... in malloc ... + #1 ... in case_uaf() /tmp/asand/cases.cpp:17 + ... + +SUMMARY: AddressSanitizer: heap-use-after-free /tmp/asand/cases.cpp:20 in case_uaf() +``` + +(上面把 build-id 等无关行省略了,关键信息全在。)你看 ASan 给了三段信息:**这次非法读发生在哪**(`case_uaf()` 第 20 行的 `return *p`)、**这块内存是哪里 free 的**(第 19 行)、**它最初是哪里 malloc 的**(第 17 行)。三段凑一起,整条「申请→释放→又访问」的因果链一目了然。这就是红区机制加上「free 后影子字节翻成 `fd`」的功劳——`free` 之后那块内存对 ASan 来说不再是「可访问」,再碰就报。 + +### 4.3 堆缓冲区越界 —— 尾部红区 + +跑 case 3(`a[4]` 越界,`a` 只开了 4 个 int): + +```text +$ ./cases 3 +================================================================= +==44191==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x7288a7be0020 ... +WRITE of size 4 at 0x7288a7be0020 thread T0 + #0 ... in case_oob() /tmp/asand/cases.cpp:26 + ... + +0x7288a7be0020 is located 0 bytes after 16-byte region [0x7288a7be0010,0x7288a7be0020) +allocated by thread T0 here: + #0 ... in malloc ... + #1 ... in case_oob() /tmp/asand/cases.cpp:25 + ... +``` + +`located 0 bytes after 16-byte region`——这块内存是 16 字节(4 个 int),访问点正好踩在它**结尾之后的第一个字节**,也就是尾部红区的起点。这就是 ASan 抓 off-by-one 的原理:malloc 返回的块后面紧跟一圈红区,红区的影子字节是 `fa`(heap left redzone,其实是堆块周围的 poison),`a[4]` 落进红区,插桩代码一查影子字节不是 `00`,当场报错。 + +> **一个源笔记提到、但容易误解的点**:源笔记说「Valgrind 不检查静态分配数组」。这点对老 memcheck 是真的(栈/全局数组越界历史上是 memcheck 的弱项),但 **ASan 不是这样**——ASan 对栈数组、全局变量都铺红区(影子字节 `f1`~`f3` 是栈红区、`f9` 是全局红区),栈上数组越界它抓得很利索。所以「静态数组越界查不到」这条结论,只对 Valgrind 成立,对 ASan 不成立。别把两个工具的局限混为一谈。 + +### 4.4 内存泄漏 —— LSan 在程序退出时扫堆 + +跑 case 4(`./cases 4`,故意不 free): + +```text +$ ./cases 4 + +================================================================= +==44296==ERROR: LeakSanitizer: detected memory leaks + +Direct leak of 4 byte(s) in 1 object(s) allocated from: + #0 ... in malloc ... + #1 ... in case_leak() /tmp/asand/cases.cpp:34 + #2 ... in main /tmp/asand/cases.cpp:58 + ... + +SUMMARY: AddressSanitizer: 4 byte(s) leaked in 1 allocation(s). +``` + +注意这个报错是 **`LeakSanitizer`**,不是 ASan 本体——LSan 是 ASan 默认捆绑的泄漏检测器,在**程序正常退出时**扫一遍整个堆,把「分配了但没有任何指针指向」的块揪出来。它报告的是「still reachable / definitely lost」这套分类里的「definitely lost」。这和 memcheck 的泄漏检测思路一致(都是退出时扫堆),只是 LSan 是 ASan 工具链的一部分。 + +> **守护进程怎么办?** LSan 默认在程序 `exit` 时才扫,长跑的 daemon/服务进程不会自己退出。这时可以发信号让它中途 dump:`ASAN_OPTIONS=abort_on_error=0:detect_leaks=1` 配合 `kill`,或用 LSan 的 `__lsan_do_leak_check()` API 在代码里主动触发一次扫描。Valgrind 那边对应的办法是另一个终端 `kill` 掉 memcheck 进程让它输出(源笔记提过这招)。 + +### 4.5 malloc 配 delete —— 分配/释放不匹配 + +跑 case 5: + +```text +$ ./cases 5 +================================================================= +==44300==ERROR: AddressSanitizer: alloc-dealloc-mismatch (malloc vs operator delete) ... + #0 ... in operator delete(void*, unsigned long) ... + #1 ... in case_mismatch() /tmp/asand/cases.cpp:42 + ... + +0x71a3249e0010 is located 0 bytes inside of 4-byte region [0x71a3249e0010,0x71a3249e0014) +allocated by thread T0 here: + #0 ... in malloc ... + #1 ... in case_mismatch() /tmp/asand/cases.cpp:40 + ... +``` + +`alloc-dealloc-mismatch (malloc vs operator delete)`——ASan 替每个分配记下了「是用谁申请的」,释放时一比对,`malloc` 配 `delete` 不匹配,当场报。memcheck 抓的是同一类(手册 4.2.5「freed with an inappropriate deallocation function」),两边能力对齐。 + +> **平台差异提醒**:这个 `alloc-dealloc-mismatch` 检查在 Windows 上**默认是关的**(MSVC 的 ASan,因为 Windows 上 `delete` 和 `free` 经常实际等价)。Linux/macOS 默认开。如果你在 Windows 上发现这类错误没被抓,查一下 `ASAN_OPTIONS=alloc_dealloc_mismatch=1`。 + +### 4.6 双重释放 + +跑 case 6: + +```text +$ ./cases 6 +================================================================= +==44193==ERROR: AddressSanitizer: attempting double-free on 0x6d0d527e0010 in thread T0: + #0 ... in free ... + #1 ... in case_double_free() /tmp/asand/cases.cpp:49 + ... + +0x6d0d527e0010 is located 0 bytes inside of 4-byte region [0x6d0d527e0010,0x6d0d527e0014) +freed by thread T0 here: + #0 ... in free ... + #1 ... in case_double_free() /tmp/asand/cases.cpp:48 + ... +``` + +`attempting double-free`——第一次 `free` 之后影子字节翻成 `fd`,第二次再 `free` 同一地址,ASan 发现它已经是 `fd` 状态(已释放),直接判定为 double-free。还贴心地告诉你「上次是在第 48 行 free 的」。 + +### 4.7 额外彩蛋:栈上 use-after-return + +ASan 还能抓一个 memcheck 历史上很难抓的东西——**栈帧返回后被访问**(函数返回了,调用方却还持有指向函数内局部变量的指针)。这个要显式开: + +```cpp +// suar2.cpp +#include +static int* g = nullptr; +void stash() { int local = 0xc0ffee; g = &local; } // 把局部变量地址存出去 +int main() { stash(); return *g; } // local 已随 stash 返回而消失 +``` + +```text +$ g++ -std=c++20 -O0 -g -fsanitize=address suar2.cpp -o suar2 +$ ASAN_OPTIONS=detect_stack_use_after_return=1 ./suar2 +================================================================= +==44702==ERROR: AddressSanitizer: stack-use-after-return on address 0x6da50b8f0020 ... +READ of size 4 at 0x6da50b8f0020 thread T0 + #0 ... in main /tmp/asand/suar2.cpp:4 + ... + +Address 0x6da50b8f0020 is located in stack of thread T0 at offset 32 in frame + #0 ... in stash() /tmp/asand/suar2.cpp:3 + + This frame has 1 object(s): + [32, 36) 'local' (line 3) <== Memory access at offset 32 is inside this variable +HINT: this may be a false positive if your program uses some custom stack unwind mechanism ... +SUMMARY: AddressSanitizer: stack-use-after-return /tmp/asand/suar2.cpp:4 in main +``` + +注意那个地址 `0x6da50b8f0020`——它在进程地址空间里**很靠前**(不是普通栈区),因为开了 `detect_stack_use_after_return` 后,ASan 会把「可能被逃逸指针指向的局部变量」挪到一块专门的「假栈(fake stack)」上,函数返回时把那块假栈标成毒,再访问就报 `stack-use-after-return`(影子字节 `f5`)。默认它是关的,因为有一定开销和少量误报(看那个 HINT)。但这种「函数返回后还在用栈内存」的 bug 极其难查,值得知道有这招。 + +## 五、Valgrind 怎么用:把上面那些错误喂给 memcheck + +讲完原理,我们把第四节那同一个 `cases.cpp`(这次不带 `-fsanitize=`,普通编译)塞进 valgrind 跑一遍,看 memcheck 对同一批错误是怎么报的——两套话术正面相对,对照才看得清。本机用的是 valgrind 3.25.1。 + +先用 `-g` 编一个干净版(valgrind 不需要 ASan 那套插桩,但要 `-g` 才能在报告里给出行号): + +```bash +g++ -std=c++20 -g -O0 cases.cpp -o cases_plain + +# 最常用:memcheck 全量查泄漏 +valgrind --tool=memcheck --leak-check=full ./cases_plain 4 + +# 更狠:连 still reachable 也列出来 + 跟进子进程 +valgrind --tool=memcheck --leak-check=full --show-leak-kinds=all --trace-children=yes ./cases_plain +``` + +几个关键参数:`--leak-check=full` 是完全检查泄漏(给出行号),`--show-leak-kinds=all` 连「still reachable」(还有指针指着、理论上还能 free)的块也列出来(老版本的 `--show-reachable=yes` 是它的别名,仍能用但已不推荐),`--trace-children=yes` 跟进 `fork`/`exec` 出来的子进程。换工具就改 `--tool=`:`callgrind`、`cachegrind`、`helgrind`、`drd`、`massif`。 + +### 5.1 同一个 UAF,memcheck 这么报 + +跑 case 2(就是第四节那个 use-after-free): + +```text +$ valgrind --tool=memcheck --leak-check=full ./cases_plain 2 +==453796== Memcheck, a memory error detector +... +==453796== Invalid read of size 4 +==453796== at 0x40011E9: case_uaf() (cases.cpp:20) +==453796== by 0x4001377: main (cases.cpp:56) +==453796== Address 0x4ee9080 is 0 bytes inside a block of size 4 free'd +==453796== at 0x48529EF: free (vg_replace_malloc.c:989) +==453796== by 0x40011E4: case_uaf() (cases.cpp:19) +==453796== Block was alloc'd at +==453796== at 0x484F8A8: malloc (vg_replace_malloc.c:446) +==453796== by 0x40011CA: case_uaf() (cases.cpp:17) +uaf=42 +... +==453796== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0) +``` + +注意行号——`cases.cpp:20` 读、`:19` free、`:17` malloc,和第四节 ASan 报的**一模一样**(ASan 那边也是 :20/:19/:17)。同一个 bug,两个工具各自定位到同一行,只是话术不同: + +- ASan 说 `heap-use-after-free` + `located 0 bytes inside of 4-byte region`; +- memcheck 说 `Invalid read of size 4` + `Address ... is 0 bytes inside a block of size 4 free'd`。 + +memcheck 还多了句 `Block was alloc'd at ... :17`——它靠 A-bit 账本记下了这块内存的「一生」(在哪申请、在哪释放、现在又被读),整条因果链一次给全,和 ASan 的「allocated by / freed by」三段式是同一个思路、两套措辞。 + +### 5.2 泄漏:LEAK SUMMARY 对位 LSan + +跑 case 4(故意不 free): + +```text +$ valgrind --tool=memcheck --leak-check=full ./cases_plain 4 +==453446== HEAP SUMMARY: +==453446== in use at exit: 4 bytes in 1 blocks +==453446== total heap usage: 3 allocs, 2 frees, 77,828 bytes allocated +==453446== 4 bytes in 1 blocks are definitely lost in loss record 1 of 1 +==453446== at 0x484F8A8: malloc (vg_replace_malloc.c:446) +==453446== by 0x400123D: case_leak() (cases.cpp:34) +==453446== by 0x40013B5: main (cases.cpp:58) +==453446== LEAK SUMMARY: +==453446== definitely lost: 4 bytes in 1 blocks +==453446== indirectly lost: 0 bytes in 0 blocks +==453446== possibly lost: 0 bytes in 0 blocks +==453446== still reachable: 0 bytes in 0 blocks +==453446== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0) +``` + +`definitely lost: 4 bytes`——对位第四节 ASan 那边 LSan 的 `Direct leak of 4 byte(s)`。两边都是「程序退出时扫一遍堆」,只是 memcheck 把泄漏分成 `definitely lost / indirectly lost / possibly lost / still reachable` 四档(更细),LSan 默认只报 `Direct` 和 `Indirect` 两档。行号同样是 `:34`,和 ASan 一致。 + +> **别再去下源码包手动编译。** 源笔记给的安装流程是 `tar -jxvf valgrind-3.12.0.tar.bz2 && ./configure && make && sudo make install`——`3.12.0` 是 2016 年的版本,**十年前了**,而且对现代内核/新 CPU 指令(比如较新的 AVX)支持差,跑新编的程序容易各种报错。现在直接用发行版包:Debian/Ubuntu 是 `apt install valgrind`、Fedora/RHEL 是 `dnf install valgrind`、Arch 是 `pacman -S valgrind`,装上就是 3.2x 版本(本机 3.25.1)。 + +## 六、两条路线怎么选 + +说了这么多,到底什么时候用哪个?给你一套实战决策: + +**默认用 ASan。** 日常开发、CI 里挂着的内存错误检测,首选 ASan——它快(慢 2 倍 vs 20~50 倍,CI 能忍),跨平台(Windows/macOS/Linux 通吃,MSVC 也支持),报告干净。现代 C++ 项目里,`-fsanitize=address,undefined` 几乎是调试构建的标配。我们卷一的[动态内存管理](../vol1-fundamentals/ch12/02-new-delete.md)里讲 ASan 抓泄漏、卷五的并发调试里讲 TSan,都是这条路上的工具。 + +**这几类场景,必须上 Valgrind:** + +1. **只有二进制、没源码**,或者重新编译成本太高(比如巨大的遗留项目)。ASan 必须重新编,Valgrind 拿现成二进制就能跑。 +2. **要抓未初始化读取,但只有 GCC**。ASan 没 V-bit,MSan 又只有 Clang——用 GCC 编的项目要抓未初始化,memcheck 是现成的。 +3. **要性能 profiling**(callgrind/cachegrind/massif)。这几个工具 ASan 完全没有对应物,想看 cache miss、堆增长曲线、调用图,只有 Valgrind 这一套。 +4. **要全盘覆盖,包括没带 ASan 编译的第三方库**。Valgrind 在指令层拦截,连没源码的 .so 里的内存错误也能抓;ASan 只覆盖被插桩的代码。 + +反过来,**这几类 Valgrind 干不了、或干不好,得用 ASan**:抓栈数组/全局数组越界(ASan 的栈/全局红区是强项)、跑得快(CI 友好)、Windows 平台(Valgrind 基本不支持 Windows)、抓 stack-use-after-return(ASan 有专门的 fake stack 机制)。 + +一句话收口:**ASan 是「开发期」的标配,Valgrind 是「疑难杂症 / 性能 / 遗留二进制」的专科」。** 它们不是替代关系,是互补关系——很多团队是 CI 里挂 ASan 做日常守门,遇到 ASan 抓不到的怪问题再上 Valgrind 复查。 + +## 七、回到 C++:工具是兜底,RAII 才是治本 + +讲了一整篇工具,最后必须把话拉回来:**这些工具再强,也是「事后抓 bug」,不是「消灭 bug」。** 真正让内存错误从根上消失的,是 C++ 的 RAII 和智能指针。 + +回头看那六类错误,你会发现它们**清一色都建立在「裸 malloc/free、裸指针」**上: + +- 泄漏?用 `std::unique_ptr` / `std::vector`,对象出作用域自动释放,根本没机会忘 `free`; +- use-after-free?智能指针的所有权语义让「这块内存还能不能用」变成编译期就能约束的事; +- double-free?`unique_ptr` 不能拷贝、移动后原指针置空,物理上就 double 不了; +- 越界?`std::vector` 配 `.at()` 会抛异常、`std::span` 带边界,别用裸 `[]` 配手工长度。 + +C 风格的 `malloc`/`free`/裸指针把「内存什么时候释放、谁能访问」全部丢给程序员记——人脑记这些必然出错,所以才需要 Valgrind 和 ASan 这种「记账工具」来兜底。Modern C++ 的思路是把这套记账**搬到类型系统里**:资源的生命周期绑死在对象上,编译器替你保证释放。这是从「工具抓 bug」到「语言消灭 bug」的根本跃迁,我们卷一的[动态内存管理](../vol1-fundamentals/ch12/02-new-delete.md)整篇就在讲这个。 + +但这**不**意味着 Modern C++ 项目就不需要 ASan/Valgrind 了。只要你的代码还会调 C 库、还会用 `new`/`delete`、还会碰第三方没有 RAII 包装的接口,内存错误就还有缝可钻。所以正确的姿势是:**先用 RAII 把 99% 的内存错误在写代码时就消灭,再用 ASan 把漏网的那 1% 在测试期抓出来,最后拿 Valgrind 兜底那些最古怪的疑难杂症。** 三层防线,缺一不可。 diff --git a/documents/vol6-performance/12-sanitizer-toolchain-and-memory-safety.md b/documents/vol6-performance/12-sanitizer-toolchain-and-memory-safety.md new file mode 100644 index 000000000..925d0ef4b --- /dev/null +++ b/documents/vol6-performance/12-sanitizer-toolchain-and-memory-safety.md @@ -0,0 +1,332 @@ +--- +title: "Sanitizer 工具链全景:从 -fsanitize 到内核 KASAN/KFENCE" +description: "把用户态 -fsanitize=address/memory/undefined/thread 和内核态 KASAN/KMSAN/UBSAN/KCSAN/KFENCE 摆在同一张表里对照,讲透『编译期插桩 vs 采样』两条路线和『调试 vs 上生产』的分层防御" +chapter: 6 +order: 12 +platform: host +difficulty: advanced +cpp_standard: [11, 14, 17, 20] +reading_time_minutes: 22 +prerequisites: + - "ASan 工具家族与内存安全:shadow memory、Heartbleed 与 sanitizer 选型" + - "Valgrind 与 ASan 对照:JIT 解释 vs 编译期插桩" +related: + - "ASan 工具家族与内存安全:shadow memory、Heartbleed 与 sanitizer 选型" + - "Valgrind 与 ASan 对照:JIT 解释 vs 编译期插桩" + - "并发程序调试技巧(ThreadSanitizer)" + - "动态内存管理(new/delete 与智能指针)" +tags: + - host + - cpp-modern + - advanced + - 内存安全 + - 调试 + - 工具链 +--- + +# Sanitizer 工具链全景:从 -fsanitize 到内核 KASAN/KFENCE + +> PS: 这部分内容由大学期间的笔记迁移而来、并经查证核对;用户态 sanitizer 本机实跑,内核态工具本机无法运行、以 kernel.org 官方文档为准。若有疏漏,欢迎 Issue 或 PR。 + +前面两篇我们已经把用户态的 ASan / UBSan / MSan / TSan 和 Valgrind 拆得很细了——shadow memory 怎么记账、JIT 解释和编译期插桩两条路线差在哪、五种 sanitizer 之间为什么互斥。但如果你只把目光停在「`g++ -fsanitize=address` 加个 flag」上,会漏掉一个更大的图景:**sanitizer 不是用户态的专利,内核里也有一整套对应的工具**,而且两者的设计取舍完全不一样**。 + +这一篇我们要做的,是把整张 sanitizer 工具链拉平了看。用户态的 `-fsanitize=*` 一边,内核态的 `CONFIG_KASAN / CONFIG_KMSAN / CONFIG_KFENCE` 一边——它们抓的是同一类 bug(越界、释放后使用、未初始化、数据竞争),但站在完全不同的约束下:用户态可以为了抓 bug 把程序拖慢 2~5 倍,内核态不行,内核一旦拖慢 5 倍整台机器就废了。所以内核这边演化出了「采样」这条路——KFENCE 用极低开销换「能在生产环境一直开着」,和 KASAN 那种「只能调试期开」的重型工具分层共存。 + +## 先把用户态这一侧收个口 + +在往内核走之前,先把用户态 sanitizer 的四个 flag 用真报告钉一下,后面好和内核做对照。详细的 shadow memory 原理和 Heartbleed 故事在上一篇已经讲透,这里只放最小可复现的代码和真实终端输出,方便对照「每一种 bug 对应哪个 flag」。 + +四个 flag 一句话分工:`-fsanitize=address`(ASan,越界/UAF/泄漏)、`-fsanitize=undefined`(UBSan,未定义行为)、`-fsanitize=memory`(MSan,未初始化读)、`-fsanitize=thread`(TSan,数据竞争)。 + +### ASan:一次抓到三种错 + +堆越界、释放后使用、内存泄漏,ASan 一把全收。我们把三个错误分别写成最小例子(放一个程序里 ASan 会在第一个错误处 abort,后面两个看不到,所以拆开): + +```cpp +// uaf.cpp —— 释放后使用(use-after-free) +#include +int main() { + int* p = new int(7); + delete p; + printf("*p = %d\n", *p); // p 已 delete,悬空 + return 0; +} +``` + +用 `g++ -std=c++20 -O0 -g -fsanitize=address -fno-omit-frame-pointer uaf.cpp -o uaf` 编出来,跑出来: + +```text +================================================================= +==118313==ERROR: AddressSanitizer: heap-use-after-free on address 0x72c9e1de0010 at pc 0x5d222d6ed26f bp 0x7ffc31d299a0 sp 0x7ffc31d29990 +READ of size 4 at 0x72c9e1de0010 thread T0 + #0 0x5d222d6ed26e in main /tmp/sanit/uaf.cpp:6 + ... +SUMMARY: AddressSanitizer: heap-use-after-free /tmp/sanit/uaf.cpp:6 in main +``` + +`-g` 让报告带上 `uaf.cpp:5` 这种源码定位,这是 ASan 能不能用的分水岭——没有调试符号,报告只剩一串地址,等于白报。栈上的越界它一样抓,换一个跨函数的栈缓冲: + +```cpp +// stack_oob.cpp —— 栈缓冲越界 +#include +void fill(char* p) { // 跨函数,检测能跨栈帧 + for (int i = 0; i <= 8; ++i) p[i] = 'A'; // 合法下标 0..7,8 越界 +} +int main() { + char buf[8]; + fill(buf); + printf("done\n"); + return 0; +} +``` + +同样的 flag 编出来跑: + +```text +================================================================= +==119120==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x6ec9ef2f0028 at pc 0x5f38ab644200 bp 0x7fff6db78e20 sp 0x7fff6db78e10 +WRITE of size 1 at 0x6ec9ef2f0028 thread T0 + #0 0x5f38ab6441ff in fill(char*) /tmp/sanit/stack_oob.cpp:4 + #1 0x5f38ab64429d in main /tmp/sanit/stack_oob.cpp:8 + ... +Address 0x6ec9ef2f0028 is located in stack of thread T0 at offset 40 in frame + #0 0x5f38ab644220 in main /tmp/sanit/stack_oob.cpp:6 +``` + +注意它不光告诉你越界,还告诉你「这块内存是 `main` 栈帧里 offset 40 的那个 `buf`」——栈红区(redzone)连栈上数组的归属都标出来了。这就是 shadow memory 的威力,上一篇详细拆过,这里不再展开。 + +内存泄漏走的是 ASan 自带的 LeakSanitizer(LSan),程序退出时扫一次: + +```cpp +// leak.cpp —— 忘记 delete +#include +int main() { + int* leak = new int(99); + *leak = 100; + printf("leak = %d (故意不 delete)\n", *leak); + return 0; +} +``` + +```text +================================================================= +==118322==ERROR: LeakSanitizer: detected memory leaks + +Direct leak of 4 byte(s) in 1 object(s) allocated from: + #0 0x7c2b9dd2d341 in operator new(unsigned long) (/usr/lib/libasan.so.8+0x12d341) + #1 0x609649f361ba in main /tmp/sanit/leak.cpp:4 +``` + +ASan 的代价是实打实的:程序慢 2~5 倍、内存多 3~5 倍。所以**生产构建一定要摘掉 `-fsanitize=address`**,只在调试和测试期开。这条约束听起来无所谓,但到了内核那边,同样的「开销太大」直接催生出了完全不同的工具——这就是后面 KFENCE 的来历。 + +### UBSan:专治未定义行为 + +ASan 管的是「这块内存能不能碰」,UBSan 管的是「这个操作本身合不合法」。有符号整数溢出、数组下标越界、空指针解引用、错误位移,这些在 C++ 标准里是未定义行为(UB),不一定会崩,但结果不可预测: + +```cpp +// ub.cpp —— 三种 UB +#include +#include +int main() { + int32_t big = 2147483647; // INT32_MAX + int32_t sum = big + 1; // (1) 有符号加法溢出 → UB + int arr[4] = {0,1,2,3}; + int idx = 10; + int v = arr[idx]; // (2) 下标越界 → UBSan 的 bounds 检查 + printf("sum=%d v=%d\n", sum, v); + return 0; +} +``` + +用 `g++ -std=c++20 -O0 -g -fsanitize=undefined ub.cpp -o ub`(默认 recover,把所有 UB 都打出来再继续): + +```text +ub.cpp:6:13: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int' +ub.cpp:9:20: runtime error: index 10 out of bounds for type 'int [4]' +ub.cpp:9:9: runtime error: load of address 0x7fffed140f28 with insufficient space for an object of type 'int' +sum=-2147483648 v=0 +``` + +这里有个真实的坑要先提醒:**UBSan 和 ASan 可以一起开**(`-fsanitize=address,undefined`),很多人这么干,因为一个管内存一个管算术,互补。但 UBSan 默认「打印完继续跑」(recover),如果你希望碰到第一个 UB 就 abort(更接近线上行为),加 `-fno-sanitize-recover=all`。反过来,ASan 是碰到就 abort 的,改不了。 + +### MSan:未初始化读,只有 Clang 有 + +MSan 抓的是「用了没初始化的值」,这是 ASan 抓不到的一类——内存合法、访问也合法,但值是垃圾。坑在于:**MSan 只有 Clang 实现,GCC 压根不支持这个 flag**: + +```cpp +// msan.cpp —— 用了没初始化的变量 +#include +int main() { + int x; // 故意不初始化 + if (x) // 拿垃圾值做分支判断 → MSan 抓 + printf("x is truthy\n"); + else + printf("x is zero\n"); + return 0; +} +``` + +GCC 直接报错: + +```text +$ g++ -std=c++20 -fsanitize=memory msan.cpp -o msan +g++: error: unrecognized argument to '-fsanitize=' option: 'memory' +``` + +换成 Clang 就能编能跑(`clang++ -std=c++20 -O0 -g -fsanitize=memory -fno-omit-frame-pointer msan.cpp -o msan`): + +```text +==118932==WARNING: MemorySanitizer: use-of-uninitialized-value + #0 0x58f3129f5677 (/tmp/sanit/msan+0xd7677) + ... +SUMMARY: MemorySanitizer: use-of-uninitialized-value +``` + +> **踩坑预警**:MSan 有个硬限制——**整个程序(包括它链接的所有库)都必须用 MSan 插桩编译**。你直接 `clang++ -fsanitize=memory` 链一个没插桩的 `libc++` 或第三方库,会爆出一堆假阳性,因为 MSan 把库返回的值都当未初始化。所以 MSan 在实际项目里很少用,通常要配合「用 MSan 重新构建整个 toolchain」才能跑干净。这一点上一篇讲过,这里强调一下,因为内核那边的 KMSAN 也有类似的「全链路插桩」要求。 + +至于 TSan(数据竞争),它和 ASan 互斥、开销 5~15 倍,专门抓并发 bug,讲并发那一卷的「并发程序调试技巧」已经拆透了,这里只标一下它在全景图里的位置,不重复。 + +## 现在问题来了:内核怎么办? + +把用户态的四个 flag 记牢之后,接下来才是这一篇真正想讲的东西。**内核也是 C 代码,也会越界、也会 UAF、也会有数据竞争,能不能直接把 `-fsanitize=address` 套到内核上?** + +答案是:**能,而且内核确实这么干了,但代价大到你只能在调试时开**。这就是 KASAN——Kernel AddressSanitizer。它的底层和用户态 ASan 是同一套(shadow memory + 编译期插桩),但内核有自己的约束: + +1. **影子内存要占内核虚拟地址空间的一大块**。用户态 ASan 的影子内存是「进程地址空间的 1/8」,内核这边直接划走内核 VAS 的一大段(`KASAN_SHADOW_START` 到 `KASAN_SHADOW_END`)。在 64 位内核上地址空间够大(128 TB),还能撑住;32 位就紧张得多,所以早期 KASAN 只能跑在 64 位上,直到 5.11 才有 Linus Walleij 给 ARM-32 做的精简版。 + +2. **整机的每一处内存访问都被插桩**。内核不是一个进程,是所有进程共享的底层,一旦开 KASAN,整机性能直接塌方——这就是为什么 `CONFIG_KASAN` 只用于调试内核,生产内核绝对不开。 + +3. **它要配特定的内存分配器**。内核用 SLAB 或 SLUB 分配器,KASAN 要在分配器里埋红区、给释放的页打「投毒」标记(`KASAN_SANITIZE_*`),才能在 UAF/OOB 时立刻逮到。这和用户态 ASan 拦截 `malloc/free` 是同一思路,只是换到了 `kmalloc/kfree`。 + +源笔记里写「KASAN 适用于 x86_64 和 AArch64,4.x 及以上」,这个版本号要核一下。实际上 KASAN 是 **Linux 4.0** 合入主线(最初支持 x86_64),AArch64 跟进,**5.11** 才补上 ARM-32 的优化版。机制没错,但别记成笼统的「4.x」。 + +### KASAN 长什么样(官方报告样式) + +KASAN 报告长什么样?按 kernel.org dev-tools/kasan 文档的示例结构,把官方例子里的 `kmalloc_oob_right` 换成虚拟的 `buggy_driver_write`(字段和层级完全对应官方报告),大致是这样: + +```text +================================================================== +BUG: KASAN: slab-out-of-bounds in buggy_driver_write+0x3e/0x60 [buggy] +Write of size 1 at addr ffff888006c42185 by task cat/1234 + +CPU: 0 PID: 1234 Comm: cat Tainted: G B +Call Trace: + dump_stack_lvl+0x49/0x63 + print_report+0x171/0x486 + kasan_report+0xb1/0x130 + buggy_driver_write+0x3e/0x60 [buggy] + ... + +Allocated by task 1234: + kasan_save_stack+0x1e/0x40 + __kasan_kmalloc+0x81/0xa0 + kmalloc_trace+0x21/0x30 + buggy_driver_init+0x2a/0x60 [buggy] + ... + +The buggy address belongs to the object at ffff888006c42180 + which belongs to the cache kmalloc-8 of size 8 +The buggy address is located 5 bytes inside of + 8-byte region [ffff888006c42180, ffff88800642188) +``` + +和用户态 ASan 的报告结构几乎一模一样:**先说在哪炸(slab-out-of-bounds、越界写、发生在哪个驱动函数),再给分配栈(这块内存是谁、在哪一次 `kmalloc` 分出来的)**。内核报告多了「属于哪个 slab cache(`kmalloc-8`)、在对象的第几字节」这种内核分配器专属的信息。看懂了用户态 ASan 报告,内核 KASAN 报告基本也能读。 + +## 全景对照表:用户态 ↔ 内核态 + +到这里把两边对齐,这张表是这一篇的核心——源笔记原是一张外部 PNG,我们用 Markdown 自己画: + +| 抓的 bug | 用户态 flag | 内核工具 | 内核合入版本 | 能否上生产 | +|---------|-----------|---------|------------|----------| +| 越界 / UAF / 双重释放 | `-fsanitize=address` (ASan) | **KASAN** | 4.0(x86_64)/ 5.11(ARM-32 优化) | 否,仅调试 | +| 未初始化读 | `-fsanitize=memory` (MSan,仅 Clang) | **KMSAN** | 5.16 起可用补丁分支,**6.1** 起主线完整可用,仅 Clang 14.0.6+ + 仅 x86_64 | 否,开销巨大 | +| 未定义行为(溢出/越界/位移) | `-fsanitize=undefined` (UBSan) | **UBSAN** | 4.5 合入 | 部分检查可上(见下) | +| 数据竞争 | `-fsanitize=thread` (TSan) | **KCSAN** | 5.8 合入,基于采样 | 否,仅调试 | +| 内存泄漏 | ASan 自带 LSan | **kmemleak** / eBPF `memleak` | kmemleak 早已存在 | 谨慎,有误报 | +| 采样式内存错误 | (用户态无对应) | **KFENCE** | **5.12** | **可以,默认就常开** | +| 访问模式分析(非 bug 检测) | (无) | **DAMON** | **5.15** | 可以,就是为生产设计的 | + +这张表里有几个一定要记牢的对应关系: + +- **ASan ↔ KASAN**:同一个 shadow memory 思路搬到内核,代价是整机性能塌方,只能调试开。 +- **MSan ↔ KMSAN**:都只认 Clang,都要全链路插桩,都开销巨大。KMSAN 官方文档明说「not intended for production use, because it drastically increases kernel memory footprint and slows the whole system down」。 +- **UBSan ↔ UBSAN**:内核 UBSAN 在 4.5 合入,而且**它的一部分检查(比如 `CONFIG_UBSAN_BOUNDS`)在现代发行版内核里默认开启**,因为这部分开销很低——这是内核 sanitizer 里少数能「常驻」的。 +- **TSan ↔ KCSAN**:注意 TSan 是编译期全插桩,KCSAN 不一样——它基于**采样**(watchpoint),开销可控,但相应的,它检测数据竞争靠的是「碰巧采到」,不是 TSan 那种「理论上一定检测到」。5.8 合入主线(google/kernel-sanitizers 仓库明说「in mainline since 5.8」)。 + +源笔记里把 KMSAN 标成「6.1 及以上版本」——**这个版本号是对的**,别记错。KMSAN 的补丁系列由 Google 的 Alexander Potapenko 维护了多年,直到 2021 年底都还只是分支补丁、未进主线(kernel.org 官方示例报告跑在打了补丁的 `5.16.0-rc3+` 上,用的就是 google/kmsan 分支,不是主干);Google 官方仓库 (google/kmsan) 的 README 明确写着「Linux 6.1+ contains a fully-working KMSAN implementation which can be used out of the box」,即 **6.1 起主线完整可用**。所以 KMSAN 是这一批内核 sanitizer 里进主线最晚的。注意别把「5.16 补丁分支能跑」和「6.1 进主线」搞混——这是这类版本号最常见的误读。 + +## KFENCE:把 sanitizer 搬上生产的关键一招 + +KASAN 的问题太明显了——只能调试开,但你公司线上跑的内核出了内存 bug 怎么办?你总不能拿一台生产机器换成开了 KASAN 的调试内核去复现,那样业务早就挂了。真正缺的是一个**开销低到能一直开着的内存错误检测器**。 + +这就是 KFENCE(Kernel Electric-Fence),**Linux 5.12 合入主线**。它的思路和 KASAN 完全不同,不再「检测每一次访问」,而是改成**采样**: + +- KFENCE 维护一个固定大小的对象池(默认 `CONFIG_KFENCE_NUM_OBJECTS=255`,每个对象占 2 页——1 页放对象、1 页当守卫页 guard page,池里对象页和守卫页交错排布,所以每个对象页两边都是守卫页;默认配置下整个池约 2 MiB)。 +- 内核的 slab 分配器(`kmalloc`)会**被一个采样定时器钓进 KFENCE 池**:KFENCE 有个以毫秒为单位的采样间隔(启动参数 `kfence.sample_interval`,默认可由 `CONFIG_KFENCE_SAMPLE_INTERVAL` 配),每个采样间隔里下一次 `kmalloc` 分配就被「钓」交给 KFENCE 来管。 +- 一旦进了 KFENCE 池,这次分配就被放在两个守卫页之间——任何越界读写都会踩到守卫页,立刻触发 page fault,内核报出精确的错误和分配栈。 +- 释放后,KFENCE 把这页标记成「不可访问」,再有人碰它就是 use-after-free,同样立刻报。 + +采样的代价是:**绝大多数分配根本不经过 KFENCE**,所以大部分 bug 它抓不到——你得跑足够长时间、让足够多的分配流经 KFENCE 池,才有机会逮到。但换来的是**极低的开销**(官方说接近零,实际生产负载几乎感知不到),于是它成了**第一个能一直开在生产内核上的内存 sanitizer**。事实上,只要架构支持、且开了 SLAB 或 SLUB,KFENCE 在很多发行版里默认就是开的。 + +源笔记原话「KFENCE 必须运行长时间,但开销足够低,甚至可以在生产环境中运行」——机制描述没错,我们补上版本号(5.12)和「采样」这个关键词,再强调一下「默认常开」这个工程意义。它取代的是更老的 `kmemcheck`(那个在 4.15 就被删了,因为开销太大、和 KFENCE 思路冲突)。 + +## DAMON:另一条「采样」路线,但目标不是抓 bug + +提到「采样」,顺带要把 DAMON(Data Access MONitor)讲一下,因为它和 KFENCE 在哲学上是同一类——**不去全量跟踪,而是采样代表性样本**。但 DAMON 不是 sanitizer,它不抓 bug,它**监控内存访问模式**: + +- **Linux 5.15 合入主线**,目的是帮开发者(和内核自己)看清「进程到底在怎么访问内存」,从而优化布局、指导回收。 +- DAMON 把目标进程的地址空间切成等大的区域,**采样**每个区域里的若干代表页,记录访问频次,形成直方图。区域如果是热点,就再细分——这种「智能放大」让它在超大地址空间上也能低成本运行。 +- 内核组件是「生产者」(产出访问模式),用户态(或内核)是「消费者」。消费者甚至能根据访问模式反过来调 `madvise()` 改内存属性——比如把确认冷的数据区建议内核换出。 + +DAMON 的接口有三个:用户态的 `damo` 工具(来自 awslabs/damo)、`/sys/kernel/mm/damon/admin/` 下的 sysfs、以及给内核开发者的内核 API。旧的 debugfs 接口已废弃。它和 KFENCE 放在一起看,你会发现内核在 5.12~5.15 这一波,系统性地用「采样」补上了「全量插桩太贵」这个口子——KFENCE 抓 bug,DAMON 看模式,都能上生产。 + +## 三层防御:把工具按场景摆好 + +把用户态和内核态的 sanitizer 摆在一起,内存安全的工具链其实是个**分层的防御纵深**,每一层有不同的开销/覆盖权衡: + +::: tip 开发期:全量插桩,抓到为止 +开发自测、CI、fuzzing 阶段,**开销不是问题,覆盖最重要**。用户态开 `-fsanitize=address,undefined`(再单独跑一轮 `-fsanitize=thread`),内核调试构建开 `CONFIG_KASAN` + `CONFIG_KCSAN` + `CONFIG_UBSAN`。这一层假设 bug 一定能被全量插桩逮到,代价是程序/整机慢几倍,只在非生产环境承受。 +::: + +::: tip 测试/准生产:采样插桩,长期运行 +预发、灰度、长时间负载测试,**不能接受整机塌方,但要跑足够久才能暴露罕见 bug**。这一层用 KFENCE——采样、低开销、能一直开着,让成千上万的分配流经守卫页池,逮到那些「跑一万次才出现一次」的越界和 UAF。用户态这一层目前没有完全对等的东西(Valgrind 太慢、ASan 太重),所以内核这边 KFENCE 的工程价值特别突出。 +::: + +::: tip 生产:默认开启的轻量检查 + 事后分析 +真正的线上内核,**只开开销可忽略的检查**:KFENCE(默认常开)、`CONFIG_UBSAN_BOUNDS` 这类轻量 UBSAN 子集、再加上 DAMON 做访问模式分析指导优化。出了事故靠事后工具——内核 oops 日志、kdump/crash 分析、eBPF 的 `memleak-bpfcc` 跟踪未释放的分配。这一层不再指望「当场抓 bug」,而是「留够证据,事后能查」。 +::: + +这套分层就是为什么内核要同时养 KASAN 和 KFENCE 两个看似重复的工具——**同一个 bug(比如 UAF),开发期用 KASAN 抓,生产期用 KFENCE 抓**,工具不重复,场景不重叠。用户态目前只有第一层(开发期插桩)用得顺手,第二、第三层还没有 kernel 那么成熟的工具,这也是为什么「在 C++ 用户态里彻底搞定内存安全」比内核还难——内核好歹有 KFENCE 能兜底生产,用户态出了线上 UAF,经常只能等它崩了再去看 core dump。 + +## 顺带一提:静态分析和事后工具 + +除了上面这些运行时 sanitizer,内核和用户态都还有一组**不靠运行、靠看代码或看日志**的工具,源笔记里也提到了,这里收个尾,不展开: + +- **静态分析**:内核侧有 `sparse`、`smatch`、`Coccinelle`、`checkpatch.pl`,用户态有 `clang-tidy`、`cppcheck`。它们不跑代码、开销为零,但只能抓「代码模式上明显有问题」的那类,抓不到运行时才暴露的 UAF/OOB。和 sanitizer 是互补不是替代——静态分析抓规范、sanitizer 抓运行时。 +- **事后分析**:内核 oops/panic 日志、`kdump`/`crash` 工具分析 dump、`[K]GDB` 调试。这些是 bug 已经炸了之后的取证手段,和「提前抓 bug」的 sanitizer 不在一个阶段。 + +C++ 用户态的事后分析,在「动态内存管理」那一章我们用 `-fsanitize=address` 在退出时报泄漏见过一次,在「并发程序调试技巧」用 TSan 见过并发 bug 的事后定位。整个工具链是**开发期 sanitizer → 生产期轻量检查 → 事后分析**一条龙,缺哪一环,对应的那类 bug 就会在那个阶段反复咬你。 + +## 小结 + +这一篇把 sanitizer 工具链从用户态拉到内核态,几个关键结论收一下: + +- 用户态四个 flag 分工明确:ASan(越界/UAF/泄漏)、UBSan(未定义行为)、MSan(未初始化读,仅 Clang)、TSan(数据竞争)。它们之间大多互斥(ASan/MSan/TSan 不能两两同开),UBSan 能和 ASan 叠加。本机 GCC 16.1.1 / Clang 22 全部真跑出了报告。 +- 内核态有完全对应的工具:KASAN(↔ASan,4.0)、KMSAN(↔MSan,6.1 起主线完整可用)、UBSAN(↔UBSan,4.5)、KCSAN(↔TSan,5.8)。机制同源,但受内核约束,大多只能调试开。 +- **KFENCE(5.12)是分水岭**:用「采样 + 守卫页」把内存错误检测的开销压到能上生产,默认常开,填补了 KASAN 留下的生产空白。 +- **DAMON(5.15)**走同一条采样路线,但不抓 bug,监控访问模式,指导内存优化。 +- 整套工具链是三层防御:开发期全量插桩(KASAN/ASan)→ 准生产采样(KFENCE)→ 生产轻量检查 + 事后分析(UBSAN 子集/kdump)。 +- 源笔记两处版本号已核正:KFENCE 版本 = 5.12(源没标,补上);KMSAN 源写「6.1 及以上」其实是对的,核过 Google 官方仓库 README 确认 6.1 起主线完整可用——补丁在 5.16 分支已能跑,但进主线是 6.1。 + +下一篇我们继续在性能与正确性这条线上走,去看编译器优化怎么在不改变语义的前提下把代码变快——以及它在什么时候会「偷偷」改变语义,让你精心写的并发代码跑出和你预期不一样的结果。 + +## 参考资源 + +- [kernel.org: Kernel Address Sanitizer (KASAN)](https://www.kernel.org/doc/html/latest/dev-tools/kasan.html) —— KASAN 机制、配置项与示例报告 +- [kernel.org: Kernel Memory Sanitizer (KMSAN)](https://www.kernel.org/doc/html/latest/dev-tools/kmsan.html) —— KMSAN 要求 Clang 14.0.6+,仅 x86_64,明确「not for production」 +- [kernel.org: Kernel Electric-Fence (KFENCE)](https://www.kernel.org/doc/html/latest/dev-tools/kfence.html) —— KFENCE 采样机制、`CONFIG_KFENCE_NUM_OBJECTS`、生产可用定位 +- [kernel.org: UndefinedBehaviorSanitizer (UBSAN)](https://www.kernel.org/doc/html/latest/dev-tools/ubsan.html) —— 内核 UBSAN 各子检查与开销 +- [kernel.org: Kernel Concurrency Sanitizer (KCSAN)](https://www.kernel.org/doc/html/latest/dev-tools/kcsan.html) —— KCSAN 基于 watchpoint 的采样竞态检测 +- [kernel.org: DAMON](https://www.kernel.org/doc/html/latest/admin-guide/mm/damon/usage.html) —— DAMON sysfs/schemes 接口与访问模式监控 +- [Clang: UndefinedBehaviorSanitizer](https://clang.llvm.org/docs/UndefinedBehaviorSanitizer.html) —— 用户态 UBSan 各子检查清单 +- [Clang: MemorySanitizer](https://clang.llvm.org/docs/MemorySanitizer.html) —— MSan 全链路插桩要求与用法 diff --git a/documents/vol6-performance/index.md b/documents/vol6-performance/index.md index 15689de2a..207b4b9ad 100644 --- a/documents/vol6-performance/index.md +++ b/documents/vol6-performance/index.md @@ -22,4 +22,7 @@ tags: 内联与编译器优化 性能与大小评估 AVX/AVX2 深入 + ASan 工具家族与内存安全 + Valgrind 与 ASan 对照:JIT 解释 vs 编译期插桩 + Sanitizer 工具链全景:从 -fsanitize 到内核 KASAN/KFENCE diff --git a/documents/vol8-domains/networking/.pages b/documents/vol8-domains/networking/.pages deleted file mode 100644 index 4623ca546..000000000 --- a/documents/vol8-domains/networking/.pages +++ /dev/null @@ -1 +0,0 @@ -title: 网络编程 diff --git a/documents/vol8-domains/networking/index.md b/documents/vol8-domains/networking/index.md deleted file mode 100644 index ea5826bf7..000000000 --- a/documents/vol8-domains/networking/index.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -title: "网络编程" -description: "Socket、HTTP、异步 I/O、WebSocket、RPC" -platform: host -tags: - - cpp-modern - - host - - intermediate ---- - -# 网络编程 - -> 状态:规划中 - -## 概述 - -本子领域覆盖 C++ 网络编程,包括 Socket 基础、HTTP、异步 I/O、WebSocket、RPC。 - -预计 13 篇文章。 - -## 章节导航 - -> 内容编写中,敬请期待。 diff --git a/scripts/tags.py b/scripts/tags.py index 972160d06..8f8088365 100644 --- a/scripts/tags.py +++ b/scripts/tags.py @@ -48,6 +48,9 @@ # General '基础', '入门', '进阶', '实战', '优化', '工程实践', + # Domains / engineering practices (notebook-migration batch) + '网络编程', '内存安全', '调试', '测试', + # Platforms 'host', 'stm32f1', From 162a6702b0930f3636524edcfdda030c4b2f8ce3 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Thu, 25 Jun 2026 20:37:48 +0800 Subject: [PATCH 2/2] fix: ci --- documents/en/vol8-domains/index.md | 1 - documents/vol8-domains/index.md | 1 - 2 files changed, 2 deletions(-) diff --git a/documents/en/vol8-domains/index.md b/documents/en/vol8-domains/index.md index cf506f59b..a51d4276f 100644 --- a/documents/en/vol8-domains/index.md +++ b/documents/en/vol8-domains/index.md @@ -35,7 +35,6 @@ An estimated 80 to 100 articles. Embedded Development - Network Programming — Planned GUI and Graphics — Planned Data Storage — Planned Algorithms and Data Structures — Planned diff --git a/documents/vol8-domains/index.md b/documents/vol8-domains/index.md index 201ce0beb..11ed95006 100644 --- a/documents/vol8-domains/index.md +++ b/documents/vol8-domains/index.md @@ -29,7 +29,6 @@ tags: 嵌入式开发 - 网络编程 — 规划中 GUI 与图形 — 规划中 数据存储 — 规划中 算法与数据结构 — 规划中