26、内存错误检测:ASAN、UBSan、MSan 使用
内存错误,是 C 语言开发者的老朋友了。说难听点,每个写过三个月以上 C 的人,都至少被段错误(Segmentation Fault)折磨过一次。我自己刚入行那会儿,为了找一个野指针的 bug,硬是熬了三个通宵。后来才知道,原来有工具可以自动帮我定位这类问题。
今天聊的三个工具——ASAN、UBSan、MSan,就是专门干这个的。它们不是魔法,但用好了,能让你少掉 80% 的头发。
为什么需要内存错误检测工具?
C 语言的内存管理,说白了就是「手动挡」。你申请了内存,就得自己释放;你访问了数组,就得自己保证不越界。编译器不会帮你检查这些,运行时也不会主动报错——除非你踩到了红线,程序直接崩溃。
但更可怕的是那种「没崩溃,但结果不对」的情况。我遇到过一回,一个全局变量莫名其妙被改了值,查了两天才发现是隔壁线程的 memcpy 越界写到了这里。这种 bug,靠肉眼几乎不可能找到。
所以我们需要工具。它们会在程序运行时插入检查代码,一旦发现非法操作,立刻打印出详细的调用栈和错误信息。
ASAN:AddressSanitizer
ASAN 是这三个里最常用的。它能检测:
- 堆内存越界(比如 malloc 了 10 字节,却写了第 11 个)
- 栈内存越界(局部数组访问超了)
- 全局变量越界
- 释放后使用(Use-After-Free)
- 双重释放(Double Free)
- 内存泄漏(Memory Leak)
使用方式非常简单。用 GCC 或 Clang 编译时,加上 -fsanitize=address 即可:
gcc -fsanitize=address -g -o test test.c
./test
看一个例子:
#include <stdlib.h>
int main() {
int *p = (int*)malloc(10 * sizeof(int));
p[10] = 42; // 越界了!
free(p);
return 0;
}
编译运行后,ASAN 会输出类似这样的信息:
==12345==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x6020000000f8
WRITE of size 4 at 0x6020000000f8 thread T0
#0 0x4006e4 in main /home/user/test.c:5
#1 0x7f1234567890 in __libc_start_main (...)
0x6020000000f8 is located 0 bytes after 10-byte region
allocated by thread T0 here:
#0 0x4006c0 in malloc (...)
#1 0x4006d8 in main /home/user/test.c:4
它直接告诉了你三件事:
- 错误类型:堆缓冲区溢出
- 出错位置:test.c 第 5 行
- 内存是在哪里申请的:test.c 第 4 行
我个人习惯在开发阶段一直开着 ASAN。虽然运行会慢一些(大约 2 倍),但换来的是 bug 的快速定位,非常值。
ASAN_OPTIONS=detect_leaks=1 环境变量来开启内存泄漏检测。我一般会在 CI 里加上这个选项。
UBSan:UndefinedBehaviorSanitizer
UBSan 检测的是「未定义行为」。C 标准里有很多行为是未定义的,比如:
- 有符号整数溢出
- 除零操作
- 空指针解引用
- 移位操作超过位宽
- 数组下标为负
这些行为在语法上没错,但结果不可预测。不同编译器、不同优化级别下,结果可能完全不同。
使用方式:
gcc -fsanitize=undefined -g -o test test.c
./test
举个例子:
#include <stdio.h>
int main() {
int x = 2147483647;
int y = x + 1; // 有符号整数溢出,未定义行为
printf("%d\n", y);
return 0;
}
UBSan 会输出:
test.c:5:13: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
我记得有一次,一个同事写的代码在 Debug 模式下跑得好好的,Release 模式下就崩了。查了半天,发现是整数溢出导致编译器优化时做了不同的假设。从那以后,我们团队就把 UBSan 加到了日常构建里。
-fsanitize=undefined -fno-sanitize-recover=all,这样一旦检测到未定义行为,程序会直接终止并打印错误。
MSan:MemorySanitizer
MSan 检测的是「使用未初始化内存」。这个 bug 比较隐蔽——变量没赋值就直接拿来用,结果可能是随机的,也可能恰好是 0,导致问题时隐时现。
使用方式:
gcc -fsanitize=memory -g -o test test.c
./test
看一个例子:
#include <stdio.h>
int main() {
int a;
if (a > 0) { // a 未初始化
printf("positive\n");
}
return 0;
}
MSan 会输出:
test.c:5:9: runtime error: use of uninitialized value 'a'
这里有个坑:MSan 要求所有代码(包括依赖的库)都用 -fsanitize=memory 编译,否则会产生误报。我刚开始用的时候没注意这个,结果一堆假阳性,排查了半天才发现是第三方库没重新编译。
- ASAN 检测内存越界、释放后使用、内存泄漏——最常用,建议开发时一直开着
- UBSan 检测未定义行为——适合在 CI 中运行,捕获潜在的移植性问题
- MSan 检测未初始化内存——需要全量编译,适合在干净的构建环境中使用
三者对比
| 工具 | 检测目标 | 性能开销 | 编译选项 | 注意事项 |
|---|---|---|---|---|
| ASAN | 越界、释放后使用、泄漏 | 约 2x 慢 | -fsanitize=address | 内存占用增加约 2-3 倍 |
| UBSan | 整数溢出、除零、移位越界等 | 约 1.2x 慢 | -fsanitize=undefined | 建议配合 -fno-sanitize-recover |
| MSan | 未初始化内存使用 | 约 2x 慢 | -fsanitize=memory | 所有代码必须重新编译 |
实际使用建议
我个人的工作流是这样的:
- 日常开发:开着 ASAN。它覆盖的场景最多,性价比最高。
- 提交代码前:跑一遍 UBSan。很多未定义行为在特定平台下才会暴露,提前发现能省很多事。
- 集成测试:加上 MSan。尤其是那些从堆上分配内存后直接使用的代码,很容易踩坑。
曾经有一次,我在一个嵌入式项目里遇到了随机崩溃的问题。ASAN 和 UBSan 都没报错,最后是 MSan 抓到了一个结构体成员未初始化的问题。那个成员在大部分情况下是 0,但偶尔会是随机值,导致指针访问异常。嗯,从那以后,我再也不敢轻视「未初始化」这三个字了。
知识体系图
下面这张图展示了三种工具在内存错误检测中的定位和关系:
公众号:蓝海资料掘金营,微信 deep3321