第10章 指令选择
导读
指令选择(Instruction Selection)是编译器后端的第一个关键步骤。它的任务是将中间表示(IR)转换为目标机器的机器指令。指令选择的质量直接影响最终生成代码的性能——好的指令选择能充分利用目标机器的指令集特性,生成高效、紧凑的目标代码;差的指令选择则可能浪费指令周期,产生冗余操作。
指令选择看似简单——不就是把IR映射到机器指令吗?但实际上,这是一个复杂的组合优化问题。目标机器通常有数百条指令,每条指令有不同的操作数约束、执行延迟和资源需求。如何在这些指令中选择最优的组合,同时满足正确性约束,是一个NP完全问题。
本章将系统地介绍指令选择的理论和技术。我们将从目标机器的建模开始,学习如何用树模式匹配、动态规划等方法进行指令选择;然后讨论基于覆盖(Covering)的指令选择方法;最后介绍实际编译器中使用的指令选择技术,包括基于模板的选择、基于规则的选择和基于机器描述的选择。
通过本章的学习,你将理解编译器是如何将抽象的中间表示转换为具体的机器指令的,你将了解指令选择中的各种挑战和解决方法,你也将认识到指令选择在整个编译过程中的重要地位。
核心概念详解
10.1 目标机器建模
目标机器(Target Machine)是编译器生成代码的目标平台。不同的目标机器有不同的指令集架构(ISA)、寄存器组织、内存层次结构等。
指令集架构(ISA)的分类:
CISC(Complex Instruction Set Computer):
- 指令长度可变
- 指令功能复杂(一条指令可完成复杂操作)
- 支持多种寻址模式
- 代表:x86、VAX
RISC(Reduced Instruction Set Computer):
- 指令长度固定
- 指令功能简单(一条指令完成一个基本操作)
- 寻址模式少
- 大量通用寄存器
- 代表:ARM、MIPS、RISC-V
目标机器的关键特性:
指令格式:
- 操作码(opcode)
- 操作数(源操作数、目标操作数)
- 寻址模式
寄存器组织:
- 通用寄存器数量和用途
- 特殊寄存器(程序计数器、栈指针、帧指针等)
- 浮点寄存器
内存模型:
- 字节序(大端/小端)
- 对齐要求
- 地址空间
执行模型:
- 流水线结构
- 功能单元(ALU、FPU、Load/Store单元等)
- 延迟槽
10.2 指令选择的本质
指令选择是将中间表示的每个操作映射到目标机器的一条或多条指令的过程。
示例:
中间表示(三地址码):
t1 = a + b
t2 = t1 * cx86指令选择:
mov eax, [a] ; 加载a
add eax, [b] ; t1 = a + b
imul eax, [c] ; t2 = t1 * c
mov [result], eax ; 存储结果ARM指令选择:
ldr r0, =a ; 加载a的地址
ldr r0, [r0] ; 加载a的值
ldr r1, =b
ldr r1, [r1]
add r2, r0, r1 ; t1 = a + b
ldr r3, =c
ldr r3, [r3]
mul r4, r2, r3 ; t2 = t1 * c
ldr r5, =result
str r4, [r5] ; 存储结果指令选择的挑战:
一对多映射:一个IR操作可能对应多条机器指令
多对一映射:多条IR操作可能合并为一条机器指令
约束满足:指令有操作数类型、寄存器约束等限制
代价优化:不同指令序列有不同的代价(时间、空间)
10.3 基于树模式匹配的指令选择
树模式匹配(Tree Pattern Matching)是指令选择的经典方法。它将IR表示为树,将机器指令表示为树模式,通过模式匹配选择指令。
基本思想:
将IR表达式表示为树
为每条机器指令定义一个树模式(带代价)
用动态规划找到代价最小的树覆盖
示例:
IR树:
+
/ \
* d
/ \
a b机器指令模式(x86):
模式1: reg → id 代价: 2 (mov reg, [id])
模式2: reg → reg + reg 代价: 1 (add reg, reg)
模式3: reg → reg * reg 代价: 3 (imul reg, reg)
模式4: reg → reg + id 代价: 2 (add reg, [id])
模式5: reg → reg * id 代价: 3 (imul reg, [id])最优覆盖:
+(2)
/ \
*(3) d(2)
/ \
a(2) b(2)
总代价: 2 + 3 + 2 + 2 + 2 = 11对应的指令序列:
mov eax, [a] ; 代价2
imul eax, [b] ; 代价3
add eax, [d] ; 代价2动态规划算法:
function tree_match(node):
if node是叶子:
return 最小代价的叶子模式
for 每个匹配node的模式P:
cost = P.cost
for 每个子节点child:
cost += tree_match(child)
记录最小代价
return 最小代价10.4 基于覆盖的指令选择
覆盖(Covering)方法将IR的DAG表示覆盖为一组机器指令。
DAG覆盖的挑战:
与树不同,DAG中的节点可能有多个父节点(公共子表达式)。覆盖DAG时需要确保:
每个节点至少被覆盖一次
公共子表达式的结果被正确共享
示例:
DAG:
+
/ \
* *
/ \ / \
a b b c这里 b 被两个乘法共享。
覆盖策略:
计算 t1 = a * b
计算 t2 = b * c(复用b的值)
计算 result = t1 + t2
10.5 寻址模式与指令选择
寻址模式(Addressing Mode)决定操作数的获取方式。不同的寻址模式影响指令选择的效率。
常见寻址模式:
立即数(Immediate):操作数在指令中
```asm
add eax, 5 ; eax = eax + 5
```
寄存器(Register):操作数在寄存器中
```asm
add eax, ebx ; eax = eax + ebx
```
直接(Direct):操作数在内存中,地址在指令中
```asm
add eax, [0x1000] ; eax = eax + mem[0x1000]
```
间接(Indirect):操作数在内存中,地址在寄存器中
```asm
add eax, [ebx] ; eax = eax + mem[ebx]
```
基址+偏移(Base+Offset):操作数地址 = 基址寄存器 + 偏移量
```asm
add eax, [ebx+8] ; eax = eax + mem[ebx+8]
```
变址(Indexed):操作数地址 = 基址 + 变址 * 比例
```asm
add eax, [ebx+ecx4] ; eax = eax + mem[ebx+ecx4]
```
寻址模式对指令选择的影响:
利用复杂的寻址模式可以生成更紧凑、更高效的代码。
IR: a[i] = b[i] + c
低效实现:
mov eax, [i]
shl eax, 2 ; eax = i * 4
mov ebx, [b+eax] ; 加载b[i]
add ebx, [c] ; 加c
mov [a+eax], ebx ; 存储到a[i]
高效实现(利用变址寻址):
mov eax, [i]
mov ebx, [b+eax*4] ; 直接加载b[i]
add ebx, [c]
mov [a+eax*4], ebx ; 直接存储到a[i]10.6 寄存器分配与指令选择的交互
寄存器分配和指令选择是紧密耦合的优化问题。
寄存器约束:
某些指令要求操作数在特定寄存器中:
- x86的乘法指令:一个操作数必须在EAX,结果在EDX:EAX
- x86的移位指令:移位次数必须在CL中
- 函数调用约定:参数通过特定寄存器传递
示例:
int result = a * b; // x86乘法指令选择需要考虑寄存器约束:
mov eax, [a] ; 必须加载到eax
imul dword [b] ; 另一个操作数在内存
; 结果在edx:eax寄存器分配对指令选择的影响:
如果寄存器不足,可能需要额外的load/store指令:
寄存器充足:
add eax, ebx ; 直接在寄存器中操作
寄存器不足(ebx被溢出):
mov ebx, [spill_b] ; 从内存加载
add eax, ebx
mov [spill_b], ebx ; 存回内存10.7 指令调度与指令选择的交互
指令调度(重新排列指令顺序)和指令选择也相互影响。
示例:
IR:
t1 = a + b
t2 = c + d
t3 = t1 * t2选择1(顺序执行):
mov eax, [a]
add eax, [b] ; t1
mov ebx, [c]
add ebx, [d] ; t2
imul eax, ebx ; t3选择2(交错执行,利用流水线):
mov eax, [a]
mov ebx, [c]
add eax, [b] ; t1
add ebx, [d] ; t2
imul eax, ebx ; t310.8 延迟槽与指令选择
延迟槽(Delay Slot)是某些RISC架构的特性。分支指令之后的指令仍然会被执行(在分支生效之前)。
分支指令
延迟槽指令 ; 总是执行
分支目标编译器利用延迟槽:
原始代码:
if (x > 0) goto L1
y = y + 1
L1: ...
利用延迟槽:
bgtz x, L1 ; 如果x>0跳转到L1
add y, y, 1 ; 延迟槽:y=y+1(分支不跳转时执行)
L1: ...填充延迟槽的策略:
从延迟槽之前取指令:将分支前的指令移到延迟槽
从分支目标取指令:将分支目标的指令移到延迟槽
从其他地方取指令:如果没有合适的指令,插入NOP
10.9 条件码与指令选择
条件码(Condition Codes)是处理器状态寄存器中的标志位,用于记录最近运算的结果特征(零、负、进位、溢出等)。
条件码的使用:
cmp eax, ebx ; 比较eax和ebx,设置条件码
jg label ; 如果大于(根据条件码)跳转条件码对指令选择的影响:
合并比较和分支:
```
IR: if (a > b) goto L
低效:
mov eax, [a]
cmp eax, [b]
setg al ; 设置结果
test al, al
jnz L
高效:
mov eax, [a]
cmp eax, [b]
jg L ; 直接使用条件码
```
避免冗余比较:
```
if (x > 0) {
if (x > 5) { // 不需要重新比较x
...
}
}
```
重要知识点
10.10 指令选择的代价模型
代价模型用于评估不同指令选择方案的质量。
常见的代价度量:
指令条数:生成的指令总数
执行周期:指令的执行时间(考虑流水线)
代码大小:指令占用的字节数
能耗:指令执行的能量消耗
代价模型的构建:
代价(指令序列) = Σ 代价(指令i) + 惩罚(冒险)
其中:
- 代价(指令i) = 指令i的执行周期
- 惩罚(冒险) = 流水线停顿的周期数10.11 基于模板的指令选择
模板(Template)是指令选择的简单方法。为每种IR模式定义一个模板,模板包含匹配的IR模式和生成的指令序列。
示例模板:
模板1:
匹配: ADD(reg, reg)
生成: add %0, %1
代价: 1
模板2:
匹配: ADD(reg, imm)
生成: addi %0, %1
代价: 1
模板3:
匹配: ADD(reg, mem)
生成: load temp, %2
add %0, %1, temp
代价: 3模板匹配算法:
遍历IR中的每个操作
查找匹配的模板
选择代价最小的模板
生成对应的指令序列
10.12 基于机器描述的指令选择
现代编译器使用机器描述文件来定义目标机器的指令集,然后自动生成指令选择代码。
LLVM的指令选择:
LLVM使用TableGen工具从机器描述文件生成指令选择代码。
// 定义指令
def ADDrr : Instruction {
let OutOperandList = (outs GPR:$dst);
let InOperandList = (ins GPR:$src1, GPR:$src2);
let AsmString = "add $dst, $src1, $src2";
let Pattern = [(set GPR:$dst, (add GPR:$src1, GPR:$src2))];
}GCC的指令选择:
GCC使用机器描述(MD)文件定义指令模式。
(define_insn "addsi3"
[(set (match_operand:SI 0 "register_operand" "=r")
(plus:SI (match_operand:SI 1 "register_operand" "r")
(match_operand:SI 2 "register_operand" "r")))]
""
"add\\t%0, %1, %2"
)10.13 指令选择的优化技术
1. 指令融合(Instruction Fusion)
将多条IR操作融合为一条机器指令。
IR:
t1 = a << 2
t2 = b + t1
融合为:
add eax, [b+eax*4] ; 一条指令完成2. 指令分裂(Instruction Split)
将复杂的IR操作分裂为多条机器指令。
IR:
t = a / b ; 除法
分裂为:
mov eax, [a]
cdq ; 符号扩展
idiv dword [b] ; 除法,商在eax3. 指令合并(Instruction Combining)
将相邻的指令合并为更高效的指令。
原始:
mov eax, [a]
mov ebx, [b]
add eax, ebx
合并:
mov eax, [a]
add eax, [b] ; 直接使用内存操作数10.14 特殊指令的处理
某些IR操作没有直接对应的机器指令,需要特殊处理。
1. 浮点运算:
IR: t = sqrt(a)
x87 FPU:
fld [a] ; 加载a到FPU栈
fsqrt ; 计算平方根
fstp [t] ; 存储结果
SSE:
movss xmm0, [a]
sqrtss xmm0, xmm0
movss [t], xmm02. 原子操作:
IR: atomic_add(&x, 1)
x86:
lock add dword [x], 13. 系统调用:
IR: syscall(SYS_write, fd, buf, count)
x86-64 Linux:
mov rax, 1 ; SYS_write
mov rdi, [fd]
mov rsi, [buf]
mov rdx, [count]
syscall常见误区
误区一:指令选择只是简单的模式匹配
指令选择不仅仅是模式匹配,还需要考虑:
- 寄存器约束和分配
- 指令调度和流水线
- 代价优化
- 目标机器的特殊特性
误区二:RISC的指令选择比CISC简单
虽然RISC指令简单,但指令选择并不一定更简单:
- RISC需要更多的指令来完成相同操作
- 寄存器分配更关键
- 延迟槽需要特殊处理
- 指令调度更重要
误区三:指令选择可以独立于寄存器分配
指令选择和寄存器分配是紧密耦合的:
- 指令约束影响寄存器分配
- 寄存器分配影响指令选择
- 需要协同优化
误区四:最优指令选择可以在多项式时间内找到
指令选择是NP完全问题:
- 需要启发式方法
- 实际编译器使用贪心算法或动态规划
- 最优解可能需要指数时间
实践应用
10.15 实际编译器中的指令选择
LLVM的指令选择:
- 使用DAG-to-DAG指令选择
- 基于TableGen的机器描述
- 支持多种目标架构
GCC的指令选择:
- 使用RTL(Register Transfer Language)
- 基于机器描述文件
- 支持多种优化pass
Rust编译器(Cranelift):
- 使用ISLE(Instruction Selection Language)
- 基于规则的指令选择
- 注重编译速度
10.16 指令选择的调试
调试指令选择问题的方法:
查看汇编输出:使用 -S 选项生成汇编文件
对比不同优化级别:比较 -O0、-O2、-O3 的输出
使用编译器探索器:如 godbolt.org 在线查看汇编
分析性能计数器:使用 perf 等工具分析指令执行
本章小结
本章系统介绍了指令选择的理论和实践。核心内容包括:
目标机器建模:CISC与RISC的区别,指令集架构的关键特性。
指令选择的本质:将IR映射到机器指令,面临一对多、多对一映射和约束满足的挑战。
树模式匹配:使用动态规划找到代价最小的树覆盖。
寻址模式:不同寻址模式对指令选择效率的影响。
寄存器分配交互:寄存器约束和分配对指令选择的影响。
延迟槽与条件码:特殊硬件特性的处理。
代价模型:评估指令选择方案的质量。
机器描述:使用声明式方法定义指令集,自动生成指令选择代码。
指令选择是编译器后端的核心环节,其质量直接影响生成代码的性能。理解指令选择的原理对于编写高效代码和优化编译器都至关重要。