编译器优化:ARM 的桶形移位器
编译器优化中反复出现的一个主题是:最佳策略完全取决于目标架构。在 x86 上最优的做法,换到 ARM 上可能截然不同,而桶形移位器(barrel shifter)正是这一原则的绝佳例证。
什么是桶形移位器?
在 ARM 上,大多数算术和逻辑指令允许对第二个操作数直接附加一次移位。这并不是一条单独的指令 —— 而是作为一个可选修饰符,直接烘焙进指令编码本身。执行这一移位的硬件被称为桶形移位器,在现代内核上,对于较小的移位量,它的额外开销基本为零。
一个具体的例子:
add w0, w0, w0, lsl #1 ; w0 = w0 + (w0 << 1),等价于 w0 * 3
一条 add 指令就能计算 x + (x * 2),不需要任何独立的移位步骤。在现代的 Cortex-A76 上,左移四位以内与 add 或 sub 组合使用,完全没有吞吐量上的代价。
ARM 编译器如何处理常量乘法
由于 ARM 具备这一能力,针对 AArch64 的编译器在乘数是较小整数时,很少会直接使用乘法指令。下表列出了 GCC 或 Clang 通常会生成的代码:
| 表达式 | 生成的汇编 | 说明 |
|---|---|---|
x * 2 | lsl w0, w0, #1 | 简单的左移 |
x * 3 | add w0, w0, w0, lsl #1 | x + (x << 1) |
x * 4 | lsl w0, w0, #2 | 左移 2 位 |
x * 16 | lsl w0, w0, #4 | 左移 4 位 |
x * 25 | mov w1, #25 / mul w0, w0, w1 | 回退到 mul |
x * 522 | mov w1, #522 / mul w0, w0, w1 | 回退到 mul |
像 25 和 522 这样的常量无法分解成几条能胜过单条 mul 的移位加法组合,因此编译器会把常量加载到寄存器中再直接相乘。ARM 固定 32 位的指令宽度限制了立即数的编码空间,这也是为什么较大的常量必须先用 mov 物化到寄存器中。
32 位 ARM 的一个小技巧:rsb
ARMv7(32 位 ARM)还有一条让这种模式更具表达力的额外指令:rsb,即 Reverse Subtract(反向减法)。普通的 sub 计算 op1 - op2,而 rsb 计算 op2 - op1。搭配带移位的第二操作数,可以用一条指令完成 (2^n - 1) * x 的计算:
mul_by_7:
rsb r0, r0, r0, lsl #3 ; r0 = (r0 << 3) - r0 = 8x - x = 7x
bx lr
这种写法可以一条指令处理乘以 3、7、15、31 等值。AArch64 架构移除了 rsb —— 这是对 ISA 的刻意精简,用这一小众能力换取了更干净的设计。
与 x86 的对比
在 x86 上,类似的工具是 lea 指令(Load Effective Address)。虽然它最初是为地址计算设计的,但 lea 能执行 a + b * k(其中 k 为 1、2、4 或 8),并能将结果写入任意输出寄存器,还不修改标志位。编译器在 x86 上使用它来做小常量乘法,方式与 ARM 使用带移位的 add 完全类似。
关键区别在于,ARM 的方案更干净地融入了指令流:移位是算术指令自身的一部分,而不是被”征用”的独立地址计算指令。另一方面,x86 上的 lea 能在一步之内组合基址、索引和位移,这是 ARM 模型不能直接比拟的。
这对源代码意味着什么
实际含义非常简单直接:写 x * 3,而不是 x + (x << 1)。编译器熟知目标架构的性能模型的细节。对 x86 它会生成 lea,对 ARM 它会生成带移位的 add。手写的位操作不仅损害可读性 —— 还可能阻碍编译器识别这类模式、选择真正最优的指令序列。
Compiler Explorer 让这一切很容易验证。在 AArch64 目标、-O2 下编译一个含 x * 7 的函数,看看输出。然后手工写一个”优化版本”,观察编译器要么和你写的结果一致,要么更可能地,生成更优的代码。
小结
ARM 的桶形移位器是 ISA 设计如何塑造编译器输出的一个最清晰的例子。通过将移位能力嵌入数据处理指令,ARM 为编译器提供了一个零成本的常量乘法工具,既紧凑又快速。这一切都由编译器自动完成 —— 你要做的,是在源代码中清晰地表达意图,然后把剩下的交给优化器。