10. 万花筒:结论和其他有用的 LLVM 小技巧

10.1. 教程结论

欢迎来到“使用 LLVM 实现语言”教程的最后一章。在本教程的学习过程中,我们已经将我们的小型万花筒语言从一个无用的玩具发展成一个半有趣(但可能仍然无用)的玩具。:)

很高兴看到我们走了多远,以及只用了这么少的代码。我们构建了整个词法分析器、解析器、AST、代码生成器、交互式运行循环(带有 JIT!),并在独立可执行文件中输出了调试信息 - 所有这些都在不到 1000 行(非注释/非空白)代码中完成。

我们的小型语言支持一些有趣的功能:它支持用户定义的二元和一元运算符,它使用 JIT 编译进行即时求值,并且它支持一些带有 SSA 构造的控制流结构。

本教程的部分目的是向您展示定义、构建和玩转语言是多么容易和有趣。构建编译器不必是一个可怕或神秘的过程!既然您已经了解了一些基础知识,我强烈建议您使用代码并进行修改。例如,尝试添加

  • 全局变量 - 虽然全局变量在现代软件工程中的价值值得怀疑,但当组合像 Kaleidoscope 编译器本身这样的小型快速 hack 时,它们通常很有用。幸运的是,我们当前的设置使得添加全局变量非常容易:只需让值查找检查未解析的变量是否在全局变量符号表中,然后再拒绝它。要创建新的全局变量,请创建 LLVM GlobalVariable 类的实例。

  • 类型变量 - Kaleidoscope 目前仅支持 double 类型的变量。这使得该语言非常优雅,因为仅支持一种类型意味着您永远不必指定类型。不同的语言有不同的处理方式。最简单的方法是要求用户为每个变量定义指定类型,并将变量的类型及其 Value* 记录在符号表中。

  • 数组、结构体、向量等 - 一旦添加了类型,您就可以开始以各种有趣的方式扩展类型系统。简单的数组非常容易,并且对于许多不同的应用程序非常有用。添加它们主要是学习 LLVM getelementptr 指令如何工作:它非常巧妙/非常规,它 有自己的 FAQ

  • 标准运行时 - 我们当前的语言允许用户访问任意外部函数,我们将其用于诸如“printd”和“putchard”之类的操作。当您扩展语言以添加更高级别的构造时,通常这些构造如果被降低为对语言提供的运行时的调用,则最有意义。例如,如果您向语言中添加哈希表,则将例程添加到运行时而不是完全内联它们可能更有意义。

  • 内存管理 - 目前我们只能在 Kaleidoscope 中访问堆栈。如果能够分配堆内存,无论是通过调用标准 libc malloc/free 接口还是使用垃圾回收器,也将很有用。如果您想使用垃圾回收,请注意 LLVM 完全支持 精确垃圾回收,包括移动对象并需要扫描/更新堆栈的算法。

  • 异常处理支持 - LLVM 支持生成 零成本异常,该异常可以与其他语言编译的代码互操作。您还可以通过隐式地使每个函数返回错误值并检查它来生成代码。您还可以显式使用 setjmp/longjmp。这里有很多不同的方法。

  • 面向对象、泛型、数据库访问、复数、几何编程,…… - 真的,您可以添加到语言中的疯狂功能是无穷无尽的。

  • 不寻常的领域 - 我们一直在讨论将 LLVM 应用于许多人感兴趣的领域:为特定语言构建编译器。但是,还有许多其他领域可以使用通常不被考虑的编译器技术。例如,LLVM 已被用于实现 OpenGL 图形加速、将 C++ 代码翻译成 ActionScript 以及许多其他可爱而巧妙的东西。也许您将成为第一个使用 LLVM 将正则表达式解释器 JIT 编译为本机代码的人?

玩得开心 - 尝试做一些疯狂和不寻常的事情。像其他人一样构建语言总是比尝试一些有点疯狂或出格的东西并看看结果如何要有趣得多。如果您遇到困难或想讨论它,请在 LLVM 论坛上发帖:那里有很多人对语言感兴趣,并且经常愿意提供帮助。

在本教程结束之前,我想谈谈一些关于生成 LLVM IR 的“技巧和窍门”。这些是一些可能不明显的更微妙的事情,但如果您想利用 LLVM 的功能,它们非常有用。

10.2. LLVM IR 的属性

我们有一些关于 LLVM IR 形式代码的常见问题 - 让我们现在就解决这些问题,好吗?

10.2.1. 目标独立性

Kaleidoscope 是“可移植语言”的一个例子:任何用 Kaleidoscope 编写的程序在它运行的任何目标上都将以相同的方式工作。许多其他语言也具有此属性,例如 lisp、java、haskell、javascript、python 等(请注意,虽然这些语言是可移植的,但并非它们的所有库都是)。

LLVM 的一个优点是它通常能够在 IR 中保持目标独立性:您可以获取 Kaleidoscope 编译程序的 LLVM IR,并在 LLVM 支持的任何目标上运行它,甚至可以发出 C 代码并在 LLVM 本身不支持的目标上编译它。您可以很容易地看出 Kaleidoscope 编译器生成目标独立的代码,因为它在生成代码时从不查询任何目标特定的信息。

LLVM 为代码提供了紧凑、目标独立的表示形式,这一事实让很多人感到兴奋。不幸的是,当这些人询问语言可移植性问题时,他们通常会想到 C 或 C 系列的语言。我说“不幸的是”,因为除了到处发送源代码之外,真的没有办法使(完全通用的)C 代码可移植(当然,C 源代码通常实际上也不是可移植的 - 有没有将非常旧的应用程序从 32 位移植到 64 位?)。

C 语言(再次,在其完全通用性中)的问题在于它充满了目标特定的假设。作为一个简单的例子,预处理器在处理输入文本时经常破坏性地从代码中删除目标独立性

#ifdef __i386__
  int X = 1;
#else
  int X = 42;
#endif

虽然可以设计越来越复杂的解决方案来解决此类问题,但无法以比发送实际源代码更好的方式完全通用地解决。

也就是说,C 语言有一些有趣的子集可以使其可移植。如果您愿意将原始类型固定为固定大小(例如 int = 32 位,long = 64 位),不关心与现有二进制文件的 ABI 兼容性,并且愿意放弃一些其他次要功能,那么您就可以获得可移植的代码。这对于诸如内核内语言之类的专门领域来说是有意义的。

10.2.2. 安全保证

上面的许多语言也是“安全”语言:用 Java 编写的程序不可能破坏其地址空间并导致进程崩溃(假设 JVM 没有错误)。安全性是一个有趣的属性,它需要语言设计、运行时支持以及通常是操作系统支持的结合。

当然可以在 LLVM 中实现安全语言,但 LLVM IR 本身并不保证安全性。LLVM IR 允许不安全的指针转换、释放后使用错误、缓冲区溢出以及各种其他问题。安全性需要在 LLVM 之上作为一层来实现,而且方便的是,一些小组已经对此进行了调查。如果您对更多详细信息感兴趣,请在 LLVM 论坛上提问。

10.2.3. 语言特定的优化

关于 LLVM,让很多人望而却步的一件事是,它并没有在一个系统中解决世界上所有的问题。一个具体的抱怨是,人们认为 LLVM 无法执行高级语言特定的优化:LLVM“丢失了太多信息”。以下是一些关于此的观察结果

首先,您说得对,LLVM 确实会丢失信息。例如,在撰写本文时,无法在 LLVM IR 中区分 SSA 值来自 ILP32 机器上的 C “int” 还是 C “long”(除了调试信息)。两者都被编译为 ‘i32’ 值,并且关于它来自哪里的信息丢失了。这里更普遍的问题是,LLVM 类型系统使用“结构等价”而不是“名称等价”。另一个让人们感到惊讶的地方是,如果您在高级语言中有两种类型具有相同的结构(例如,两个不同的结构体都只有一个 int 字段):这些类型将被编译为单个 LLVM 类型,并且无法分辨它来自哪里。

其次,虽然 LLVM 确实会丢失信息,但 LLVM 不是一个固定的目标:我们继续以各种不同的方式增强和改进它。除了添加新功能(LLVM 并非一直支持异常或调试信息)之外,我们还扩展了 IR 以捕获重要的优化信息(例如,参数是符号扩展还是零扩展,关于指针别名的信息等)。许多增强功能都是用户驱动的:人们希望 LLVM 包含某些特定功能,因此他们会继续扩展它。

第三,添加语言特定的优化是*可能的且容易的*,并且您有很多选择如何执行此操作。作为一个简单的例子,很容易添加语言特定的优化pass,这些 pass“知道”关于为语言编译的代码的事情。在 C 系列的情况下,有一个优化 pass “知道”关于标准 C 库函数的信息。如果您在 main() 中调用“exit(0)”,它知道将其优化为“return 0;”是安全的,因为 C 指定了 ‘exit’ 函数的作用。

除了简单的库知识外,还可以将各种其他语言特定的信息嵌入到 LLVM IR 中。如果您有特定的需求并遇到障碍,请在 llvm-dev 列表上提出该主题。在最坏的情况下,您可以始终将 LLVM 视为“哑代码生成器”,并在您的前端、语言特定的 AST 上实现您所需的高级优化。

10.3. 技巧和窍门

在与 LLVM 一起工作之后,您会了解到各种有用的技巧和窍门,这些技巧和窍门乍一看并不明显。本节不是让每个人重新发现它们,而是讨论其中一些问题。

10.3.1. 实现可移植的 offsetof/sizeof

如果您尝试保持编译器生成的代码“目标独立”,那么一个有趣的事情是,您通常需要知道某些 LLVM 类型的大小或 llvm 结构中某些字段的偏移量。例如,您可能需要将类型的大小传递给分配内存的函数。

不幸的是,这在不同目标之间可能会有很大差异:例如,指针的宽度是微不足道的目标特定的。但是,有一种 巧妙的方法可以使用 getelementptr 指令,让您以可移植的方式计算它。

10.3.2. 垃圾回收的栈帧

一些语言希望显式管理它们的栈帧,通常是为了对它们进行垃圾回收或允许轻松实现闭包。通常有比显式栈帧更好的方法来实现这些功能,但是如果您愿意,LLVM 确实支持它们。它要求您的前端将代码转换为 Continuation Passing Style 并使用尾调用(LLVM 也支持)。