写一个编译器非常简单(第 1 部分)

编写编译器非常简单(第 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_literalvariablenegationaddition 是一些构建相应表达式的帮助函数。

产生以下输出:

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 ...

结论

如果我们愿意保持简单,编写编译器并不像看起来那么难。如果我们自己避免引入复杂性,那么其主要来源是理解目标架构,而不是编译过程本身。

在接下来的部分中,我们将了解如何编译经典的控制流结构,例如 ifwhile ,以及函数调用和指针。


原文地址

相关推荐
IT_陈寒20 分钟前
Vite5.0性能翻倍秘籍:7个极致优化技巧让你的开发体验飞起来!
前端·人工智能·后端
Edward.W24 分钟前
用 Go + HTML 实现 OpenHarmony 投屏(hdckit-go + WebSocket + Canvas 实战)
开发语言·后端·golang
南囝coding1 小时前
Claude 封禁中国?为啥我觉得是个好消息
前端·后端
六边形工程师1 小时前
Docker安装神通数据库ShenTong
后端
六边形工程师1 小时前
快速入门神通数据库
后端
重生成为编程大王1 小时前
FreeMarker快速入门指南
java·后端
Dear.爬虫1 小时前
Golang的协程调度器原理
开发语言·后端·golang
元闰子1 小时前
怎么用CXL加速数据库?· SIGMOD'25
数据库·后端·面试
幂简集成2 小时前
GraphQL API 性能优化实战:在线编程作业平台指南
后端·性能优化·graphql
编码浪子2 小时前
趣味学RUST基础篇(构建命令行程序1)
开发语言·后端·rust