汇编基础:寄存器、栈槽和返回值
本节阅读量:CPU 不能直接执行我们写的小语言源码,它真正能执行的是很低层的机器指令。汇编就是这些机器指令的一种文本写法:人能勉强读懂,工具也能把它变成机器码。
所以编译器后半段要做的事,可以先理解成:
|
|
前面我们已经把源码:
|
|
拆成了 IR:
|
|
现在再往下走一步:把这些计算步骤写成 x86-64 汇编。
这一节要讲到哪里
第一章不要求你掌握整套 x86-64,也不要求你从零设计函数调用。但栈帧不能只当成一段需要背下来的固定外壳,因为当前编译器确实用它保存 IR 临时变量。
读完这一节,应该能够说明:
|
|
至于参数寄存器、调用者与被调用者分别保存哪些寄存器、多层函数调用等完整调用约定,会留到函数章节再展开。
最小核心
如果只看 40 + 2 这件事,核心汇编可以写成:
|
|
先按普通话读它:
|
|
这是加法计算最核心的三步。第一章的真实输出还会在外面建立栈帧,并把中间结果保存到栈槽;理解完指令和操作数后,我们再把这些部分合起来。
指令和操作数
看第一行:
|
|
可以拆成三块:
|
|
这里的 $ 很重要。AT&T 汇编里,$40 表示整数值 40 本身,不是名叫 40 的变量,也不是某个内存位置。
movq 末尾的 q 可以先读作 quad,表示这里使用 8 字节、64 位的指令形式。因此会看到 movq、addq、pushq、popq、retq 这一类名字。对 pushq 和 retq 来说,64 位形式还与当前 x86-64 调用栈上的地址宽度有关,不只是因为小语言的整数使用 64 位。
寄存器可以先理解成 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: 是 C 运行库随后调用的入口函数;进程真正启动时还会先经过一小段运行库代码,第一章暂时不用展开。
栈向低地址方向增长
真实输出里多出来的栈相关指令,是为了给 t.0 准备一个不会被 %rax 覆盖的保存位置。栈是内存中的一块区域,可以先把它理解成当前函数临时放数据的地方。
x86-64 的栈向较小的内存地址增长。这里先认识两个寄存器:
|
|
x86-64 并不强制每个函数都把 %rbp 当作 frame pointer;这是第一章生成器为了让栈槽位置清楚、稳定而采用的方案。
因此,对 %rsp 做减法会把栈顶移向更低的地址,也就为当前函数划出新的空间:
|
|
可以把它读成:
|
|
这不是创建了一个 C++ 对象,也不会把 16 字节自动清零。它只是移动栈顶,声明这块空间接下来由当前函数使用。
三条指令怎样建立栈帧
函数开头的三条指令是:
|
|
它们按顺序完成三件事:
|
|
pushq 本身也会改变 %rsp:先把 %rsp 减少 8,再把旧 %rbp 写到新的栈顶。随后 %rbp 取到这个位置,后面的临时变量就可以一直相对 %rbp 寻址,不必关心 %rsp 是否又发生变化。
把三个步骤连起来看,两个寄存器的位置这样变化:
|
|
这里说的“栈顶”始终是 %rsp 指向的位置。因为栈向低地址增长,它会画在下面布局图的低地址一端,而不是视觉上的最上方。
对只有一个临时变量 t.0 的程序,建立完成后的布局可以画成:
|
|
运行库调用 main 时已经把返回地址放在栈上;pushq %rbp 又保存了旧 %rbp。当前函数通过 subq 新得到的局部空间,位于 %rbp 的下方,所以使用负偏移。
一个临时变量只需要 8 字节,但当前生成器会把局部空间向上取整到 16 的倍数,所以这里分配 16 字节。不是说每个 8 字节栈槽都必须独占 16 字节,多出的 8 字节只是填充。
这样取整是为了维持 System V x86-64 的栈对齐:
|
|
第一章只使用这个生成规则,完整的函数调用要求会在函数章节继续解释。
用栈槽保存和取回值
AT&T 汇编里的:
|
|
表示内存地址“%rbp 保存的地址减去 8”。它不是整数 -8,也不是对 %rbp 做一次永久修改。因为 %rbp 在当前函数中保持不变,所以每次写出 -8(%rbp),都会找到同一个 8 字节栈槽。
保存 t.0 时生成:
|
|
这条指令把 %rax 当前的 64 位值复制到栈槽。以后即使另一条指令覆盖了 %rax,栈槽里的值仍然保留。
需要 t.0 时再生成:
|
|
这次方向相反:从同一个栈槽读取值,放回 %rax。因此 IR 名字和机器保存位置之间建立了明确对应:
|
|
一次收回整块栈空间
函数结尾是:
|
|
第一行直接让 %rsp 回到建立栈帧时记住的位置:
|
|
这样就一次放弃了 subq 划出的整块局部空间,不需要逐个删除 t.0、t.1。原来的比特可能暂时还留在内存里,但已经不再属于当前函数,后续栈操作可以覆盖它们。
接着:
|
|
retq 不负责计算或打印返回值。调用约定另外规定函数返回值放在 %rax,所以返回前必须先让 %rax 保存小语言程序的最终结果。
到这里,应该能把栈帧读成一个完整过程:
|
|
逐行读一遍
把注释加上以后,目前生成的汇编可以这样读。# 后面的内容是说明:
|
|
这里的注释是为了讲解加上的,第一章生成器输出的 out.s 不包含这些注释。
编译并运行汇编
汇编文本还不能直接运行。它通常先放在一个 .s 文件里,再交给系统里的 cc 汇编并链接成可执行文件。
直接生成并运行本章讲解的完整版本:
|
|
mini compile 会根据当前平台选择 main 或 _main。如果使用 Apple Silicon Mac,cc 默认可能按 arm64 处理;因为本项目生成的是 x86-64 汇编,链接时改用:
|
|
这个程序不会打印 42。它只是把 42 放进 %rax,然后从入口函数返回。程序启动代码会把 main 的返回值变成进程退出码。
echo $? 显示上一个程序的退出码,所以这里会看到:
|
|
进入 compile 前
下一节会看编译器怎样根据临时变量数量计算栈帧大小,再生成这些指令。进入下一节前,不需要掌握完整 x86-64,但应该能读懂下面这些:
|
|
还应该能把下面四条变化连起来:
|
|
能够说明这四步为什么成对出现,就已经具备阅读第一章汇编生成器所需的栈帧知识。