寄存器、栈帧与 16 字节 — 调用约定
一句话总结
原生代码生成就是把树翻译成 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),所以如果直接使用,含义就会与解释器出现分歧。
在现场相遇的样子
gcc -O0 -S的样子。没有优化的 gcc 输出也和这种方式很像——所有局部变量都在-8(%rbp)之类的栈格子里,每次使用都要读写。到第 10 个模块,把它和-O2的输出并排来看,就能看出寄存器分配和优化去掉了什么。- 对齐事故。手写汇编或 JIT 生成的代码在调用 C 函数时违反了对齐,在
movaps处崩溃,这在实际中很常见。栈对齐在 bug 报告中会表现为“只有某些输入会在 printf 内部发生段错误”。 - 其他 ABI。Windows x64 的参数寄存器是
%rcx %rdx %r8 %r9,而且调用方必须准备 32 字节的“影子空间”。即使是同一种 CPU,不同的操作系统约定也不同,这是交叉编译中经常碰到的地方。
下一项实验要做什么
在 codegen.py 中依次做出 main 与输出、算术、比较与运行时辅助函数调用、全局变量(.data)、块内局部变量(栈帧槽位)、if、while 和短路求值(标号与跳转)、函数与参数寄存器,并确认在计算过程中的调用里栈对齐能够保持,最后做出把整个程序翻译成汇编并交给 gcc 烧制的 build。评分器会在每个步骤都把你的汇编真的烧制出来并运行,看它是否打印出与解释器相同的行。