[LLVM翻译]创建BOLT COMPILER:第二部分——那么如何架构一个编译器项目呢?

1,458 阅读9分钟

原文地址:mukulrathi.co.uk/create-your…

原文作者:mukulrathi.co.uk/

发布时间:2020年5月25日-6分钟阅读

系列。创建BOLT编译器

即将推出


编写编译器和其他软件工程项目一样,它涉及到很多关键的设计决策:你使用什么语言,如何在repo中组织文件,你应该使用哪些工具?大多数编译器教程都集中在一个玩具例子上,而选择忽略这些实际问题。

通过Bolt,我想强调一个更大的编译器和我所做的设计决定。如果你正在阅读这篇文章,并且你正在为一种更成熟的语言开发工业编译器,请在Twitter上联系我! 我很想听听你所做的设计决定!

在工作中使用正确的语言,而不仅仅是你最熟悉的语言。

"使用Y语言编写编译器"(插入你最喜欢的语言)教程是一毛不拔的。一开始使用你所知道的语言编写编译器似乎更容易,因为少了一件需要学习的事情,但这只是短期的收获。选择正确的语言就像学习触摸式输入法一样:当然,开始的时候会比较慢,但想想一旦你掌握了它,你会快得多。

JavaScript是一门非常适合网络应用的语言,对于初学者来说很容易上手。但我会用它写一个编译器吗?坦率地说,不会。我并不是讨厌JavaScript(我在这个网站上就使用了它),只是它不适合我们的目标。

对于编译器,我们关心的是什么?

  • 覆盖率--我们需要考虑所有可能的Bolt表达式,并确保我们处理所有的情况--如果我们的编译器在我们忘记考虑的Bolt程序上崩溃,那就不好了。我们的语言是否能帮助我们跟踪这些情况?

  • 数据表示--我们如何在编译器中表示和操作Bolt表达式?

  • 工具--我们的语言是否有我们可以用于编译器的库?在边做边学和不必要地重新发明轮子之间要有一个平衡。

  • 速度--有两个不同的方面。首先,编译后的Bolt代码有多快?第二,编译器的速度有多快(编译Bolt代码需要多长时间)?这里面有一个权衡--为了获得更快的编译代码,你需要在编译器中加入更多的优化步骤,使编译器变得更慢。

没有灵丹妙药:每个编译器设计本身就有其权衡。我选择主要用OCaml编写我的编译器。

为什么选择OCaml?

OCaml是一种函数式编程语言,具有强大的类型系统。你可能有两个问题:为什么是函数式编程,什么叫强大的类型系统?

在一个大型的编译器中,有很多移动的部件,跟踪状态让我们的生活变得更加困难。函数式编程更容易推理:如果你给一个函数传递相同的输入,它总是会返回相同的输出。使用函数式编程,我们不必担心副作用或状态,它让我们专注于高层设计。

另一种选择是出于性能的考虑,用Rust编写编译器。虽然你可能会有一个更快的编译器,但我不认为速度能证明额外的低级细节,比如Rust要求你跟踪的内存管理。就我个人而言,由于我没有写成千上万行的Bolt代码,所以我不太关心Bolt编译器编译程序所需的时间。

类型让你将程序与编译器配对

如果你来自JS或Python这样的动态类型语言,OCaml丰富的类型可能会让你感到陌生,可能会觉得累赘。我对OCaml中类型的看法是,它们给OCaml编译器提供了更多关于你的程序的信息--你告诉它的越多,它就越能帮助你!

回到我们的覆盖程序,我们想说的是,Bolt表达式要么是一个整数,要么是一个if-else表达式,要么是一个方法调用,要么是一个while循环等。通常要表示这样的东西,你会使用一个enum和一个switch语句。在OCaml中,我们使用变体类型将这个 "枚举 "烘焙到我们的类型系统中。我们可以在类型中对每个表达式的结构进行编码! 例如,要访问一个变量,您只需要知道它的名称x。要访问一个对象的字段,您需要知道它的名称x和您要访问的字段f。

type identifier =
| Variable of Var_name.t
| ObjField of Var_name.t * Field_name.t

let do_something id = match id with
| Var(x) -> ...
| ObjField(x,f) -> ...

我还没有提到最好的部分。因为我们已经将Bolt表达式结构编码在一个类型中,OCaml编译器会检查我们是否覆盖了所有情况!这对我们来说少了一件事,就是跟踪。这样我们就少了一件需要跟踪的事情!

twitter.com/mukulrathi_…

所以OCaml负责覆盖,我们决定将Bolt表达式编码为变体类型,所以这就是数据表示的排序。OCaml还为编译器的lexer和解析器阶段提供了很好的工具(在下一篇文章中讨论),这也打消了我们的另一个标准。

用LLVM瞄准性能

我们提到了这样一个事实:我们并不真正关心编译器本身的性能。然而,我们确实希望我们编译后的Bolt代码是快速的(名字里就有!)。正如上一篇文章中提到的,我们不必重新发明轮子。通过以LLVM IR为目标,我们可以连接到C/C++工具链,然后免费获得我们的优化。

LLVM为语言作者提供了生成LLVM IR的API--本地API是在C++中。LLVM提供了OCaml绑定,但它们只映射了部分C++ API。在实现的时候还不支持实现内存栅栏(正确实现锁所需要的机器指令),所以我用C++实现了编译器的这一部分。C++对于API来说也感觉更自然:虽然你可以用OCaml写命令式代码,但它更适合函数式编程。

你可能会问一个很自然的问题:既然你要使用LLVM C++ API,为什么不把所有的东西都用C++来写呢?我决定为编译器的每个部分选择最好的语言。权衡的结果是,现在我们必须在OCaml编译器前端和C++编译器后端之间传递数据。更强大,但编译器更复杂。

如果在阅读本系列文章时,OCaml LLVM 绑定对你的语言来说已经足够了,我鼓励你用它来代替。API几乎是相同的--用OCaml函数代替C++方法调用。

软件工程方法论

资源库中包含更多关于编译器结构的信息,在REPO_OVERVIEW.md中。其中大部分是一般的软件工程技巧,所以我就简单介绍一下。如果你想了解更多细节,欢迎联系我!)。

首先,版本库的结构是模块化的。编译器的每个阶段都有自己的库,你可以查看其文档。函数被分组为模块(.ml文件提供模块的实现,.mli文件提供模块的接口)。你可以把每个模块看成是在一个阶段中执行某个角色,例如type_expr.ml类型检查表达式,pprint_parser_tokens.ml漂亮地打印解析器标记。这使得每个模块更加集中,避免了长达几百行的单片文件难以阅读。

为了构建这些文件,我使用OCaml的Dune构建系统(我有一篇博客文章解释它)和C++的Bazel构建系统。对于大型资源库来说,手动编译每个文件(例如运行clang++ foo.cpp)和链接相互依赖的文件几乎是不可能的--构建系统为我们自动完成这些工作(运行一个make构建命令就可以编译资源库中的所有文件)。Bazel的主要好处之一是依赖关系都是自足的,所以可以跨机器工作。

对于测试,我主要使用的库是简街的Expect测试库。这个库真的很容易写测试,因为它能自动生成预期的输出。我有一篇关于用OCaml进行测试的博文介绍了这个问题。这篇文章还解释了我是如何设置持续集成的(在这里,测试会被运行,并且在每次提交到存储库时都会生成文档)。

twitter.com/mukulrathi_…

我还使用Makefile和一些脚本来自动完成常见任务。我强烈推荐的一个工具是自动格式化工具--我用ocamlformat来处理OCaml代码,用clang-format来处理C++代码--它可以帮你格式化你的代码,这样你就可以免费得到漂亮的代码了。你可以使用 repo 中的 git pre-commit 钩子(它会在你每次提交时对你的代码进行过滤和格式化)或者通过 IDE format-on-save 集成来自动完成。最后一个小贴士:使用VSCode的OCaml IDE扩展或同等的IDE--每当你悬停在一个函数上时,它就会显示它的类型签名和与之相关的任何文档注释。

总结

在前几篇文章中,我已经解释了Bolt在编程语言谱中的位置,以及编译器设计和软件工程决策。在下一篇文章中,我们将真正开始构建这个程序。在这之前,我有几个行动项目要给你。

  • 掌握OCaml的速度 Real World OCaml是一个很好的免费资源。
  • Fork这个repo。进行快速的高级扫描,但先不要担心细节问题。我们将在自己的文章中对编译器的每个阶段进行分解,并在整个文章中介绍更多的编译器。

www.deepl.com 翻译