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

10.1. 教程结论

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

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

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

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

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

  • 类型化变量 - 万花筒目前仅支持双精度类型的变量。这使得语言具有非常好的优雅性,因为仅支持一种类型意味着您无需指定类型。不同的语言有不同的处理方法。最简单的方法是要求用户为每个变量定义指定类型,并在符号表中记录变量的类型及其 Value*。

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

  • 标准运行时 - 我们当前的语言允许用户访问任意外部函数,我们将其用于“printd”和“putchard”等内容。当您扩展语言以添加更高级别的结构时,这些结构通常只有在降低到对语言提供的运行时的调用时才有意义。例如,如果您向语言添加哈希表,那么将例程添加到运行时可能更有意义,而不是一直内联它们。

  • 内存管理 - 目前我们只能在万花筒中访问栈。能够分配堆内存也很有用,可以通过调用标准 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. 目标独立性

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

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

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 值来自 C“int”还是来自 ILP32 机器上的 C“long”(除了调试信息)。两者都编译成 'i32' 值,并且关于其来源的信息丢失了。这里更普遍的问题是,LLVM 类型系统使用“结构等价”而不是“名称等价”。另一个让人感到意外的地方是,如果您在高级语言中具有两个具有相同结构的类型(例如,两个具有单个 int 字段的不同结构体):这些类型将编译成单个 LLVM 类型,并且无法判断其来源。

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

第三,添加特定于语言的优化是可能且容易的,并且您有多种选择来实现它。举一个简单的例子,很容易添加特定于语言的优化过程,这些过程“了解”为某种语言编译的代码。在 C 系列语言的情况下,有一个优化过程“了解”标准 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 也支持)。