前置知识: C++

未定义行为全景:编译器为什么「可以为所欲为」

11 min高级

UB 的心智模型与防御体系:定义行为的四个层级、UB 的分类地图(内存/整数/求值顺序/并发)、优化器的「时间旅行」原理、sanitizer 与 constexpr 两道防线,附事故复现实验与面试题思路。

前置知识

  • 知道指针解引用、数组下标的基本语义(指针);
  • 经历过至少一次悬垂指针或越界事故(引用类型 的调试实录)。

学习目标

读完本文你将能够:

  1. 说清「未定义行为」与「实现定义」「未指明」的层级差异,以及标准为什么这样设计;
  2. 用「优化器的合法假设」模型解释 UB 的反直觉表现(时间旅行、分支消失、死循环被删);
  3. 按内存、整数、求值顺序、并发四类定位常见 UB,并知道每类的观察工具;
  4. 把「-Wall -Wextra -Werror + sanitizers + constexpr」组织成项目的 UB 防御体系。

预计 60 到 90 分钟。

1. 你现在要解决什么问题

C++ 与 Rust/Java 最大的分歧不在语法,而在对错误代码的态度。越界读一个数组, Java 抛异常,Rust panic,C++ 标准说:无要求——可能崩溃、可能读到脏数据、可能把 你的 if 判断删掉。「未定义行为」(undefined behavior,UB)不是「 behaves badly」, 是「标准授权编译器不必考虑这种情况」。不理解这一点,你会把调试器里看到的任何诡异 现象都归因于「编译器 bug」或「玄学」,而看不到自己代码里那张空头支票。

2. 最小可运行示例:UB 如何「时间旅行」

cpp
// compile: g++ -std=c++17 -O2 time_travel.cpp && ./a.out
#include <cstring>
#include <iostream>

bool contains(const char* s) {
    if (s == nullptr) return false;   // 判空保护?晚了
    return std::strlen(s) > 0;
}

int main() {
    // 故意传一个「不可能为空」的引用路径,先解引用后判空的组合到处可见
    const char* p = nullptr;
    std::cout << std::boolalpha << contains(p) << '\n';
}

这个例子里判空还在解引用之前,输出 false,一切正常。但只要把 strlen 内联进来 (-O2 就会),优化器会推理:「strlen(s) 要解引用 s,而标准保证解引用空指针是 UB,UB 不该发生,所以 s 一定非空,if (s == nullptr) 恒为假,删掉」——判空代码 被合法地抹除。这个现象在真实事故里的名字叫「空指针检查莫名失效」,在这里的 根因只有一个:标准允许编译器假定 UB 不发生。

这就是 UB 的正确打开方式:它不是「运行时会出错」,而是「你的程序从这一刻起失去 了标准的保护」,编译器在此之后的每个变换都无需考虑你的预期。

3. 发生了什么:四个层级的心智模型

标准对「标准没规定的行为」分了层级,混淆它们是误解 UB 的起点:

层级标准的态度典型例子跨平台表现
良定义唯一结果int 加法不溢出到处一致
实现定义实现必须文档化选了什么sizeof(int)、char 有无符号有差异但可查文档
未指明实现可任选且不必文档化,允许多个合法结果函数实参求值顺序有差异、无文档
未定义无任何要求解引用空指针、有符号溢出一切皆可能

「一切皆可能」的机制:编译器把 UB 视作「不可能事件」,并以此为基础做优化。具体地, 它推理的每一步都局部正确,串起来却产生「时间旅行」:

  1. *p 解引用成立(否则 UB);
  2. UB 不该发生,所以 p 非空;
  3. if (p == nullptr) 恒假,删除;
  4. 后续所有依赖该判断的代码随之重排。

由此可以推出 UB 的两个特点:它可能在你写错的那行毫无表现(错误传播到很远的地方 才爆);它可能在修改代码后「消失」或「搬家」(优化决策变了)——这就是「加了一行 printf 就不崩了」的原理。

4. UB 分类地图:四大常驻区域

4.1 内存类(最高产)

  • 解引用空指针、野指针、悬垂指针/引用(080/120 两篇的实录都在此列);
  • 数组越界读写(栈上越界可能恰好没炸——「UB 不保证每次都惩罚你」);
  • 生命周期违规:使用已析构对象、delete 两次、delete 与 new[] 错配;
  • 违反严格别名规则:用 float* 读 int 对象(memcpy 与 std::byte 视图除外);
  • 读取未初始化变量。

4.2 整数类

  • 有符号整数溢出是 UB(优化器据此假设「循环变量恒增」来向量化循环);无符号 溢出是良定义的回绕;
  • 移位越界:x << 32(int 为 32 位时)是 UB;C++20 起有符号右移明确为算术移位、 有符号整数明确为补码(P0907),左移负数仍 UB;
  • 除以零(整数)。

4.3 求值顺序类

  • 同一序列点(C++11 后:同一求值内)对同一对象无顺序地修改两次:i = i++ + 1; 是经典 UB。C++17(P0145)把函数实参改为「不定顺序但 不交错」,f(i++, i++) 不再是 UB,但结果仍依赖求值顺序——不写这种代码才是正解。

4.4 并发类

  • 数据竞争:两个线程无同步地访问同一内存位置且至少一个是写——标准直接判 UB (不是「读到旧值」,是任何值任何行为)。这是 内存模型 与 MemoryOrder 与无锁 两篇全部纪律的出发点。

5. 防御体系:把 UB 拦在不同阶段

按「越早发现越便宜」排列,一套工程化的 UB 防线:

bash
# 第一道:编译期警告(全项目默认开启,警告即错误)
g++ -std=c++20 -Wall -Wextra -Wpedantic -Werror -c main.cpp

# 第二道:运行期 sanitizer(测试与 CI 全开;有开销,生产通常不开)
g++ -std=c++20 -g -fsanitize=address,undefined -fno-omit-frame-pointer app.cpp -o app
g++ -std=c++20 -g -fsanitize=thread app.cpp -o app        # 数据竞争专项

# 第三道:静态分析与常量求值(离线阶段)
clang++ --analyze main.cpp     # clang 静态分析器
# 把逻辑尽量写成 constexpr / consteval:常量求值路径上 UB 是编译错误

各工具的专长分工:

工具拦截对象开销
-Wall -Wextra明显的可疑模式(未初始化、签名混淆、失效比较)零(编译期)
ASan越界、悬垂、重复释放(堆与栈的影子内存)约 2 倍慢、内存涨
UBSan有符号溢出、错误移位、错误对齐、空指针解引用约 1.1 倍慢
TSan数据竞争与死锁约 5 到 15 倍慢
constexpr 求值常量路径上的任何 UB(编译错误,最便宜)零

sanitizer 不是测试工具的替代,而是放大器:它把「UB 的随机表现」变成「确定的 报告 + 精确调用栈」,配合 030 篇起的每一个「预期输出」实验使用,是把认知写进肌肉 记忆的最快路径。注意「通过 sanitizer 不等于无 UB」:它只覆盖被测试路径触发的那部分。

6. 与模块内知识的串联

  • 指针与引用篇的悬垂事故是内存类 UB 的两个入口;智能指针 (深水区,循环引用并入其 weak_ptr 章节) 是「消灭生命周期类 UB」的工程终局;
  • 移动语义 的 moved-from 状态是「有效但未指明」, 不是 UB——但对它调用有前置条件的成员(如 size() 之外的值语义操作)就是 UB; 这个边界在 100 篇的事故里已经出现过;
  • 协程 中悬挂协程句柄、模块 与 链接符号 中的 ODR 违规(同一实体多份不一致定义)同属 UB, 且「无诊断要求」——链接器不会替你报错。

7. 常见坑

  1. 「我跑了一次没崩」不构成安全证据:UB 的表现依赖优化级别、编译器版本、内存 布局。验收标准是「sanitizer 全绿 + 警告为零」,不是「能跑」。
  2. 把实现定义当 UB 恐慌:sizeof(int)、补码表示这类实现定义行为是可查、可依赖 的(写进项目文档即可);真正要零容忍的是 UB。
  3. 在 UB 之上做「聪明优化」:手写「利用有符号溢出回绕」的哈希、用 << 代替乘法 却忽略移位上限,都是在给优化器递刀。需要回绕语义就显式用无符号,需要饱和就 std::clamp。
  4. release 与 debug 表现不一致就怀疑玄学:-O0 下「正常」、-O2 下「崩」,几乎总是 UB 的标准剧本(未初始化、越界、生命周期),先开 sanitizer 再说。
  5. 只在一个平台开 sanitizer:MSVC 的 /analyze、ASan 的 Windows 支持各有盲区, CI 里至少覆盖 GCC/Clang + sanitizers 一条链路。

8. 面试题思路

  1. 「什么是未定义行为?为什么要存在?」——先给定义(标准无要求),再给动机: 给优化器合法假设(省去运行期检查、支撑向量化与常量折叠),并降低跨平台标准的 规定负担。最后用「时间旅行」例子证明你理解它的影响方式,而不是背定义。
  2. 「有符号溢出和无符号溢出的区别?」——答出「UB vs 良定义回绕」这一个分水岭即及格; 追加「优化器如何利用有符号不溢出做循环分析」与「C++20 补码标准化」是加分项。
  3. 「如何系统性排查一个只在 release 版出现的崩溃?」——期望的框架:debug/release 差异指向 UB;开 ASan/UBSan 复跑测试;查未初始化变量(-Wmaybe-uninitialized、 MSan);核对生命周期与并发路径。能说出「先假设是自己的 UB,再怀疑编译器」的 顺序优先级,比列工具更重要。

9. 动手实验

  1. 时间旅行复现:写「先解引用后判空」的函数,分别在 -O0 与 -O2 下看反汇编 (objdump -d)或直接观察判空分支是否还在;阅读 Godbolt Compiler Explorer 里同一代码在不同优化级别的输出差异。
  2. 有符号溢出实验:int x = INT_MAX; ++x; 分别在 -O0、-O2、 -fsanitize=undefined 下编译运行,观察三种表现(回绕/静默错误/运行期报告); 换成 unsigned 再跑一遍,体会「无符号回绕是良定义」。
  3. 未初始化读取实验:写 int y; if (y > 100) ...,用 -Wmaybe-uninitialized 与 -fsanitize=memory(Clang)观察;再把它改成 int y = 0; 体会「声明即初始化」这条纪律的出处。
  4. 严格别名实验:用 float* 读一个 int 对象,对比 memcpy 版本,在 -O2 下 观察输出差异;这是「类型双关请走 memcpy 或 bit_cast(C++20)」的出处。
  5. constexpr 防线实验:把有符号溢出的表达式放进 constexpr int v = ...;, 亲眼看到编译错误——常量求值路径上 UB 无处遁形。

10. 小练习(先自己做,再展开参考实现)

练习 1:UB 侦探。判断下列四段代码各属哪个层级(良定义/实现定义/未指明/未定义), 并给「有问题的」写一句修复方案:

cpp
// (a)
int a = 1, b = 2;
int c = a + b * 2;

// (b)
std::vector<int> v{1, 2, 3};
const int& r = v[0];
v.push_back(4);
std::cout << r;

// (c)
char c = 200;
std::cout << (c < 0);

// (d)
int n = 0;
for (int i = 0; i < 64; ++i) n = (n << 1) | 1;

参考答案:(a) 良定义(无溢出,运算符优先级由标准唯一规定);(b) 未定义—— push_back 可能扩容搬迁,r 悬垂(080 篇成因二的原始版本,修复:push_back 之后 重新取 v[0]);(c) 实现定义——char 是有符号还是无符号由实现决定,c < 0 的 结果随平台变化(可查文档;要确定性就用 signed char/unsigned char);(d) 未定义——第 31 轮时 n 为 0x7FFFFFFF,n << 1 的结果在 32 位 int 中不可 表示:C++20 前这直接是 UB,C++20 起该次移位被定义(得 -2),但下一轮「左移负数」 仍然是 UB(修复:把 n 声明为 unsigned,移位与回绕皆良定义)。

练习 2:给项目装上防线。取本模块任意一篇的示例工程(如 移动语义 的对照实验),完成三件事并在 README 记录:(a) 加 -Wall -Wextra -Werror 重编译,记录新出现的警告并修完; (b) 用 -fsanitize=address,undefined 跑全部测试;(c) 挑一个纯计算函数改成 constexpr 并加一条静态断言测试。

参考实现(以 CMake 为例,GCC/Clang 命令行版见第 5 节):

cmake
add_library(core src/core.cpp)
target_compile_options(core PRIVATE
    $<$<CXX_COMPILER_ID:GNU,Clang>:-Wall -Wextra -Wpedantic -Werror>)

add_executable(core_tests tests/core_tests.cpp)
target_link_libraries(core_tests PRIVATE core
    $<$<CXX_COMPILER_ID:GNU,Clang>:-fsanitize=address,undefined>)
target_compile_options(core_tests PRIVATE
    $<$<CXX_COMPILER_ID:GNU,Clang>:-fsanitize=address,undefined -g>)

自检问题:为什么 sanitizer 的编译选项要同时给编译与链接两端?(答:ASan 需要 运行时库与插桩代码配对,只在一端开会导致链接失败或拦截失效——「报告为零」必须 建立在「工具真的在工作」之上。)

挑战题(不给参考实现):在本模块已完成的某个项目里(如 毕业项目 的 bufx),故意植入三类 UB(悬垂引用、 有符号溢出、未初始化读取各一),写一个「只能靠 sanitizer 发现」的测试让它们全部 现形,再逐个修复并提交两个 commit(引入与修复)。写完自查:修复后的版本在 -O2 下行为与 -O0 一致吗?如何证明?

11. 官方文档

12. 自我检查

  • 能画出四层级行为表并各举一例;
  • 能用「优化器合法假设」模型解释至少一种时间旅行现象;
  • 能按四类地图归类常见 UB 并配对观察工具;
  • 能为项目列出从警告到 sanitizer 到 constexpr 的完整防线。

本章总结

UB 是 C++ 性能的价格标签:标准以「无要求」换取优化器的合法假设,代价是错误代码 失去一切保护,且表现为可传播、可潜伏、可随优化搬家。防御不靠小心,靠体系:警告 当错误、sanitizer 进 CI、纯计算进 constexpr、并发走内存模型纪律。写每一行可能 越界、可能悬垂、可能溢出的代码时,先问一句:这行代码在 -O2 的世界里,还受标准 保护吗?

下一步

进入 工具链:把本文的编译选项、sanitizer 与静态分析组织 成日常开发的默认工作流。