yuqi-zheng

编译器优化:用 lea 做整数算术


在 x86 上,“把两个整数相加,再把结果放到第三个寄存器里”这件事,比你想象的更麻烦。标准的 add 指令只接受两个操作数,会覆盖其中一个源寄存器。编译器有一个优雅的解法:lea 指令本为地址计算而设计,却意外地成为一个高效的算术工具。

两操作数的难题

大多数 x86 算术指令采用两操作数形式:

add dst, src   ; dst <- dst + src

目标寄存器同时也是源,结果会覆盖其中一个输入。对简单情形这没问题,但来看一个将两个整数相加的 C 函数:

int add(int a, int b) { return a + b; }

按 System V ABI,a 通过 edi 传入,b 通过 esi 传入,返回值放在 eax 中。用 add 的朴素实现需要两条指令:

mov eax, edi   ; 把 a 复制到 eax
add eax, esi   ; eax = eax + b

这里的 mov 纯粹是为了不破坏 edi。它没有贡献任何计算价值。

lea 到底做了什么

lea 指令 —— Load Effective Address —— 计算一个内存地址并把它写入寄存器,而不实际访问内存。它支持完整的 x86 寻址模式语法:

[base + index * scale + displacement]

其中 scale 可以是 1、2、4 或 8。硬件把它当作一个算术表达式来计算,而不是内存引用。结果写入你指定的任意寄存器。

对我们的加法问题来说,这意味着:

lea eax, [rdi + rsi]

这条指令计算 rdi + rsi 并把结果写入 eax —— 一次三操作数加法,一条指令,两个源寄存器都没有被修改。

编译器为什么偏爱 lea

当 GCC 或 Clang 编译上面那个 add(int a, int b) 函数时,典型输出是:

lea eax, [rdi + rsi]
ret

它带来的好处是具体的:

更少的指令。 一条 lea 取代了一条 mov 加一条 add。代码更小意味着更好的指令缓存利用率和更小的译码压力。

源寄存器被保留。 rdirsi 都没被动。如果调用方稍后还需要这些值,编译器就不必保存/恢复,也不必重新安排计算顺序。

独立的目标寄存器。 结果直接落在 eax 中,没有临时量,也没有溢出。

硬件支持。 现代 x86 处理器有专门的地址生成单元。lea 通常是单周期操作,在超标量内核上可以与其他指令并行发射。

一个小细节:操作数使用的是 64 位寄存器(rdirsi),尽管 int 是 32 位的。这是有意为之。把一个 32 位结果写入 eax 会自动将 rax 的高 32 位清零,符合 x86-64 的惯例,而无需额外的 mov 或掩码步骤。

不只是简单加法

寻址模式语法让 lea 比普通的 add 更具表达力:

表达式汇编
a + blea eax, [rdi + rsi]
a + b * 2lea eax, [rdi + rsi*2]
a + b * 4 + 10lea eax, [rdi + rsi*4 + 10]
x * 5(即 x + x*4lea eax, [rdi + rdi*4]

这让 lea 在”乘以某个小常数且恰好比 2 的幂多一”的情形下非常好用:3、5、9。编译器在数组下标计算和循环强度削减中频繁使用它。

更宏观的意义

lea 的这种妙用是一个很好的例子:编译器后端如何利用那些在源代码层面完全看不到的架构细节。C 代码 return a + b 看不出任何不寻常的东西;而编译器的目标专属代码生成器对指令集足够熟悉,能认出 lea 在这里是最合适的工具 —— 不是因为它在做地址计算,而是因为它的寻址硬件恰好能实现程序所需的算术。

这也是为什么”手工优化汇编”比看上去更难。要匹配甚至打败现代编译器,你需要知道的不只是一条指令语义上做什么,还要知道硬件如何执行它,以及整个指令集中有哪些可选项。