23、运行时检查:AddressSanitizer、UndefinedBehaviorSanitizer、Valgrind使用、运行时边界检查

运行时检查,说白了就是让工具在程序跑起来的时候,帮你盯着内存和逻辑有没有出格。我见过太多代码,编译时一声不吭,一跑就崩,或者更糟——跑得挺欢,但隔三差五出个诡异bug。嗯,这时候你就需要一套趁手的运行时检查工具了。

我个人习惯,在开发阶段就把AddressSanitizer、UndefinedBehaviorSanitizer和Valgrind这三样东西安排上。它们就像三个不同风格的保安,各有各的绝活。今天咱们就一个一个聊透。

AddressSanitizer(ASan)——内存越界的火眼金睛

AddressSanitizer,简称ASan,是Google出品的内存错误检测工具。它内嵌在GCC和Clang的编译流程里,用起来特别简单:加个编译选项就行。

它的核心原理,是在你的程序周围布下一圈“红区”(redzone)。每次你访问内存,ASan都会检查你是不是踩到了红区上。一旦越界,立刻报错,精确到文件名和行号。

核心能力:

  • 检测堆内存越界(malloc出来的数组访问超了)
  • 检测栈内存越界(局部数组访问超了)
  • 检测全局变量越界
  • 检测释放后使用(use-after-free)
  • 检测双重释放(double-free)

怎么用?看代码:

// 编译时加上 -fsanitize=address
// gcc -fsanitize=address -g -o test test.c

#include <stdlib.h>

int main() {
    int *arr = (int*)malloc(10 * sizeof(int));
    arr[10] = 42;  // 越界了!arr只有0-9
    free(arr);
    free(arr);     // 双重释放!
    return 0;
}

运行这个程序,ASan会直接告诉你:heap-buffer-overflow,在test.c:7。然后还会打印出调用栈。我在项目中遇到过好几次,明明逻辑看起来没问题,但就是偶发崩溃。一开ASan,立马定位到是某个循环的边界条件写错了。

小技巧:ASan会占用更多内存(大约2-3倍),也会让程序变慢(大约2倍)。所以只在开发和测试阶段开启,别带到生产环境。

UndefinedBehaviorSanitizer(UBSan)——未定义行为的照妖镜

C语言里有很多“未定义行为”(Undefined Behavior)。编译器遇到这些行为,可以自由发挥——它可能正常工作,也可能直接崩掉,甚至可能在你老板面前格式化硬盘。你想想看,这多吓人。

UBSan就是专门抓这些未定义行为的。它也是编译期插入检查代码,运行时一旦触发未定义行为,立刻报警。

常见的未定义行为包括:

  • 有符号整数溢出(比如INT_MAX + 1)
  • 除零操作
  • 空指针解引用
  • 移位操作超过位宽
  • 数组下标越界(C标准里这是未定义行为)
// 编译时加上 -fsanitize=undefined
// gcc -fsanitize=undefined -g -o test test.c

#include <limits.h>

int main() {
    int x = INT_MAX;
    int y = x + 1;  // 有符号整数溢出,未定义行为!
    return 0;
}

UBSan会输出类似:runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'。清清楚楚,明明白白。

注意:UBSan和ASan可以一起用。编译时加上-fsanitize=address,undefined就行。我建议你直接把这个组合加到项目的默认编译配置里。

Valgrind——内存问题的老牌侦探

Valgrind是个更重量级的工具。它不依赖编译选项,而是直接运行你的程序,在虚拟机里模拟执行,逐条指令检查内存访问。代价就是——慢,非常慢。程序跑起来可能慢10-20倍。

但它的能力也更强:

  • 检测未初始化的内存读取
  • 检测内存泄漏(malloc了没free)
  • 检测无效的free
  • 检测栈溢出(这个ASan也能做)
  • 检测线程相关的竞态条件(用Helgrind工具)

用法很简单:

// 先正常编译,加-g保留调试信息
// gcc -g -o test test.c
// 然后用valgrind运行
// valgrind --leak-check=full ./test

#include <stdlib.h>

int main() {
    int *p = (int*)malloc(100);
    // 忘记free了!
    return 0;
}

Valgrind会报告:LEAK SUMMARY: definitely lost: 100 bytes in 1 blocks。我曾经在一个遗留项目里,用Valgrind扫了一遍,发现了几十处内存泄漏。修复之后,程序的内存占用从持续增长变成了稳定在一个值上。

我的建议:Valgrind适合做深度检查,尤其是内存泄漏。但因为它太慢,不适合跑单元测试。我一般是在集成测试或者压力测试前,用Valgrind跑一遍关键路径。

运行时边界检查——自己动手,丰衣足食

除了工具,我们还可以在代码里主动做边界检查。尤其是在嵌入式环境里,ASan和Valgrind可能跑不起来(资源受限),这时候就得靠我们自己了。

常见的做法:

  • 封装数组访问函数,每次访问前检查下标
  • 使用断言(assert)在调试阶段检查边界
  • 在结构体里记录缓冲区长度,每次操作时比对
#include <assert.h>
#include <stddef.h>

// 带边界检查的数组访问
int safe_get(int *arr, size_t len, size_t index) {
    assert(index < len && "数组下标越界!");
    return arr[index];
}

// 或者更温和的方式:返回错误码
int safe_get_soft(int *arr, size_t len, size_t index, int *out) {
    if (index >= len) {
        return -1;  // 越界
    }
    *out = arr[index];
    return 0;
}

我个人习惯,在开发阶段用assert,发布版本里换成带错误码的版本。这样既保证了调试时的安全性,又不会在正式运行时因为assert而崩溃。

避坑指南:我曾经在一个项目里,因为没做边界检查,导致一个缓冲区溢出覆盖了相邻的变量。那个变量恰好是任务调度器的状态标志。结果系统随机死机,查了整整三天。从那以后,我所有涉及数组的操作,都强制加边界检查。

知识体系总览

下面这张图,帮你理清运行时检查的脉络:

运行时检查工具与边界防护 AddressSanitizer 编译期插桩,检测越界/UAF UBSan 编译期插桩,检测未定义行为 Valgrind 运行时模拟,检测内存泄漏 ASan 检测能力 • 堆/栈/全局越界 • use-after-free • double-free UBSan 检测能力 • 整数溢出 • 除零/空指针 • 移位越界 Valgrind 检测能力 • 内存泄漏 • 未初始化读取 • 无效free 运行时边界检查(手动防御) assert + 带错误码的封装函数 开发阶段:ASan + UBSan + 手动边界检查 | 集成测试:Valgrind

总结一下我的经验:开发阶段,ASan和UBSan常开,配合手动边界检查,基本能挡住90%的内存问题。到了集成测试阶段,用Valgrind做一次深度扫描,把内存泄漏和未初始化的问题一网打尽。这套组合拳打下来,你的代码会结实很多。

嗯,今天就聊到这儿。记住,运行时检查不是银弹,但它绝对是你防御式编程工具箱里最趁手的几件家伙之一。


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