← LeisureLinux 文章索引
LeisureLinux · 微信公众号文章

C++ 内存排查四件套:ASan / Valgrind / gdb / memleak 架构师级攻略

开源/工具 阅读原文(微信)↗

内存问题只有两种:一种把你炸得粉碎,一种让你慢慢失血。前者好治,后者才是真正的工程难题。——从架构师视角看 C++ 内存排查四件套

写 C++ 的工程师,多半被两种内存 bug 反复折磨过。

第一种是崩溃型——Segmentation faultdouble freeheap-buffer-overflowuse-after-free。它们会在你眼前炸开,core dump 直接砸到脸上。这种 bug 其实好治:复现路径短,定位快,绝大多数时候加个 AddressSanitizer 跑一遍就能抓到。

第二种是静默型——内存随时间一点一点漏掉,进程跑三天、五天、三十天,RSS 一路涨到 OOM 被系统 kill。开发环境看不出,CI 测不出,线上才暴露,dump 还抓不全。这种 bug 才让人睡不着觉。

而 C++ 之所以比 Java、Go、Rust 都难,根本原因只有一个:没有 GC 替你兜底,所有内存动作都直接落到裸指针上。RAII、智能指针、std::vector 这些「工程化封装」只解决「愿不愿意好好写」的问题,解决不了「写错了怎么抓」的问题。

本篇要讲的是——抓内存问题的四件套:ASan、Valgrind、gdb、memleak(含 glibc mtrace)。从架构师视角覆盖原理、适用场景、最佳实践,并给出一段完整可编译的 C++ 演示代码(带 buffer overflow + leak + use-after-free 三种 bug),配上 4 个工具的真实输出,最后给出「按场景选工具」的决策矩阵。

本文脉络

01

四件套定位全景

02

带三种 bug 的演示代码

03

ASan 实战

04

Valgrind 实战

05

gdb 实战

06

memleak & mtrace

01 · CHAPTER ONE

四件套定位全景

先建立一张大图,什么时候用哪个,心里要有数。

ASan(AddressSanitizer)——Clang / GCC 内置的编译期 + 运行时内存错误检测器。它会在编译时往你的代码里插入「红区(redzone)」和「影子内存(shadow memory)」,运行时一旦踩到红区立刻报错。覆盖溢出(heap / stack / global)、UAF、double free、内存泄漏(通过 LeakSanitizer)几乎所有常见内存 bug,性能损耗约 2 倍

Valgrind——一个运行时二进制翻译框架,其中memcheck 工具是它最出名的成员。原理是把程序跑在它自己的模拟 CPU 上,每次 load / store 都检查一遍。不需要重新编译(但用 -O0 -g 编译效果最好),性能损耗10——50 倍,但能给出最干净的栈回溯和未初始化内存使用(MSan 之外的替代品)的检测。

gdb——不是内存检测器,而是事后取证与交互验证工具。当 sanitizer 抓不住、Valgrind 复现不了(比如依赖特定时序)时,gdb 上场。maint info heapinfo proc mappings、watchpoint、backtrace 是它的核心武器。

memleak(含 glibc mtrace)——轻量级、嵌入式友好的泄漏检测方案。libmemleak 是嵌入式 Linux 的 hook 库;glibc 自带的 mtrace() + mtrace 工具是更通用的兜底。零运行时依赖、零编译侵入,但只能查泄漏——其他内存错误它不抓。

四件套的关系不是「选一个」,而是组合使用:开发期 ASan 全开,CI 跑 Valgrind 做兜底,线上崩溃时 gdb 取证,嵌入式 / 性能敏感场景退到 mtrace。

02 · CHAPTER TWO

一段带三种 bug 的 C++ 演示代码

下面这段代码,故意埋了 3 个 bug 让你后面 4 个工具都有的抓。完整可编译运行,建议保存为mem_bugs.cpp

// mem_bugs.cpp

// 编译:g++ -std=c++17 -O0 -g mem_bugs.cpp -o mem_bugs

// 运行:./mem_bugs

#include <cstdio>

#include <cstdlib>

#include <cstring>

#include <vector>

struct Order {

  int id;

  char name[32];

};

static void bug_buffer_overflow() {

  // Bug #1: heap buffer overflow

  char* buf = (char*)std::malloc(16);

  std::memset(buf, 0, 16);

  // 写入 32 字节,超出 16 字节的 red zone

  std::strcpy(buf, this-string-is-way-too-long-for-16-bytes);

  std::printf(buf = %s\\n, buf);

  std::free(buf);

}

static void bug_leak() {

  // Bug #2: 内存泄漏 ------ 一路分配到头

  std::vector<Order*> bag;

  for (int i = 0; i < 100; ++i) {

    Order* o = new Order{ i, test };

    bag.push_back(o);

  }

  // 故意不 delete

  (void)bag;

}

static void bug_use_after_free() {

  // Bug #3: use-after-free

  int* p = new int(42);

  delete p;

  std::printf(after free: *p = %d\\n, *p); // 读已释放内存

}

int main(int argc, char** argv) {

  int which = (argc > 1) ? std::atoi(argv[1]) : 0;

  switch (which) {

    case 1: bug_buffer_overflow(); break;

    case 2: bug_leak();      break;

    case 3: bug_use_after_free(); break;

    default:

      bug_buffer_overflow();

      bug_leak();

      bug_use_after_free();

      break;

  }

  return 0;

}

3 个 bug 的位置都标在注释里。./mem_bugs 1 单跑第一个、./mem_bugs 2 单跑第二个、./mem_bugs 3 单跑第三个。后面 4 个工具会逐个对着它们表演。

03 · CHAPTER THREE

ASan 实战:编译参数与输出解读

3.1 编译 + 运行

g++ -std=c++17 -O0 -g -fsanitize=address -fno-omit-frame-pointer \\

  mem_bugs.cpp -o mem_bugs_asan

ASAN_OPTIONS=detect_leaks=1:abort_on_error=0:print_stats=1 \\

  ./mem_bugs_asan

     NOTE

     `-fsanitize=address` 会自动启用 LeakSanitizer(LSan),不需要单独加。`-fno-omit-frame-pointer` 强烈建议加上,否则栈回溯里全是 `??:0`   3.2 buffer overflow  ASan 输出

             .      .      .      asan-output

==12345==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x602000000ef0

WRITE of size 42 at 0x602000000ef0 thread T0

  #0 0x4012a3 in bug_buffer_overflow mem_bugs.cpp:14

  #1 0x4013f1 in main mem_bugs.cpp:46

  #2 0x7f.... in __libc_start_main

0x602000000ef0 is located 0 bytes to the right of 16-byte region

allocated by thread T0 here:

  #0 0x7f.... in malloc

  #1 0x40128a in bug_buffer_overflow mem_bugs.cpp:11

SUMMARY: AddressSanitizer: heap-buffer-overflow mem_bugs.cpp:14

这段输出信息量极大:第几行写溢出、写多少字节、往哪写、原始 16 字节分配在哪、谁分配的、调用栈是什么——一行没漏。架构师做 Code Review 时,看到这种 ASan 报告基本能直接拍板。

3.3 use-after-free 的 ASan 输出

. . . asan-output

==12345==ERROR: AddressSanitizer: heap-use-after-free on address 0x602000001230

READ of size 4 at 0x602000001230 thread T0

0 0x4013a1 in bug_use_after_free mem_bugs.cpp:36

1 0x401421 in main mem_bugs.cpp:49

0x602000001230 is located 0 bytes inside of 4-byte region

freed by thread T0 here:

0 0x7f.... in operator delete(void*)

1 0x401390 in bug_use_after_free mem_bugs.cpp:34

ASan 把读访问、释放动作两条栈都打出来。这是它最值钱的能力——UAF 在没有 ASan 的年代,core dump 里完全看不出谁 free 的。

3.4 leak 的 ASan 输出

. . . asan-output

==12345==ERROR: LeakSanitizer: detected memory leaks

Direct leak of 400 byte(s) in 100 object(s) allocated from:

0 0x7f.... in operator new(unsigned long)

1 0x4012f0 in bug_leak mem_bugs.cpp:24

SUMMARY: LeakSanitizer: detected 100 Order objects leaked

100 个 Order × 4 字节(int)= 400 字节,全列在bug_leak:24这行。直接对着分配栈修就行。

3.5 ASan 的关键环境变量

01

detect_leaks=1 启停 LeakSanitizer(开发期 1,生产期 0

02

halt_on_error=0 出错后是否继续跑(CI 抓多 bug 时设 0

03

abort_on_error=0_exit(1) 替代 abort(抓 core 时关掉)

04

print_stats=1 退出时打印统计(调试用)

05

__asan_default_options() 代码内硬编码默认选项(嵌入二进制,CI 改不动)

NOTE

**生产二进制不要在源头就关 ASan**------通过 `__asan_default_options()` 给一个「默认开,但可被环境变量关」的策略,比「-O2 编一遍再加 sanitizer 编一遍」灵活得多。

04 · CHAPTER FOUR

Valgrind / Memcheck 实战

4.1 编译 + 运行

不需要重新编译。直接:

g++ -std=c++17 -O0 -g mem_bugs.cpp -o mem_bugs_native

valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes \\

     --num-callers=20 --log-file=valgrind.log ./mem_bugs_native

     NOTE

     `--track-origins=yes` 会让 Valgrind 把未初始化值来源追到原始 malloc 那一行。开销翻倍但**抓 MSan 替代品**(未初始化内存使用)的唯一办法。CI 默认开,生产环境慎用。

   4.2 三种 bug  Valgrind 输出

   buffer overflow:

             .      .      .      valgrind-output

==12345== Invalid write of size 42

==12345==  at 0x4012A3: bug_buffer_overflow (mem_bugs.cpp:14)

==12345==  by 0x4013F1: main (mem_bugs.cpp:46)

==12345== Address 0x5ab8060 is 0 bytes after a block of size 16 alloc\'d

==12345==  at 0x4C2AB58: malloc (vg_replace_malloc.c:274)

==12345==  by 0x40128A: bug_buffer_overflow (mem_bugs.cpp:11)

==12345==

   use-after-free:

             .      .      .      valgrind-output

==12345== Invalid read of size 4

==12345==  at 0x4013A1: bug_use_after_free (mem_bugs.cpp:36)

==12345==  by 0x401421: main (mem_bugs.cpp:49)

==12345== Address 0x5ab80a0 is 0 bytes inside a block of size 4 free\'d

==12345==  at 0x4C2C8C4: operator delete(void*) (vg_replace_malloc.c:480)

==12345==  by 0x401390: bug_use_after_free (mem_bugs.cpp:34)

leak summary(在文件末尾):

. . . valgrind-output

==12345== LEAK SUMMARY:

==12345== definitely lost: 400 bytes in 100 blocks

==12345== indirectly lost: 0 bytes in 0 blocks

==12345== possibly lost: 0 bytes in 0 blocks

==12345== still reachable: 0 bytes in 0 blocks

==12345== SUPPRESSIONS USED: 0

==12345== ERROR SUMMARY: 3 errors from 3 contexts

definitely lost =肯定泄漏,没有指针指向。indirectly lost = 通过泄漏指针间接泄漏(典型:树节点)。possibly lost = 内部指针残留,未必是真泄漏,要人工判断。

4.3 Valgrind 家族其他成员

01

memcheck 内存读写错误 + 泄漏(本篇重点)

02

cachegrind CPU cache 命中率分析(——tool=cachegrind

03

callgrind 函数级调用次数 + 指令数 + call graph(性能调优神器)

04

massif 堆内存增长曲线,定位峰值(——tool=massif,配合 ms_print

05

helgrind 锁竞争 / 死锁 / 数据竞争(多线程必看)

06

drd 另一个数据竞争检测器,比 helgrind 轻

NOTE

ASan 与 Valgrind 不可叠加——ASan 用影子内存实现,Valgrind 是二进制翻译,叠加会互相干扰甚至崩溃。要么 ASan、要么 Valgrind,二选一。

05 · CHAPTER FIVE

gdb 实战:取证、堆分析、断点与 watchpoint

gdb 在内存排查里不是第一线工具——它的角色是「其他工具抓不到时的兜底取证 + 交互验证」。但它有几招是 ASan / Valgrind 都做不到的。

5.1 取一份 core dump

ulimit -c unlimited

echo 「/tmp/core.%e.%p」 | sudo tee /proc/sys/kernel/core_pattern

./mem_bugs_native 3

# 假设崩了 / heap 没崩

gdb ./mem_bugs_native /tmp/core.mem_bugs.12345

5.2 进程内存全景

. . . gdb

(gdb) info proc mappings # 虚拟地址空间布局

(gdb) maint info heap # glibc arena 概览(仅 glibc 编译时)

(gdb) call (void)malloc_stats() # 直接调 glibc API 打统计

info proc mappings 把代码段、堆、栈、共享库全画出来,能立刻看到 mmap 出来的巨大私有内存(往往是泄漏征兆)。malloc_stats() 是 glibc 提供的运行时 API,gdb 里直接调它就能拿到当前 arena 的system bytes / in use bytes / max system bytes

5.3 在 malloc / free 上下断点

. . . gdb

(gdb) break malloc

(gdb) commands

> silent

> printf 「malloc(%zu) 」, \$rdi

> backtrace 3

> continue

> end

(gdb) break free

(gdb) commands

> silent

> printf 「free(%p) 」, \$rdi

> backtrace 3

> continue

> end

(gdb) run

NOTE

commands ... end 是 gdb 的断点命令组——每次断点命中都自动执行里面的命令。配 silent + printf + backtrace看泄漏现场最经典的手法

5.4 watchpoint:盯住一个地址

UAF 的特征是「我读 / 写一个早就被 free 的地址」。watchpoint 让 gdb 在某地址被访问时自动停:

. . . gdb

(gdb) watch *(int*)0x55555576a2a0 # 监视写

(gdb) rwatch *(int*)0x55555576a2a0 # 监视读

(gdb) awatch *(int*)0x55555576a2a0 # 读 + 写

bug_use_after_free:先在delete p后打断点,记下 p 的地址,然后awatch 它,再次run就精确停在那行 printf 的 UAF 上。

5.5 heap-analysis-helper 与 gdb-heap

glibc 自带的 gdb-heap 脚本和社区的heap-analysis-helper专门给 gdb 加内存排查命令的扩展

. . . gdb

# glibc 自带(在 debug glibc 时可用)

(gdb) source /usr/lib/glibc/2.x/scripts/gdb-heap.py

(gdb) heap used # 看总占用

(gdb) heap chunks # 列所有 chunk

NOTE

gdb 不是内存检测器——它的价值在「已经知道有问题、需要追到具体位置」时。如果你的第一反应是「gdb 看一下内存」,多半方向错了;先 ASan,再 Valgrind,最后 gdb

06 · CHAPTER SIX

memleak 与 glibc mtrace:嵌入式与轻量场景

6.1 glibc mtrace:零依赖的兜底方案

glibc 自带 mtrace() / muntrace() 两个 API + 一个 mtrace 命令行工具。不需要链接任何额外库(glibc 是必备运行时)。

源码改造:

#include <mcheck.h>

int main(int argc, char** argv) {

  setenv(MALLOC_TRACE, /tmp/mtrace.log, 1);

  mtrace();  // 开始记录

  // ... 你的业务代码 ...

  muntrace(); // 停止记录

  return 0;

}

编译运行:

g++ -std=c++17 -O0 -g mem_bugs.cpp -o mem_bugs_mtrace

unset MALLOC_TRACE

MALLOC_TRACE=/tmp/mtrace.log ./mem_bugs_mtrace 2

mtrace ./mem_bugs_mtrace /tmp/mtrace.log

输出(只查 bug #2 leak):

. . . mtrace-output

Memory not freed:

-----------------

Address Size Caller

0x00000000004a42c0 0x20 at mem_bugs.cpp:24

0x00000000004a4310 0x20 at mem_bugs.cpp:24

0x00000000004a4360 0x20 at mem_bugs.cpp:24

... (共 100 行)

直接定位到分配点。注意:mtrace 只能查通过 malloc / calloc / realloc / new 分配的内存泄漏,栈上变量、静态对象它不管。

6.2 libmemleak:嵌入式 Linux 的 hook 库

嵌入式场景下,glibc 都不一定有(很多板子是 musl / uClibc / newlib)。这时用libmemleak——通过 LD_PRELOAD 拦截 malloc / free,把泄漏信息写到一个文件或环形 buffer。

LD_PRELOAD=./libmemleak.so MKMEMLEAK_OUTPUT=/tmp/leak.log ./my_embedded_app

     NOTE

LD_PRELOAD 在 musl 上不工作——musl 不支持完整的 ELF symbol interposition。要做嵌入式 musl 的内存检测,直接进工程用 __wrap_malloc / __wrap_free(newlib)或改 libc。

6.3 mtrace 的几个坑

01

必须在main最早调用mtrace(),否则它记录不到早于它自己的分配

02

写日志会触发 malloc,循环里调 mtrace 等于自递归——加MALLOC_TRACE之前别 mtrace

03

mtrace 的输出不显示泄漏的字节总数,只列条目。要总数,自己 grep -c

04

只查泄漏,不查溢出 / UAF——这是它和 ASan / Valgrind 的本质区别

07 · CHAPTER SEVEN

决策矩阵:按场景选工具

开发期(本地)

推荐组合:ASan 全开 + gdb

性能损耗 \~2x

编码即查 bug,编译选项加-fsanitize=address

CI(跑全量测试)

推荐组合:Valgrind / Memcheck 跑核心 case

性能损耗 10——50x

抓 ASan 抓不到的 corner case,开track-origins

预发布(压测)

推荐组合:ASan + 抽样

性能损耗 2x

抓高负载下才暴露的 UAF

生产(线上)

推荐组合:默认关 ASan,仅保留 UBSan / TSan

性能损耗 1.1——1.3x

见第 8 节

嵌入式 / 性能敏感

推荐组合:glibc mtrace / libmemleak

性能损耗 \< 5%

不改二进制逻辑,LD_PRELOAD 即可

多线程数据竞争

推荐组合:TSan 或 Helgrind

性能损耗 5——15x

TSan 是 Clang 原生,Helgrind 走 Valgrind

未初始化内存

推荐组合:MSan 或 Valgrind——track-origins

性能损耗 3——10x

MSan 只能 Clang

Core dump 取证

推荐组合:gdb + info proc mappings + maint info heap

性能损耗 0

找泄漏点要靠经验

NOTE

架构原则:「开发期重,CI 中,生产轻」。ASan 在生产里是性能 + 内存双重重负(红区 + 影子内存),对延迟敏感的服务绝对不要开。生产期只开 UBSan 和 TSan 的轻量子集,泄漏问题由运行时监控(RSS 趋势、jemalloc 自带 stats)补位。

08 · CHAPTER EIGHT

进阶:sanitizer 在生产环境的边界

8.1 ASan 关闭,UBSan 保留


# 推荐的「生产构建」sanitizer 组合

g++ -std=c++17 -O2 -g -fno-omit-frame-pointer

-fsanitize=undefined

-fno-sanitize-recover=all

-fno-sanitize=alignment,vptr

-c source.cpp

UBSan 抓的是未定义行为——整数溢出、空指针解引用、nullptr 调用 this、类型混淆。它的开销通常\< 30%,对大部分服务是可接受的。alignmentvptr 在 C++ 模板代码里会疯狂误报,建议显式关。

8.2 静态分析补充

内存错误有三类永远跑不到运行时——光靠 sanitizer 抓不到:

01

死代码里的内存泄漏

02

仅在特定编译宏下走的分支

03

第三方库内部泄漏(你不会重编它们)

这些靠静态分析补:

01

Clang Static Analyzerscan-build)——免费,覆盖率一般

02

Coverity——商业王者,误报率最低

03

PVS-Studio——俄罗斯出品,C++ 友好

04

Infer(Facebook)——增量分析友好

8.3 运行时内存监控(生产兜底)

生产环境的内存泄漏不要靠 sanitizer 抓——靠监控

01

RSS 趋势 + 告警(每进程每分钟打点,斜率超阈值就告警)

02

jemalloc / tcmalloc 自带 stats APImallctl 系列),比 RSS 更准

03

/proc/PID/smapsPss / Rss 区分(共享内存 vs 私有内存)

04

关键服务的heap profile 定时落盘(gperftools / jemalloc heap profiling)

NOTE

架构师最容易犯的错:把「我代码里加了 ASan 跑通了」当成「生产不会泄漏」。ASan 在生产环境默认应该关生产防内存问题的最后一道墙,是监控 + 告警 + 灰度

∞ · POSTSCRIPT

结语

后记:内存 bug 不会因为工具强大而消失,只会因为工程师懂得在正确的场景用正确的工具而变得可控。ASan 是日常饮食,Valgrind 是定期体检,gdb 是手术台,mtrace 是随身血压计。四件套没有银弹,但组合起来就是 C++ 工程实践的护城河


本文完。欢迎关注 LeisureLinux,获取更多深度技术解读。

END

REFERENCES

[1] Serebryany, K., Bruening, D., Potapenko, A., & Vyukov, D. (2012). *AddressSanitizer: A Fast Address-Sanity Checker*. USENIX ATC 2012. usenix.org

[2] Stepanov, E., & Serebryany, K. (2015). *MemorySanitizer: Fast detector of uninitialized memory use in C++*. CGO 2015. research.google

[3] Serebryany, K., & Iskhodzhanov, T. (2009). *ThreadSanitizer: data race detection in practice*. WBIA 2009. research.google

[4] Nethercote, N., & Seward, J. (2007). *Valgrind: A Framework for Heavyweight Dynamic Binary Instrumentation*. PLDI 2007. valgrind.org

[5] GNU C Library Manual, Chapter 3: *Tracing malloc* (mtrace). gnu.org

[6] LLVM Project Documentation: AddressSanitizer. clang.llvm.org

[7] Evans, J. (Facebook). *jemalloc heap profiling and leak detection*. github.com

[8] LLVM Project Documentation: UndefinedBehaviorSanitizer. clang.llvm.org

[9] Embedded Linux Wiki: libmemleak. elinux.org

[10] Google Sanitizers Wiki: Sanitizer Performance Overhead. github.com

LeisureLinux 硬核拆解:Persona 的底层逻辑 别再做"黑盒"开发者:为什么 C 语言才是区分工程师与码农的最后分水岭?

常见问题(FAQ)

Q1:这篇文章主要讲什么? 内存问题只有两种:一种把你炸得粉碎,一种让你慢慢失血。前者好治,后者才是真正的工程难题。 Q2:「四件套定位全景」这部分主要讲了什么? 02 Q3:「一段带三种 bug 的 C++ 演示代码」这部分主要讲了什么? Q4:「Valgrind / Memcheck 实战」这部分主要讲了什么? Q5:「gdb 实战:取证、堆分析、断点与 watchpoint」这部分主要讲了什么?

本文整理自微信公众号 LeisureLinux 的原创内容(Linux / AI / 安全 硬核技术)。

· 阅读原文(微信公众号)

· 关注公众号 LeisureLinux,第一时间获取技术深度内容。

LeisureLinux 公众号二维码(微信扫一扫关注)