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 加到了日常构建里。

注意: 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,但偶尔会是随机值,导致指针访问异常。嗯,从那以后,我再也不敢轻视「未初始化」这三个字了。

知识体系图

下面这张图展示了三种工具在内存错误检测中的定位和关系:

C语言内存错误检测工具体系 C 程序 ASAN 越界 / 释放后使用 / 泄漏 UBSan 整数溢出 / 除零 / 移位越界 MSan 未初始化内存使用 堆栈信息 + 错误类型 未定义行为详情 未初始化变量位置 使用建议 日常开发 → ASAN | 提交前 → UBSan | 集成测试 → MSan
一句话总结: ASAN 抓越界和泄漏,UBSan 抓未定义行为,MSan 抓未初始化。三个一起用,基本能覆盖 90% 以上的内存相关 bug。

公众号:蓝海资料掘金营,微信 deep3321