TT Lab
开始
学习 学习路径 课程

编译器 — 从头到尾亲手打造一门小语言

寄存器、栈帧与 16 字节 — 调用约定

在 TT Lab 中继续学习

一句话总结

原生代码生成就是把树翻译成 CPU 指令(这里是 x86-64 的 GNU as 语法)。表达式的值放在寄存器(%rax)里,不够用时临时压到栈上;函数则按调用约定(System V AMD64)规定的方式,用寄存器传参并建立栈帧。这些约定中,最容易被悄悄违反的,是在 call 之前把栈对齐到 16 字节这条规则。

为什么需要它

第 6 个模块的 VM 每执行一条指令,Python 的循环就要转一圈。原生代码没有这个循环——CPU 直接读取指令。作为代价,原来由 VM 代劳的事,现在全要由编译器来决定:值放在哪个寄存器,局部变量放在内存的哪里,调用函数时参数传到哪里,又怎么守住返回的位置。

这些决定里有一部分,我们不能随心所欲地定。我们生成的代码要想和 C 库(printf)或其他编译器生成的代码互相调用,就必须遵守同一套约定。在 Linux x86-64 上,这套约定就是 System V AMD64 ABI。

工作原理

表达式放在 %rax,不够就放栈上。本课程的代码生成器使用最简单的方式:所有表达式的值都放在 %rax 里。二元运算先计算左边,用 push %rax 压到栈上,再计算右边并移到 %rcx,然后把左边用 pop %rax 弹出来进行运算。它不使用节省寄存器的方法(第 9 个模块的寄存器分配),所以慢,但任何表达式都能翻译得不出错。

print 1 + 2 * 3;                           main 의 프레임
    movabs $1, %rax                        높은 주소
    push %rax          ← 왼쪽(1)을 올림     │ 돌아갈 주소        │  call 이 넣음
    movabs $2, %rax                        │ 이전 %rbp          │  ← %rbp
    push %rax          ← 왼쪽(2)을 올림     │ 지역 변수 슬롯 0    │  -8(%rbp)
    movabs $3, %rax                        │ 지역 변수 슬롯 1    │  -16(%rbp)
    mov %rax, %rcx                         │ (16의 배수로 맞춤)  │
    pop %rax           ← 2                 │ 식 계산용 push …    │  ← %rsp
    imul %rcx, %rax    ← 6                 낮은 주소
    mov %rax, %rcx
    pop %rax           ← 1
    add %rcx, %rax     ← 7
    mov %rax, %rdi     ← 첫 인자
    call mini_print_int

调用约定。整数参数按 %rdi %rsi %rdx %rcx %r8 %r9 的顺序传递,返回值通过 %rax 传回。接收方用 push %rbp · mov %rsp, %rbp · sub $N, %rsp 建立栈帧,并把参数转存到自己的槽位(-8(%rbp) ……)——因为在计算函数体的过程中,这些寄存器会被别的调用用到。结束时用 leave · ret 撤掉栈帧并返回。参数从左边开始计算,依次 push,全部算完之后倒过来依次 pop 装进寄存器,这样即使计算参数的过程中有调用覆盖了寄存器,也是安全的。

16 字节对齐。ABI 要求在 call 的那一刻,%rsp 是 16 的倍数。进入函数时,因为返回地址(8 字节)而差了 8,用 push %rbp 又对齐了,如果把栈帧大小取为 16 的倍数,那么在函数体开头就是对齐的。此后,每多一次用于计算表达式的 push,就会错开 8 字节、对齐、再错开。所以如果在已压入的格数为奇数的情况下 call,就违反了规则——比如 1 + f(2) 这种把左边压入栈之后,又在右边里调用函数的情形。这时要用 sub $8, %rsp 对齐后再调用,返回后用 add $8, %rsp 恢复。

违反这条规则,通常是悄无声息的。只有在 printf 内部碰到要求 16 字节对齐的 SSE 指令(movaps)的那一天,才会因段错误而崩溃——有的输入正常,有的输入不行,这是最糟糕的一类 bug。本实验的运行时辅助函数(/opt/fixtures/mini/runtime.c)每次被调用都会检查对齐,立刻告诉你。

运行时辅助函数。除以 0、INT64_MIN / -1、负指数的检查,不是由代码生成器在每个表达式里零散地写,而是把行和列一起交给用 C 写的辅助函数(mini_div、mini_mod、mini_pow)。idiv 在 INT64_MIN / -1 时会引发 CPU 异常(SIGFPE),所以如果直接使用,含义就会与解释器出现分歧。

在现场相遇的样子

下一项实验要做什么

在 codegen.py 中依次做出 main 与输出、算术、比较与运行时辅助函数调用、全局变量(.data)、块内局部变量(栈帧槽位)、if、while 和短路求值(标号与跳转)、函数与参数寄存器,并确认在计算过程中的调用里栈对齐能够保持,最后做出把整个程序翻译成汇编并交给 gcc 烧制的 build。评分器会在每个步骤都把你的汇编真的烧制出来并运行,看它是否打印出与解释器相同的行。