19、内存调试工具:Valgrind、AddressSanitizer、GDB
写C语言程序,最头疼的是什么?
我个人觉得,不是算法难写,也不是逻辑复杂,而是——内存问题。
野指针、内存泄漏、越界访问……这些家伙就像幽灵一样,平时不出现,一出现就让你加班到凌晨。我刚开始工作的头两年,没少被它们折磨。后来我学乖了,开始用工具。今天聊的三把刀——Valgrind、AddressSanitizer、GDB,就是我这些年最顺手的家伙。
19.1 Valgrind:内存泄漏的照妖镜
Valgrind 是什么?说白了,它是一个动态分析工具。它会在你的程序运行时,悄悄地在背后盯着每一块内存的申请和释放。
我记得第一次用 Valgrind 时,是在一个服务器项目里。程序跑了两天,内存占用从 200MB 涨到了 2GB。领导问我怎么回事,我一脸懵。后来用 Valgrind 一查,好家伙,一个循环里每次 new 了对象,但只有一半被 delete。
19.1.1 基本用法
// 示例:一个典型的内存泄漏
#include <stdlib.h>
void leaky_function() {
int *p = (int*)malloc(10 * sizeof(int));
// 忘记 free(p);
}
int main() {
leaky_function();
return 0;
}
编译时记得加 -g 选项,保留调试信息:
gcc -g -o leaky leaky.c
valgrind --leak-check=full ./leaky
输出会告诉你:哪一行申请的內存没释放,有多少字节泄漏了。清清楚楚,明明白白。
核心参数速查:
--leak-check=full:显示每个泄漏的详细信息--show-leak-kinds=all:显示所有类型的泄漏(definitely lost, indirectly lost 等)--track-origins=yes:追踪未初始化值的来源(会慢一些,但信息更全)
19.1.2 常见问题与避坑
Valgrind 跑起来会慢很多,通常是正常速度的 5-10 倍。所以别在生产环境上直接跑,我一般是在测试环境或者本地调试时用。
注意:Valgrind 只能检测动态分配的内存问题(malloc/calloc/realloc)。栈上的越界访问,它管不了。这时候需要 AddressSanitizer 出马。
19.2 AddressSanitizer:编译时的守护神
AddressSanitizer(简称 ASan)是 Google 开发的工具。它跟 Valgrind 不一样,是在编译阶段插入检查代码的。说白了,它让你的程序自己给自己做体检。
我为什么喜欢 ASan?因为它快。Valgrind 跑一个大型项目可能要等半小时,ASan 编译时多花点时间,运行时只慢 2-3 倍。对于日常开发来说,这个代价完全可以接受。
19.2.1 基本用法
// 示例:数组越界
#include <stdio.h>
int main() {
int arr[5] = {1, 2, 3, 4, 5};
printf("%d\n", arr[10]); // 越界访问!
return 0;
}
编译时加上 -fsanitize=address:
gcc -g -fsanitize=address -o asan_test asan_test.c
./asan_test
程序一跑,ASan 会直接报错,告诉你哪一行越界了,访问了哪个地址,甚至画出内存布局图。
我的习惯:在开发阶段的 CMakeLists.txt 里,我会默认加上 -fsanitize=address -fno-omit-frame-pointer。等要发布 Release 版本时再去掉。这样既能保证开发质量,又不影响线上性能。
19.2.2 ASan 能检测什么
| 问题类型 | 说明 |
|---|---|
| 堆越界 | malloc 出来的内存,访问超出申请范围 |
| 栈越界 | 局部数组、局部变量访问越界 |
| 全局变量越界 | 全局数组或静态数组访问越界 |
| 释放后使用 | free 之后继续访问那块内存 |
| 双重释放 | 对同一块内存 free 两次 |
你看,ASan 覆盖的场景比 Valgrind 更广。而且它还能检测栈上的问题,这是 Valgrind 做不到的。
19.3 GDB:最后的防线
Valgrind 和 ASan 都搞不定的时候,就该 GDB 上场了。
GDB 是 GNU 的调试器。它不像前两个工具那样自动帮你找问题,它更像一个显微镜,让你自己去看程序内部发生了什么。
我曾经遇到过一个极其诡异的 bug:程序在 Release 模式下跑得好好的,Debug 模式就崩溃。用 Valgrind 和 ASan 都查不出问题。最后是 GDB 一步步单步跟踪,发现是一个未初始化的局部变量在 Release 模式下被编译器优化掉了,Debug 模式下却保留了垃圾值。
19.3.1 常用命令速记
gcc -g -o my_program my_program.c
gdb ./my_program
进入 GDB 后,常用的命令:
break main:在 main 函数入口设断点break file.c:42:在 file.c 的第 42 行设断点run:开始运行程序next:单步执行(不进入函数内部)step:单步执行(进入函数内部)print var:打印变量 var 的值backtrace:查看函数调用栈info locals:查看当前函数的所有局部变量
调试内存问题的 GDB 技巧:
- 用
print ptr查看指针的值,确认它是不是 NULL - 用
x/10x ptr查看指针指向的内存内容(16进制显示10个字节) - 用
watch *ptr监控某个内存地址的变化,一有改动就停下来
19.3.2 实战:用 GDB 定位段错误
// segment_fault.c
#include <stdio.h>
void crash_me() {
int *p = NULL;
*p = 42; // 对空指针解引用,必崩
}
int main() {
crash_me();
return 0;
}
编译运行,程序直接段错误。用 GDB 跑一下:
gdb ./segment_fault
(gdb) run
// 程序崩溃,GDB 自动停在崩溃位置
(gdb) backtrace
// 显示调用栈:main -> crash_me
(gdb) print p
// 输出:$1 = (int *) 0x0
// 看到没?p 是 NULL,问题找到了
嗯,就是这么简单。但实际项目中,调用栈可能很深,变量名也可能很复杂。这时候就需要耐心,一层层往上查。
19.4 三把刀怎么选
我个人的经验是这样的:
- 日常开发:编译时开 ASan,跑单元测试。大部分问题都能在这步解决。
- 集成测试:用 Valgrind 跑一遍,重点查内存泄漏。尤其是长时间运行的服务,泄漏一点积累起来就是大问题。
- 疑难杂症:ASan 和 Valgrind 都查不出来,或者报错信息看不懂,上 GDB 手动调试。
这三把刀配合使用,基本上能覆盖 95% 以上的内存问题。剩下的 5%,嗯……那可能是硬件问题或者编译器 bug,遇到了算你运气好。
一句话总结:Valgrind 查泄漏,ASan 查越界,GDB 查疑难。三个工具配合使用,内存问题无处遁形。