C++ 太复杂了!即使编写的时候非常小心,也不能完全杜绝内存安全问题的出现。就算是算法竞赛时编写的百余行简短代码,也时不时会有数组越界、变量未初始化、未定义行为等问题困扰我们。
在算法竞赛短短几个小时中,花费时间检查这种与算法本身无关的错误,无疑是浪费时间。那有没有什么工具能帮助我们检查这类错误呢?
Sanitizers 是由 Google 发起的一套开源工具集,包含了数个帮助开发者查找 C++ 代码中疑难杂症的工具。其中有些工具,在算法竞赛领域也十分实用。
AddressSanitizer
AddressSanitizer 简称 ASan,可以帮助我们检查代码中的内存使用问题。
它从 LLVM 3.1 起被集成到 Clang 中,从 GCC 4.8 起被集成到 GCC 中。你可以通过在编译参数中添加 -fsanitize=address
来使用它。
解引用悬垂指针
来看一段显然存在问题的代码:
int main() {
char *x = new char[10];
delete[] x;
return x[5];
}
我们申请了一段长度为 10 的 char
数组,随后释放了它,这时我们再取出数组的第 5 个元素——然而这个位置的内存已经被我们释放掉了!
像这种使用已经被释放的内存的问题,我们称之为解引用悬垂指针。让我们试试 ASan 能不能帮我们发现这个问题吧!
首先尝试编译这份代码:
g++ main.cpp -o main -g -fsanitize=address
代码顺利编译,看上去什么都没有发生?不要着急,我们试试执行编译出的程序:
./main
程序崩溃了!同时给出了很长一段错误信息:
=================================================================
==19311==ERROR: AddressSanitizer: heap-use-after-free on address 0x602000000015 at pc 0x562f45c941de bp 0x7ffc3b87b640 sp 0x7ffc3b87b630
READ of size 1 at 0x602000000015 thread T0
#0 0x562f45c941dd in main /home/yur/Workspace/Codes/main.cpp:4
#1 0x7ff979dca28f (/usr/lib/libc.so.6+0x2328f)
#2 0x7ff979dca349 in __libc_start_main (/usr/lib/libc.so.6+0x23349)
#3 0x562f45c940a4 in _start ../sysdeps/x86_64/start.S:115
0x602000000015 is located 5 bytes inside of 10-byte region [0x602000000010,0x60200000001a)
freed by thread T0 here:
#0 0x7ff97a38e35a in operator delete[](void*) /usr/src/debug/gcc/libsanitizer/asan/asan_new_delete.cpp:155
#1 0x562f45c941a1 in main /home/yur/Workspace/Codes/main.cpp:3
#2 0x7ff979dca28f (/usr/lib/libc.so.6+0x2328f)
previously allocated by thread T0 here:
#0 0x7ff97a38d7f2 in operator new[](unsigned long) /usr/src/debug/gcc/libsanitizer/asan/asan_new_delete.cpp:98
#1 0x562f45c9418a in main /home/yur/Workspace/Codes/main.cpp:2
#2 0x7ff979dca28f (/usr/lib/libc.so.6+0x2328f)
SUMMARY: AddressSanitizer: heap-use-after-free /home/yur/Workspace/Codes/main.cpp:4 in main
Shadow bytes around the buggy address:
0x0c047fff7fb0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0c047fff7fc0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0c047fff7fd0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0c047fff7fe0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0c047fff7ff0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x0c047fff8000: fa fa[fd]fd fa fa fa fa fa fa fa fa fa fa fa fa
0x0c047fff8010: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x0c047fff8020: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x0c047fff8030: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x0c047fff8040: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x0c047fff8050: 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
Stack use after scope: f8
Global redzone: f9
Global init order: f6
Poisoned by user: f7
Container overflow: fc
Array cookie: ac
Intra object redzone: bb
ASan internal: fe
Left alloca redzone: ca
Right alloca redzone: cb
==19311==ABORTING
通过这段错误信息,我们可以看到 ASan 告诉我们,在 main.cpp
的第 4 行出现了一个 heap-use-after-free
错误,也就是我们在释放了某块堆内存后又使用了它。
不仅如此,它还告诉我们这块内存是在 main.cpp
的第 2 行申请的,在 main.cpp
的第 3 行被释放掉,并且打印出了这三个地方的调用栈!
也就是说,ASan 很好地完成了它的工作,但是是在运行时而不是编译时。这听上去有些令人沮丧,但这已经是最好的结果了,因为很多运行时错误只有在特定的输入下才会出现,在编译时发现它们是不可能的!
换一个角度想想,利用 ASan,我们可以在遇到运行时错误的时候,快速定位到错误的原因,以及发生错误的位置,这已经极大地方便调试了!
全局数组越界
看到这里,你可能会想:好吧 ASan 确实是个很棒的工具,可是在算法竞赛中我们真的很少很少会手动分配堆内存,事实上如果不使用 STL,我的程序可能根本不会用到任何堆内存,那么 ASan 对我来说不就没有用处了吗?
我们来看一段更贴近算法竞赛的,显然存在问题的代码:
#include <iostream>
constexpr int N = 100010;
int n, a[N];
int main() {
for (int i = 0; i < N; ++ i)
a[i] = i;
std::cin >> n;
std::cout << a[n] << std::endl;
return 0;
}
我申请了一个长为 $N$ 的全局数组——这种做法在算法竞赛中很常见,然后我将数组中每个位置的值初始化为它的下标。
接着我们输入了一个 $n$,并输出下标为 $n$ 的位置的值。这太糟糕了!当输入的 $n$ 足够大的时候,这段代码显然会出现数组越界!
让我们再看看 ASan 的表现:
不出所料,ASan 也能检测到这个错误,它告诉我们在 main.cpp
的第 12 行出现了一个 global-buffer-overflow
错误:
=================================================================
==65354==ERROR: AddressSanitizer: global-buffer-overflow on address 0x558afdb55f68 at pc 0x558afdaf12e3 bp 0x7fff39a9d580 sp 0x7fff39a9d570
READ of size 4 at 0x558afdb55f68 thread T0
#0 0x558afdaf12e2 in main /home/yur/Workspace/Codes/main.cpp:12
#1 0x7fc18ed9c28f (/usr/lib/libc.so.6+0x2328f)
#2 0x7fc18ed9c349 in __libc_start_main (/usr/lib/libc.so.6+0x23349)
#3 0x558afdaf1124 in _start ../sysdeps/x86_64/start.S:115
0x558afdb55f68 is located 0 bytes to the right of global variable 'a' defined in 'main.cpp:5:8' (0x558afdaf44c0) of size 400040
SUMMARY: AddressSanitizer: global-buffer-overflow /home/yur/Workspace/Codes/main.cpp:12 in main
Shadow bytes around the buggy address:
0x0ab1dfb62b90: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0ab1dfb62ba0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0ab1dfb62bb0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0ab1dfb62bc0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0ab1dfb62bd0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x0ab1dfb62be0: 00 00 00 00 00 00 00 00 00 00 00 00 00[f9]f9 f9
0x0ab1dfb62bf0: f9 f9 f9 f9 00 00 00 00 00 00 00 00 00 00 00 00
0x0ab1dfb62c00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0ab1dfb62c10: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0ab1dfb62c20: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0ab1dfb62c30: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
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
Stack use after scope: f8
Global redzone: f9
Global init order: f6
Poisoned by user: f7
Container overflow: fc
Array cookie: ac
Intra object redzone: bb
ASan internal: fe
Left alloca redzone: ca
Right alloca redzone: cb
==65354==ABORTING
以及…
除此之外,ASan 还能检测堆栈上数组的越界、使用离开作用域时被析构的对象、初始化顺序导致的 bug、内存泄漏等疑难杂症。由于它们在算法竞赛中出现得较少,这里就不多赘述。
值得一提的是,既然 ASan 是在运行时进行的检测,那它就必然会对程序的效率产生一定影响。经过测试,开启 ASan 后的程序效率平均只有原先的一半左右。这个损失会根据平台和程序的不同而有所不同,但无论如何,请记得在进行重视效率的测试(如测试你的程序执行大样例时是否会超时)时关掉 ASan 以保证测试结果准确。
UndefinedBehaviorSanitizer
UndefinedBehaviorSanitizer 简称 UBSan,可以帮助我们检查代码中的未定义行为。
它从 LLVM 3.3 起被集成到 Clang 中,从 GCC 4.9 起被集成到 GCC 中。你可以通过在编译参数中添加 -fsanitize=undefined
来使用它。
按位位移溢出
int main() {
int x = 42;
x <<= 27;
return 0;
}
上面的代码,按位左移的时候会溢出 int
的上界,这是未定义行为。如果你使用了 UBSan,它会在运行时报错:
main.cpp:3:5: runtime error: left shift of 42 by 27 places cannot be represented in type 'int'
数值溢出
using i64 = long long;
constexpr int mod = 998244353;
int main() {
int x = 114514;
int y = 1919810;
x = ((i64)x * x + y * y) % mod;
return 0;
}
上面的代码,y * y
的部分会溢出 int
的上界,这是未定义行为。如果你使用了 UBSan,它会在运行时报错:
main.cpp:9:23: runtime error: signed integer overflow: 1919810 * 1919810 cannot be represented in type 'int'
使用空指针
#include <iostream>
struct Node {
Node *left, *right;
int value;
Node(int _value):
left(nullptr),
right(nullptr),
value(_value) { }
~Node() {
delete left;
delete right;
}
};
void print(Node *now) {
if (now->left != nullptr)
print(now->left);
printf("%d ", now->value);
if (now->right != nullptr)
print(now->right);
}
int main() {
Node *root = new Node(5);
root->left = new Node(3);
root->right = new Node(8);
root->left->right = new Node(4);
print(root);
delete root;
print(nullptr);
return 0;
}
上面的代码是使用指针实现的简单的二叉树,其中 print
函数存在一个致命的问题:如果传入的参数本身是空指针,它就会在 now->left
这一步对空指针取左儿子,这是未定义行为。如果你使用了 UBSan,它会在运行时报错:
main.cpp:19:12: runtime error: member access within null pointer of type 'struct Node'
如果希望 UBSan 打印出错位置的调用栈,可以在执行程序时加上 UBSAN_OPTIONS=print_stacktrace=1
环境变量:
除此之外…
UBSan 还能检测十数种未定义行为,并且每种未定义行为是否需要被检测可以通过编译参数自由控制。与 ASan 相同的是,UBSan 也会影响程序运行效率,在实际使用时请务必注意!
ThreadSanitizer
ThreadSanitizer 简称 TSan,可以帮助我们检查多线程代码中的数据竞争。
算法竞赛不涉及多线程程序,因此这里不讨论 TSan 相关内容,有兴趣可以自行了解。
MemorySanitizer
MemorySanitizer 简称 MSan,可以帮助我们检查对未初始化的值的使用。
它从 LLVM 4.0 起被集成到 Clang 中。而不幸的是,GCC 并没有集成这个工具。你只能通过在 Clang 中使用 -fsanitize=memory
参数来使用它。
#include <iostream>
// please ignore unused `argv` :)
int main(int argc, char *argv[]) {
int a[10];
for (int i = 2; i < 10; ++ i)
a[i] = i;
int x = a[argc];
int y = x * 2;
if (y == 0) {
std::cout << "Hello" << std::endl;
} else {
std::cout << "MSan" << std::endl;
}
return 0;
}
上面的代码中,当我们通过 ./main
执行程序时,argc
的值将会为 $1$,然而我们没有初始化 a[1]
,MSan 将会在运行时报错:
==95226==WARNING: MemorySanitizer: use-of-uninitialized-value
#0 0x55a96ddb3c96 in main /home/yur/Workspace/Codes/main.cpp:12:7
#1 0x7f1b4f9d628f (/usr/lib/libc.so.6+0x2328f) (BuildId: 1e94beb079e278ac4f2c8bce1f53091548ea1584)
#2 0x7f1b4f9d6349 in __libc_start_main (/usr/lib/libc.so.6+0x23349) (BuildId: 1e94beb079e278ac4f2c8bce1f53091548ea1584)
#3 0x55a96dd221d4 in _start /build/glibc/src/glibc/csu/../sysdeps/x86_64/start.S:115
SUMMARY: MemorySanitizer: use-of-uninitialized-value /home/yur/Workspace/Codes/main.cpp:12:7 in main
Exiting
可以发现,MSan 直到第 12 行 if (y == 0)
处才报错,这是因为 MSan 不会在读取未初始化值时立即报错,而是会等到这个值影响程序执行时才报错!在此之前,它会跟踪并记录这个未初始化值产生的影响。
如果一个未初始化值经过很远的传播后才引发 MSan 报错,你可能得花很大力气才能找到这个值是在哪里产生的。这时你可以选择在编译时加上 -fsanitize-memory-track-origins
参数,这样 MSan 报错时会从最初的未初始化值开始追踪,打印它的每一次移动,直到来到报错的位置:
==96812==WARNING: MemorySanitizer: use-of-uninitialized-value
#0 0x559164b57fe0 in main /home/yur/Workspace/Codes/main.cpp:12:7
#1 0x7fd4af63f28f (/usr/lib/libc.so.6+0x2328f) (BuildId: 1e94beb079e278ac4f2c8bce1f53091548ea1584)
#2 0x7fd4af63f349 in __libc_start_main (/usr/lib/libc.so.6+0x23349) (BuildId: 1e94beb079e278ac4f2c8bce1f53091548ea1584)
#3 0x559164ac61d4 in _start /build/glibc/src/glibc/csu/../sysdeps/x86_64/start.S:115
Uninitialized value was stored to memory at
#0 0x559164b57f5d in main /home/yur/Workspace/Codes/main.cpp:10:7
#1 0x7fd4af63f28f (/usr/lib/libc.so.6+0x2328f) (BuildId: 1e94beb079e278ac4f2c8bce1f53091548ea1584)
Uninitialized value was stored to memory at
#0 0x559164b57e95 in main /home/yur/Workspace/Codes/main.cpp:9:7
#1 0x7fd4af63f28f (/usr/lib/libc.so.6+0x2328f) (BuildId: 1e94beb079e278ac4f2c8bce1f53091548ea1584)
Uninitialized value was created by an allocation of 'a' in the stack frame of function 'main'
#0 0x559164b57920 in main /home/yur/Workspace/Codes/main.cpp:4
SUMMARY: MemorySanitizer: use-of-uninitialized-value /home/yur/Workspace/Codes/main.cpp:12:7 in main
Exiting
Work with sanitizers
上述例子中的所有代码,在开启 GCC 最高级别警告的情况下都不会产生任何警告(最后一个例子的 unused parameter
除外),它们几乎只能在运行时才能发现问题,而这也就是 Sanitizers 的职责所在。
如果你在调试的时候遇到了恼人的「段错误」(运行时错误),而你又没有头绪,不妨试试 Sanitizers 吧。只需要在编译时加上 -fsanitize=address,undefined
参数,然后重新执行样例,兴许就能发现问题了。
才疏学浅,如有纰漏,欢迎指出!
9 条评论
2025年10月新盘 做第一批吃螃蟹的人coinsrore.com
新车新盘 嘎嘎稳 嘎嘎靠谱coinsrore.com
新车首发,新的一年,只带想赚米的人coinsrore.com
新盘 上车集合 留下 我要发发 立马进裙coinsrore.com
做了几十年的项目 我总结了最好的一个盘(纯干货)coinsrore.com
新车上路,只带前10个人coinsrore.com
新盘首开 新盘首开 征召客户!!!coinsrore.com
新项目准备上线,寻找志同道合的合作伙伴coinsrore.com
新车即将上线 真正的项目,期待你的参与coinsrore.com
新盘新项目,不再等待,现在就是最佳上车机会!coinsrore.com
新盘新盘 这个月刚上新盘 新车第一个吃螃蟹!coinsrore.com
做了几十年的项目 我总结了最好的一个盘(纯干货)
新盘新盘 这个月刚上新盘 新车第一个吃螃蟹!coinsrore.com
部分语句稍显冗长,可精简以增强节奏感。
建议在开头增加背景铺垫,增强读者代入感。
作者的才华横溢,让这篇文章成为了一篇不可多得的艺术品。
立意高远,以小见大,引发读者对社会/人性的深层共鸣。
作者以简洁明了的语言,传达了深刻的思想和情感。