深入解析:编译器为何偏爱 XOR EAX, EAX 进行寄存器清零

本文探讨了为什么在x86汇编中,编译器经常使用XOR EAX, EAX指令来将寄存器清零,而非更直观的MOV EAX, 0。文章揭示了这种做法的原因,包括节省代码空间、优化指令缓存,以及CPU如何识别并进一步优化这种“清零习惯用法”以提升执行效率。

为什么是 xor eax, eax? 作者:我(本人撰写),由大语言模型校对。 细节见文末。

在我的一次关于汇编语言的演讲中,我展示了一份平均x86 Linux桌面上执行次数最多的20条指令列表。所有常见的指令都在那里,mov、add、lea、sub、jmp、call等等,但令人惊讶的闯入者是xor——“异或”。在我捣鼓6502处理器的日子里,异或指令的存在是明确无误的信号,表明你找到了代码的加密部分,或者某种精灵处理例程。因此,一台正常运行的Linux机器竟然会执行这么多xor指令,这令人意外。

直到你想起,编译器在将寄存器设置为零时,喜欢生成xor指令:

1
2
3
int zero() {
    return 0;
}

我们使用异或将任何值与自身进行异或运算会得到零,但为什么编译器要生成这条指令呢?它只是在炫耀吗?

在上面的例子中,我使用了-O2优化级别进行编译,并启用了Compiler Explorer的“编译为二进制对象”选项,以便你可以查看CPU看到的机器代码,具体是:

1
2
31 c0           xor eax, eax
c3              ret

如果你将GCC的优化级别降低到-O1,你会看到:

1
2
b8 00 00 00 00  mov eax, 0x0
c3              ret

更清晰、更能揭示意图的mov eax, 0指令将EAX寄存器设置为零,但它占用了五个字节,而异或指令只占用两个字节。通过使用一个稍微晦涩一点的指令,每次我们需要将寄存器清零时(这是一个非常常见的操作),我们节省了三个字节。节省字节可以使程序更小,并更有效地利用指令缓存。

但这还不是全部!由于这是一个非常常见的操作,x86 CPU在流水线早期就能识别出这种“清零习惯用法”,并可以专门围绕它进行优化:乱序执行跟踪系统知道“eax”(或任何被清零的寄存器)的值不依赖于eax之前的值,因此它可以分配一个新的、无依赖关系的零寄存器重命名槽。完成此操作后,它将该操作从执行队列中移除——也就是说,xor指令的消耗为零个执行周期!它基本上被CPU优化掉了!

你可能会好奇,为什么你看到的是xor eax, eax,却从未见过xor rax, rax(64位版本),即使在返回一个long类型时:

1
2
3
long zero_long() {
    return 0;
}

在这个例子中,尽管rax需要容纳完整的64位long结果,但通过对eax进行写操作,我们得到了一个很好的效果:与其他部分寄存器写入不同,当写入像eax这样的“e”寄存器时,架构会自动将高32位清零。因此,xor eax, eax将全部64位都设为零。

有趣的是,在清零“扩展”编号寄存器(如r8)时,GCC仍然使用d(双字宽,即32位)变体:

1
2
3
4
5
long zero_long_r8() {
    register long r8 asm("r8") = 0;
    asm("" : "+r"(r8));
    return r8;
}

注意看,它是xor r8d, r8d(32位变体),即使使用REX前缀(这里是45),对完整的r8寄存器进行异或(xor r8, r8)也占用相同的字节数。这可能让编译器内部的某些处理更容易,因为clang编译器也这么做。

xor eax, eax节省了你的代码空间和执行时间!感谢编译器!

观看随本文发布的视频。

本文是《2025年编译器优化降临历》的第一天内容,这是一个为期25天的系列,探讨编译器如何转换我们的代码。

本文由人类(Matt Godbolt)撰写,并经过大语言模型和人类审查校对。

在Patreon或GitHub上支持Compiler Explorer,或通过Compiler Explorer商店购买CE产品。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计