文章目录
- 前言
- 一、经典编译器设计简介
- 二、 现有的语言实现
- 三、LLVM的代码表示:LLVM IR
- 四、LLVM对三阶段设计的实现
- 五、可重定向LLVM代码生成器的设计
- 六、模块化设计提供的有趣功能
- 七、回顾和未来方向
- 参考链接
前言
本文为官网文档http://www.aosabook.org/en/llvm.html的翻译及阅读笔记,如有出入,一切以官方文档为主
提示:以下是本篇文章正文内容
一、经典编译器设计简介
传统静态编译器(与大多数 C 编译器一样)最流行的设计是三阶段设计,其主要组件是前端(front end)、优化器(optimizer)和后端(back end),如图所示。
前端的功能:前端解析源代码,它会进行词法分析,语法分析,语义分析,然后检查错误,并构建特定于语言的(language-specific)的抽象语法树来表示输入代码。LLVM的前端还会生成中间代码(IR)
优化器的功能:负责进行各种各样的转换以尝试改善代码的运行时间,例如消除冗余计算。
后端(又称为代码生成器)的功能:将代码映射到目标指令集,生成机器语言。并且进行机器相关的代码优化。
1.1 本设计的意义
当编译器决定支持多种源语言或目标架构时,三阶段设计的优点就体现出来了。如果编译器在其优化器中使用公共代码表示,则可以为任何语言编写前端,可以为任何目标架构编写后端,如图所示。
通过这种设计,移植编译器以支持新的源语言(例如Algol或BASIC)需要实现一个新的前端,但可以重复使用现有的优化器和后端。如果不采用三阶段设计,移植操作将需要从头开始,因此支持N个目标和M种源语言将需要N*M个编译器,而采用三阶段设计并且使用统一的中间表示(IR),则只需要N+M个编译器。
二、 现有的语言实现
虽然三阶段设计的好处在编译器教科书中引人注目并有详细说明,但在实践中几乎从未完全实现。
这个模型有三个主要的成功案例:
第一个是 Java 和 .NET 虚拟机。
第二个成功案例是最流行的重用编译器技术的方式:将输入源转换为 C 代码(或某种其他语言)并通过现有的 C 编译器编译。但会存在一些问题。
该模型的最终成功实施是 GCC 4。GCC 支持许多前端和后端,并拥有活跃而广泛的贡献者社区,但仍有很大的局限性。
三、LLVM的代码表示:LLVM IR
LLVM设计最重要的方面是LLVM中间表示(IR),这是它用于在编译器中表示代码的形式。
LLVM IR is designed to host mid-level analyses and transformations that you find in the optimizer section of a compiler.
LLVM IR设计用于承载(host)中级分析和转换,您可以在编译器的优化器部分找到这些分析和转换。
为了具体说明,这里有一个简单的.ll
文件示例
define i32 @add1(i32 %a, i32 %b) {
entry:
%tmp1 = add i32 %a, %b
ret i32 %tmp1
}
define i32 @add2(i32 %a, i32 %b) {
entry:
%tmp1 = icmp eq i32 %a, 0
br i1 %tmp1, label %done, label %recurse
recurse:
%tmp2 = sub i32 %a, 1
%tmp3 = add i32 %b, 1
%tmp4 = call i32 @add2(i32 %tmp2, i32 %tmp3)
ret i32 %tmp4
done:
ret i32 %b
}
该LLVM IR对应的C代码如下,它提供了两种不同的整数相加方法:
unsigned add1(unsigned a, unsigned b) {
return a+b;
}
// Perhaps not the most efficient way to add two numbers.
unsigned add2(unsigned a, unsigned b) {
if (a == 0) return b;
return add2(a-1, b+1);
}
从这个例子中可以看出,LLVM IR 是一个低级的类似于 RISC 的虚拟指令集。像真正的 RISC 指令集一样,它支持简单指令的线性序列,如加、减、比较和分支。这些指令采用三地址形式,这意味着它们接受一定数量的输入并在不同的寄存器中产生结果。LLVM IR 支持标签并且通常看起来像一种奇怪的汇编语言形式。
与大多数 RISC 指令集不同,LLVM 是强类型的,具有简单的类型系统,并且机器的一些细节被抽象掉了。与机器代码的另一个显著区别是 LLVM IR 不使用一组固定的命名寄存器,它使用一组非限定的以%+字符串命名的临时变量。
LLVM IR主要有三种格式:一种是可读的IR,类似于汇编代码,但其实它介于高等语言和汇编之间,这种表示就是给人看的,磁盘文件后缀为.ll;第二种是不可读的二进制IR,被称作位码(bitcode),磁盘文件后缀为.bc;第三种表示是一种内存格式。
编译器的中间表示很有趣,因为它可以是编译器优化器的“完美世界”:与编译器的前端和后端不同,优化器不受特定源语言或特定目标机器的约束. 另一方面,它必须很好地服务于两者:它必须被设计为易于前端生成并且具有足够的表现力以允许对真实目标执行重要的优化。
3.1 编写LLVM IR优化
为了直观了解优化的工作原理,浏览一些示例很有用。有很多不同种类的编译器优化,因此很难提供解决任意问题的方法。但是大多数优化都遵循简单的三部分结构:
- 寻找要转换的模式
- 验证转换对于匹配的实例是否安全/正确
- 进行转换,更新代码
最简单的优化是对算术恒等式的模式匹配,例如:对于任何整数X
,X-X
is 0
, X-0
is X
, (X*2)-X
is X
。如下所示:
⋮ ⋮ ⋮
%example1 = sub i32 %a, %a
⋮ ⋮ ⋮
%example2 = sub i32 %b, 0
⋮ ⋮ ⋮
%tmp = mul i32 %c, 2
%example3 = sub i32 %tmp, %c
⋮ ⋮ ⋮
对于这些转换,LLVM提供了一个指令简化接口。这些特殊的转换在SimplifySubInst
函数中,如下:
// X - 0 -> X
if (match(Op1, m_Zero()))
return Op0;
// X - X -> 0
if (Op0 == Op1)
return Constant::getNullValue(Op0->getType());
// (X*2) - X -> X
if (match(Op0, m_Mul(m_Specific(Op1), m_ConstantInt<2>())))
return Op1;
…
return 0; // Nothing matched, return null to indicate no transformation.
在此代码中,Op0和Op1绑定到整数减法指令的左右操作数。LLVM是用C++实现的,提供了一个非常通用的模板系统,允许我们实现类似的东西。该match
函数和m_
函数允许我们对LLVM IR代码执行声明性模式匹配操作。例如,m_Specific
谓词仅在乘法的左侧与Op1相同时才匹配。
总之,这三种情况都是模式匹配的,如果可以,函数返回替换(比如将X-X
替换成0
),如果不可能替换,则返回空指针。
四、LLVM对三阶段设计的实现
在基于 LLVM 的编译器中,前端负责解析、验证和诊断输入代码中的错误,然后将解析的代码转换为 LLVM IR(通常,但不总是,通过构建 AST,然后将 AST 转换为 LLVM IR)。该 IR 可以选择性地通过一系列分析和优化通道来改进代码,然后被发送到代码生成器以生成本地机器代码,如图所示。这是三阶段设计的一个非常简单的实现。
4.1 LLVM IR 是一个完整的代码表示
LLVM IR 是优化器的特定的、唯一的接口。这种特性意味着为 LLVM 编写前端所需的全部知识就是 LLVM IR 是什么、它是如何工作的以及它期望的不变量(invariants)。
LLVM IR是LLVM的一个非常新颖的特性,也是其在广泛的不同应用程序中取得成功的主要原因之一。即使是广泛成功且相对良好架构的 GCC 编译器也不具备此属性。
4.2 LLVM是一个库集合
在 LLVM IR 的设计之后,LLVM 的下一个最重要的方面是它被设计为一组库。让我们以优化器的设计为例:它读取 LLVM IR,稍微处理它,然后发出 优化后的LLVM IR。在 LLVM(与许多其他编译器一样)中,优化器被设计为有不同优化通道的管道,每个通道都在并行地处理着输入。
每个 LLVM优化程序都被编写为一个 C++ 类,该类(间接)从Pass类派生。大多数Passes都写在一个.cpp文件
中,并且它们的Pass子类在匿名命名空间中定义(这使得它对定义文件完全私有)。简化的pass的例子如下:
namespace {
class Hello : public FunctionPass {
public:
// Print out the names of functions in the LLVM IR being optimized.
virtual bool runOnFunction(Function &F) {
cerr << "Hello: " << F.getName() << "\n";
return false;
}
};
}
FunctionPass *createHelloPass() { return new Hello(); }
如上所述,LLVM 优化器提供了数十种不同的优化程序,每一种都以类似的风格编写。这些优化程序被编译成一个或多个.o
文件,然后将这些文件构建到一系列库(Unix 系统上的.a
文件)中。
LLVM 优化器基于库的设计可以让我们可以灵活选择要组合的优化程序以及自定义优化程序之间的执行顺序,这两点在图像处理领域很有意义:如果在对编译延时很敏感的图片处理上弄一个很臃肿的编译器,那么时间就都被浪费在内联功能上了。由于传递子系统是模块化的,因此实现者可以*地实现自己的特定于语言(language-specific)的优化程序以弥补 LLVM 优化器中的缺陷或明确的特定于语言(language-specific)的优化机会。如图为我们假设的XYZ图像处理系统的一个简单示例:
在我们上面的例子中,因为只有 PassA 和 PassB 被引用,所以这两个优化程序会被链接进最终的产物。因为 PassB 使用了 PassD 来做一些分析,PassD 也会被链接进去。然而,PassC(和其他数十个优化程序)没有被使用,它们不会被链接进图片处理程序中。
一旦选择了优化程序集(代码生成器也是如此),图像处理编译器将被打包成可执行文件或动态库。由于 LLVM 优化过程对优化程序的唯一引用就是每个 .o
文件的create函数,同时优化程序存放在.a
库中,因此只有那些真正使用到的优化程序,才会最终被链接进最终优化程序应用中,而不是整个 LLVM 优化程序。
五、可重定向LLVM代码生成器的设计
LLVM 代码生成器负责将 LLVM IR 转换为目标特定的机器代码。
一方面,代码生成器的工作是为任何给定的目标生成尽可能好的机器代码。另一方面,每个目标的代码生成器需要解决非常相似的问题。例如,每个目标都需要为寄存器赋值,虽然每个目标都有不同的寄存器文件,但使用的算法应该尽可能共享。
与优化器中的方法类似,LLVM 的代码生成器将代码生成问题拆分为单独的通道——指令选择、寄存器分配、调度、代码布局优化和程序集发射——并提供许多默认运行的内置通道。
代码生成器的开发者可以选择默认的实现或者用完全自定义的实现来覆盖默认实现。例如 x86 架构寄存器很少,所以使用减缓寄存器压力调度策略,但是 PowerPC 因为没有寄存器压力,所以后端使用延迟优化调度策略。
5.1 LLVM目标描述文件
“组合和匹配”的方案允许目标作者选择对其架构有意义的内容,并允许跨不同目标进行大量代码重用。这带来了另一个挑战:每个共享组件都需要能够以通用方式推理目标特定属性。例如,共享寄存器分配器需要知道每个目标的寄存器文件以及指令与其寄存器操作数之间存在的约束。
LLVM 对此的解决方案是为每个目标提供由 tblgen 工具处理的特定域的声明语言(一组.td
文件)。x86 架构简化的构建过程如下:
由.td
文件支撑的不同子系统允许目标机开发者为其目标机建立不同的定义。
例如,x86 后端定义了叫做"GR32"的寄存器类,这个类持有如下所有的32 位寄存器,如下所示:
def GR32 : RegisterClass<[i32], 32,
[EAX, ECX, EDX, ESI, EDI, EBX, EBP, ESP,
R8D, R9D, R10D, R11D, R14D, R15D, R12D, R13D]> { ... }
此定义表示此类中的寄存器可以保存 32 位整数值(“i32”),使用 32 位进行对齐,具有指定的 16 个寄存器(在.td 文件的其他地方定义)并有更多信息去指定首选分配顺序和其他内容。给定此定义,特定指令引用此定义,将其用作操作数。例如,“补充一个32 位寄存器”指令定义为:
let Constraints = "$src = $dst" in
def NOT32r : I<0xF7, MRM2r,
(outs GR32:$dst), (ins GR32:$src),
"not{l}\t$dst",
[(set GR32:$dst, (not GR32:$src))]>;
该定义说NOT32r是一个指令(它使用 I
tblgen类),指定编码信息(0xF7
, MRM2r
),指定定义了一个“output” 32位寄存器 $dst
,并且具有名为$src
的32位"input”寄存器“ (在GR32
定义的寄存器类上面定义了哪些寄存器对操作数有效),指定指令的汇编语法(使用{ }
语法处理 AT&T 和 Intel 语法),指定指令的效果并且提供它在最后一行应该匹配的模式。第一行的“let”约束告诉寄存器分配器输入和输出寄存器必须分配到同一个物理寄存器上。
这个定义是对指令的非常密集的描述,普通的 LLVM 代码可以利用从它(通过tblgen
工具)导出的信息做很多事情。
对编译器来说,这一定义足以让指令选择通过输入 IR 代码进行模式匹配并生成指令。
它还告诉寄存器分配器如何处理它,足以将指令编码和解码为机器码字节,并足以以文本形式解析和打印指令。
随着 LLVM 不断增加新目标,增加.td
文件中可以表达的目标数量变得越来越重要,我们将继续增加.td
文件的表达能力来处理这个问题。一个很大的好处是,随着时间的推移,在 LLVM 中编写目标变得越来越容易。
六、模块化设计提供的有趣功能
除了其优雅的设计之外,模块化还为 LLVM 库的客户端提供了一些有趣的功能。
6.1 选择每个阶段运行的时间和地点
如前所述,LLVM IR 可以有效地(正/反)序列化为称为 LLVM 位码的二进制格式。由于 LLVM IR 是独立的,并且序列化是一个无损过程,我们可以进行部分编译,将进度保存到磁盘,然后在未来的某个时间点继续工作。此功能提供了许多有趣的功能,包括对链接时和安装时优化的支持,这两者都延迟了"编译时“的代码生成。
链接时优化 (LTO) 解决了编译器传统上一次只能看到一个转换单元(例如,一个.c
文件及其所有头文件)的问题,因此无法跨文件边界进行优化(如内联)。像 Clang 这样的 LLVM 编译器通过-flto
或-O4
命令行选项支持这一点。此选项指示编译器将 LLVM 位码发送到 .o
文件而不是写出本机目标文件,并将代码生成延迟到链接时间,如图所示:
在 LLVM 中,LTO 自然而然地脱离了系统的设计,并且可以跨不同的源语言(与许多其他编译器不同)工作,因为 IR 是真正的源语言中立的。
安装时优化的思想是将代码生成延迟甚至晚于链接时间,一直到安装时间,如图所示 。安装时间是一个非常有趣的时间(在软件打包、下载、上传到移动设备等的情况下),因为这是您找出目标设备的细节的时候。例如,在 x86 系列中,有各种各样的芯片和特性。通过延迟指令选择、调度和代码生成的其他方面,您可以为应用程序最终运行的特定硬件选择最佳答案。
6.2 单元测试优化器
编译器非常复杂,质量又很重要,因此测试至关重要。例如,在修复导致优化器崩溃的错误后,应添加回归测试以确保它不会再次发生。对此进行测试的传统方法是编写一个通过编译器运行的.c
文件,并使用一个测试工具来验证编译器不会崩溃。这是 GCC 测试套件使用的方法。
这种方法的问题在于,编译器由许多不同的子系统组成,甚至优化器中的还包含许多不同的子优化程序,所有这些都有机会在输入到达原先有 bug 地方之前修改代码。如果前端或早期优化器中的某些内容发生变化,则测试用例很容易无法测试它应该测试的内容。
通过使用带有模块化优化器的 LLVM IR 的文本形式,LLVM 测试套件具有高度集中的回归测试,可以从磁盘加载 LLVM IR,运行它通过一次优化传递,并验证预期的行为。除了崩溃之外,还需要给更复杂的情况编写测试用例并验证优化是否生效。这是一个简单的测试用例,用于检查常量相加是否生效:
; RUN: opt < %s -constprop -S | FileCheck %s
define i32 @test() {
%A = add i32 4, 5
ret i32 %A
; CHECK: @test()
; CHECK: ret i32 9
}
该RUN
行指定要执行的命令:在这个例子中,使用opt
和FileCheck
命令行工具。该 opt
程序是一个简单的 LLVM 通道管理器包装器,它链接所有标准通道(并且可以动态加载包含其他通道的插件)并将它们通过命令行公开。该FileCheck
工具验证其标准输入是否与一系列CHECK
指令匹配。在这种情况下,这个简单的测试是验证constprop
程序将`4和5相加的结果是否为9。
6.3 使用BugPoint减少测试用例
当在编译器或 LLVM 库的其他客户端中发现错误时,修复它的第一步是获取重现问题的测试用例。一旦有了测试用例,最好将其最小化为重现问题的最小示例,并将其缩小到 LLVM 出现问题的部分,例如优化程序错误。虽然您最终会学会如何执行此操作,但该过程是乏味的、手动的,并且对于编译器生成不正确的代码但没有崩溃的情况尤其痛苦。
LLVM BugPoint 工具使用LLVM的 IR 序列化和模块化设计来自动化这个过程。
例如,给定一个输入.ll或.bc文件以及导致优化器崩溃的优化程序列表,BugPoint 将输入减少到一个小的测试用例并确定哪个优化器有问题。然后输出简化的测试用例和opt
用于重现故障的命令。
七、回顾和未来方向
LVM 的模块化最初并不是为了直接实现这里描述的任何目标而设计的。这是一种自卫机制:很明显,我们不会在第一次尝试时就做对。例如,模块化优化程序的存在是为了更容易隔离,以便在被更好的实现替代后可以将其丢弃。
LLVM 保持灵活的另一个主要方面(也是库客户的一个有争议的话题)是我们愿意重新考虑以前的决定并对 API 进行广泛的更改,而不必担心向后兼容性。例如,对 LLVM IR 本身的侵入性更改需要更新所有优化程序并导致 C++ API 大量流失。我们已经多次这样做了,虽然这会给客户带来痛苦,但为了保持快速前进,这样做是正确的。为了使开发者更轻松,我们提供了许多流行的API和LLVM的新版本并且 LLVM 的新版也会继续支持旧版的.ll
和.bc
文件。
展望未来,我们希望继续使 LLVM 更加模块化且更易于子集化。例如,代码生成器仍然过于单一:目前无法根据功能对 LLVM 进行子集化。例如,如果您想使用 JIT,但不需要内联汇编、异常处理或调试信息生成,则应该可以在不链接的情况下构建代码生成器以支持这些功能。我们还在不断提高优化器和代码生成器生成的代码质量,添加 IR 功能以更好地支持新语言和目标结构,并添加更好的支持以在 LLVM 中执行特定于高级语言的优化。
LLVM 项目继续以多种方式发展和改进。看到 LLVM 在其他项目中以不同方式被应用以及它如何在其设计者从未想过的令人惊讶的新环境中不断出现,真是令人兴奋。新的 LLDB 调试器就是一个很好的例子:它使用 Clang 中的 C/C++/Objective-C 解析器来解析表达式,使用 LLVM JIT 将这些转换为目标代码,使用 LLVM 反汇编器,并使用 LLVM 目标来处理调用约定等。LLVM使开发调试器的人专注于编写调试器逻辑,重用现有代码,而不是重新实现另一个C++ 解析器。
尽管迄今为止取得了一些成功,但仍有很多工作要做,而且 LLVM 随着年龄的增长将变得不那么灵活。虽然这个问题没有很好的答案,但我希望继续接触新的问题领域,愿意重新评估以前的决定,重新设计和丢弃代码会有所帮助。毕竟,目标不是完美,而是随着时间的推移不断变得更好。
参考链接
http://www.aosabook.org/en/llvm.html
https://blog.csdn.net/Mr_yu__/article/details/120131493
https://www.jianshu.com/p/1367dad95445
https://zhuanlan.zhihu.com/p/140462815
https://zhuanlan.zhihu.com/p/100241322