内存问题只有两种:一种把你炸得粉碎,一种让你慢慢失血。前者好治,后者才是真正的工程难题。——从架构师视角看 C++ 内存排查四件套
写 C++ 的工程师,多半被两种内存 bug 反复折磨过。
第一种是崩溃型——Segmentation fault、double free、heap-buffer-overflow、use-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 heap、info 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%,对大部分服务是可接受的。alignment、vptr 在 C++ 模板代码里会疯狂误报,建议显式关。
8.2 静态分析补充
内存错误有三类永远跑不到运行时——光靠 sanitizer 抓不到:
01
死代码里的内存泄漏
02
仅在特定编译宏下走的分支
03
第三方库内部泄漏(你不会重编它们)
这些靠静态分析补:
01
Clang Static Analyzer(scan-build)——免费,覆盖率一般
02
Coverity——商业王者,误报率最低
03
PVS-Studio——俄罗斯出品,C++ 友好
04
Infer(Facebook)——增量分析友好
8.3 运行时内存监控(生产兜底)
生产环境的内存泄漏不要靠 sanitizer 抓——靠监控:
01
RSS 趋势 + 告警(每进程每分钟打点,斜率超阈值就告警)
02
jemalloc / tcmalloc 自带 stats API(mallctl 系列),比 RSS 更准
03
/proc/PID/smaps 的 Pss / 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」这部分主要讲了什么?