C++ Sanitizers (ASan, UBSan, MSan):编译期运行时错误检测

好的,各位听众,大家好!欢迎来到今天的“C++ Sanitizers:编译期运行时错误检测”专题讲座。今天咱们不搞那些高大上的理论,就聊点实实在在的,能帮大家少掉头发的工具。

开场白:谁还没踩过几个坑?

咱们写C++的,谁还没遇到过几个让人抓狂的bug?内存泄漏、野指针、数组越界……这些坑,一个比一个深,一个比一个隐蔽。有时候,代码跑起来好像没问题,但一上线,立马给你整出幺蛾子。

别怕!今天咱们就来聊聊C++ Sanitizers,这是一套强大的工具,能在编译期和运行时帮你检测出这些常见的错误。有了它们,就像给你的代码装上了“体检仪”,提前发现问题,避免上线“猝死”。

什么是Sanitizers?

Sanitizers,直译过来就是“消毒器”、“净化器”。在C++的世界里,它们是一组编译器内置的工具,专门用来检测各种类型的运行时错误。它们通过在编译时插入额外的检查代码,并在程序运行时进行监控,一旦发现问题,立即报错,让你debug起来事半功倍。

目前主流的Sanitizers主要有三种:

  • AddressSanitizer (ASan): 主要检测内存错误,比如堆栈溢出、使用释放后的内存(use-after-free)、双重释放(double-free)等。
  • UndefinedBehaviorSanitizer (UBSan): 主要检测未定义行为,比如有符号整数溢出、空指针解引用、访问未初始化的变量等。
  • MemorySanitizer (MSan): 主要检测使用未初始化的内存。

为什么选择Sanitizers?

可能有人会问,我已经有Valgrind、静态代码分析工具了,为什么还要用Sanitizers?

  • 速度快: Sanitizers是编译器内置的,性能开销比Valgrind小得多,更适合在日常开发和测试中使用。
  • 精度高: Sanitizers能精确定位到出错的代码行,比静态代码分析工具更准确。
  • 易于使用: 只需要在编译时加上几个flag,就能启用Sanitizers,非常方便。
  • 集成度高: Sanitizers与GCC、Clang等主流编译器完美集成,无需额外安装。

Sanitizers实战演练

光说不练假把式,接下来咱们就通过几个实际的例子,来演示一下Sanitizers的用法。

1. AddressSanitizer (ASan):内存错误检测

ASan是检测内存错误的一把好手。咱们先来看一个经典的例子:

#include <iostream>

int main() {
  int *ptr = new int[10];
  ptr[10] = 123; // 数组越界!
  delete[] ptr;
  return 0;
}

这段代码很简单,就是分配了一个大小为10的整型数组,然后尝试访问索引为10的元素,这明显是一个数组越界错误。

接下来,咱们用ASan来编译这段代码:

g++ -fsanitize=address -fno-omit-frame-pointer -g  main.cpp -o main
  • -fsanitize=address: 启用ASan。
  • -fno-omit-frame-pointer: 保留帧指针,方便调试。
  • -g: 生成调试信息,方便定位错误。

编译完成后,运行程序:

./main

你会看到类似下面的错误信息:

==12345==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x602000000040 at pc 0x000000400788 bp 0x7ffc00000000 sp 0x7ffc00000000
WRITE of size 4 at 0x602000000040 thread T0
    #0 0x400787 in main /path/to/main.cpp:5
    #1 0x7ffff7a000b3 in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x270b3)
    #2 0x40063e in _start (/path/to/main+0x40063e)

0x602000000040 is located 0 bytes to the right of 40-byte region [0x602000000010,0x602000000038)
defined by allocation at:
    #0 0x4006ff in operator new[](unsigned long) (/path/to/main+0x4006ff)
    #1 0x400751 in main /path/to/main.cpp:4
    #2 0x7ffff7a000b3 in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x270b3)

Shadow bytes around the buggy address:
  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>00 00 00 00 00 00 00 fa fa fa fa fa fa fa fa fa
  fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
Shadow byte legend (one shadow byte represents 8 application bytes):
  Addressable:           00
  Partially addressable: 01 02 03 04 05 06 07
  Heap left redzone:       fa
  Freed heap region:       fd
  Stack left redzone:      f1
  Stack mid redzone:       f2
  Stack right redzone:     f3
  Stack after return:      f5
  Non-call frame:        f4
  Thread argument:       f0
  Kernel memory:         fe
  Shadow memory dynamics allocator: ff
  Zero poisoned shadow:      00
==12345==ABORTING

ASan的错误信息非常详细,它告诉你:

  • 错误类型:heap-buffer-overflow,堆缓冲区溢出。
  • 出错地址:0x602000000040
  • 出错位置:/path/to/main.cpp:5,第5行代码。
  • 分配内存的位置:/path/to/main.cpp:4,第4行代码。

有了这些信息,你就能快速定位到错误,轻松修复bug。

再看一个例子,use-after-free:

#include <iostream>

int main() {
  int *ptr = new int(10);
  delete ptr;
  *ptr = 20; // 使用释放后的内存!
  return 0;
}

同样,用ASan编译运行:

g++ -fsanitize=address -fno-omit-frame-pointer -g  main.cpp -o main
./main

你会看到类似下面的错误信息:

==23456==ERROR: AddressSanitizer: use-after-free READ of size 4 at 0x602000000010 thread T0
    #0 0x400787 in main /path/to/main.cpp:6
    #1 0x7ffff7a000b3 in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x270b3)
    #2 0x40063e in _start (/path/to/main+0x40063e)

0x602000000010 is located 0 bytes inside of 4-byte region [0x602000000010,0x602000000014)
freed by thread T0 here:
    #0 0x40073f in operator delete(void*) (/path/to/main+0x40073f)
    #1 0x40077a in main /path/to/main.cpp:5
    #2 0x7ffff7a000b3 in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x270b3)

Shadow bytes around the buggy address:
  fa fa fa fa fa fa fa fa fd fd fd fd fd fd fd fd
=>fd fd fd fd fa fa fa fa fa fa fa fa fa fa fa fa
  fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
Shadow byte legend (one shadow byte represents 8 application bytes):
  Addressable:           00
  Partially addressable: 01 02 03 04 05 06 07
  Heap left redzone:       fa
  Freed heap region:       fd
  Stack left redzone:      f1
  Stack mid redzone:       f2
  Stack right redzone:     f3
  Stack after return:      f5
  Non-call frame:        f4
  Thread argument:       f0
  Kernel memory:         fe
  Shadow memory dynamics allocator: ff
  Zero poisoned shadow:      00
==23456==ABORTING

ASan同样会告诉你,你在第6行代码使用了已经释放的内存,并且告诉你这块内存是在第5行代码被释放的。

2. UndefinedBehaviorSanitizer (UBSan):未定义行为检测

UBSan专门用来检测各种未定义行为,这些行为可能在不同的编译器、不同的平台上表现不一致,非常难以调试。

咱们来看一个有符号整数溢出的例子:

#include <iostream>
#include <limits>

int main() {
  int x = std::numeric_limits<int>::max();
  x = x + 1; // 有符号整数溢出!
  std::cout << x << std::endl;
  return 0;
}

这段代码中,x被赋值为int类型的最大值,然后加1,这会导致有符号整数溢出,结果是未定义的。

用UBSan编译运行:

g++ -fsanitize=undefined -fno-omit-frame-pointer -g  main.cpp -o main
./main

你会看到类似下面的错误信息:

/path/to/main.cpp:6:9: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
-2147483648

UBSan会告诉你,在第6行代码发生了有符号整数溢出。

再看一个空指针解引用的例子:

#include <iostream>

int main() {
  int *ptr = nullptr;
  *ptr = 123; // 空指针解引用!
  return 0;
}

用UBSan编译运行:

g++ -fsanitize=undefined -fno-omit-frame-pointer -g  main.cpp -o main
./main

你会看到类似下面的错误信息:

/path/to/main.cpp:5:3: runtime error: null pointer dereference
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior /path/to/main.cpp:5:3

UBSan会告诉你,你在第5行代码解引用了一个空指针。

3. MemorySanitizer (MSan):未初始化内存检测

MSan用来检测使用未初始化的内存,这在C++中也是一个常见的错误。

#include <iostream>

int main() {
  int x; // 未初始化
  int y = x + 10; // 使用未初始化的内存!
  std::cout << y << std::endl;
  return 0;
}

用MSan编译运行:

g++ -fsanitize=memory -fno-omit-frame-pointer -g  main.cpp -o main
./main

你会看到类似下面的错误信息:

==12345== WARNING: MemorySanitizer: use-of-uninitialized-value
    #0 0x400787 in main /path/to/main.cpp:6
    #1 0x7ffff7a000b3 in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x270b3)
    #2 0x40063e in _start (/path/to/main+0x40063e)

  Uninitialized value was created by an allocation of 'x' in the stack frame of function 'main'
    #0 0x400751 in main /path/to/main.cpp:5
    #1 0x7ffff7a000b3 in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x270b3)
    #2 0x40063e in _start (/path/to/main+0x40063e)

SUMMARY: MemorySanitizer: use-of-uninitialized-value /path/to/main.cpp:6

MSan会告诉你,你在第6行代码使用了未初始化的变量x

Sanitizers的配置和使用技巧

  • 编译选项:

    • -fsanitize=address: 启用ASan。
    • -fsanitize=undefined: 启用UBSan。
    • -fsanitize=memory: 启用MSan。
    • -fno-omit-frame-pointer: 保留帧指针,方便调试。
    • -g: 生成调试信息。
  • 同时使用多个Sanitizers: 可以同时启用多个Sanitizers,比如-fsanitize=address,undefined
  • Suppressions: 有时候,Sanitizers可能会误报一些错误,或者你暂时不想修复某些错误,可以使用Suppressions来忽略这些错误。Suppressions的配置方法因Sanitizer而异,具体可以参考官方文档。
  • 性能开销: Sanitizers会带来一定的性能开销,ASan的开销相对较小,UBSan和MSan的开销较大。因此,建议在开发和测试阶段启用Sanitizers,在生产环境中关闭。
  • 与CI/CD集成: 将Sanitizers集成到你的CI/CD流程中,可以自动化检测代码中的错误,尽早发现问题。

Sanitizers的优缺点

特性 优点 缺点
ASan 快速,精确,易于使用,能检测多种内存错误 性能开销,可能会与某些库冲突
UBSan 能检测多种未定义行为,帮助你写出更健壮的代码 性能开销较大,可能会误报
MSan 能检测使用未初始化的内存,这在C++中是一个常见的错误 性能开销非常大,需要特殊编译选项,可能会与某些库冲突
总体 与编译器集成,使用方便,能尽早发现错误,提高代码质量 性能开销,可能会误报,需要了解Sanitizers的工作原理

一些注意事项

  • 编译时需要加上-fno-omit-frame-pointer-g选项,这样才能生成详细的调试信息,方便定位错误。
  • MSan的性能开销非常大,因此在生产环境中不建议使用。
  • Sanitizers可能会与其他调试工具冲突,比如GDB。
  • Sanitizers不能检测所有的错误,比如逻辑错误。

Sanitizers与其他工具的比较

工具 优点 缺点
Sanitizers 快速,精确,易于使用,与编译器集成 性能开销,可能会误报,不能检测所有的错误
Valgrind 功能强大,能检测多种内存错误,无需重新编译 性能开销非常大,不适合在日常开发中使用
静态代码分析工具 能在编译时发现错误,无需运行程序 可能会误报,需要配置和维护

总结

C++ Sanitizers是一套强大的工具,能帮助你检测出各种类型的运行时错误,提高代码质量,减少上线风险。虽然它们有一定的性能开销,但与它们带来的好处相比,这些开销是值得的。

希望今天的讲座能帮助大家更好地理解和使用C++ Sanitizers。记住,写代码就像盖房子,地基一定要打牢,Sanitizers就是帮你打牢地基的工具。

彩蛋

最后,给大家分享一句我经常用来安慰自己的话:

“Bug是程序员的朋友,它们让我们成长。”

当然,有了Sanitizers,咱们可以少交一些“朋友”。

谢谢大家!

Q&A环节

(接下来可以进行Q&A环节,回答听众提出的问题。)

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注