PAYATHON 2026

CPU 能否对 nullptr 分支条件进行 speculative execution?

技术老齐

可以。支持推测执行的 CPU 会预测 ptr != nullptr 对应的分支,预测错误时,也可能尝试执行通过空指针读取内存的指令。在常见的现代 x86-64 处理器上,处理器检测到这类错误路径上的访问故障,并不会立刻导致程序崩溃。

指令开始执行和执行结果成为架构上可见的状态,是两个不同的时刻。同步异常的处理也遵循这一区分。

CPU 如何处理错误路径上的空指针访问?

假设编译器生成了条件跳转,处理器预测会进入 if 分支,典型过程是:

  1. CPU 根据分支预测结果,提前获取并执行 if 分支中的指令。
  2. 读取 *ptr 的指令尝试访问相应地址。如果地址未映射或访问权限不允许,处理器就会检测到故障。
  3. 处理器暂存这条指令的故障信息,不会立即交给操作系统处理。
  4. 当前面的条件分支确定应进入 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 也就没有对应的分支可预测。

分析这类问题时,可以按以下顺序检查:

  1. 查看编译结果,确认条件跳转和读取指令是否存在。
  2. 区分错误路径上的推测访问与程序实际执行的读取。
  3. 检查指针的有效性和生命周期;ptr != nullptr 只能排除空指针。

丢弃错误路径后,推测执行仍可能留下缓存等微架构状态变化,这也是 Spectre 类侧信道问题涉及的机制。普通空指针检查足以表达这里的 C++ 控制流,但不能据此认定处理器完全没有触及错误路径。

备注:内容仅供参考。