汇编:让 CPU 真的选择道路
本节阅读量:上一节已经把 if lowering 成带控制流的 IR:
|
|
这一节把这些 IR 操作翻译成 x86-64 汇编。第二章已有的 copy、add、栈槽分配和函数外壳继续使用;第三章真正新增的是三件事:
|
|
代码在:
|
|
比较有两种用途
第三章会在两个地方用到比较:
|
|
这两件事都从 cmpq 开始,但后面的指令不同。
谓词要产生一个值
先看 eq?:
|
|
它是一个表达式,后面可能继续被 if、+ 或别的表达式使用。因此汇编生成器必须把比较结果保存成一个普通整数。
核心输出是:
|
|
可以先按这条线读:
|
|
关键在中间三步。
cmpq $0, %rax 使用的是 AT&T 语法,操作数顺序是“源,目标”。它会比较目标 %rax 和源 $0,也就是问:%rax 里的值是不是等于 0?
cmpq 自己不会把 0 或 1 写进普通寄存器。它只是更新 CPU 里的标志位,其中有一个标志表示“刚才比较的两个值是否相等”。后面的 sete 会读取这个标志:
|
|
如果刚才比较相等,sete %al 就把 %al 写成 1;否则写成 0。
这里的 %al 是 %rax 的低 8 位。sete 只能写一个字节,不会自动清理 %rax 剩下的高位。为了得到一个干净的 64 位整数,后面要执行:
|
|
movzbq 可以读成 “move with zero extend from byte to quad word”:从一个字节读值,用 0 填满高位,扩展成 64 位。执行完这条指令后,整个 %rax 才可靠地等于 0 或 1,可以安全保存到 t.0 的栈槽。
对应的代码是 OpKind::equal 这一支:
|
|
到这里,eq? 只是“会产生 0 或 1 的普通表达式”。真正改变执行路线的是下一条 branch。
branch 只决定下一步去哪
IR 里的 branch 是:
|
|
它不会产生新值,也不会分配栈槽。它只读取 condition,然后选择下一段代码。
本章生成的汇编是:
|
|
意思是:
|
|
这里的 cmpq 也只是更新标志位。和前面的 sete 不同,je 不把相等结果变成整数,而是直接根据“相等”这个标志决定是否跳转:
|
|
为什么这里没有 jmp .Lthen0?
因为上一节的 lowering 固定把 then0: 放在 branch 后面:
|
|
所以 condition 非 0 时,CPU 顺着往下执行,自然进入 then 分支。只有 condition 为 0 时,才需要用 je .Lelse0 跳开 then。
对应的代码分支也很短:
|
|
label 和 jump 保住两条路
IR label 只是名字:
|
|
汇编里给它们加上 .L 前缀,变成本函数内部使用的标签:
|
|
label 本身不执行计算,只是在汇编文本里标出一个位置:
|
|
jump 则是不看条件,直接前往目标:
|
|
then 分支末尾的这条跳转很关键:
|
|
没有它,执行完 then 后会继续落入紧随其后的 else,两个分支就都会写结果。jmp .Lend0 的作用是:then 已经算完了,直接去汇合点,跳过 else。
同一个结果名对应同一个栈槽
上一节说过,if 的两个分支会写同一个结果名:
|
|
汇编生成器分配栈槽时,只给会产生值的操作分配位置:
|
|
第一次看到 t.1 时分配一个栈槽;第二次看到同名 t.1 时继续使用原来的位置。因此这个例子里可以理解成:
|
|
then 写 -16(%rbp),else 也写 -16(%rbp)。运行时只会执行其中一个分支,所以到达 end0 时,-16(%rbp) 中就是整个 if 的结果。
完整走一遍
源码:
|
|
去掉函数外壳后,核心汇编是:
|
|
这段汇编要按“执行路线”读,而不是只按文件顺序读。
在这个例子里,(eq? 0 0) 先得到 1,保存到 -8(%rbp)。branch 再比较这个值和 0:因为它不是 0,je .Lelse0 不会跳转,于是执行继续落到 .Lthen0:,把 42 写进 -16(%rbp),然后 jmp .Lend0 跳过 else。
如果 condition 是 0,je .Lelse0 会直接跳到 else,then 分支不会执行。两条路线最后都会到达 .Lend0:,再把共享结果栈槽读回 %rax,作为整个程序的返回值。
到这里,第三章的编译路径就接通了:
|
|
解释器在 C++ 里选择一个分支;编译器则生成两段代码,让运行时的 CPU 用跳转选择其中一段。
手动验证
|
|
退出码应该是:
|
|