CPU 能否对 nullptr 分支条件进行 speculative execution?
可以。支持推测执行的 CPU 会预测 ptr != nullptr 对应的分支,预测错误时,也可能尝试执行通过空指针读取内存的指令。在常见的现代 x86-64 处理器上,处理器检测到这类错误路径上的访问故障,并不会立刻导致程序崩溃。
指令开始执行和执行结果成为架构上可见的状态,是两个不同的时刻。同步异常的处理也遵循这一区分。
CPU 如何处理错误路径上的空指针访问?
假设编译器生成了条件跳转,处理器预测会进入 if 分支,典型过程是:
- CPU 根据分支预测结果,提前获取并执行
if分支中的指令。 - 读取
*ptr的指令尝试访问相应地址。如果地址未映射或访问权限不允许,处理器就会检测到故障。 - 处理器暂存这条指令的故障信息,不会立即交给操作系统处理。
- 当前面的条件分支确定应进入
else时,CPU 会清除错误路径上的指令,包括那条读取指令及其故障信息,然后沿正确路径继续执行。
这依赖于精确异常(precise exceptions)机制。以采用重排序缓冲区(reorder buffer,ROB)的典型乱序处理器为例,指令可以乱序执行,但必须按程序顺序退休(retire)。只有故障指令确实应当在架构上执行,并到达相应的退休位置时,处理器才会正式报告异常。
前面的分支尚未确定时,后面的读取指令不能越过它退休。如果分支预测错误,处理器就会丢弃这条读取指令。因此,检测到推测访问的故障与向操作系统交付异常是两个步骤,并不存在“程序已经崩溃,来不及丢弃结果”的问题。
C++ 的 UB 与 CPU 异常有什么区别?
未定义行为(undefined behavior,UB)属于 C++ 语言规则;page fault 等异常属于处理器和操作系统机制。CPU 不理解 C++ 的 nullptr 类型,也不会直接判断某条指令是否构成 C++ UB。
例如:
int read_or_zero(const int* ptr)
{
if (ptr != nullptr) {
return *ptr;
}
return 0;
}
当 ptr == nullptr 时,按照 C++ 抽象机的执行规则,*ptr 不会被求值。即使硬件在预测错误的路径上尝试读取内存,也不会因此让这次调用产生 C++ 层面的空指针解引用 UB。
这段代码的前提是:ptr 非空时,必须指向一个仍在生命周期内、可读取的 int 对象。非空检查本身无法保证这些条件。
如果程序实际执行的是:
int* ptr = nullptr;
int value = *ptr;
通过空指针读取 int 就是 UB。C++ 不保证它一定触发 page fault,也不保证程序一定以某种方式崩溃。在常见操作系统配置下,如果生成的读取指令访问了未映射的地址,操作系统通常会报告内存访问错误。这是具体平台上的表现。
用机器指令看执行过程
前面的 read_or_zero 可以用下面的 x86-64 汇编示意。假设 rdi 保存 ptr,eax 保存返回值;实际编译结果取决于编译器和优化设置。
test rdi, rdi
je .null
mov eax, DWORD PTR [rdi]
ret
.null:
xor eax, eax
ret
test 检查指针对应的寄存器值是否为零,je 决定是否跳到 .null。
当 rdi == 0 时,正确路径是跳转到 .null。如果处理器错误地预测“不跳转”,就可能提前尝试执行:
mov eax, DWORD PTR [rdi]
假设地址 0 未映射,这条读取指令会遇到 page fault 条件。不过,它位于预测错误的分支之后,无法退休。处理器纠正分支预测时,会将它一并清除,程序最终返回 0。
CPU 是否来得及发出这次读取,取决于流水线时序、操作数是否就绪等因素。因此,并非每次预测错误都会发生空地址访问。
CPU 会为了避免 UB 而优先预测 nullptr 吗?
不会。分支预测器主要根据分支指令地址、过去的跳转结果、分支历史等信息预测控制流,具体策略因处理器而异。
它通常只预测“这条条件跳转会不会跳转”,并不知道源码写的是:
ptr != nullptr
还是:
a > b
源码中的 if 分支可能被编译为跳转目标,也可能成为顺序执行路径。CPU 不会采用“总是进入 else 就能避免 UB”这样的规则。任意一个分支都可能包含潜在故障,处理器通过推测结果的提交和异常处理机制保证正确性。
原始代码还可能被编译器直接简化
对于原始示例:
int* ptr = nullptr;
// do some work
if (ptr != nullptr) {
std::cout << *ptr << std::endl;
} else {
// do something else
}
如果编译器能证明中间的工作没有改变 ptr,就能确定条件恒为假,直接保留 else 中的工作。此时,机器码中可能根本没有这个条件分支,CPU 也就没有对应的分支可预测。
分析这类问题时,可以按以下顺序检查:
- 查看编译结果,确认条件跳转和读取指令是否存在。
- 区分错误路径上的推测访问与程序实际执行的读取。
- 检查指针的有效性和生命周期;
ptr != nullptr只能排除空指针。
丢弃错误路径后,推测执行仍可能留下缓存等微架构状态变化,这也是 Spectre 类侧信道问题涉及的机制。普通空指针检查足以表达这里的 C++ 控制流,但不能据此认定处理器完全没有触及错误路径。
备注:内容仅供参考。
