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 字节的位置。非常清晰。

我的小技巧: Valgrind 跑起来会慢 10-20 倍。我一般不会全程开着它跑,而是写个测试用例,专门触发可疑路径。另外,记得编译时加 -g 选项,否则看不到行号。

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 行
注意: ASan 会显著增加二进制文件大小(大约 2-3 倍)。生产环境部署时记得去掉 -fsanitize=address。另外,ASan 和 Valgrind 不能同时使用,它们是互斥的。

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 已经写到了堆内存的后面。这就是典型的越界。

我的调试流程:
  1. Valgrind 跑一遍,定位问题代码行
  2. ASan 编译,确认问题是否稳定复现
  3. GDB 打断点,观察内存变化
  4. 修复后,再用 Valgrind 验证一遍

这套流程我用了十几年,基本没失手过。

4.4 三件套对比

你可能会问:这三个工具到底该用哪个?我的建议是:

  • 开发阶段: 用 ASan。编译快,运行快,能快速迭代。
  • 集成测试: 用 Valgrind。检测更全面,能发现 ASan 漏掉的问题。
  • 疑难杂症: 用 GDB。深入现场,看内存布局,理解问题本质。

我曾经遇到过一个非常诡异的 bug:程序运行 72 小时后必崩。用 ASan 跑了一周没抓到,用 Valgrind 跑了一次就定位到了——是一个全局数组在某个极端路径下越界了 1 个字节。那个字节恰好覆盖了另一个全局变量的最低位,导致逻辑判断出错。这种问题,没有 Valgrind 我根本不可能找到。

总结一下:

  • Valgrind:慢但全面,适合深度排查
  • ASan:快但略粗糙,适合日常开发
  • GDB:灵活但需要经验,适合现场分析

三者互补,缺一不可。

内存越界排查工具链 Valgrind AddressSanitizer GDB 模拟执行,全面检测 编译插桩,运行快速 现场分析,灵活调试 集成测试 / 深度排查 开发阶段 / CI 流程 疑难杂症 / 现场分析 使用建议 开发用 ASan → 测试用 Valgrind → 疑难用 GDB 三者互补,覆盖 90% 内存问题

好了,这一章的内容就到这里。记住,工具只是辅助,真正重要的是你对内存模型的理解。多动手,多踩坑,慢慢就有感觉了。

可可精品资料库