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

本节阅读量:

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

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

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

前面我们已经把源码:

1
(+ 40 2)

拆成了 IR:

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

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

最小核心

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

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

先按普通话读它:

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

编译器生成的就是这一串步骤,让机器执行完以后,产生结果 42

指令和操作数

看第一行:

1
movq $40, %rax

可以拆成三块:

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

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

movq 末尾的 q 可以先读作 quad,也就是 8 字节、64 位。目前处理的是 64 位整数,所以会看到 movqaddqpushqpopqretq 这一类名字。

寄存器可以先理解成 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.1t.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: 是程序开始执行的位置。

%rsp 和 %rbp

真实输出里多出来的栈相关指令,是为了给 t.0 准备一个可以保存结果的位置。

栈可以先理解成:

1
当前函数临时放东西的地方。

这一节只认识两个寄存器:

1
2
%rsp  stack pointer,指向当前栈顶。
%rbp  base pointer,当作当前函数的稳定参考点。

为什么需要两个?

%rsp 会随着保存数据、分配空间而变化。%rbp 建好以后通常不动,所以用它当参考点更方便。

建立栈帧

这三行出现在函数开头:

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

可以先这样读:

1
2
3
pushq %rbp       保存旧的 %rbp
movq %rsp, %rbp  把当前 %rsp 记为新的参考点
subq $16, %rsp   给当前函数留出 16 字节空间

这块空间叫栈帧。现在用它保存 IR 里的临时变量。

这里只有一个临时变量 t.0,本来 8 字节就够了。生成器仍然分配了 16 字节,是为了让留出的栈空间保持 16 的倍数,这是一个常见对齐要求。

栈槽:-8(%rbp)

这两行和临时变量有关:

1
2
movq %rax, -8(%rbp)
movq -8(%rbp), %rax

-8(%rbp) 可以读作:

1
从 %rbp 这个参考点往下 8 字节的位置。

也就是当前栈帧里的一个小格子。这个格子常叫栈槽。

所以:

1
movq %rax, -8(%rbp)

表示:

1
把 %rax 里的值保存到这个栈槽。

而:

1
movq -8(%rbp), %rax

表示:

1
把这个栈槽里的值取回 %rax。

收回栈帧并返回

函数结尾是:

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

可以先这样读:

1
2
3
movq %rbp, %rsp  收回当前函数留出的栈空间
popq %rbp        恢复旧的 %rbp
retq             返回,返回值在 %rax 里

retq 本身不写返回值。它只是返回;返回值在哪里,是约定好的:在 %rax 里。

如果栈帧的建立和收回现在还看不太懂,不用担心。后面讲函数调用时,会再系统讲栈帧。现在只需要知道它们是在给临时变量准备和收回栈空间,了解通过 subq 分配空间,通过偏移量进行访问即可。

逐行读一遍

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

 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 变成可执行文件。

为了演示怎么编译运行,这里先写最小版本,不保存 t.0

例如有一个 add.s 文件:

1
2
3
4
5
.globl main
main:
    movq $40, %rax
    addq $2, %rax
    retq

可以这样编译和运行:

1
2
3
cc add.s -o add
./add
echo $?

如果你在 macOS 上,还要把 add.s 开头的入口名字改成 _main

1
2
.globl _main
_main:

如果使用 Apple Silicon Mac,cc 默认可能按 arm64 汇编。因为这里写的是 x86-64 汇编,还要指定 x86-64 架构:

1
cc -arch x86_64 add.s -o add

这个程序不会打印 42。它只是把 42 放进 %rax,然后从入口函数返回。

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

1
42

进入 compile 前

下一节会看编译器怎样生成这些汇编。进入下一节前,不需要完全理解 x86-64 的所有细节,只要能读懂下面这些就够了:

1
2
3
4
5
6
$2          整数值 2 本身
%rax        当前计算结果,也是返回值位置
movq        把一个值放到另一个位置
addq        把源操作数加到目标操作数上
-8(%rbp)    当前栈帧里的一个栈槽
retq        返回,返回值已经放在 %rax 里

如果你能把下面三行读成“把 40 放进 %rax,再加上 2,然后返回”,就可以继续读下一节:

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

1.6 IR:编译器的草稿步骤

上一节

1.8 compile:把 IR 翻译成汇编

下一节