yuqi-zheng

编译器优化:为什么是 xor eax, eax 而不是 mov eax, 0


只要你读过一会儿 C 或 C++ 编译器的 x86 汇编输出,几乎一定见过这样一条指令:

xor eax, eax

意图很清楚 —— 把 eax 寄存器清零。但为什么写成这种形式?更直观的 mov eax, 0 也能把寄存器清零,而且任何读汇编的人都能一眼看懂它在做什么。可 GCC、Clang 和 MSVC 却一致偏爱 xor 这种写法。原因有两个:指令编码体积和 CPU 微架构。

编码体积的理由

mov eax, 0 必须把立即数 0 编码为一个完整的 32 位操作数,哪怕这个值小到不能再小。机器码是:

b8 00 00 00 00

5 个字节。第一个字节是操作码,后四个字节编码字面量 0。

相比之下,xor eax, eax 是一个没有立即数的寄存器到寄存器操作:

31 c0

2 个字节。一条指令的编码体积减少了 60%。在一个会清零很多寄存器的程序里 —— 函数序言、循环初始化、返回值设置常常如此 —— 这种差异会累积起来。代码更小意味着更多指令能装进 L1 指令缓存,直接减小取指压力。

微架构的理由

光编码体积一条理由就够用,但现代 x86 CPU 更进一步:它们把 xor reg, reg(以及 sub reg, reg 等少数类似写法)识别为一个特殊的”清零惯用写法”(zeroing idiom),与普通算术指令区别对待。

按普通语义,xor eax, eax 会建立对 eax 之前值的数据依赖。XOR 读取两个操作数、产生结果;如果先前的某条指令写入了 eax 且尚未完成,XOR 就必须等待。

但 CPU 知道:xor reg, reg 无论寄存器当前值是多少,结果恒为 0。它根本不必去读操作数。这意味着:

  • 假依赖被消除。 指令不需要等待 eax 的上一次写者完成。
  • 寄存器重命名可以分配一个全新的、初值为 0 的物理寄存器,而不是检查或修改现有的那个。
  • 在某些微架构上,该指令甚至完全在 rename 阶段处理,根本不占用执行端口。它被标记为”产出 0”,退役流程照常进行,但执行单元保持空闲。

结果就是:在现代 Intel 和 AMD 内核上,xor eax, eax 几乎可以拥有零延迟和零吞吐代价;而 mov eax, 0 仍要经过一个执行单元、并建立完整的写依赖。

为什么是 eax 而不是 rax

在 64 位模式下,写入 32 位寄存器会自动把对应 64 位寄存器的高 32 位清零。这是 x86-64 规范里明确规定的一部分。所以 xor eax, eax 实际上会把 rax 的全部 64 位清零 —— 和 xor rax, rax 的效果一样。

rax 形式需要一个 REX.W 前缀字节来表明 64 位操作数大小,让这条指令变成 3 字节而不是 2。既然 32 位形式已经产生正确结果又更短,编译器就一致使用它。

对于扩展寄存器 r8r15,无论如何都需要 REX 前缀(用来编码寄存器号),因此 xor r8d, r8dxor r8, r8 都是 3 字节。编译器仍然使用 32 位形式,既为一致,也因为它仍被识别为清零惯用写法。

对比

方法大小执行清零 64 位寄存器
mov eax, 05 字节正常执行,有依赖是(写 eax,清上 32 位)
xor eax, eax2 字节清零惯用写法,无依赖
xor rax, rax3 字节清零惯用写法,无依赖

xor eax, eax 在每一条轴上都胜出:编码最小、微架构上待遇最好、64 位全寄存器清零效果。

更广的模式

这是一个好的例子:编译器的输出对人类读者可能显得奇怪,但完全是有意为之。这条指令看起来不像在清零寄存器,但 CPU 确切知道它在做什么。编译器写给 CPU 看,不是给读反汇编的人看。

它同时也说明:ISA 的小怪癖 —— 比如”32 位写入会清零高 32 位”这条规则 —— 会创造出让编译器系统地加以利用的优化机会。写 C 或 C++ 时你不需要考虑这一切;写 return 0;int x = 0; 就会自动产生 xor eax, eax。但理解它为什么出现在那里,对阅读汇编、推理编译器行为,是有用的背景知识。