① 钩子:一个"数学上恒真"的表达式,被编译成恒 1
x + 1 > x—— 数学上,只有当x = INT_MAX(x+1溢出)时才为假,其余恒为真。
但在-O2下,编译器把它编译成了movl $1, %eax; ret——无条件返回 1。即使你传入INT_MAX,它也返回 1。
因为有符号溢出是未定义行为(UB)。编译器假设你的程序永远不触发 UB,于是认为"x+1不会溢出 → 恒真",直接折叠成常量。
这一集把"UB 的代价"钉在汇编上:编译器不是"坏了",而是"信任你没写 UB",然后按这份信任激进地重写你的代码。
② 源码 vs 汇编对照
__attribute__((noinline))intoverflow_never(intx){returnx+1>x;// 有符号溢出 = UB}__attribute__((noinline))intshift_far(intx){returnx<<33;// 移位量 ≥ 类型位宽 = UB}__attribute__((noinline))intderef(int*p){return*p+1;// 空指针解引用 = UB}-O2下三个函数的汇编(本机 g++ 15.2.0 实测):
overflow_never(int): movl $1, %eax ; 折叠成恒 1!x+1 的加法整个消失了 ret shift_far(int): xorl %eax, %eax ; 编译器判定结果恒为 0 ret deref(int*): movl (%rcx), %eax addl $1, %eax ret运行时证据(-O2实跑):
overflow_never(INT_MAX) = 1 ; 数学上应为假,但溢出是 UB → 编译器当它不发生 shift_far(1) = 0 ; 1<<33 是 UB → 编译器给了个"随便"的结果 0 deref(&y) = 42 ; 正常指针,正常结果加上-fsanitize=undefined后,插桩立现(汇编证据):
; overflow_never 被折叠成常量后,UBSan 反而无处插桩——常量折叠先于插桩发生 overflow_never(int): movl $1, %eax ret ; shift_far:UBSan 插入了"移位越界"检查 shift_far(int): movl $33, %r8d movslq %ecx, %rdx leaq .Lubsan_data2(%rip), %rcx call __ubsan_handle_shift_out_of_bounds ; ← 拦截! xorl %eax, %eax ret ; deref:UBSan 插入了空指针 + 对齐 + 加法溢出三重检查 deref(int*): testq %rcx, %rcx je .L5 ; 空指针 → __ubsan_handle_type_mismatch_v1 testb $3, %cl jne .L5 ; 未对齐 → 同上 .L6: movl (%rax), %eax addl $1, %r9d jo .L15 ; 溢出 → __ubsan_handle_add_overflow ret诚实备注:MinGW ucrt64 的 g++ 不带
libubsan运行时,本机-fsanitize=undefined能编译出插桩汇编、但链接 .exe 会报cannot find -lubsan。Linux 发行版 g++ 自带该运行时,可完整链接运行并打印报错。汇编层面的插桩证据在本机已验证。
③ 为什么这么设计
- 未定义行为 = 编译器获得的最大授权:标准对 UB 的表述是"行为无约束"。编译器利用这点做激进假设——因为"合法程序里 UB 不会发生",所以它可以放心折叠、删除、重排任何"仅当 UB 时才成立"的代码。
overflow_never的原理:x+1 > x在"不溢出"的域内恒真;溢出是 UB,编译器假设不存在。于是整个表达式折叠为 1,x+1的加法被删掉——即使你传INT_MAX进去,也没有溢出发生,因为它根本没算加法。shift_far的原理:x << 33对 32 位 int 是 UB。编译器可以随便处理——它选了个"结果 0"。不是"硬件会怎样",而是"UB 范围内编译器可以给任何答案"。- UB 清单(部分):有符号溢出、移位量 ≥ 位宽、空指针/越界解引用、悬垂引用、除零、未初始化读取、数据竞争……本集演示了前三类。
- 为什么 UBSan 能救你:
-fsanitize=undefined在优化前把检查插进 IR,让"假设"变成"运行期验证"——触发 UB 时跳到处理函数报错而不是悄悄给出任意结果。注意overflow_never因常量折叠太早没被插桩,这本身就是"优化先于检查"的注脚。 - 真实事故:历史上大量漏洞(如 Linux 内核
-fno-strict-overflow、CVE 里空指针检查被删除导致提权)都源于编译器利用 UB 删除"无意义"检查。安全上已普遍用-fwrapv/-fno-strict-aliasing等方式关掉部分激进假设。
④ 深入一:UB 的三种"结果形态"
触碰 UB 后,程序可能表现为:
- 静默错误结果(本集
overflow_never/shift_far):返回任意值,程序继续跑——最阴险,因为"看起来正常"; - 优化删除安全检查(真实漏洞模式):编译器把"空指针检查"当"永假"删掉 → 后续解引用崩溃/提权;
- 跨编译单元/平台漂移:
-O0正常、-O2反转、换 clang 又不同——UB 程序没有"稳定行为"可依赖。
为什么是这三种:编译器只承诺"无 UB 程序的行为",UB 程序在任何优化决策下都可能被任意改写,所以三种形态都可能出现,且无法预测哪个出现。
⑤ 深入二:为什么"优化删除安全检查"是真实漏洞
考虑:
voidf(int*p){if(p==nullptr)return;// 空指针检查*p=42;// 解引用}若编译器先看到*p = 42并"证明"(按 UB 假设)p非空,它可能把空指针检查当成永假而删除——因为"如果 p 为空,*p=42 已经是 UB,前面的检查毫无意义"。这就是"UB 让防御性代码失效"的机器级原理,也是大量 CVE 的根因。
教训:不要用"先检查后使用"来防御 UB——编译器可能认为检查多余。要在根源上保证不产生 UB(边界检查在写入前、指针一定有效),或靠 sanitizer 兜底。
⑥ 常见误区
- 误区 1:“UB 只是"结果不确定”":不是——UB 是"整个程序行为无约束",可能在任何编译档/编译器下以任何方式失败(包括"看起来正常")。
- 误区 2:“我在 -O0 下测过没问题”:UB 程序在
-O0正常、-O2反转是常态(本集就是-O0会真的算x+1)。 - 误区 3:“溢出就溢出,反正是二补数”:有符号溢出是 UB,无符号溢出才是定义行为(回绕)。二补数只约束"无符号"。
- 误区 4:“UBSan 能抓所有 UB”:UBSan 抓"运行期可观测"的 UB;常量折叠后消失的(如
overflow_never)它无处插桩——它抓的是"真发生"的。 - 误区 5:“
volatile能防止 UB 优化”:volatile 只影响"对该变量的访问",不改变"表达式本身是 UB"的事实。UB 的优化权利在 IR 层,volatile 挡不住。 - 误区 6:“无符号溢出也要小心”:不用——无符号溢出是定义行为(回绕),编译器必须保留。只有有符号溢出、移位越界、解引用等才是 UB。"溢出=UB"的直觉只对有符号成立。
⑦ 实战启示
- 开
-Wall -Wextra -Werror:编译器自己会警告明显的 UB(如x << 33直接警告left shift count >= width)。 - 用 UBSan / ASan 做开发期检测:
-fsanitize=undefined,address能在运行期抓到溢出/空指针/越界,成本远低于线上事故。 - 理解"编译器完全信任你":写代码时以"优化器视角"自问——这段代码在"永不触发 UB"的假设下会被怎样改写?
- 不要依赖"我机器上跑得好好的":UB 程序在
-O0可能正常、-O2反转、换个编译器又不同——结果不可预测正是 UB 的惩罚。 - 写防御性代码要"在根源保证":不要靠"先检查后使用"补 UB(编译器可能删检查);用索引边界、
std::optional、span(带界)从源头避免 UB。
⑧ 扩展专题一:无符号溢出 vs 有符号溢出的汇编差异
- 无符号:
unsigned溢出有定义(模 2^32 回绕)——编译器必须保留回绕语义; - 有符号:溢出UB——编译器可以"当作不发生"折叠/假设。
unsignedu_inc(unsignedx){returnx+1;}// 定义良好:回绕ints_inc(intx){returnx+1;}// UB(若 x=INT_MAX)汇编:u_inc必须有addl $1(回绕是语义的一部分);s_inc的"x+1 可能溢出"让编译器在需要"溢出前先判断"的写法(如x+1 > x)里随意假设。这就是"同样一行加法,无符号要守着、有符号能放飞"的机器级区别。
⑨ 扩展专题二:-fwrapv与"关掉 UB 假设"的代价
-fwrapv:告诉编译器"有符号溢出按回绕处理"(把 UB 变成定义行为)——编译器不再做"溢出不会发生"的假设;- 代价:可能丢失依赖该假设的优化(如
x+1 > x不再折叠成 1); - 收益:行为更"可预期",遗留代码/内核这类"必须稳妥"的软件常开。
抉择:日常应用不必开-fwrapv(靠 sanitizer + 正确性保证);只有"不能容忍 UB 任意结果"的底层软件才需要。理解它 = 理解"优化授权与稳定性"的取舍。
⑩ 扩展 FAQ
- Q:
INT_MAX+1在-O0下真的算吗?
A:-O0通常真的执行加法(结果按二补数回绕,但这是"实际行为"不是"定义行为");-O2可能直接按 UB 假设给出任意值。同一个 UB,不同档位不同命运。 - Q:UBSan 和 ASan 区别?
A:UBSan 抓"未定义行为"(溢出、移位越界、空指针等),ASan 抓"内存错误"(越界、use-after-free)。两者互补,常一起开。 - Q:数据竞争是 UB 吗?
A:是(E16/E17 讲过)。多线程 UB 最麻烦——sanitizer 用 TSan 抓。 - Q:
reinterpret_cast会制造 UB 吗?
A:合法使用(按对象真实类型访问)不 UB;"把 int* 当 float* 读写"违反严格别名 = UB。memcpy/bit_cast是安全替代。 - Q:公司代码要不要全开 sanitizer?
A:开发/CI 强烈建议;生产环境视性能预算(sanitizer 有开销)。至少测试与 CI 必须开。
⑪ 扩展实验
- 三档对比:
E20_ub.cpp用-O0/-O1/-O2 -S,看overflow_never何时从"真算"变"折叠成 1"。 - UBSan 插桩:
-O2 -fsanitize=undefined -S,对照shift_far/deref的插桩调用点。 -fwrapv对照:-O2 -fwrapv -S,看x+1 > x是否恢复成真实比较。- 无符号对照:写
u+1 > u(无符号),-O2 -S确认不折叠(回绕语义被保留)。 - 空指针删除演示:写"先检查后使用",
-O2看检查是否被删(真实漏洞模式)。
⑬ 扩展专题三:UBSan 插桩的"时机"——为什么有的 UB 抓不到
overflow_never在 UBSan 下没有插桩(仍被折叠成movl $1)——这不是 bug,是顺序:
- 编译器先做常量折叠/优化(把
x+1 > x折叠成 1); - 折叠后的代码里已经没有"溢出操作"可检查了;
- UBSan 只能在"优化后仍存在"的操作上插桩。
所以 UBSan 是"补充"不是"兜底":它抓"运行期真会执行到的 UB",抓不到"被优化提前消除"的 UB。这就是为什么"别依赖任何单一工具,用 -Wall + sanitizer + 代码审查 三层"。
⑭ 扩展专题四:未初始化读取——UB 的"最隐蔽"形态
intf(){intx;returnx+1;}// x 未初始化 = UB-O0可能"碰巧"读栈上残留值;-O2下编译器可以"假设 x 有某个值"进行优化——结果同样不可预测;- 为什么隐蔽:没有明显"爆点"(不崩、不越界),只是"结果偶尔不对";
- 怎么抓:MSan(MemorySanitizer)专抓未初始化读取;
-Wmaybe-uninitialized能提前警告。
机器级本质:未初始化变量 = “编译器可以自由选择假设其值"的许可证——和shift_far一样,属于"UB 范围内给任意答案”。所有变量初始化是让编译器"可预测"的第一步。
⑮ 扩展 FAQ(第二轮)
- Q:
std::optional能避免"未初始化"吗?
A:能——optional 把"是否有值"变成类型的一部分,读取空 optional 是定义好的(抛bad_optional_access),不是 UB。用类型表达状态,比"可能未初始化的裸变量"安全。 - Q:为什么编译器不"帮我检查"溢出?
A:检查要钱(每条加法都判断 jo),编译器默认"程序合法,无需检查"。这就是 UBSan 存在的原因——开发期你要"检查",发布期你要"快",两者分开。 - Q:
-fsanitize=address能抓 UB 吗?
A:不能——它抓内存错误(越界/UAF),不抓溢出/移位。两个 sanitizer 语义不同,按需组合。 - Q:第三方库里有 UB 怎么办?
A:能用 sanitizer 跑它的测试最好;不能就"假设它内部可能有 UB,但别让它接到你的 UB 输入上"(边界校验)。 - Q:UB 和"implementation-defined"区别?
A:UB 是"行为无约束";implementation-defined 是"实现必须给出明确行为并文档化"(如sizeof(int))。后者可预期、可查文档,前者不可预期。
⑯ 扩展实验(第二轮)
- 未初始化演示:
-O0vs-O2跑未初始化读取,观察结果变化(UB 漂移的直接证据)。 -Wmaybe-uninitialized:开-Wall -Wextra看编译器是否警告;初始化后再编译对比。- optional 对照:
std::optional<int>读取空值,确认是定义行为(抛异常)而非 UB。 - MSan 说明:在支持环境用 MemorySanitizer 抓未初始化读取(gcc 下可先读文档/用 clang)。
-fno-strict-aliasing:对"类型双关"代码开/关该选项,看优化差异与结果漂移。
⑱ 扩展专题五:如何写出"让编译器无法误解"的代码
UB 的根源是"编译器从你代码推断出的假设"可能错误。要让推断正确,从源头消除歧义:
- 初始化一切变量:未初始化 = 自由假设许可证;
- 用边界安全容器:
std::vector::at/span/迭代器代替裸指针下标(越界 = UB); - 用定义良好的运算:需要回绕就用
unsigned/uint64_t,需要饱和就手写分支(别指望溢出"自然"); - 用
std::optional/std::variant表达"可能没有值":比"哨兵值/裸空指针"更明确; - 避免类型双关:用
memcpy/std::bit_cast而非reinterpret_cast读不同类型。
每一条的机器理由:都在"让编译器相信的假设与你的真实意图一致"——一致,它放心优化;不一致,它按错误假设重写。UB 的终极解药不是"更小心",而是"用类型和容器让 UB 无从发生"。
⑲ 扩展专题六:UB 与"未指定行为"的边界
标准里容易混淆的三种:
| 类别 | 含义 | 例子 | 编译器能做什么 |
|---|---|---|---|
| 未定义行为 | 行为无约束 | 有符号溢出 | 任意重写/删除/假设 |
| 未指定行为 | 实现从若干选择中选一个,但必须一致 | 求值顺序 | 合法地选一种,但不会"崩溃" |
| implementation-defined | 实现必须文档化 | sizeof(int)、char 是否有符号 | 有文档、可查、可预期 |
为什么区分重要:UB 是"别依赖,任何结果都可能";未指定/实现定义是"有约束的变体,可查文档"。把"UB 恐惧"扩散到"未指定行为"是过度谨慎——后者是可预期地选择。
⑳ 扩展 FAQ(第三轮)
- Q:
std::span能防越界吗?
A:它带界信息,配合at()/边界检查可防越界;但operator[]仍不检查(要快)。span 的价值是"让越界有地方检查"而非"自动防"。 - Q:生产代码能开
-fwrapv吗?
A:能(行为更可预期),但要接受"依赖溢出折叠的优化丢失"。大多数应用用 sanitizer 而非-fwrapv。 - Q:为什么
x+1 > x这种"常识"会被优化?
A:因为编译器按"无 UB"建模,而x+1在有符号下溢出即 UB——"常识"只在无符号/数学整数成立,C++ 有符号整数不是数学整数。 - Q:
INT_MIN / -1是 UB 吗?
A:是——结果(2^31)无法用 int 表示,除法溢出是 UB。边界值要显式处理。 - Q:UBSan 在生产能开吗?
A:开销小(大多数检查很轻),但仍有。常用做法:CI/测试开 UBSan+ASan,生产用无 sanitizer + 边界审查。 - Q:
x << 31(恰好等于位宽-1)算 UB 吗?
A:不算(移位量 < 位宽),但若左移使符号位变 1 属有符号溢出风险,具体看后续运算。移位量的 UB 边界是"≥ 位宽";符号位问题另算。
㉑ 扩展实验(第三轮)
INT_MIN/-1演示:-O2下除以 -1 的汇编与结果,确认 UB 漂移。memcpyvsreinterpret_cast:类型双关两种写法,-O2 -fstrict-aliasing对比是否被优化出反直觉结果。- span 越界:
std::span越界读,对比"有检查"与"无检查"的两种访问。 - 求值顺序:写"顺序相关的表达式"(未指定行为),
-O2确认编译器合法地选择一种。 - bit_cast 替代:把
reinterpret_cast双关改成std::bit_cast,确认定义良好且同样高效。 -fsanitize=address对照:越界读触发 ASan 报错路径 vs UBSan 的差异(ASan 管内存,UBSan 管语义)。
㉒ 扩展专题七:UB 与安全的"最后一公里"
UB 不只是性能问题,更是安全边界问题:
- 攻击者常利用"UB 导致的安全检查被删除"提权(CVE 经典模式);
- 现代防御:
-fstack-protector、ASLR、PIE(E04 提过)、sanitizer 进 CI; - 写安全代码 = 写无 UB 代码 + 用类型消除边界歧义。
为什么"安全审查"要懂汇编:很多漏洞在源码层"看不见"(编译器删除检查后才有问题),只有看.s/.exe才能确认"检查还在不在"。本系列教你看汇编的终极价值之一:能判断优化是否偷走了你的防御。
㉓ 扩展 FAQ(第四轮)
- Q:
-fno-strict-overflow和-fwrapv一样吗?
A:不完全一样。-fwrapv强制"回绕语义"(结果可预期);-fno-strict-overflow只是"别做依赖无溢出的优化"(更宽松)。底层软件常用后者保兼容。 - Q:
std::numeric_limits<int>::max()判断溢出可以吗?
A:可以——在相加之前用x > max - y判断(无 UB);相加之后判断就晚了。判断要在可能 UB 的操作之前做。 - Q:为什么
memcpy比reinterpret_cast双关更"安全"?
A:memcpy逐字节拷贝是定义良好的(任何对象可拷贝到字节数组再拷回);reinterpret_cast读不同类型违反严格别名。两者通常编译成同样的mov,但语义上一个安全一个 UB。
㉔ 扩展实验(第四轮)
- 溢出前判断:写
x > max - y的版本 vsx + y > max的版本,-O2 -S对比(后者是 UB 陷阱)。 -fstack-protector:开/关编译同一函数,反汇编看栈 canary 是否插入(安全防御的机器形态)。-fno-strict-overflow对照:对overflow_never开/关该选项,看x+1>x是否恢复真实比较。- CVE 模式复现:写"空指针检查 + 解引用"的典型函数,
-O2看检查是否被删(理解漏洞根因)。 - 无 UB 重构:把本集三个 UB 函数重写成无 UB 版本(溢出判断/移位检查/可选指针),确认语义正确且仍被优化。
㉕ 悬念
编译器在-O2下无所不能,可为什么一个 64MB 的数组,换个遍历顺序就能差 5 倍?优化的尽头,真正的瓶颈早已不在指令里,而在 CPU 的缓存和内存层次。