编写编译器非常简单(第 1 部分)
从十几岁起,我就想创建自己的系统编程语言。这样的编程语言肯定需要编译为本机代码,这意味着我必须编写一个编译器。
尽管我设法编写了几个 半成品 的解析器,但我总是在生成汇编代码的阶段失败,因为任务变得太复杂了。
在这篇博客中,我打算向十几岁的自己展示如何编写一个代码生成器,事实上,一点也不复杂,它可以在几个周末内完全完成。(只要我们做一些简化的假设)
我假设您已经知道在representing programs within a program is surprisingly easy
从哪里开始呢?
今天的目标是将高级语言转换为 x86-64 机器代码。高级语言将具有整数、变量、求反和加法。
为了使工作更简单,我们将把高级代码转换为 x86-64 汇编代码,并使用已存在的汇编器(如 GCC 中的汇编器)将其转换为机器代码。
让我们开始吧。以下是我们表示 整数字面值 的方式:
c
struct int_literal {
int value;
};
编译它们很简单:
c
void compile_int_literal(struct int_literal* e) {
printf("mov $%d, %%rax\n", e->value);
}
printf
使用%
字符来表示格式化命令,因此使用双%%
来逐字打印 %字符。
这里我使用了x86-64 mov
指令。该指令将数据从一个地方移动到另一个地方。在本博客中使用的符号中,数据流是从左到右的(即指令的工作方式为 mov source, destination
)
在本例中,我们将 立即数(直接写入代码中的数字常量,前面用 $
表示)移至 寄存器(直接位于 CPU 内部的一小块数据空间) ,写为 %rax
)
x86-64 mov
指令可以通过多种方式使用("寻址模式"):立即数到寄存器(我们刚刚使用的)、寄存器到内存、内存到寄存器、寄存器到寄存器等。稍后我们会看到其中的一些。
我想指出的是,在此刻,许多设计决策已经确定。最明显的是:
-
我们的编译器将使用
printf
将 AT&T 风格的汇编代码输出到stdout
。 -
编译表达式的结果将是 计算该表达式的值 并将其存储在
%rax
中的汇编代码
最后一点在很大程度上使编译器变得如此简单,但这也意味着我们将生成的代码不是很有效。
对于初学者来说,我们可以添加一些明显的东西来缓解这个问题,那就是 调试优化器和寄存器分配器。但这些会迫使我们实现一些中间表示,并使我们的编译器变得更加复杂,所以我们不会这样做。
让我们继续。
如何实现变量?
在编译器中表示变量的方式是使用整数。是的,不是使用与变量名称相对应的字符串,而是使用表示该变量在内存中的位置的索引。事实上,所有变量都将存储在计算机的主存储器中。特别是,他们会生活在一个叫做栈的地方。
栈是一种奇妙的机制,它允许我们在内存中快速存储一些值,并在不需要时安全地丢弃它们。在内存中,它只是像 64 位整数数组一样排列。
它的工作方式非常简单:CPU 有一个寄存器------称为栈指针 %rsp
------它总是告诉我们栈中可以使用的第一个槽。之后的每个槽都会空闲,之前的每个槽都会被占用。与所有低级别的事情一样,这不会神奇地发生,我们将负责使该寄存器保持最新状态。
我们有点懒,暂时不更新栈指针。只要我们的代码不执行任何函数调用,这基本上就没有问题。
考虑到这一点,我们可以使用索引来表示变量,该索引告诉我们在第一个 可用的 槽之后存储了多少个槽。
c
struct variable {
int slot;
};
在更完整的编译器中,前面的步骤将负责为每个声明的变量分配栈槽,并将变量名称转换为栈槽。我们不希望语言用户手动输入插槽。
编译它们很简单,但我们确实需要学习更多 x86-64 汇编才能完全掌握它。
c
void compile_variable(struct variable* e) {
int slot = e->slot;
printf("mov %d(%%rsp), %%rax\n", -8 * (slot + 1));
}
这里我们使用不同版本的 mov
指令。这个看起来像 mov number(register1), register2
,它的意思是"获取地址 number+register1
处的值,并将其存储在 register2
中"。 (内存到寄存器模式)
这就是我们实现栈访问的方式。栈指针保存第一个空闲槽的地址,我们添加一个偏移量来访问保存所需变量的槽。
除此之外,还有一些看起来很有趣的数学。关于栈有两件事需要了解:
- 每个槽保存一个 64-bit 的值。这等于 8 个字节,这就是我们乘以 8 的原因
- 栈向下增长。这意味着 已占用槽的地址 高于 空闲插槽的地址。这就解释了为什么8是负数而不是正数
- 这有一个 +1的操作,因为我撒谎了。栈指针实际上指向最后一个被占用的槽,而不是第一个空闲槽。
取反
取反的表示同样简单,对于那些阅读过上一篇博客的人来说应该是显而易见的。
c
struct negation {
struct expression* target;
};
我现在将省略
struct expression
的定义,因为它会分散我们对本节要点的注意力。
为了编译 取反,我们将利用这样一个事实:编译表达式将生成将其结果存储在 %rax
中的代码。这意味着,如果我们在计算要取反的值的代码之后立即发出一些对 %rax
取反的代码,我们将成功计算出所需的取反。
幸运的是,x86-64 有一条指令可以做到这一点。
c
void compile_negation(struct negation* e) {
compile_expression(e->target);
printf("neg %%rax\n");
}
但是为了适应我们必须要解决的难题来编译一些更复杂的操作,让我们避免使用 neg
。 x86-64 有一条 sub
指令,用于从一个操作数中减去另一个操作数。尝试使用它。
特别是,让我们:
- 计算目标表达式(将结果保留在
%rax
中) - 将零放入
%rcx
- 从
%rcx
中减去%rax
(将取反结果保留在%rcx
中) - 将取反结果保存到
%rax
c
void compile_negation(struct negation* e) {
compile_expression(e->target);
printf("mov $0, %%rcx\n");
printf("sub %%rax, %%rcx\n");
printf("mov %%rcx, %%rax\n");
}
我们使用
%rcx
而不是%rbx
,因为%rbx
是 x86-64 中 被调用者 保存的寄存器,这意味着我们不允许在没有先存储其旧值的情况下使用它。栈中的内容,然后恢复其值。这是 x86-64 调用约定所强制执行的。这太麻烦了,所以我们只使用最近的非被调用者保存的寄存器。
虽然不是太复杂,但这说明了为了编译更高级的操作,我们有时必须跳过的那种障碍。我不认为这会使编译变得困难,但是它会变得相当乏味。。
另外,请注意 reg-to-reg 寻址模式的使用
加法
与上一篇博客一样,我们将 加法表示为如下:
c
struct addition {
struct expression* left_term;
struct expression* right_term;
};
这里的总体思路是首先编译左项,然后编译右项,然后发出一条 add
指令来添加它们的结果。出现的主要问题是,当我们计算右侧项时,左侧项的结果将丢失。
一个简单的修复可能是将结果移动到不同的寄存器,如下所示:
c
void compile_addition(struct addition* e) {
compile_expression(e->left_term);
printf("mov %%rax, %%rcx\n");
compile_expression(e->right_term);
printf("add %%%rcx, %%rax\n");
}
不幸的是,当正确的项在中间步骤中也在' %rcx '中存储一些东西时,这将不起作用。相反,我们将从我们的好朋友------栈那里得到一些帮助。
我们将该中间值存储在栈中,并在正确的项计算完成后将其读回。如果我们注意为正确项生成的代码不会写入同一个栈槽中,那么我们可以确保该值将被保留。
防止重复使用同一栈槽的机制是一个简单的计数器。由于我们还在栈中存储变量,因此它的值必须大于分配给变量的任何槽。现在我们用一些大的数字来初始化它,比如 10。
c
int temp_counter = 10;
void compile_addition(struct addition* e) {
compile_expression(e->left_term);
int slot = temp_counter++; // allocate a new slot
printf("mov %%rax, %d(%%rsp)\n", -8 * (slot + 1));
compile_expression(e->right_term);
printf("add %d(%%rsp), %%rax\n", -8 * (slot + 1));
temp_counter--; // restore the counter
}
这里我们终于看到了一个使用reg-to-mem寻址模式的
mov
。另请注意,add
也可以使用 mem-to-reg 模式。
把它们放在一起
目前,最后一个难题是 struct expression
数据类型及其相应的 compile_expression
函数。考虑到我们到目前为止所看到的内容,这些内容几乎是微不足道的,但为了完整起见,我会将它们打印出来。
c
enum expression_tag {
EXPRESSION_INT_LITERAL,
EXPRESSION_VARIABLE,
EXPRESSION_NEGATION,
EXPRESSION_ADDITION,
};
struct expression {
enum expression_tag tag;
union {
struct int_literal as_int_literal;
struct variable as_variable;
struct negation as_negation;
struct addition as_addition;
};
};
void compile_expression(struct expression* e) {
switch (e->tag) {
case EXPRESSION_INT_LITERAL:
compile_int_literal(&e->as_int_literal);
break;
case EXPRESSION_VARIABLE:
compile_variable(&e->as_variable);
break;
case EXPRESSION_NEGATION:
compile_negation(&e->as_negation);
break;
case EXPRESSION_ADDITION:
compile_addition(&e->as_addition);
break;
}
}
测试一下
至此,我们已经能够编写简单的算术表达式了。为了测试这一点,我们可以编写一个像这样的小程序:
c
int main() {
// var0 + (-var1 + 42)
struct expression* e =
addition(
variable(0),
addition(
negation(variable(1)),
int_literal(42)));
compile_expression(e);
}
int_literal
、variable
、negation
和addition
是一些构建相应表达式的帮助函数。
产生以下输出:
c
mov -8(%rsp), %rax
mov %rax, -88(%rsp)
mov -16(%rsp), %rax
neg %rax
mov %rax, -96(%rsp)
mov $42, %rax
add -96(%rsp), %rax
add -88(%rsp), %rax
然后,为了能够运行它,我们只需添加一些程序集,该程序集将接受两个参数并将它们存储在堆栈槽 0 和 1 中,并在执行代码后返回。
c
.global foo
foo:
mov %rdi, -8(%rsp)
mov %rsi, -16(%rsp)
mov -8(%rsp), %rax
mov %rax, -88(%rsp)
mov -16(%rsp), %rax
neg %rax
mov %rax, -96(%rsp)
mov $42, %rax
add -96(%rsp), %rax
add -88(%rsp), %rax
ret
最后,我们从 C 中 调用 此代码,并检查它是否返回正确的内容:
c
#include <stdio.h>
#include <stdint.h>
int64_t foo(int64_t a, int64_t b);
int main() {
for (int i = 0; i < 10; ++i) {
for (int j = 0; j < 10; ++j) {
printf("expected: %d, got: %ld\n", i-j+42, foo(i, j));
}
}
}
为此,我们使用 GCC 进行编译并在终端中运行它:
c
$ gcc main.c foo.s -o main
$ ./main
expected: 42, got: 42
expected: 41, got: 41
expected: 40, got: 40
... and so on ...
结论
如果我们愿意保持简单,编写编译器并不像看起来那么难。如果我们自己避免引入复杂性,那么其主要来源是理解目标架构,而不是编译过程本身。
在接下来的部分中,我们将了解如何编译经典的控制流结构,例如 if
和 while
,以及函数调用和指针。