10.1. 教程结尾
欢迎来到“用LLVM实现一门语言”教程的最后一章。在本教程的过程中,我们已经把我们的Kaleidoscope语言从一个无用的玩具变成了一个半有趣(但可能仍然无用)的玩具。)
有趣的是,我们已经走了这么远,而且只用了这么少的代码。我们构建了整个词法分析器、解析器、AST、代码生成器、交互式运行循环(带有JIT!),并在独立可执行文件中发出调试信息——所有这些都在1000行(无注释/非空白)代码中完成。
本教程的目的是向您展示定义、构建和使用语言是多么容易和有趣。构建编译器不必是一个可怕或神秘的过程!既然您已经了解了一些基础知识,我强烈建议您使用这些代码并对其进行修改。例如,试着添加:
-
全局变量——尽管全局变量在现代软件工程中的价值值得怀疑,但它们通常在将像万花筒编译器本身这样的快速小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中保持目标独立性:您可以将LLVM IR用于Kaleidoscope编译程序,并在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包含一些特定的特性,所以他们继续扩展它。
第三,添加特定于语言的优化是可能的,也很容易,而且您有很多选择。举一个简单的例子,很容易添加特定于语言的优化传递,这些传递“知道”针对一种语言编译的代码的信息。在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也支持)。