﻿# Valgrind 的原理 —— 动态二进制插桩,不用重编译的内存侦探

> 崩溃/内存排查工具簇的第三位成员。`./asan.md` 讲编译期插桩的 AddressSanitizer,`./tsan.md` 讲数据竞争检测,本篇讲 **Valgrind**:一个**不需要重新编译**、直接把现成二进制"包"起来跑的动态分析框架。它最出名的工具 **Memcheck** 能抓 ASan 抓不到的**使用未初始化内存**。
> 相关文档:`../crash/core-dump.md`(崩溃后的现场还原)、`../cache/cache-organization.md`(Cachegrind 缓存模拟的理论基础)、`../elf/memory-layout.md`(堆/栈/redzone 的地址布局)。

---

## 一、Valgrind 是什么,和 sanitizer 的根本区别

Valgrind 是一个**动态二进制插桩(DBI, Dynamic Binary Instrumentation)** 框架。它的工作方式和 sanitizer 完全相反:

- **ASan/TSan**:是**编译期插桩**。编译器在生成机器码时就把检测逻辑织进你的代码里,所以**必须重新编译**(加 `-fsanitize=address` 等),换句话说要能拿到源码、能改编译流程。
- **Valgrind**:是**运行期(动态)插桩**。它直接接管一个**已经编译好的二进制**,在运行时逐条翻译、插入检测代码。**不用重新编译**——哪怕只有一个二进制、没有源码,也能跑(但带 `-g` 符号能让报告显示到源码行号,体验好得多)。

这就是二者的根本分界线:

| | 何时插桩 | 需要重编译吗 | 需要源码吗 |
|---|---|---|---|
| ASan/TSan | 编译期(编译器织入) | **必须** | 通常需要 |
| Valgrind | 运行期(翻译机器码时织入) | **不需要** | 不需要(有更好) |

Valgrind 是一个**工具框架**,上面挂着多个工具,最常用的是 **Memcheck**(内存错误检测,默认工具),其他还有 Helgrind/DRD、Cachegrind、Callgrind、Massif(见第六节)。

**一句话**:Valgrind 靠"运行时重写机器码"实现检测,所以不用重编译;这是它和"编译期插桩"的 sanitizer 的本质区别。

---

## 二、核心原理:动态二进制翻译 + VEX IR

这是理解 Valgrind 的关键,也是它"为什么不用重编译"和"为什么慢"两个特点的共同来源。

**Valgrind 并不直接运行你的程序**,而是把你的程序当成"数据"来处理:它按**基本块(basic block)** 取出一段机器码,翻译成一种与机器无关的**中间表示 VEX IR**,让工具(如 Memcheck)在 IR 上**插入检测桩**,再把带桩的 IR **JIT 编译回机器码**,最后放到 Valgrind 的**合成 CPU(synthetic CPU)** 上执行。

也就是说:**你程序的每一条指令,都跑在 Valgrind 的模拟之上,而不是裸跑在真实 CPU 上。**

```plantuml
@startuml
skinparam shadowing false
skinparam componentStyle rectangle
skinparam rectangle {
  BackgroundColor<<bin>>  #E3F2FD
  BorderColor<<bin>>      #1976D2
  BackgroundColor<<core>> #FFF9C4
  BorderColor<<core>>     #F9A825
  BackgroundColor<<ir>>   #E1BEE7
  BorderColor<<ir>>       #6A1B9A
  BackgroundColor<<tool>> #FFCDD2
  BorderColor<<tool>>     #C62828
  BackgroundColor<<cpu>>  #C8E6C9
  BorderColor<<cpu>>      #388E3C
}
rectangle "原始二进制\n(现成机器码,无需重编译)" <<bin>> as BIN
rectangle "Valgrind 核心\n按基本块取指" <<core>> as CORE
rectangle "翻译成 VEX IR\n(与机器无关的中间表示)" <<ir>> as IR
rectangle "工具(Memcheck)\n在 IR 上插入检测桩" <<tool>> as TOOL
rectangle "JIT 回机器码\n(带检测逻辑)" <<core>> as JIT
rectangle "在合成 CPU 上执行\n(维护影子状态)" <<cpu>> as CPU
BIN -down-> CORE
CORE -down-> IR
IR -down-> TOOL
TOOL -down-> JIT
JIT -down-> CPU
CPU -up-> CORE : 下一个基本块\n(翻译结果会缓存复用)
note right of TOOL : 这一步是"检测"发生的地方:\n每次内存读写都被加上\n"检查影子状态"的指令
note right of CPU : 每条应用指令都被\n翻译+插桩+模拟执行
@enduml
```
从这张图能同时读出 Valgrind 的两个核心特性:

- **为什么不用重编译**:它直接对**机器码**动手(取指 → 翻译 → 插桩 → JIT),源码和编译器完全不参与。任何现成二进制都能被"套"进这个流程。
- **为什么慢 10–50x**:你程序的**每一条指令**都要经历"翻译成 IR → 插入检测桩 → JIT 回机器码 → 模拟执行",还要在每次内存访问时更新/检查影子状态。相比裸跑,开销是数量级的。这也是它比 ASan(约 2x)慢得多的根本原因——ASan 的检测码是提前编译好的、直接跑在真 CPU 上,而 Valgrind 是在"模拟 CPU"上一条条翻译着跑。

> 翻译结果会被缓存(translation cache),同一段代码第二次执行时直接复用译码结果,所以循环体不会每轮都重翻,但"模拟执行 + 影子状态维护"的开销始终存在。

**一句话**:Valgrind 把机器码逐块翻译成 VEX IR、插桩后 JIT 回机器码在合成 CPU 上跑——"操作机器码"让它免于重编译,"每条指令都被模拟"让它慢 10–50 倍。

---

## 三、Memcheck 的两套影子状态

Memcheck 之所以能精确抓内存错误,靠的是为应用内存维护两套**影子状态(shadow state)**,以及接管堆分配器。

### 1. V bits(valid-value bits)——追踪"是否已初始化"

Memcheck 为应用数据的**每一位**配一个 shadow bit,记录"这一位当前是否持有已定义(已初始化)的值"。

- 变量刚分配、还没写入 → 对应 V bits 标记为"未定义"。
- 一旦被赋值 → 对应 V bits 变为"已定义"。
- V bits 会随数据**传播**:未初始化的值参与运算、拷贝,结果也被标记为未初始化。
- 只有当一个**未初始化的值真正影响了程序的可观测行为**(比如作为分支条件、作为 `write` 的数据、作为地址)时,Memcheck 才报错。这避免了大量误报。

这就是 Memcheck 的**独门绝技**:抓**使用未初始化内存**——而这正是 ASan 抓不到的(需要另一个工具 MSan)。

### 2. A bits(addressable bits)——追踪"能否合法访问"

Memcheck 为应用内存的**每个字节**配一位,记录"这个地址当前是否允许访问"。

- 堆块内合法范围 → 可寻址。
- 堆块前后的 **redzone**(保护带)、已 `free` 的内存 → 不可寻址。
- 每次内存读写,Memcheck 都检查目标地址的 A bit;访问不可寻址的字节 → 立即报**越界**或**访问已释放内存(UAF)**。

### 3. 接管 malloc/free —— 抓堆错误与泄漏

Memcheck 用自己的实现**替换**了 `malloc`/`free`/`new`/`delete`:

- 在每个堆块前后加 **redzone**,配合 A bits 抓**越界读写**。
- `free` 后不立即归还,而是**延迟释放(放进 quarantine 队列)** 并把该区间标为不可寻址 → 抓 **use-after-free** 和 **double-free**。
- 记录每一次分配的调用栈和大小;程序退出时,统计**还有多少块没被 free** → 报**内存泄漏**,并按"确定丢失 / 可能丢失 / 仍可达"分类。

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<rz>>  #FFCDD2
  BorderColor<<rz>>      #C62828
  BackgroundColor<<use>> #C8E6C9
  BorderColor<<use>>     #388E3C
}
rectangle "redzone\n(不可寻址)" <<rz>> as R1
rectangle "用户可用堆块\n(A bits=可寻址, V bits 跟踪初始化)" <<use>> as U
rectangle "redzone\n(不可寻址)" <<rz>> as R2
R1 -right-> U
U -right-> R2
note bottom of U : 越界读写会踩到 redzone → A bit 报错\nfree 后整块转不可寻址 + 进 quarantine → UAF/double-free 报错
@enduml
```
**一句话**:V bits 逐位追踪"初始化了没"(抓未初始化使用),A bits 逐字节追踪"能不能访问"(抓越界/UAF),再加上接管堆分配器(redzone + 延迟释放 + 退出时统计),Memcheck 就能覆盖几乎所有堆内存错误。

---

## 四、Memcheck 能抓什么

| 错误类型 | 靠什么机制抓到 |
|---|---|
| **使用未初始化内存**(独门,ASan 抓不到) | V bits 传播,直到影响可观测行为才报 |
| **堆越界读写**(heap buffer overflow) | redzone + A bits |
| **use-after-free** | free 后标不可寻址 + quarantine 延迟释放 |
| **double-free** | 接管 free,检查块状态 |
| **内存泄漏** | 退出时统计未释放的堆块 |
| **非法 free**(free 一个非堆指针/野指针) | 接管 free,校验指针 |
| **mismatched new/delete**(`new[]` 配 `delete`) | 接管 new/delete,记录分配方式 |

> 注意:Memcheck 对**栈**和**全局区**的越界检测不如对堆强(它主要围绕堆分配器工作)。这类越界 ASan 反而更擅长——两者互补。

---

## 五、核心对比:ASan / TSan / Valgrind(Memcheck)

| 维度 | ASan | TSan | Valgrind(Memcheck) |
|---|---|---|---|
| 是否需要重编译 | **必须**(`-fsanitize=address`) | **必须**(`-fsanitize=thread`) | **不需要**(直接跑二进制) |
| 原理 | 编译期插桩 + shadow memory | 编译期插桩 + happens-before | **动态二进制翻译**(VEX IR + 合成 CPU) |
| 速度开销 | 约 **2x** | 约 **5–15x** | 约 **10–50x**(最慢) |
| 内存开销 | 中等(shadow 约 1/8 地址空间) | 大 | 大 |
| 越界(堆/栈/全局) | ✔(堆+栈+全局都强) | ✘ | ✔(主要是堆) |
| use-after-free / double-free | ✔ | ✘ | ✔ |
| 数据竞争(data race) | ✘ | **✔(专长)** | ✘(用 Helgrind/DRD) |
| **使用未初始化内存** | **✘**(要用 MSan) | ✘ | **✔(独门)** |
| 内存泄漏 | ✔(自带 LeakSanitizer) | ✘ | ✔(分类更细) |

### 选型建议

- **能重编、又要快** → 用 **ASan**(日常首选,越界/UAF/泄漏一把抓,只慢约 2x)。
- **查数据竞争** → 用 **TSan**(或 Valgrind 的 Helgrind/DRD)。
- **不能重编(只有二进制)/ 要查未初始化内存 / 要最全面地查泄漏** → 用 **Valgrind**。代价是慢,但覆盖面(尤其"未初始化")最广,且零重编译门槛。

**一句话**:能重编且要快选 ASan,查竞争选 TSan,不能重编或要抓"未初始化/泄漏"就选 Valgrind——用速度换覆盖面和零编译门槛。

---

## 六、Valgrind 家族的其他工具

Valgrind 是框架,换个 `--tool=` 就换一套分析能力,全都基于第二节的"翻译 + 插桩"引擎:

| 工具 | 作用 | 类比 / 呼应 |
|---|---|---|
| **Memcheck**(默认) | 内存错误检测 | 本篇主角 |
| **Helgrind / DRD** | **数据竞争 + 死锁**检测 | 类似 `./tsan.md`,但**动态**、不用重编译 |
| **Cachegrind** | **模拟 CPU 缓存**(L1/L2/LL),统计命中/失效、分支预测 | 呼应 `../cache/cache-organization.md` 的缓存组织原理 |
| **Callgrind** | 调用图 + 指令级 profile(Cachegrind 超集) | 配合 KCachegrind 可视化调用关系 |
| **Massif** | **堆内存 profile**,画出堆用量随时间变化 | 找"内存越用越多"的元凶 |

> Cachegrind 之所以能"数缓存命中",正是因为程序跑在合成 CPU 上——它可以顺便模拟一套缓存层级,把每次访存映射到 cache line,统计命中与否。理论背景见 `../cache/cache-organization.md`。

---

## 七、用法与观测

```bash
# 编译时带 -g,报告才能定位到源码行号(不带也能跑,但只有地址)
g++ -g -O0 main.cpp -o app
# 默认就是 Memcheck;--leak-check=full 打印每处泄漏的详细调用栈
valgrind --leak-check=full ./app
# 常用组合:显示可达内存 + 出错即打印来源栈
valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes ./app
# 换工具:数据竞争 / 缓存模拟 / 堆 profile
valgrind --tool=helgrind ./app
valgrind --tool=cachegrind ./app
valgrind --tool=massif ./app
```
报告要点:

- **Invalid read/write of size N** → 越界或 UAF,后面跟出错地址和调用栈。
- **Use of uninitialised value** → 用了未初始化内存(配 `--track-origins=yes` 能追到"未初始化的源头在哪")。
- **definitely lost / indirectly lost / possibly lost / still reachable** → 泄漏分类,`definitely lost` 是必须修的。
- 带 `-g` 时每条都能定位到 `文件:行号`;结合 `../crash/core-dump.md` 的读栈方法一起看更顺手。

**一句话**:`valgrind --leak-check=full ./app` 直接跑现成二进制就能查内存问题,配 `-g` 看行号、`--track-origins=yes` 追未初始化的来源。

---

## 八、一句话总结

**Valgrind 是动态二进制插桩框架:它把你程序的机器码逐块翻译成 VEX IR、插入检测桩、JIT 回机器码在"合成 CPU"上模拟执行——因此无需重新编译(直接对机器码动手),代价是慢 10–50 倍(每条指令都被翻译+插桩+模拟)。其主力工具 Memcheck 用 V bits(逐位追踪初始化)和 A bits(逐字节追踪可寻址性)加接管 malloc/free,抓越界、UAF、double-free、内存泄漏,尤其能抓 ASan 抓不到的"使用未初始化内存"。选型上:能重编要快用 ASan,查竞争用 TSan,不能重编或要查未初始化/泄漏最全就用 Valgrind。**
