汇编基础:寄存器、栈槽和返回值
本节阅读量:CPU 不能直接执行我们写的小语言源码,它真正能执行的是很低层的机器指令。汇编就是这些机器指令的一种文本写法:人能勉强读懂,工具也能把它变成机器码。
所以编译器后半段要做的事,可以先理解成:
|
|
前面我们已经把源码:
|
|
拆成了 IR:
|
|
现在再往下走一步:把这些计算步骤写成 x86-64 汇编。
最小核心
如果只看 40 + 2 这件事,核心汇编可以写成:
|
|
先按普通话读它:
|
|
编译器生成的就是这一串步骤,让机器执行完以后,产生结果 42。
指令和操作数
看第一行:
|
|
可以拆成三块:
|
|
这里的 $ 很重要。AT&T 汇编里,$40 表示整数值 40 本身,不是名叫 40 的变量,也不是某个内存位置。
movq 末尾的 q 可以先读作 quad,也就是 8 字节、64 位。目前处理的是 64 位整数,所以会看到 movq、addq、pushq、popq、retq 这一类名字。
寄存器可以先理解成 CPU 里很小、很快的存储位置。
现在先记住一个寄存器:
|
|
所以:
|
|
意思就是:
|
|
AT&T 语法的顺序
第二行是:
|
|
本项目输出的是 AT&T 语法。AT&T 语法里,操作数顺序是:
|
|
所以这一行读作:
|
|
也就是:
|
|
注意不是 %rax 加到 2 上。第一次看 AT&T 汇编时,最容易写反的就是这个顺序。
临时变量需要栈槽
上一节的 IR 里有一个临时变量 t.0:
|
|
更复杂一点时,还会有 t.1、t.2:
|
|
现在用 %rax 做当前计算结果。%rax 只有一个,后一次计算会覆盖前一次结果。
所以编译器需要给这些临时变量找一个保存位置,目前先使用一个统一规则:
|
|
因此,即使是 (+ 40 2) 这种只有一个临时变量的程序,真实输出里也会多出“建立栈帧、保存 t.0、取回 t.0、收回栈帧”这些指令:
|
|
如果你在 macOS 上,入口名字通常会是 _main,所以开头是:
|
|
入口名字是平台约定。现在先不用深究,只要知道 main: 或 _main: 是程序开始执行的位置。
%rsp 和 %rbp
真实输出里多出来的栈相关指令,是为了给 t.0 准备一个可以保存结果的位置。
栈可以先理解成:
|
|
这一节只认识两个寄存器:
|
|
为什么需要两个?
%rsp 会随着保存数据、分配空间而变化。%rbp 建好以后通常不动,所以用它当参考点更方便。
建立栈帧
这三行出现在函数开头:
|
|
可以先这样读:
|
|
这块空间叫栈帧。现在用它保存 IR 里的临时变量。
这里只有一个临时变量 t.0,本来 8 字节就够了。生成器仍然分配了 16 字节,是为了让留出的栈空间保持 16 的倍数,这是一个常见对齐要求。
栈槽:-8(%rbp)
这两行和临时变量有关:
|
|
-8(%rbp) 可以读作:
|
|
也就是当前栈帧里的一个小格子。这个格子常叫栈槽。
所以:
|
|
表示:
|
|
而:
|
|
表示:
|
|
收回栈帧并返回
函数结尾是:
|
|
可以先这样读:
|
|
retq 本身不写返回值。它只是返回;返回值在哪里,是约定好的:在 %rax 里。
如果栈帧的建立和收回现在还看不太懂,不用担心。后面讲函数调用时,会再系统讲栈帧。现在只需要知道它们是在给临时变量准备和收回栈空间,了解通过 subq 分配空间,通过偏移量进行访问即可。
逐行读一遍
把注释加上以后,目前生成的汇编可以这样读。# 后面的内容是说明:
|
|
这里的注释是为了讲解加上的,生成器输出的 out.s 里不一定有这些注释。
编译并运行汇编
汇编文本还不能直接运行。它通常先放在一个 .s 文件里,再交给系统里的 cc 变成可执行文件。
为了演示怎么编译运行,这里先写最小版本,不保存 t.0。
例如有一个 add.s 文件:
|
|
可以这样编译和运行:
|
|
如果你在 macOS 上,还要把 add.s 开头的入口名字改成 _main:
|
|
如果使用 Apple Silicon Mac,cc 默认可能按 arm64 汇编。因为这里写的是 x86-64 汇编,还要指定 x86-64 架构:
|
|
这个程序不会打印 42。它只是把 42 放进 %rax,然后从入口函数返回。
echo $? 显示上一个程序的退出码,所以这里会看到:
|
|
进入 compile 前
下一节会看编译器怎样生成这些汇编。进入下一节前,不需要完全理解 x86-64 的所有细节,只要能读懂下面这些就够了:
|
|
如果你能把下面三行读成“把 40 放进 %rax,再加上 2,然后返回”,就可以继续读下一节:
|
|