找回密码
立即注册
搜索
发回帖 发新帖

6666

积分

0

好友

856

主题
发表于 13 小时前 | 查看: 1| 回复: 0

做 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++ 代码时,不能只关注它现在能不能跑,还要确认它在语言标准规定的范围内是否真的正确。




上一篇:Linux免费但不是零成本:学习、维护、硬件与软件兼容的隐性投入
下一篇:实战复盘:仿真渗透环境从GetShell到提权Root全流程
您需要登录后才可以回帖 登录 | 立即注册

关闭

站长推荐上一条 /1 下一条

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-11-4 14:58 , Processed in 0.084480 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表