编译器优化:用 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。代码更小意味着更好的指令缓存利用率和更小的译码压力。
源寄存器被保留。 rdi 和 rsi 都没被动。如果调用方稍后还需要这些值,编译器就不必保存/恢复,也不必重新安排计算顺序。
独立的目标寄存器。 结果直接落在 eax 中,没有临时量,也没有溢出。
硬件支持。 现代 x86 处理器有专门的地址生成单元。lea 通常是单周期操作,在超标量内核上可以与其他指令并行发射。
一个小细节:操作数使用的是 64 位寄存器(rdi、rsi),尽管 int 是 32 位的。这是有意为之。把一个 32 位结果写入 eax 会自动将 rax 的高 32 位清零,符合 x86-64 的惯例,而无需额外的 mov 或掩码步骤。
不只是简单加法
寻址模式语法让 lea 比普通的 add 更具表达力:
| 表达式 | 汇编 |
|---|---|
a + b | lea eax, [rdi + rsi] |
a + b * 2 | lea eax, [rdi + rsi*2] |
a + b * 4 + 10 | lea eax, [rdi + rsi*4 + 10] |
x * 5(即 x + x*4) | lea eax, [rdi + rdi*4] |
这让 lea 在”乘以某个小常数且恰好比 2 的幂多一”的情形下非常好用:3、5、9。编译器在数组下标计算和循环强度削减中频繁使用它。
更宏观的意义
lea 的这种妙用是一个很好的例子:编译器后端如何利用那些在源代码层面完全看不到的架构细节。C 代码 return a + b 看不出任何不寻常的东西;而编译器的目标专属代码生成器对指令集足够熟悉,能认出 lea 在这里是最合适的工具 —— 不是因为它在做地址计算,而是因为它的寻址硬件恰好能实现程序所需的算术。
这也是为什么”手工优化汇编”比看上去更难。要匹配甚至打败现代编译器,你需要知道的不只是一条指令语义上做什么,还要知道硬件如何执行它,以及整个指令集中有哪些可选项。