﻿# AddressSanitizer (ASan) 的原理 —— 编译期插桩的内存错误猎手

> 崩溃排查主线的"提前防御"环节。事后看 core（[../crash/core-dump.md](/crash/core-dump.md)）是"死了再验尸"；ASan 是在**错误发生的那一刻**就抓现行，精确报出错误类型、内存地址、分配栈和访问栈。属于内存/线程错误检测器三兄弟之一：**ASan 抓内存访问错误、[TSan](/crash/tsan.md) 抓数据竞争、[Valgrind](/crash/valgrind.md) 无需重编译但慢**。

## 一、ASan 是什么、解决什么

AddressSanitizer（简称 ASan）是 **GCC / Clang 内置**的内存错误检测器，编译时加上 `-fsanitize=address` 即可开启。它的核心手段是**编译期插桩（instrumentation）**：编译器在生成机器码时，往每一次内存读写前面插入一小段检查代码，运行时实时判断这次访问是否合法。

它能抓的错误类型：

| 错误类型 | 英文 | 典型场景 |
|----------|------|----------|
| 堆越界 | heap-buffer-overflow | `malloc(8)` 后访问第 9 个字节 |
| 栈越界 | stack-buffer-overflow | 局部数组 `int a[4]` 访问 `a[4]` |
| 全局越界 | global-buffer-overflow | 越界访问全局/静态数组 |
| 释放后使用 | use-after-free | `free(p)` 后又读写 `*p`（悬垂指针）|
| 返回后使用 | use-after-return | 返回了局部变量的地址，函数返回后还在用 |
| 重复释放 | double-free | 同一块内存 `free` 两次 |
| 非法释放 | invalid-free | free 一个非 malloc 返回的指针 |
| 内存泄漏 | memory leak | 由内置的 **LeakSanitizer (LSan)** 负责，进程退出时报未释放的块 |

> 应用内存的堆、栈、全局各在进程地址空间的哪个位置，见 [../elf/memory-layout.md](/concepts/elf/memory-layout.md)。ASan 对这三个区域用的手段略有不同（堆靠 redzone、栈靠编译器改写栈帧、全局靠改写全局变量布局），但**判定机制统一走 shadow memory**。

**和 core dump 的分工**：很多内存错误（越界、UAF）不一定当场崩，可能踩坏了别处的数据，过很久才在一个"无辜"的地方触发 [SIGSEGV/SIGABRT](/crash/signals.md)——这时 core 里的调用栈指向的是"受害者"而非"凶手"。ASan 则在**第一次非法访问**就叫停并打印凶案现场，把"隔了十万八千里才崩"的难题变成"当场抓现行"。

**一句话**：ASan = 编译期给每次内存访问装摄像头，越界/悬垂当场报警。

## 二、核心原理一：shadow memory（影子内存）

这是 ASan 的灵魂。ASan 把整个应用地址空间"镜像"出一份**影子内存**，用极少的空间记录"每个字节能不能访问"。

### 8:1 的映射比例

- **每 8 字节应用内存 → 1 字节 shadow**（1/8 的空间开销）。
- 之所以是 8，是因为 `malloc` 保证 8 字节对齐，一个 8 字节块的可访问状态可以用一个 shadow 字节完整描述。

一个 shadow 字节的取值含义：

| shadow 字节值 | 含义 |
|---------------|------|
| `0` | 这 8 字节**全部可访问** |
| `1` ~ `7` | 这 8 字节里**只有前 k 个可访问**（部分可用，常见于 `malloc(9)` 这种非 8 整数倍，尾部对齐补齐的边界字节）|
| **负值**（如 `0xfa`、`0xfd`、`0xf1`…）| 整块**中毒（poisoned）/ 不可访问**，不同负值编码不同"毒因"（redzone、已释放、栈越界、全局越界等）|

常见的"毒因"编码（负值）：

| shadow 值 | 含义 |
|-----------|------|
| `0xfa` | 堆左 redzone（heap left redzone）|
| `0xfb` | 堆右 redzone |
| `0xfd` | 已释放的堆内存（freed，在 quarantine 里）|
| `0xf1` | 栈左 redzone |
| `0xf2`/`0xf3` | 栈中/右 redzone |
| `0xf9` | 全局变量 redzone |

### 地址映射公式

应用地址到 shadow 地址是一个简单的移位加偏移：

```bash
shadow_addr = (addr >> 3) + offset
```

`>> 3` 就是"除以 8"（8 字节压成 1 字节），`offset` 是一段预留的固定偏移（64 位 Linux 默认 `0x7fff8000`）。这个计算极快，只有一次移位和一次加法。

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<app>> #BBDEFB
  BorderColor<<app>>     #1976D2
  BackgroundColor<<sh>>  #FFE0B2
  BorderColor<<sh>>      #EF6C00
  BackgroundColor<<bad>> #FFCDD2
  BorderColor<<bad>>     #C62828
}
title 应用内存 (8 字节/格) ↔ shadow 内存 (1 字节/格)
rectangle "应用内存" {
  rectangle "addr..+8\n可用" as A0 <<app>>
  rectangle "addr+8..+16\n可用" as A1 <<app>>
  rectangle "addr+16..+24\n前 5 字节可用" as A2 <<app>>
  rectangle "addr+24..+32\nredzone(越界区)" as A3 <<bad>>
}
rectangle "shadow 内存  (addr>>3)+offset" {
  rectangle "0x00\n全可用" as S0 <<sh>>
  rectangle "0x00\n全可用" as S1 <<sh>>
  rectangle "0x05\n前5可用" as S2 <<sh>>
  rectangle "0xfb\n中毒" as S3 <<bad>>
}
A0 --> S0
A1 --> S1
A2 --> S2
A3 --> S3
note bottom of S3 : 访问落到这里\nshadow 为负 → 报错
@enduml
```

### 插桩：每次访问前先查 shadow

编译器把源码里每一处形如 `*p = ...` / `... = *p` 的内存访问，改写成"先查影子、再真访问"。伪代码：

```c
// 原始:  *addr = value;
// 插桩后:
byte  *shadow = (byte*)((addr >> 3) + offset);
int8_t sh     = *shadow;                 // 读那一个 shadow 字节
if (sh != 0) {                           // 非全可用，需要细判
    // 若访问跨越到了不可用字节(如 addr 的低 3 位 + 访问宽度 越过 sh 记录的可用数)
    if (SlowPathCheck(sh, addr, size))
        __asan_report_error(addr);       // 非法 → 报错并中止
}
*addr = value;                           // 合法 → 放行
```

- `sh == 0`（全可用）是最快的快路径，绝大多数访问一比较就过，开销极小。
- `sh` 为 `1~7` 时要看这次访问是否越过了可用边界（慢路径）；`sh` 为负一律非法。

**一句话**：shadow memory 用 1/8 的内存镜像出"每字节可不可访问"，插桩代码每次访问前查一眼，`(addr>>3)+offset` 一步定位。

## 三、核心原理二：redzone（红区）与 quarantine（隔离区）

shadow 只是"记账本"，真正让越界和悬垂"必然踩雷"的，是 ASan 改写了内存的分配/释放策略。

### redzone —— 在每块内存前后砌毒墙

ASan 接管了 `malloc`：申请 N 字节时，它实际分配的是 **`[左redzone] + [N字节用户区] + [右redzone]`**，并把两侧 redzone 在 shadow 里染成中毒值（`0xfa`/`0xfb`）。

- 只要越界访问（读/写第 N+1 字节、或第 -1 字节），必然踩进 redzone；
- 插桩代码一查 shadow 发现是负值，当场报 heap-buffer-overflow。

栈和全局同理：编译器给栈上的数组、全局数组前后也留出 redzone 并染毒（栈的 redzone 在函数进入时由插桩代码"上毒"，退出时"解毒"）。

### quarantine —— free 的内存先关禁闭，不马上复用

普通 `free` 会立刻把内存还给分配器，很可能马上被下一次 `malloc` 复用——这样 use-after-free 访问到的就是"别人的合法内存"，抓不到。ASan 改成：

1. `free(p)` 时，把这块内存在 shadow 里染成 **`0xfd`（freed）**；
2. 把它丢进 **quarantine 隔离区**（一个 FIFO 队列），**暂不归还**分配器；
3. 之后 use-after-free 访问它，shadow 一查是 `0xfd` → 报 heap-use-after-free；double-free 时也会发现它已在隔离区 → 报 double-free；
4. 隔离区总量超过阈值（`ASAN_OPTIONS` 里的 `quarantine_size_mb`）时，才把最老的块**真正释放**回分配器。

> 代价：隔离区把已释放内存"扣押"一段时间，这也是 ASan 内存开销偏高的原因之一。隔离区越大，抓 UAF 的窗口越长，但吃内存越多。

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<ok>>  #C8E6C9
  BorderColor<<ok>>      #388E3C
  BackgroundColor<<rz>>  #FFCDD2
  BorderColor<<rz>>      #C62828
}
title 一次 malloc(N) 的真实布局
rectangle "左 redzone\nshadow=0xfa" as L <<rz>>
rectangle "用户区 N 字节\nshadow=0x00" as U <<ok>>
rectangle "右 redzone\nshadow=0xfb" as R <<rz>>
L -right-> U
U -right-> R
note bottom of U : 越界向左/向右\n必然踩进 redzone
@enduml
```

**一句话**：redzone 在每块内存四周砌毒墙让越界必踩雷；quarantine 把 free 掉的内存扣押染毒，让 use-after-free 无处遁形。

## 四、一次检测的完整流程

把 shadow + 插桩 + redzone 串起来，一次内存访问的判定流程：

```plantuml
@startuml
skinparam shadowing false
start
:程序执行到一次内存访问\n*(addr) 读或写;
:插桩代码计算 shadow 地址\nshadow = (addr >> 3) + offset;
:读取该 shadow 字节 sh;
if (sh == 0 ?) then (是, 全可用)
  :放行, 执行真实访问;
  stop
else (否)
  if (访问未越过可用边界?\n(sh 为 1~7 且够用)) then (合法)
    :放行, 执行真实访问;
    stop
  else (非法 / sh 为负)
    :调用 __asan_report_error;
    :打印错误类型\n(overflow / use-after-free / double-free ...);
    :打印非法访问地址 + 访问宽度;
    :打印【访问栈】当前调用栈;
    :打印【分配栈】这块内存在哪 malloc 的;
    :若已释放, 再打印【释放栈】在哪 free 的;
    :默认中止进程 (abort);
    stop
  endif
endif
@enduml
```

报告里最有价值的就是三段栈——**访问栈**（现在谁在犯错）、**分配栈**（这块内存当初谁分配的）、**释放栈**（UAF 时谁提前释放的），一眼定位凶手，比在 core 里反推轻松得多。

**一句话**：算 shadow → 查一个字节 → 合法放行、非法就 `__asan_report` 打出错误类型 + 地址 + 访问栈 + 分配栈 + 释放栈。

## 五、开销与取舍

插桩和影子内存不是免费的：

| 维度 | 大致开销 | 来源 |
|------|----------|------|
| 内存 | 约 **2~3x** | shadow（1/8）+ 每块的 redzone + quarantine 扣押的内存 |
| 速度 | 约 **2x**（慢一倍）| 每次访问都多一次 shadow 查表与分支 |
| 二进制体积 | 明显变大 | 每个访问点都插了检查代码 |

结论：**ASan 是测试 / CI 阶段用的**，不上生产（生产环境跑 ASan 既慢又费内存，且 ASan 自身会改变内存布局）。在 CI 里跑一遍带 ASan 的测试，能在合并前就抓出越界和悬垂。

### 编译与运行

```bash
# 编译: 加 -fsanitize=address, 配 -g 才能在报告里看到源码行号
gcc -fsanitize=address -g -O1 main.c -o app        # GCC
clang -fsanitize=address -g -O1 main.c -o app      # Clang
# -O1 保留一定优化又不太影响可读性; 也可 -O0 保证行号最准
# 运行时用环境变量调参
ASAN_OPTIONS=detect_leaks=1:halt_on_error=1:quarantine_size_mb=256 ./app
```

常用 `ASAN_OPTIONS`：

| 选项 | 作用 |
|------|------|
| `detect_leaks=1` | 开启 LeakSanitizer 内存泄漏检测（Linux 上默认开）|
| `halt_on_error=0` | 报错后不中止，继续跑（一次跑出多个错误）|
| `quarantine_size_mb=N` | 隔离区大小，越大越能抓到"隔很久"的 UAF |
| `detect_stack_use_after_return=1` | 开启 use-after-return 检测（有额外开销）|
| `abort_on_error=1` | 用 `abort()` 结束，从而顺便产生 core |
| `log_path=./asan.log` | 把报告写到文件而非 stderr |

> 注意：链接时也要带 `-fsanitize=address`（它要链入 ASan 运行时库）。且 ASan 与 TSan **不能同时开**。

**一句话**：拿约 2x 速度、2~3x 内存换"当场抓错"，所以只在测试/CI 用；编译加 `-fsanitize=address -g`，运行用 `ASAN_OPTIONS` 调参。

## 六、和 core dump / 其他 sanitizer 的关系

| 工具 | 抓什么 | 手段 | 要重编译? | 速度 |
|------|--------|------|-----------|------|
| **ASan** | 内存访问错误（越界、UAF、double-free、泄漏）| 编译期插桩 + shadow memory | 是（`-fsanitize=address`）| 慢约 2x |
| **[TSan](/crash/tsan.md)** | 数据竞争（多线程未加锁并发读写）| 编译期插桩 + 记录内存访问的"发生序" | 是（`-fsanitize=thread`）| 慢 5~15x |
| **[Valgrind](/crash/valgrind.md)**（Memcheck）| 内存错误（和 ASan 重叠）| 运行期动态二进制翻译 | **否**（直接跑现有二进制）| 慢 10~50x |
| **[core dump](/crash/core-dump.md)** | 崩溃后的现场（事后）| 内核在致命信号时转储内存镜像 | 否 | 崩了才有 |

**ASan vs Valgrind 的核心差异**——两者都能抓越界和 UAF，但机制相反：

- **ASan**：编译期把检查逻辑"焊"进二进制，运行时几乎原生指令级检查 → **快，但必须重新编译源码**；
- **Valgrind（Memcheck）**：不改源码，运行时把程序指令翻译到虚拟 CPU 上逐条监控 → **不用重编、能测第三方二进制，但慢一个数量级**。

选择：有源码、追求速度、想进 CI → ASan；只有二进制、或要抓 ASan 覆盖不到的未初始化读取等 → Valgrind。

**和 core / 信号的时序关系**：ASan 通常在 [SIGSEGV / SIGABRT](/crash/signals.md) **之前**就抓到——因为越界/UAF 访问的往往还是"合法映射但逻辑非法"的内存，硬件不会立刻发段错误，而 ASan 的 shadow 检查先一步命中并主动 `abort`。也就是说，开了 ASan，很多"过很久才崩"的问题会被提前到"第一次犯错"就报出。

## 七、一句话总结

**AddressSanitizer 用 1/8 比例的 shadow memory 给每字节记账，靠 redzone 让越界必踩雷、靠 quarantine 让释放后的内存暂时染毒，再由编译期插桩在每次访问前查一眼 shadow——合法放行、非法当场打出错误类型和分配/访问/释放三段栈；以约 2x 速度、2~3x 内存的代价，把"隔很久才崩、core 里也难定位"的内存错误，变成"第一次犯错就抓现行"，是测试/CI 阶段内存问题的首选利器。**

