章节目录

汇编基础:寄存器、栈槽和返回值

本节阅读量:

CPU 不能直接执行我们写的小语言源码,它真正能执行的是很低层的机器指令。汇编就是这些机器指令的一种文本写法:人能勉强读懂,工具也能把它变成机器码。

所以编译器后半段要做的事,可以先理解成:

1
把小语言表达式,翻译成机器能执行的步骤。

前面我们已经把源码:

1
(+ 40 2)

拆成了 IR:

1
2
t.0 = 40 + 2
return t.0

现在再往下走一步:把这些计算步骤写成 x86-64 汇编。

这一节要讲到哪里

第一章不要求你掌握整套 x86-64,也不要求你从零设计函数调用。但栈帧不能只当成一段需要背下来的固定外壳,因为当前编译器确实用它保存 IR 临时变量。

读完这一节,应该能够说明:

1
2
3
4
5
%rax 为什么会覆盖前一次结果。
subq 怎样为当前函数分配栈空间。
-8(%rbp) 怎样指向一个栈槽。
临时变量怎样写入栈槽,又怎样读回来。
函数返回前怎样一次收回整块栈空间。

至于参数寄存器、调用者与被调用者分别保存哪些寄存器、多层函数调用等完整调用约定,会留到函数章节再展开。

最小核心

如果只看 40 + 2 这件事,核心汇编可以写成:

1
2
3
movq $40, %rax
addq $2, %rax
retq

先按普通话读它:

1
2
3
把 40 放进 %rax。
把 2 加到 %rax。
返回。

这是加法计算最核心的三步。第一章的真实输出还会在外面建立栈帧,并把中间结果保存到栈槽;理解完指令和操作数后,我们再把这些部分合起来。

指令和操作数

看第一行:

1
movq $40, %rax

可以拆成三块:

1
2
3
movq   指令:移动一个 64 位值
$40    源:直接写出来的整数 40
%rax   目标:一个寄存器

这里的 $ 很重要。AT&T 汇编里,$40 表示整数值 40 本身,不是名叫 40 的变量,也不是某个内存位置。

movq 末尾的 q 可以先读作 quad,表示这里使用 8 字节、64 位的指令形式。因此会看到 movq、addq、pushq、popq、retq 这一类名字。对 pushq 和 retq 来说,64 位形式还与当前 x86-64 调用栈上的地址宽度有关,不只是因为小语言的整数使用 64 位。

寄存器可以先理解成 CPU 里很小、很快的存储位置。

当前生成器先选择一个寄存器保存计算中的结果:

1
%rax 被生成器用来保存当前计算结果;调用约定也用它传递整数返回值。

所以:

1
movq $40, %rax

意思就是:

1
%rax = 40

AT&T 语法的顺序

第二行是:

1
addq $2, %rax

本项目输出的是 AT&T 语法。AT&T 语法里,操作数顺序是:

1
指令 源, 目标

所以这一行读作:

1
把整数值 2 加到 %rax 上。

也就是:

1
%rax = %rax + 2

注意不是 %rax 加到 2 上。第一次看 AT&T 汇编时,最容易写反的就是这个顺序。

临时变量需要栈槽

上一节的 IR 里有一个临时变量 t.0:

1
2
t.0 = 40 + 2
return t.0

更复杂一点时,还会有 t.1、t.2:

1
2
3
4
t.0 = 1 + 2
t.1 = 3 + 4
t.2 = t.0 + t.1
return t.2

现在用 %rax 做当前计算结果。%rax 只有一个,后一次计算会覆盖前一次结果。

所以编译器需要给这些临时变量找一个保存位置,目前先使用一个统一规则:

1
每个 IR 临时变量,都保存到一个栈槽。

因此,即使是 (+ 40 2) 这种只有一个临时变量的程序,真实输出里也会多出“建立栈帧、保存 t.0、取回 t.0、收回栈帧”这些指令:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
.globl main
main:
    pushq %rbp
    movq %rsp, %rbp
    subq $16, %rsp
    movq $40, %rax
    addq $2, %rax
    movq %rax, -8(%rbp)
    movq -8(%rbp), %rax
    movq %rbp, %rsp
    popq %rbp
    retq

如果你在 macOS 上,入口名字通常会是 _main,所以开头是:

1
2
.globl _main
_main:

入口名字是平台约定。main: 或 _main: 是 C 运行库随后调用的入口函数;进程真正启动时还会先经过一小段运行库代码,第一章暂时不用展开。

栈向低地址方向增长

真实输出里多出来的栈相关指令,是为了给 t.0 准备一个不会被 %rax 覆盖的保存位置。栈是内存中的一块区域,可以先把它理解成当前函数临时放数据的地方。

x86-64 的栈向较小的内存地址增长。这里先认识两个寄存器:

1
2
%rsp  stack pointer,指向当前栈顶。
%rbp  base pointer,被当前生成器用作栈帧的稳定参考点。

x86-64 并不强制每个函数都把 %rbp 当作 frame pointer;这是第一章生成器为了让栈槽位置清楚、稳定而采用的方案。

因此,对 %rsp 做减法会把栈顶移向更低的地址,也就为当前函数划出新的空间:

1
subq $16, %rsp

可以把它读成:

1
2
新的 %rsp = 原来的 %rsp - 16
当前函数从栈上得到 16 字节空间

这不是创建了一个 C++ 对象,也不会把 16 字节自动清零。它只是移动栈顶,声明这块空间接下来由当前函数使用。

三条指令怎样建立栈帧

函数开头的三条指令是:

1
2
3
pushq %rbp
movq %rsp, %rbp
subq $16, %rsp

它们按顺序完成三件事:

1
2
3
pushq %rbp       把调用者原来的 %rbp 压入栈中保存
movq %rsp, %rbp  让 %rbp 记住当前栈顶,成为稳定参考点
subq $16, %rsp   再把 %rsp 下移 16 字节,留出局部空间

pushq 本身也会改变 %rsp:先把 %rsp 减少 8,再把旧 %rbp 写到新的栈顶。随后 %rbp 取到这个位置,后面的临时变量就可以一直相对 %rbp 寻址,不必关心 %rsp 是否又发生变化。

把三个步骤连起来看,两个寄存器的位置这样变化:

1
2
3
4
进入 main              %rsp 指向返回地址
执行 pushq %rbp        %rsp 下移 8 字节,指向保存的旧 %rbp
执行 movq %rsp, %rbp   %rbp 固定在旧 %rbp 的保存位置
执行 subq $16, %rsp    %rsp 再下移 16 字节,局部空间建立完成

这里说的“栈顶”始终是 %rsp 指向的位置。因为栈向低地址增长,它会画在下面布局图的低地址一端,而不是视觉上的最上方。

对只有一个临时变量 t.0 的程序,建立完成后的布局可以画成:

1
2
3
4
5
6
7
8
较高地址

    8(%rbp)   返回地址
    0(%rbp)   调用者原来的 %rbp       <- %rbp
   -8(%rbp)   t.0 使用的 8 字节栈槽
  -16(%rbp)   对齐留下的空间          <- %rsp

较低地址

运行库调用 main 时已经把返回地址放在栈上;pushq %rbp 又保存了旧 %rbp。当前函数通过 subq 新得到的局部空间,位于 %rbp 的下方,所以使用负偏移。

一个临时变量只需要 8 字节,但当前生成器会把局部空间向上取整到 16 的倍数,所以这里分配 16 字节。不是说每个 8 字节栈槽都必须独占 16 字节,多出的 8 字节只是填充。

这样取整是为了维持 System V x86-64 的栈对齐:

1
2
3
4
调用 main 之前      %rsp 位于 16 字节边界
call 压入返回地址   进入 main 时偏移 8 字节
pushq %rbp          再移动 8 字节,回到 16 字节边界
subq 分配局部空间   继续减去 16 的倍数,保持对齐

第一章只使用这个生成规则,完整的函数调用要求会在函数章节继续解释。

用栈槽保存和取回值

AT&T 汇编里的:

1
-8(%rbp)

表示内存地址“%rbp 保存的地址减去 8”。它不是整数 -8,也不是对 %rbp 做一次永久修改。因为 %rbp 在当前函数中保持不变,所以每次写出 -8(%rbp),都会找到同一个 8 字节栈槽。

保存 t.0 时生成:

1
movq %rax, -8(%rbp)

这条指令把 %rax 当前的 64 位值复制到栈槽。以后即使另一条指令覆盖了 %rax,栈槽里的值仍然保留。

需要 t.0 时再生成:

1
movq -8(%rbp), %rax

这次方向相反:从同一个栈槽读取值,放回 %rax。因此 IR 名字和机器保存位置之间建立了明确对应:

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

一次收回整块栈空间

函数结尾是:

1
2
3
movq %rbp, %rsp
popq %rbp
retq

第一行直接让 %rsp 回到建立栈帧时记住的位置:

1
%rsp = %rbp

这样就一次放弃了 subq 划出的整块局部空间,不需要逐个删除 t.0、t.1。原来的比特可能暂时还留在内存里,但已经不再属于当前函数,后续栈操作可以覆盖它们。

接着:

1
2
popq %rbp  从栈顶取回调用者原来的 %rbp,同时把 %rsp 增加 8
retq       从栈顶取出返回地址,回到调用者

retq 不负责计算或打印返回值。调用约定另外规定函数返回值放在 %rax,所以返回前必须先让 %rax 保存小语言程序的最终结果。

到这里,应该能把栈帧读成一个完整过程:

1
2
3
保存旧参考点 -> 建立新参考点 -> 下移 %rsp 分配空间
             -> 用 %rbp 的负偏移读写栈槽
             -> 恢复 %rsp -> 恢复旧参考点 -> 返回

逐行读一遍

把注释加上以后,目前生成的汇编可以这样读。# 后面的内容是说明:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
.globl main
main:
    pushq %rbp           # 保存旧的 %rbp
    movq %rsp, %rbp      # 建立当前函数的参考点
    subq $16, %rsp       # 给临时变量留栈空间

    movq $40, %rax       # %rax = 40
    addq $2, %rax        # %rax = 42
    movq %rax, -8(%rbp)  # 保存 t.0
    movq -8(%rbp), %rax  # 把最终结果放回 %rax

    movq %rbp, %rsp      # 收回栈空间
    popq %rbp            # 恢复旧的 %rbp
    retq                 # 回到调用者;返回值已经放在 %rax

这里的注释是为了讲解加上的,第一章生成器输出的 out.s 不包含这些注释。

编译并运行汇编

汇编文本还不能直接运行。它通常先放在一个 .s 文件里,再交给系统里的 cc 汇编并链接成可执行文件。

直接生成并运行本章讲解的完整版本:

1
2
3
4
5
cd code/01_numbers
./mini compile examples/add.lang -o out.s
cc out.s -o out
./out
echo $?

mini compile 会根据当前平台选择 main 或 _main。如果使用 Apple Silicon Mac,cc 默认可能按 arm64 处理;因为本项目生成的是 x86-64 汇编,链接时改用:

1
cc -arch x86_64 out.s -o out

这个程序不会打印 42。它只是把 42 放进 %rax,然后从入口函数返回。程序启动代码会把 main 的返回值变成进程退出码。

echo $? 显示上一个程序的退出码,所以这里会看到:

1
42

进入 compile 前

下一节会看编译器怎样根据临时变量数量计算栈帧大小,再生成这些指令。进入下一节前,不需要掌握完整 x86-64,但应该能读懂下面这些:

1
2
3
4
5
6
7
8
9
$2           整数值 2 本身
%rax         当前计算结果,也是返回值位置
%rsp         会移动的栈顶
%rbp         当前栈帧的稳定参考点
movq         把源操作数的值复制到目标位置
addq         把源操作数加到目标操作数上
subq         减小 %rsp,为栈帧划出空间
-8(%rbp)     从 %rbp 向低地址偏移 8 字节的栈槽
retq         取出返回地址,回到调用者

还应该能把下面四条变化连起来:

1
2
3
4
subq $16, %rsp          分配 16 字节栈空间
movq %rax, -8(%rbp)     把 t.0 保存到栈槽
movq -8(%rbp), %rax     把最终结果取回 %rax
movq %rbp, %rsp         一次收回当前栈帧的局部空间

能够说明这四步为什么成对出现,就已经具备阅读第一章汇编生成器所需的栈帧知识。


1.6 IR:编译器的草稿步骤

上一节

1.8 compile:把 IR 翻译成汇编

下一节