CppCon 2017 学习:Undefined Behavior in 2017
我们来逐步列出、注释并分析你提供的代码与概念,涉及两个核心主题:
- 未定义行为(UB) 的设计理念
- 类型转换和内存对齐问题
1. √(-1) = ?
√(-1) = ?
选项:
• i
• NaN
• throw 异常
• abort 程序
• 返回任意值
• Undefined Behavior(UB)
正确答案:Undefined Behavior(UB)
分析:
在 C/C++ 中,如果你写:
#include <cmath>
std::sqrt(-1.0);
那么:
- 返回值依赖于实现(平台 + 库):
- 有时是
NaN
- 有时会抛异常(仅启用浮点异常时)
- 有时是 UB,特别是在禁用
errno
或不检测非法输入的模式下
- 有时是
- 不会返回
i
(那是复数领域) - C++ 标准不要求
sqrt(-1)
有定义行为 ⇒ 未定义行为是允许最优化的“空间”
2. UB 是为什么被选择的?
“UB 是最极端但最有效率的设计选择。”
原因:
- 没有任何标准保证 ⇒ 编译器不需要处理边界情况
- 不需要生成检查代码 ⇒ 更高效
- 避免平台特定行为限制移植性
- 使得优化器 可以大胆重排/删除看似无用的代码
例如:
if (ptr != nullptr)*ptr = 10; // 如果 ptr 是未初始化变量 ⇒ UB ⇒ 编译器甚至可能删掉整个 if 块
3. 示例代码分析
#include <iostream>
int main(void) {int a[] = {1, 2, 3};int *p = (int *)(1 + (char *)a); // 未定义行为std::cout << *p << "\n";return 0;
}
行为解释(逐行注释):
int a[] = {1, 2, 3};
// 创建一个整数数组,通常 a 在栈上分配
(char*)a
// 将 int* 强转为 char*,这样可以做逐字节偏移(因为 char 是 1 字节单位)
1 + (char*)a
// 把 char* 加 1 ⇒ 向前偏移一个字节,结果是一个 misaligned pointer(未对齐的地址)
(int*)(...)
// 把非对齐的 char* 强转回 int* ⇒ 违反对齐要求(int 通常要求4字节对齐)
*p
// 解引用一个未对齐的 int 指针 ⇒ 未定义行为(UB)
std::cout << *p << "\n";
// 输出可能值、崩溃、或垃圾
为什么这段代码有未定义行为?
标准明确要求:*解引用类型为 T 的指针时,该指针必须对齐到 alignof(T)**
int*
访问必须是 4 字节对齐(32 位)或 8 字节(64 位)char*
偏移 1 字节 ⇒ 不再对齐- x86/x86_64 架构支持非对齐访问,但这只是行为未定义 ≠ 实际会报错
- 换个平台(如 ARM)会直接崩溃
总结 C/C++ 中为什么有这么多 UB?
原因 | 目的 |
---|---|
✂ 不强制检查 | 避免性能损失 |
不要求处理异常输入 | 避免复杂性 |
不限制平台自由 | 提高移植性 |
允许优化器自由推理 | 提高执行效率 |
安全版本(避免 UB)
如果你想安全地访问整数的字节:
#include <iostream>
#include <cstring>
int main() {int a[] = {1, 2, 3};unsigned char* bytes = reinterpret_cast<unsigned char*>(a);int val;std::memcpy(&val, bytes + 1, sizeof(int)); // OK:不会违反对齐std::cout << val << "\n";return 0;
}
或者使用 std::byte
(C++17+)。
未定义行为(Undefined Behavior, UB) 在 C 和 C++ 中的典型实例分析,尤其是以移位操作(如 x << y
)为切入点的 UB 案例。我们来逐步 添加注释、理解并分析 你提供的内容,涵盖三个关键点:
1. x << y
中的未定义行为
代码示例(思考):
int x = 1;
int y = 32;
int z = x << y; // UB if int is 32-bit
为什么这是 UB?
根据 C/C++ 标准:如果 移位位数
y
≥ 目标类型中位数(例如 32 位整型),则行为是 未定义的。
例如:
int x = 1;
int y = 32; // 如果 int 是 32 位,y 等于或大于位宽,就会触发 UB。
int z = x << y; // 未定义行为
各种 CPU 行为不同:
- x86/x86-64:移位位数自动 mod 32(只看低 5 位)
- ARM:移位位数超过上限可能结果为 0
- MIPS/SPARC:直接崩溃或返回垃圾值
为什么 C/C++ 标准选择 UB?
避免自动插入“移位位数合法性”检查,使编译器生成更高效的机器码。
如果语言强制修正 y:
x << (y % 32);
那么编译器必须始终插入 %
运算 ⇒ 额外一条指令,影响所有平台性能。
所以 C/C++ 说:“你保证合法,我就优化到底。”
C11 标准的 UB 统计
- C11 标准附录 J 罗列了 199 种未定义行为
- 但不是完整的列表(只是示例)
- C++ 没有官方统计,因为:
- 有些 UB 没有标成 “undefined behavior”
- 版本演进会不断引入新的 UB
程序执行 UB 的三种可能后果
案例 | 表现 | 示例 |
---|---|---|
Case 1 | 程序立即崩溃 | 段错误、浮点异常 |
Case 2 | 程序继续运行但会晚点崩 | 内存损坏、随机行为、数据破坏 |
Case 3 | 程序表现正常 | 但换编译器/选项后突然崩溃 ⇒ 定时炸弹 |
最危险的是 Case 3:
程序一切看起来都好,但你升级了 GCC 或开启了 -O3
优化,它就:
- 删除了你的判断
- 重排了顺序
- 推理出你的代码“不可能到达”
- 结果:崩溃或潜在漏洞
攻击者也能利用 UB
你以为 UB 没触发,但编译器生成了“假设某条件恒真”的代码,攻击者利用该路径(如类型越界、内存重解释),可能造成:
- 越界写入
- 代码注入
- 破坏栈结构
- 信息泄露
最佳实践建议
建议 | 说明 |
---|---|
使用标准库函数(如 std::bit_cast ) | 避免手动类型惩罚 |
保证移位合法:assert(y < 32) | 对于定宽整数,保护移位 |
不访问未初始化或非法地址 | 即便在 x86 上“能跑” |
编译时启用 -fsanitize=undefined | 检查未定义行为 |
使用 clang , gcc 的警告:-Wall -Wextra | 能发现很多潜在错误 |
总结一句话
未定义行为不是 bug,而是设计策略,用来换取性能。但对程序员来说,它是最危险的陷阱。
提供的内容围绕 C/C++ 中“未定义行为(Undefined Behavior, UB)”的现状、趋势、实际编译器表现和优化行为,讲解得非常深入。这些代码和文字都揭示了现代编译器在面对 UB 时的优化激进程度与潜在风险。下面我将逐条添加注释、解释与分析,帮助你全面理解这些案例。
总体趋势(过去 25 年)
• UB 检测工具不断进步(如早期的 Purify、后来的 Valgrind、Sanitizers)。
• 编译器变得越来越聪明,善于用 UB 优化生成的代码。
• UB 像“定时炸弹”,很多老代码看似运行正常,但在优化器更聪明后就出问题了。
• 安全性成为关键考量,UB 也成了攻击面之一。
开发者与编译器的“对话”
Q: 提高优化等级后程序坏了,怎么办?
A: 编译器作者:- 建议你读标准。- 建议你不要写 UB。- 祝你好运。
关键点:执行 UB 的代码是程序员的责任,编译器没有义务保证结果稳定或可预测。
我们的现实困境
• 旧代码充满 UB。
• 方案一:回头修所有代码(代价大)
• 方案二:让优化器“收敛”,别那么激进
• 方案三:继续让优化器激进(现实选择)
UB 示例分析合集(带注释)
1⃣ 溢出引发 UB
int foo (int x) {return (x + 1) > x;
}
int main() {cout << ((INT_MAX + 1) > INT_MAX) << "\n"; // UBcout << foo(INT_MAX) << "\n"; // 正常,常量折叠,返回 truereturn 0;
}
分析:
INT_MAX + 1
是有符号整型溢出 ⇒ UBfoo(INT_MAX)
中(x + 1) > x
在优化时可直接编译为true
,常量推理成立- 实际输出:
0 // 因为 +1 溢出,结果行为不确定 1 // 优化器常量折叠
2⃣ Google Native Client 的移位漏洞
return addr & ~(uintptr_t)((1 << nap->align_boundary) - 1);
// 如果 align_boundary = 32,则是 1 << 32 ⇒ UB
分析:
- C/C++ 中对 32 位整数执行
1 << 32
⇒ UB - 优化器认为此表达式“无意义”,将其优化为 NOP
- 整个安全检查被移除,沙箱失效 ⇒ 漏洞产生
3⃣ 使用未初始化指针
int *p = (int*)malloc(sizeof(int));
int *q = (int*)realloc(p, sizeof(int));
*p = 1;
*q = 2;
if (p == q)printf("%d %d\n", *p, *q);
分析:
- 指针
p
被realloc
后可能失效。 - 即便
p == q
,*p
仍未定义。 - 实际表现:可能输出
1 2
,但仍是 UB。
4⃣ 条件编译影响控制流
void foo(char *p) {
#ifdef DEBUGprintf("%s\n", p); // 如果 p 为 NULL,这里崩溃
#endifif (p) bar(p);
}
汇编对比分析:
- 无
-DDEBUG
:编译器优化掉if (p)
,直接跳过bar()
调用。 - 有
-DDEBUG
:必须输出字符串,所以保留p
判断。
** UB 导致行为依赖宏定义!**
5⃣ memcpy
+ 空指针
void foo(int *p, int *q, size_t n) {memcpy(p, q, n);if (!q) abort(); // 但可能已经 dereference 了 null!
}
分析:
- 即使
n == 0
,调用memcpy(p, q, 0)
也不合法 如果 q 是 NULL - 编译器可能优化掉判断
if (!q)
,直接调用 memcpy ⇒ 崩!
6⃣ 类型转换引发推断失误
int check(int *h, long *k) {*h = 5;*k = 6;return *h;
}
编译器优化:
movl $5, (%rdi)
movq $6, (%rsi)
movl $5, %eax
- 因为 int 与 long 不可能 alias,优化器认为
*h
不受*k
修改影响,直接返回5
7⃣ 外部可见副作用顺序问题
void bar();
int foo(int z) {bar();return 100 % z;
}
如果 z == 0:
- 整除 0 是运行时错误
- 但编译器认为 bar() 没有 observable side effect ⇒ 调整执行顺序,导致程序提前崩溃或崩溃后不输出 “HELLO”
UB 可以“穿越时间”
• UB 可能使编译器提前优化“本来未来才触发的问题”
• 比如函数执行顺序、变量访问顺序
• 所以称之为:“UB 可以穿越时间”
总结建议
建议 | 原因 |
---|---|
永远不要依赖 UB | 它不可预测,后果可能随编译器/版本变化 |
用 -fsanitize=undefined 检查 | 编译时检测大量 UB |
尽可能用标准库、现代 C++(如 std::optional 、bit_cast ) | 避免底层 hack |
理解并使用 aliasing-safe 类型 punning 技术 | 如 memcpy 、std::byte* |
避免整数溢出、移位过界等位运算错误 | 位操作优化器依赖很多假设 |
这部分内容深入探讨了 C/C++ 中未定义行为(Undefined Behavior, UB) 的“回溯性影响”、编译器优化行为、以及我们程序员能做些什么来应对 UB。以下我将逐段解析并添加代码注释与解释,帮助你全面理解。
UB Can Travel Back in Time!
int fermat() {const int MAX = 1000;int a = 1, b = 1, c = 1;while (1) {if ((a * a * a) == ((b * b * b) + (c * c * c)))return 1;a++;if (a > MAX) {a = 1;b++;}if (b > MAX) {b = 1;c++;}if (c > MAX) {c = 1;}}return 0;
}
分析与注释:
- 这段代码 构造性地尝试“反驳费马大定理”。
- 但实际上这是一个 无限循环,没有任何副作用(如打印、I/O、全局状态改动)。
- 在 C++ 中,编译器允许优化掉不含副作用的无限循环。
所以 Clang 在开启优化后可能移除整个
while(1)
循环,直接返回1
。输出变成:
Fermat's Last Theorem disproved!
结论:
UB 可以“向前传播”影响编译器对代码前面部分的重写或移除。
为什么编译器能这样干?
因为 C++ 不像 C11 那样禁止移除常量控制的无限循环,所以如果没有可观察的副作用,编译器可以大胆优化。
- C11 禁止移除
while(1)
类型常量控制表达式的循环(可见n1528
提案) - C++ 没有这个限制,优化空间更大,但更危险
我们的处境
• 开发者必须理解并遵守 200+ 条 UB 规则
• 默认情况下没人告诉你哪里写错了
• 这是 bug 和漏洞滋生的土壤
开发者能做什么?(总结)
1⃣ 明确前置条件(Preconditions)
int foo(int x, int y) {return x << y; // y 必须满足 0 ≤ y < width(x)
}
y
超过位宽(如x << 32
)将导致 UB- 需要你在代码中 显式验证参数的合法性
2⃣ 静态分析(Static Analysis)
不需要运行程序也能发现潜在 UB
- ❶ 不完备但实用工具(找部分 bug):
-Wall -Wextra -Werror
编译器警告- Clang Static Analyzer
- Coverity、Klocwork、Cppcheck
- ❷ 完备工具(特定类别保证无 bug):
- Polyspace
- TrustInSoft
- Frama-C(偏 C)
建议:
- 在 CI 中强制开启
-Werror
- 将静态分析纳入代码审核流程
- 对于核心库或关键模块使用 Polyspace/TrustInSoft
3⃣ 动态分析(运行时分析)
利用 Sanitizer 系列工具在运行时检测 UB:
工具 | 功能 |
---|---|
ASan | 地址空间错误(越界、use-after-free) |
UBSan | 所有未定义行为(溢出、非法转换等) |
MSan | 未初始化内存访问 |
TSan | 多线程数据竞争 |
TySan | 类型别名/强制转换检测(开发中) |
示例编译方式:
clang++ -fsanitize=address -g your_code.cpp
clang++ -fsanitize=undefined -g your_code.cpp
4⃣ 代码审查强化:关注 UB
• 常见 UB 前置条件(应检查):- 除法除数 ≠ 0- 移位位数在合法范围内- 内存未释放后不要访问- 指针必须对齐
- 代码审查时强制写出前置条件
- 尤其要关注 循环内变量变化是否保证不越界
示例:编译器优化打破预期逻辑
int check(int *h, long *k) {*h = 5;*k = 6;return *h;
}
- 编译器认为
int*
和long*
不可能 alias ⇒ 直接返回5
- 这在别名分析下是合法优化
- 若你强制让 h 和 k 指向同一块内存,程序行为是 UB!
总结建议表
类别 | 工具/建议 |
---|---|
避免 UB | 不要写移位越界、访问未初始化、越界内存等代码 |
静态分析 | 编译器警告、Coverity、Clang Static Analyzer |
动态分析 | ASan、UBSan、TSan、MSan |
编码习惯 | 写函数时注释清楚 precondition,尤其是涉及位运算或指针 |
单元测试 | 对可能触发 UB 的代码覆盖边界情况 |
编译选项 | -fsanitize 、-Werror 、-fno-strict-aliasing (慎用) |
如果你有一个项目或代码库,我也可以帮你: |
- 检查出可能的 UB 区域
- 自动加注释说明前置条件
- 或者生成
clang-tidy
规则集来持续检测这些问题
动态分析的优缺点、未定义行为(UB)的缓解策略、当前面临的挑战以及对 C++ 开发者的建议。我帮你分点总结、解释和分析:
动态分析的优缺点
优点
- 找出代码中“热门路径”的真实 bug
动态分析通过运行时检测,能发现程序实际执行路径上的问题。 - 通常没有误报(False positives)
只要检测到错误,通常是真实存在的,而不是静态分析中常见的假阳性。 - 一般不会因为细枝末节的问题烦死你
动态工具倾向于报告真正严重或易复现的问题,而不会爆炸性地报告大量模糊警告。
缺点
- 测试覆盖决定了发现的广度和深度
动态分析只能检测程序运行过的路径,测试不到的代码分支就没法检测。 - 彻底的测试极其困难
需要足够的用例覆盖,模糊测试(fuzzing)虽有帮助,但并非万能。
UB 检测类型和工具覆盖
错误类型 | 检测工具 | 说明 |
---|---|---|
使用无效指针 | ASan, Valgrind | 检测越界、使用后释放、重复释放 |
数组越界 | ASan, Valgrind | 访问数组边界之外的内存 |
严格别名规则违规 | UBSan | 违反 C++ 严格别名规则导致的 UB |
整数溢出 | UBSan | 有符号整数溢出等 |
未定义的变量访问 | 解释器等 | 使用未初始化变量 |
变长参数错误 | UBSan | 可变参数传递错误 |
变量访问无序 | UBSan | 代码中未定义顺序的变量访问导致 UB |
UB 缓解(Mitigation)
- 禁用某些编译器优化,如 Linux 使用
-fno-delete-null-pointer-checks
防止空指针检查被移除。 - MySQL 用
-fwrapv
来让有符号整型溢出行为定义为环绕(wrap-around),消除溢出 UB。 - 禁用严格别名规则优化,
-fno-strict-aliasing
让别名相关的 UB 降低风险。 - Android 在部分组件中使用 UBSan,但要提供替代运行时保证部署安全。
- Chrome Linux 版启用了控制流完整性(CFI),由 LLVM 支持,增加安全保障且性能损耗极小。
UB 缓解面临的问题
- 缺乏标准化、可移植的解决方案,不同平台不同编译器行为差异大。
- 并发错误的缓解仍无良好方案,数据竞争、死锁等并非 UB 缓解重点。
- 内存安全缓解昂贵且有时破坏程序行为,ASan 主要是调试工具,不适合生产。
- UBSan 可配置为硬化工具,但这意味着在潜在漏洞处程序直接崩溃,需权衡稳定性和安全。
未来方向
针对所有 200 多种 C++ UB,必须做到:
- 明确定义运行时行为(让 UB 成为有定义的行为)
- 编译器能够检测到并产生致命错误
- 提供可靠的运行时检测工具(sani zer)
当前状态总结
项目 | 状态 |
---|---|
内存边界检测生产级方案 | 仍有挑战 |
严格别名规则 | 仍需改进 |
非终止循环 | 细节问题 |
未定义的副作用执行顺序(unsequenced) | 细节问题 |
给 C++ 开发者的建议
- 充分了解 UB 的含义和影响
UB 不只是错误,还可能导致不可预期的优化和行为。 - 在代码审查时有意识地考虑 UB
关注函数的前置条件,代码边界,别名规则等。 - 尽可能多地进行测试
使用覆盖率工具保证测试广度,使用模糊测试扩展测试深度。 - 使用动态分析工具(sanitizers)和静态分析工具
不断修复报告的 bug,建立健壮代码基础。 - 熟悉并愿意使用更“硬核”的检测工具
如 Polyspace、TrustInSoft 这类严谨的验证工具。