做 C++ 开发久了,有两类问题最让人头疼:一类是内存泄漏,另一类就是未定义行为(Undefined Behavior,简称 UB)。
关于内存泄漏,我们之前聊过不少,它通常还比较容易被发现。程序运行一段时间后,内存占用不断增长,用工具分析一下,往往能找到问题所在,也可以使用智能指针进行避免。
但 UB 就不一样了。
有时候,同一份代码在开发环境跑得好好的,部署到线上却突然崩溃;有时候,只是升级了一下编译器,重新编译之后,原本正常的程序就开始出现各种奇怪的问题。
更麻烦的是,问题可能根本不在刚刚修改的代码里,而是早就埋在项目中的其他地方。
今天挑几个遇到过的典型案例,看看这些问题到底是怎么回事。
概念
先解释一下什么是未定义行为。
看一段最简单的代码:
int* ptr = nullptr;
int value = *ptr;
这段代码有什么问题,应该不用多解释了:ptr 是一个空指针,却被直接解引用。在 C++ 中,这属于未定义行为。
所谓未定义行为,就是 C++ 标准没有规定程序在这种情况下应该产生什么结果。这意味着,编译器不需要保证程序按照开发者预想的方式执行。它可能直接崩溃,也可能看起来正常运行,甚至可能因为编译器优化而产生完全意想不到的结果。
还有一点需要特别注意:UB 不一定会立即暴露。
例如,一段代码在 Debug 模式下运行正常,切换到 Release 模式后却出现异常;或者使用 GCC 编译没有问题,换成 Clang 后就开始崩溃。这并不意味着编译器出了问题,也有可能是原来的代码本来就存在 UB,只是之前没有暴露出来。
整数运算
先看下面这个只有一行表达式的函数:
int bigger(int x) {
return x + 1 > x;
}
直觉上,大家可能认为它应该先执行加法,再进行比较。但编译器可以这样分析:
- 当
x < INT_MAX 时,加一不会溢出,比较结果为真。
- 当
x == INT_MAX 时,有符号整数加法溢出,触发 UB,标准不要求特定结果。
因此,对于所有有定义的输入,这个函数都返回 1。编译器可以把它优化为:
int bigger(int x) {
return 1;
}
这种优化是允许的,具体是否发生取决于编译器及其选项。
求值顺序
下面这段代码,估计不少人都见过:
int i = 0;
int result = i++ + i++; // UB
问题在于,内置 + 两侧的求值之间没有规定先后关系,两次修改同一个变量的操作未定序,即使在 C++17 及以后仍然是 UB。
可以像下面这样拆开写:
int i = 0;
int first = i++;
int second = i++;
int result = first + second;
数据竞争
这种问题在多线程里很常见:
int counter = 0;
std::thread a([&] { ++counter; });
std::thread b([&] { ++counter; });
a.join();
b.join();
两个线程都在修改普通变量 counter,这些访问之间缺少必要的同步关系,因此存在数据竞争。
最后调用 join(),只能保证主线程等待两个线程完成,不能给两个子线程的递增操作建立先后关系。即使某次运行最终得到 2,也不能证明代码就是对的。
对于这种情况,可以引入 <atomic>,并把声明改成:
std::atomic<int> counter{0};
数组访问
再看一个例子:
int read_at(int index) {
int numbers[3] = {10, 20, 30};
if (index > 3) {
return -1;
}
return numbers[index];
}
数组的合法下标是 0、1、2。调用 read_at(3) 时,index > 3 不成立,程序仍然会访问 numbers[3],造成越界。
判断条件应该改成:
if (index < 0 || index >= 3) {
return -1;
}
函数返回值
这个问题我在线上经历过两次,而且都是程序运行了很久之后,突然出现 core dump。
bool is_positive(int value) {
if (value > 0) {
return true;
}
// 忘记处理 value <= 0 的情况
}
调用 is_positive(0) 时,执行流程会走到函数末尾,却没有返回值,从而产生未定义行为。
直接返回比较结果即可:
bool is_positive(int value) {
return value > 0;
}
定位排查
了解了 UB 的常见类型,接下来就是如何在项目里发现它们。
对于 C++ 项目,我比较建议把编译器告警、静态分析和运行时检测结合起来使用。常见工具包括:
- UBSan:检查有符号整数溢出、非法移位、部分越界和其他未定义行为。
- AddressSanitizer:检查内存越界、释放后使用、重复释放等问题。
- ThreadSanitizer:检查多线程数据竞争。
例如,使用 Clang 构建一个开启地址和 UB 检查的测试版本:
clang++ -std=c++17 -O1 -g \
-Wall -Wextra -Wpedantic \
-fsanitize=address,undefined \
-fno-omit-frame-pointer \
main.cpp -o app
./app
如果要检查多线程数据竞争,可以单独构建一个 ThreadSanitizer 版本:
clang++ -std=c++17 -O1 -g \
-fsanitize=thread -pthread \
main.cpp -o app_tsan
./app_tsan
这里需要注意,AddressSanitizer 和 ThreadSanitizer 通常不能在同一个程序中同时启用,需要分别构建测试版本。
另外,Sanitizer 只能检查测试实际覆盖到的执行路径。没有报错,并不代表程序对所有输入都安全。
因此,测试数据也要有意识地覆盖边界情况:
- 整数的最小值和最大值。
- 除数为零的情况。
- 数组下标刚好等于长度的情况。
- 对象释放后仍然被访问的情况。
- 多线程同时访问和修改共享数据的情况。
对于带有前置条件的接口,还应该明确由谁负责检查,并确保检查发生在危险操作之前。
写在最后
C++ 中的未定义行为,远不只是几个容易写错的语法问题。它可能来自整数溢出、非法移位、指针越界、对象生命周期,也可能来自求值顺序和多线程数据竞争。
更重要的是,UB 不只是让某个表达式产生错误结果。编译器在优化时,会利用语言标准对有定义程序行为作出的约束。一旦程序触发 UB,很多我们习以为常的推断就不再成立。
所以,排查 C++ 问题时,不能只看代码在当前机器上能不能跑,也不能只看 Debug 模式下是否正常。
理解语言规则,检查边界条件,使用 Sanitizer 和静态分析工具,再结合不同优化等级下的测试,才能更早发现这些隐藏的问题。
有些 UB 可能运行一次就能暴露,有些却会在程序稳定运行很长时间之后,才因为某个输入或一次编译器升级而出现。
这也是为什么,写 C++ 代码时,不能只关注它现在能不能跑,还要确认它在语言标准规定的范围内是否真的正确。