4、越界排查工具:Valgrind基础使用、AddressSanitizer配置、GDB调试内存
内存越界,说白了就是程序访问了不该访问的地方。写入了别人的地盘,或者读到了野指针指向的垃圾数据。这类 bug 非常隐蔽,有时候跑几天才崩溃一次。我见过太多团队在项目交付前被这种问题折磨得焦头烂额。
今天我就把压箱底的三件套掏出来:Valgrind、AddressSanitizer、GDB。这三样东西配合使用,基本能覆盖 90% 的内存问题场景。
4.1 Valgrind 基础使用
Valgrind 是个老牌工具了。我最早接触它是在 2010 年左右,那时候它还是 3.6 版本。说实话,它的原理很简单——模拟执行你的程序,然后记录每一次内存访问。
安装很简单:
# Ubuntu/Debian
sudo apt-get install valgrind
# CentOS/RHEL
sudo yum install valgrind
# macOS (需要安装 Xcode 命令行工具)
brew install valgrind
基本用法就一行命令:
valgrind --tool=memcheck --leak-check=full ./your_program
嗯,这里要注意几个关键参数:
| 参数 | 作用 | 我的建议 |
|---|---|---|
| --tool=memcheck | 选择内存检查工具 | 默认就是 memcheck,可以不写 |
| --leak-check=full | 显示详细的内存泄漏信息 | 必加,否则只告诉你泄漏了多少字节 |
| --show-leak-kinds=all | 显示所有类型的泄漏 | 我习惯加上,免得漏掉 |
| --track-origins=yes | 追踪未初始化值的来源 | 性能开销大,但排查时很有用 |
举个例子,假设你有这么一段代码:
#include <stdlib.h>
int main() {
int *p = (int*)malloc(10 * sizeof(int));
p[10] = 42; // 越界写入
free(p);
return 0;
}
用 Valgrind 跑一下:
valgrind --leak-check=full ./a.out
你会看到类似这样的输出:
==12345== Invalid write of size 4
==12345== at 0x4005E4: main (test.c:5)
==12345== Address 0x51d2068 is 0 bytes after a block of size 40 alloc'd
==12345== at 0x4C2DB8F: malloc (vg_replace_malloc.c:299)
==12345== by 0x4005D7: main (test.c:4)
它直接告诉你:第 5 行写越界了,分配了 40 字节,你写到了后面 0 字节的位置。非常清晰。
4.2 AddressSanitizer 配置
Valgrind 虽好,但太慢了。有时候你需要在生产环境或者 CI 流程中快速检测内存问题。这时候 AddressSanitizer(简称 ASan)就派上用场了。
ASan 是编译器内置的工具。GCC 4.8+ 和 Clang 3.1+ 都支持。它的原理是在编译时插入检测代码,运行时检查每一次内存访问。
配置方法极其简单:
# GCC
gcc -fsanitize=address -g -o my_program my_program.c
# Clang
clang -fsanitize=address -g -o my_program my_program.c
# 如果是 C++ 项目
g++ -fsanitize=address -g -o my_program my_program.cpp
就这么简单。编译出来的程序自带内存检测功能。运行它,一旦发生越界,程序会立即崩溃并打印详细的错误信息。
我曾经在一个大型服务器项目里用过 ASan。那个项目有几十万行代码,Valgrind 跑一次要 3 小时。换成 ASan 后,编译时间多了 2 分钟,但运行速度只慢了 2 倍左右。CI 流程里每天跑一次,抓到了好几个隐藏多年的越界 bug。
ASan 能检测的问题:
- 堆内存越界(malloc/free)
- 栈内存越界(局部变量)
- 全局变量越界
- 释放后使用(use-after-free)
- 双重释放(double-free)
- 内存泄漏(需要配合 -fsanitize=leak)
看个例子:
#include <stdlib.h>
int main() {
int *p = (int*)malloc(10 * sizeof(int));
p[10] = 42; // 越界
free(p);
return 0;
}
编译运行:
gcc -fsanitize=address -g -o test test.c
./test
输出:
==12345==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x60200000eff8
WRITE of size 4 at 0x60200000eff8 thread T0
#0 0x4006e4 in main test.c:5
#1 0x7f8c9b2c0b96 in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x21b96)
#2 0x4005f9 in _start (test+0x4005f9)
0x60200000eff8 is located 0 bytes to the right of 40-byte region [0x60200000efd0,0x60200000eff8)
allocated by thread T0 here:
#0 0x7f8c9b5e2b40 in __interceptor_malloc (/usr/lib/x86_64-linux-gnu/libasan.so.4+0xdeb40)
#1 0x4006d7 in main test.c:4
#2 0x7f8c9b2c0b96 in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x21b96)
信息量很大。它告诉你:
- 错误类型:堆缓冲区溢出
- 操作:写入 4 字节
- 位置:test.c 第 5 行
- 分配位置:test.c 第 4 行
4.3 GDB 调试内存
Valgrind 和 ASan 能告诉你哪里出了问题,但有时候你需要深入现场,看看内存到底长什么样。这时候 GDB 就是你的手术刀。
我习惯的做法是:先用 Valgrind 或 ASan 定位到问题代码行,然后用 GDB 在那行打断点,一步步看内存变化。
常用的 GDB 内存调试命令:
| 命令 | 作用 | 示例 |
|---|---|---|
| x/格式 地址 | 查看内存内容 | x/10xw &p |
| print 变量 | 打印变量值 | print p[5] |
| watch 表达式 | 监视内存变化 | watch p[5] |
| info registers | 查看寄存器 | info registers rsp |
举个例子。假设你有这段代码:
#include <stdio.h>
#include <stdlib.h>
int main() {
int *p = (int*)malloc(5 * sizeof(int));
for (int i = 0; i <= 5; i++) { // 越界:i=5 时越界
p[i] = i * 10;
}
for (int i = 0; i <= 5; i++) {
printf("p[%d] = %d\n", i, p[i]);
}
free(p);
return 0;
}
用 GDB 调试:
gcc -g -o test test.c
gdb ./test
在 GDB 里:
(gdb) break test.c:6
(gdb) run
(gdb) print p
$1 = (int *) 0x602010
(gdb) x/10xw 0x602010
0x602010: 0x00000000 0x00000000 0x00000000 0x00000000
0x602020: 0x00000000 0x00000000 0x00000000 0x00000000
(gdb) continue
(gdb) x/10xw 0x602010
0x602010: 0x00000000 0x0000000a 0x00000014 0x0000001e
0x602020: 0x00000028 0x00000032 0x00000000 0x00000000
看到了吗?p[5] 写入了 0x32(50),但 p 只分配了 5 个 int(20 字节)。第 6 个 int 已经写到了堆内存的后面。这就是典型的越界。
- Valgrind 跑一遍,定位问题代码行
- ASan 编译,确认问题是否稳定复现
- GDB 打断点,观察内存变化
- 修复后,再用 Valgrind 验证一遍
这套流程我用了十几年,基本没失手过。
4.4 三件套对比
你可能会问:这三个工具到底该用哪个?我的建议是:
- 开发阶段: 用 ASan。编译快,运行快,能快速迭代。
- 集成测试: 用 Valgrind。检测更全面,能发现 ASan 漏掉的问题。
- 疑难杂症: 用 GDB。深入现场,看内存布局,理解问题本质。
我曾经遇到过一个非常诡异的 bug:程序运行 72 小时后必崩。用 ASan 跑了一周没抓到,用 Valgrind 跑了一次就定位到了——是一个全局数组在某个极端路径下越界了 1 个字节。那个字节恰好覆盖了另一个全局变量的最低位,导致逻辑判断出错。这种问题,没有 Valgrind 我根本不可能找到。
总结一下:
- Valgrind:慢但全面,适合深度排查
- ASan:快但略粗糙,适合日常开发
- GDB:灵活但需要经验,适合现场分析
三者互补,缺一不可。
好了,这一章的内容就到这里。记住,工具只是辅助,真正重要的是你对内存模型的理解。多动手,多踩坑,慢慢就有感觉了。