写一个编译器非常简单(第 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 ,以及函数调用和指针。


原文地址

相关推荐
小蜗牛慢慢爬行17 分钟前
有关异步场景的 10 大 Spring Boot 面试问题
java·开发语言·网络·spring boot·后端·spring·面试
A小白590839 分钟前
Docker部署实践:构建可扩展的AI图像/视频分析平台 (脱敏版)
后端
goTsHgo1 小时前
在 Spring Boot 的 MVC 框架中 路径匹配的实现 详解
spring boot·后端·mvc
waicsdn_haha1 小时前
Java/JDK下载、安装及环境配置超详细教程【Windows10、macOS和Linux图文详解】
java·运维·服务器·开发语言·windows·后端·jdk
Q_19284999061 小时前
基于Spring Boot的摄影器材租赁回收系统
java·spring boot·后端
良许Linux1 小时前
0.96寸OLED显示屏详解
linux·服务器·后端·互联网
求知若饥1 小时前
NestJS 项目实战-权限管理系统开发(六)
后端·node.js·nestjs
左羊2 小时前
【代码备忘录】复杂SQL写法案例(一)
后端
gb42152872 小时前
springboot中Jackson库和jsonpath库的区别和联系。
java·spring boot·后端
程序猿进阶2 小时前
深入解析 Spring WebFlux:原理与应用
java·开发语言·后端·spring·面试·架构·springboot