编写编译器非常简单(第 2 部分)
在我最初几次尝试编写编译器时,一个很大的障碍是编译控制流(条件、循环等)。我认为这源于这样一个事实,即互联网上流传的许多常见建议都是为了编写优化编译器,所以我会听到诸如"static single-assignment"、"control flow graphs"、"phi-nodes"之类的东西'之类的,然后迷失在试图接受这一切的过程中。
由于这些事情,我第一次认真尝试实现编程语言最终开始了 树遍历解释器(tree-walking interpreter),并且过了好几年我才敢于编写编译器。
我想表明,所有这些东西虽然在其应用领域很有趣且有价值,但并不是编写有用的编译器所必需的。特别是,我们最后会看到,即使是简单的编译策略也能在速度上轻松击败快速的tree-walking 解释器。
语句
在上一部分中,我们为算术表达式的小型语言实现了一个简单的代码生成器。今天,用一些语句来扩展我们的简单语言。这些代码片段不会计算出某个值,而仅用于修改状态、控制流或其他一些效果。
我想实现一种命令式语言,所以它肯定需要一种表达突变(mutation)的方法。进行一些突变的一个简单方法是变量分配。
在我们的简单语言中,赋值将计算某个表达式,然后将其结果存储到预定的栈槽中。
c
struct assignment {
int slot;
struct expression* value;
};
转换为汇编非常简单:计算表达式后,其结果将存储在 %rax
中,因此我们只需将其从那里移动到栈中即可。
c
void compile_assignment(struct assignment* e) {
int slot = e->slot;
compile_expression(e->value);
printf("mov %%rax, %d(%%rsp)\n", -8 * (slot + 1));
}
语句块
为了使编译器保持简单,开始时用这种语言编写的程序由一条语句组成。然而,没有一个有用的程序只执行一项赋值、一项分支或一项循环。我们确实需要具备做几件事的能力。
为了实现这一点,让我们添加一种执行多个语句的方法:块(block)。块代表一系列依次执行的语句,但在概念上是单个实体。
c
struct block {
int step_count;
struct statement** steps;
};
struct statement
是一个标记联合(tagged union),我们稍后将对其进行定义。
翻译也很简单。
c
void compile_block(struct block* e) {
for (int i = 0; i < e->step_count; ++i) {
compile_statement(e->steps[i]);
}
}
条件语句
事情越来越有趣。
大多数机器中引导控制流的方法是通过跳转。我们可以将标签添加到代码的不同部分,并且有一些指令("跳转")将控制权转移到该标签。例如,下面是一个在 x86-64 汇编器中永远向 %rax
增加 1 的循环:
c
infinite_loop:
add $1, %rax
jmp infinite_loop
还有一些指令("条件跳转")仅在满足某些条件时才执行跳转,否则将继续执行下一条指令。例如,只要 %rax
不为零,就会循环:
c
this_loop_terminates:
add $1, %rax
jnz this_loop_terminates
jnz
代表"非零跳转(jump-non-zero)"
我声称是根据 %rax
的值做出决定的,但 jnz
指令根本没有提及 %rax
。怎么会这样?
这是因为,在 x86-64 中,条件跳转根据 CPU 的标志寄存器做出决定。这是一个特殊的寄存器,用于存储一组 布尔标志,对有关最后执行的指令的一些元数据进行编码。这些标志之一(零标志)对最后一条指令是否在其目标操作数中存储了零进行编码。如果未设置零标志, jnz
执行跳转。
实际上,并非所有指令都会更新标志寄存器。值得注意的是,
mov
没有,所以我们需要采取一些措施来处理这个问题。
这将允许我们实现一个 if 语句,如果其条件评估为 非零值,则有条件地执行另一个语句。 (就像 C 语言一样!)
c
struct if_statement {
struct expression* cond;
struct statement* body;
};
我们可以通过插入条件跳转来实现此目的,如果条件为零,则跳过语句代码。因此,一个合理的实现可能如下所示:
c
void compile_if_statement(struct if_statement* e) {
compile_expression(e->cond);
printf("jz skip\n"); // skip statement if condition is zero
compile_statement(e->body);
printf("skip:\n");
}
请注意
jz
("跳转零")指令,其行为与jnz
相反。
不过,这也存在一些问题。
首先,如果我们的程序中有多个if语句,我们将使用同一个标签两次,这是不允许的。
这是通过一个简单的计数器来解决的,就像栈槽一样。
其次,我们依赖计算表达式的代码来更新标志寄存器,但它可能不会这样做(例如,读取变量,我们将其编译为单个 mov
,不会这样做)。
我们可以通过发出 test
指令来解决这个问题。 test
更新标志寄存器 ,就像在两个操作数之间执行按位与操作一样,但实际上并不修改其目标。这意味着当 test
对其自身进行寄存器操作时,当且仅当寄存器为零时才会设置零标志。
c
int label_counter = 0;
void compile_if_statement(struct if_statement* e) {
int label_id = label_counter++;
compile_expression(e->cond);
printf("test %%rax, %%rax\n");
printf("jz label%d\n", label_id); // skip statement if zero
compile_statement(e->body);
printf("label%d:\n", label_id);
}
我选择实现更通用的 if-else 语句,而不是 if 语句。他们的表示还是很简单的:
c
struct if_else {
struct expression* cond;
struct statement* true_branch;
struct statement* false_branch;
};
而且翻译起来也相当简单。我们只需要记住在执行 true 分支后跳过 false 分支:
c
void compile_if_else(struct if_else* e) {
int false_label = label_counter++;
int end_label = label_counter++;
compile_expression(e->cond);
printf("test %%rax, %%rax\n");
printf("jz label%d\n", false_label); // skip true branch if zero
compile_statement(e->true_branch);
printf("jmp label%d\n", end_label); // skip false branch after true branch
printf("label%d:\n", false_label);
compile_statement(e->false_branch);
printf("label%d:\n", end_label);
}
循环
目前,我们的小语言在有用的计算方面还不能做太多事情。大多数有用的程序往往涉及重复,通常表示为循环或递归。递归目前有点遥不可及,所以让我们实现循环。
特别是,让我们添加命令式编程的一个主要内容:while 循环。
c
struct while_loop {
struct expression* cond;
struct statement* body;
};
只要某些条件为真,while 循环就会执行一条语句。在我们的例子中,我们将遵循非零意味着 true 的约定:
c
void compile_while_loop(struct while_loop* e) {
int start_label = label_counter++;
int end_label = label_counter++;
printf("label%d:\n", start_label);
compile_expression(e->cond);
printf("test %%rax, %%rax\n");
printf("jz label%d\n", end_label); // exit loop if zero
compile_statement(e->body);
printf("jmp label%d\n", start_label); // loop to the top
printf("label%d:\n", end_label);
}
翻译与 条件语句 非常相似:如果条件为零,我们跳过循环体。不同之处在于,在循环体之后,我们向后跳到循环的开头,在那里将再次检查条件。
把它们放在一起
最后,我们只需要 派发不同的语句类型即可。
c
enum statement_tag {
STATEMENT_ASSIGNMENT,
STATEMENT_BLOCK,
STATEMENT_IF_ELSE,
STATEMENT_WHILE_LOOP,
};
struct statement {
enum statement_tag tag;
union {
struct assignment as_assignment;
struct block as_block;
struct if_else as_if_else;
struct while_loop as_while_loop;
};
};
void compile_statement(struct statement* e) {
switch (e->tag) {
case STATEMENT_ASSIGNMENT:
compile_assignment(&e->as_assignment);
break;
case STATEMENT_BLOCK:
compile_block(&e->as_block);
break;
case STATEMENT_IF_ELSE:
compile_if_else(&e->as_if_else);
break;
case STATEMENT_WHILE_LOOP:
compile_while_loop(&e->as_while_loop);
break;
}
}
测试一下
为了测试我们今天添加的功能,我创建了一个计算斐波那契数的小程序。
c
int main() {
int n = 0;
int a = 1;
int b = 2;
int c = 3;
struct statement* stmt = block3(
assignment(a, int_literal(1)),
assignment(b, int_literal(1)),
while_loop(variable(n), block4(
assignment(c, addition(variable(a), variable(b))),
assignment(a, variable(b)),
assignment(b, variable(c)),
assignment(n, addition(variable(n), int_literal(-1)))
))
);
compile_statement(stmt);
}
block3
、block4
、assignment
和while_loop
是构造 tagged union 值的帮助方法。
如果我们现在运行编译器,我们会得到以下输出:
c
mov $1, %rax
mov %rax, -16(%rsp)
mov $1, %rax
mov %rax, -24(%rsp)
label0:
mov -8(%rsp), %rax
test %rax, %rax
jz label1
mov -16(%rsp), %rax
mov %rax, -88(%rsp)
mov -24(%rsp), %rax
add -88(%rsp), %rax
mov %rax, -32(%rsp)
mov -24(%rsp), %rax
mov %rax, -16(%rsp)
mov -32(%rsp), %rax
mov %rax, -24(%rsp)
mov -8(%rsp), %rax
mov %rax, -88(%rsp)
mov $-1, %rax
add -88(%rsp), %rax
mov %rax, -8(%rsp)
jmp label0
label1:
为了测试它,我们可以再次将其绑定到 C 程序中。首先,我们添加一些程序集,使其将参数放入栈槽 0 中,并在最后返回栈槽 1 的内容。
c
.global fib
fib:
mov %rdi, -8(%rsp)
... generated code omitted ...
mov -16(%rsp), %rax
ret
然后我们就可以从C调用它:
c
#include <stdint.h>
#include <stdio.h>
uint64_t fib(uint64_t n);
int main() {
for (int i = 1; i <= 10; ++i) {
printf("fib(%d) = %lu\n", i, fib(i));
}
}
编译并运行:
shell
$ gcc fib.s fib_test.c -o fib
$ ./fib
fib(1) = 1
fib(2) = 2
fib(3) = 3
fib(4) = 5
... and so on ...
我还花了几分钟为我们目前拥有的语言编写了一个非常简单的解释器 。我们可以用它来比较解释代码与原始编译器的性能。
为了使这一点更加公平,我将测试更改为仅对 fib
进行一次调用,而不是多次调用。 (我编写解释器基准测试的方式,它必须在每次调用时重建 AST)
shell
$ gcc fib_test.c interpreter.c ast.c -g -o fib_interpreted -O2
$ gcc fib.s fib_test.c -g -o fib
$ time ./fib
fib(50000000) = 1661595531329882850
real 0m0,208s
user 0m0,207s
sys 0m0,000s
$ time ./fib_interpreted
fib(50000000) = 1661595531329882850
real 0m1,775s
user 0m1,776s
sys 0m0,000s
我认为我使用的解释器虽然简单,但却是"优化" tree-walking解释器的合理替代品。它使用本机堆栈和调用约定,并且所有值均未装箱(unboxed )和未标记(untagged)。
字节码解释器会更好,但我不想花那么多时间,然后我无论如何都会编写一个(字节码)编译器。
快速解释器与最简单的编译器生成的代码之间的比率是 8.5 倍。
结论
编译结构化控制流非常简单!同样,我们花更多的时间讨论底层架构的细节,而不是专门针对编译器的内容。
另外,我们已经看到,即使是非常幼稚的编译器,也比"快速"解释器快得多(尽管我认识到这里使用的"快速"是松散的)。在我看来,这告诉我们,上面显示的技术对于第一个或第二个编译器(甚至是第 N 个编译器,如果用作以后实验的起点)来说已经足够好了。
在接下来的部分中,我们将实现指针和函数调用,使我们的语言图灵完整。