汇编:让 CPU 真的选择道路

本节阅读量:

上一节已经把 if lowering 成带控制流的 IR:

1
2
3
4
5
6
7
8
9
t.0 = eq? 0 0
if t.0 goto then0 else else0
then0:
t.1 = 42
goto end0
else0:
t.1 = 7
end0:
return t.1

这一节把这些 IR 操作翻译成 x86-64 汇编。第二章已有的 copyadd、栈槽分配和函数外壳继续使用;第三章真正新增的是三件事:

1
2
3
eq? 表达式  ->  产生 0 或 1
branch      ->  根据 0 / 非 0 选择下一段代码
label/jump  ->  给代码位置命名,并跳到那里

代码在:

1
code/03_conditionals/src/compile/assembly.cpp

比较有两种用途

第三章会在两个地方用到比较:

1
2
3
4
5
(eq? a b)
    比较之后要产生一个普通值:0 或 1

if condition then else
    比较之后不需要产生新值,只需要决定跳到哪里

这两件事都从 cmpq 开始,但后面的指令不同。

谓词要产生一个值

先看 eq?

1
t.0 = eq? 0 0

它是一个表达式,后面可能继续被 if+ 或别的表达式使用。因此汇编生成器必须把比较结果保存成一个普通整数。

核心输出是:

1
2
3
4
5
movq $0, %rax
cmpq $0, %rax
sete %al
movzbq %al, %rax
movq %rax, -8(%rbp)

可以先按这条线读:

1
2
3
4
5
movq    先把被检查的值放进 %rax
cmpq    比较 %rax 和 0
sete    如果相等,把 %al 设成 1;否则设成 0
movzbq  把这个 8 位结果扩展成 64 位整数
movq    把 0 或 1 存进 t.0 的栈槽

关键在中间三步。

cmpq $0, %rax 使用的是 AT&T 语法,操作数顺序是“源,目标”。它会比较目标 %rax 和源 $0,也就是问:%rax 里的值是不是等于 0

cmpq 自己不会把 01 写进普通寄存器。它只是更新 CPU 里的标志位,其中有一个标志表示“刚才比较的两个值是否相等”。后面的 sete 会读取这个标志:

1
sete = set if equal

如果刚才比较相等,sete %al 就把 %al 写成 1;否则写成 0

这里的 %al%rax 的低 8 位。sete 只能写一个字节,不会自动清理 %rax 剩下的高位。为了得到一个干净的 64 位整数,后面要执行:

1
movzbq %al, %rax

movzbq 可以读成 “move with zero extend from byte to quad word”:从一个字节读值,用 0 填满高位,扩展成 64 位。执行完这条指令后,整个 %rax 才可靠地等于 01,可以安全保存到 t.0 的栈槽。

对应的代码是 OpKind::equal 这一支:

1
2
3
4
5
6
7
case OpKind::equal:
    out_ += "    movq " + operand_text(op.lhs) + ", %rax\n";
    out_ += "    cmpq " + operand_text(op.rhs) + ", %rax\n";
    out_ += "    sete %al\n";
    out_ += "    movzbq %al, %rax\n";
    out_ += "    movq %rax, " + stack_slot(op.dst) + "\n";
    return;

到这里,eq? 只是“会产生 0 或 1 的普通表达式”。真正改变执行路线的是下一条 branch

branch 只决定下一步去哪

IR 里的 branch 是:

1
if t.0 goto then0 else else0

它不会产生新值,也不会分配栈槽。它只读取 condition,然后选择下一段代码。

本章生成的汇编是:

1
2
3
movq -8(%rbp), %rax
cmpq $0, %rax
je .Lelse0

意思是:

1
2
3
4
读取 condition
比较它和 0
如果等于 0,就跳到 else
否则不跳转,继续执行下一条指令

这里的 cmpq 也只是更新标志位。和前面的 sete 不同,je 不把相等结果变成整数,而是直接根据“相等”这个标志决定是否跳转:

1
je = jump if equal

为什么这里没有 jmp .Lthen0

因为上一节的 lowering 固定把 then0: 放在 branch 后面:

1
2
3
if t.0 goto then0 else else0
then0:
...

所以 condition 非 0 时,CPU 顺着往下执行,自然进入 then 分支。只有 condition 为 0 时,才需要用 je .Lelse0 跳开 then。

对应的代码分支也很短:

1
2
3
4
5
case OpKind::branch:
    out_ += "    movq " + operand_text(op.lhs) + ", %rax\n";
    out_ += "    cmpq $0, %rax\n";
    out_ += "    je " + label_text(op.else_target) + "\n";
    return;

label 和 jump 保住两条路

IR label 只是名字:

1
2
3
then0:
else0:
end0:

汇编里给它们加上 .L 前缀,变成本函数内部使用的标签:

1
2
3
then0  -> .Lthen0:
else0  -> .Lelse0:
end0   -> .Lend0:

label 本身不执行计算,只是在汇编文本里标出一个位置:

1
2
3
case OpKind::label:
    out_ += label_text(op.target) + ":\n";
    return;

jump 则是不看条件,直接前往目标:

1
2
3
case OpKind::jump:
    out_ += "    jmp " + label_text(op.target) + "\n";
    return;

then 分支末尾的这条跳转很关键:

1
jmp .Lend0

没有它,执行完 then 后会继续落入紧随其后的 else,两个分支就都会写结果。jmp .Lend0 的作用是:then 已经算完了,直接去汇合点,跳过 else。

同一个结果名对应同一个栈槽

上一节说过,if 的两个分支会写同一个结果名:

1
2
3
4
5
6
7
then0:
t.1 = 42
goto end0
else0:
t.1 = 7
end0:
return t.1

汇编生成器分配栈槽时,只给会产生值的操作分配位置:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
case OpKind::copy:
case OpKind::add:
case OpKind::equal:
    if (slots.find(op.dst) == slots.end()) {
        slots[op.dst] = offset;
        offset -= 8;
    }
    break;
case OpKind::branch:
case OpKind::jump:
case OpKind::label:
    break;

第一次看到 t.1 时分配一个栈槽;第二次看到同名 t.1 时继续使用原来的位置。因此这个例子里可以理解成:

1
2
t.0 -> -8(%rbp)
t.1 -> -16(%rbp)

then 写 -16(%rbp),else 也写 -16(%rbp)。运行时只会执行其中一个分支,所以到达 end0 时,-16(%rbp) 中就是整个 if 的结果。

完整走一遍

源码:

1
(if (eq? 0 0) 42 7)

去掉函数外壳后,核心汇编是:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
movq $0, %rax
cmpq $0, %rax
sete %al
movzbq %al, %rax
movq %rax, -8(%rbp)

movq -8(%rbp), %rax
cmpq $0, %rax
je .Lelse0

.Lthen0:
movq $42, %rax
movq %rax, -16(%rbp)
jmp .Lend0

.Lelse0:
movq $7, %rax
movq %rax, -16(%rbp)

.Lend0:
movq -16(%rbp), %rax

这段汇编要按“执行路线”读,而不是只按文件顺序读。

在这个例子里,(eq? 0 0) 先得到 1,保存到 -8(%rbp)。branch 再比较这个值和 0:因为它不是 0je .Lelse0 不会跳转,于是执行继续落到 .Lthen0:,把 42 写进 -16(%rbp),然后 jmp .Lend0 跳过 else。

如果 condition 是 0je .Lelse0 会直接跳到 else,then 分支不会执行。两条路线最后都会到达 .Lend0:,再把共享结果栈槽读回 %rax,作为整个程序的返回值。

到这里,第三章的编译路径就接通了:

1
2
3
if 表达式
-> branch / label / jump IR
-> cmpq / je / jmp 汇编

解释器在 C++ 里选择一个分支;编译器则生成两段代码,让运行时的 CPU 用跳转选择其中一段。

手动验证

1
2
3
4
5
6
cd code/03_conditionals
make
./mini compile examples/if.lang -o out.s
cc out.s -o out
./out
echo $?

退出码应该是:

1
42

3.6 IR lowering:把 AST 变成分支和汇合

上一节

3.8 本章总结

下一节