LLVM教程(三)--LLVMIR
传统编译器的设计
<1> 最受欢迎的设计传统的静态编译器(像⼤多数C编译器)是三个阶段主要组件的前端设计,优化器和后端(下图)。前端解析代码,检查错误,并构建⼀个特定于语⾔的抽象语法树(AST)来表⽰输⼊代码。AST是优化选择转换为⼀种新的表⽰⽅法,优化器和后端上运⾏代码。
<2> 优化器负责做各种各样的转换来提⾼代码的运⾏时间,如消除冗余计算,通常是或多或少独⽴于语⾔和⽬标。后端(也称为代码⽣成器)然后映射到⽬标指令集的代码。除了要⽣成正确的代码,它还负责⽣成好的代码,利⽤不同寻常的特性⽀持的体系结构。常见的编译器后端部分包括指令选择、寄存器分配和指令调度。
这个模型同样适⽤于解释器和JIT编译器。Java虚拟机(JVM)也是此模型的⼀个实现,它使⽤Java字节码作为前端和优化器之间的接⼝。
<3> 这种经典设计的最重要的胜利来⾃编译器决定⽀持多种源语⾔或⽬标体系结构。如果编译器优化器
使⽤⼀个共同的代码表⽰,那么可以为任何语⾔编写的⼀个前端,可以编译,和后端可以写任何⽬标都可以编译。如下图
通过这个设计,移植编译器⽀持新的源语⾔(如 Algol 或者 BASIC)需要实现⼀个新的前端,但现有的优化和后端可以重⽤。如果这些部件不分离,实现⼀个新的源语⾔需要从头重新开始,所以⽀持N的⽬标需要N * M和M源语⾔编译器。
perl下载安装教程三端设计的另⼀个优点是编译器提供了更⼴泛的程序员集合,⽽不是只⽀持⼀种源语⾔和⼀个⽬标。 对于开源项⽬,这意味着有⼀个更⼤的潜在贡献者社区,这⾃然导致对编译器的更多改进和改进。 这就是为什么开放源代码编译器服务于许多社区(如GCC)倾向于⽣成更好的优化机器代码⽐较窄的编译器,如FreePASCAL。 专有编译器不是这样,其质量与项⽬的预算直接相关。 例如,英特尔ICC编译器以其⽣成的代码质量⽽⼴为⼈知,即使它为狭窄的⼈提供服务。
最后⼀个三端设计的重⼤胜利,实现前端所需的技能是不依赖所需的优化和后端。分离这些更容易“前端”加强和维护他们的编译器的⼀部分。虽然这是⼀个社会问题,⽽不是⼀个技术问题,这就更⾄关重要了。在实践中,特别是对于开放源码项⽬,想贡献尽可能减少障碍。
虽然三端设计的好处在编译器教科书中是令⼈信服的和有据可查的,但实际上它⼏乎从未完全实现。查看开源语⾔实现(LLVM启动时),您会发现Perl,Python,Ruby和Java的实现没有共享代码。此外,类似Glasgow Haskell编译器(GHC)和FreeBASIC的项⽬可以重定向到多个不同的CPU,但是它们的实现⾮常特定于他们⽀持的⼀种源语⾔。还有各种专⽤编译器技术,⽤于实现⽤于图像处理的JIT编译器,正则表达式,显卡驱动程序和需要CPU密集型⼯作的其他⼦域。
也就是说,这个模型有三个主要的成功案例,第⼀个是Java和.NET虚拟机。这些系统提供了JIT编译器,运⾏时⽀持和⾮常清晰的字节码格式。这意味着任何可以编译为字节码格式的语⾔可以利⽤放⼊优化器和JIT以及运⾏时的⽀持。折衷是这些实现在运⾏时的选择上提供了很少的灵活性:它们有效地强制JIT编译,垃圾收集和使⽤⾮常特定的对象模型。当编译与此模型不匹配的语⾔(例如C(例如,使⽤LLJVM 项⽬))时,这导致次优性能。
第⼆个成功案例可能是最不幸的,但也是最常⽤的重⽤编译器技术的⽅法:将输⼊源翻译为C代码(或其他语⾔),并通过现有的C编译器发送它。这允许重⽤优化器和代码⽣成器,提供良好的灵活
性,对运⾏时的控制,并且前端实现者很容易理解,实现和维护。不幸的是,这样做阻碍了异常处理的有效实现,提供了差的调试经验,减慢编译,并且对于需要有保证的尾调⽤(或C不⽀持的其他特性)的语⾔可能有问题。
这个模型的最终成功实现是GCC 4。GCC⽀持许多前端和后端,并且具有活跃和⼴泛的贡献者团体。GCC有⼀个悠久的历史,作为⼀个C 编译器⽀持多个⽬标与hacky⽀持其他⼏种语⾔。随着时间的推移,GCC社区正在慢慢演变⼀个更⼲净的设计。从GCC 4.4开始,它有⼀个新的优化器表⽰(称为“GIMPLE元组”),它⽐前⾯的表⽰更接近于前端表⽰。此外,它的Fortran和Ada前端使⽤⼀个⼲净的AST。
虽然⾮常成功,但这三种⽅法对它们可以⽤于什么有很⼤的局限性,因为它们被设计为单⽚应⽤。作为⼀个例⼦,将GCC嵌⼊其他应⽤程序,使⽤GCC作为运⾏时/ JIT编译器,或者提取和重⽤GCC的⽚段⽽不牵引⼤部分编译器是不现实可能的。想要使⽤GCC的C ++前端进⾏⽂档⽣成,代码索引,重构和静态分析⼯具的⼈不得不使⽤GCC作为⼀个整体应⽤程序,以XML形式发布有趣的信息,或者编写插件以将外部代码注⼊GCC进程。
有多个原因导致GCC不能作为库重⽤,包括猖獗地使⽤全局变量,弱强制的不变式,设计不良的数据结构,扩展的代码库,以及使⽤宏来阻⽌代码库编译⽀持更多每次⼀个前端/⽬标对。然⽽,最难解决
的问题是源于其早期设计和年代的固有的建筑问题。具体来说,GCC遭受分层问题和漏洞抽象:后端⾛前端AST⽣成调试信息,前端⽣成后端数据结构,整个编译器依赖于命令⾏接⼝设置的全局数据结构。
LLVM IR
<1> 有了历史背景和上下⽂,让我们来看看LLVM:它的设计的最重要的⽅⾯是LLVM中间表⽰(IR),它是⽤来在编译器中表⽰代码的形式。LLVM IR设计为托管在编译器的优化器部分中到的中级分析和转换。它被设计成具有许多具体的⽬标,包括⽀持轻量级运⾏时优化,跨功能/过程间优化,整个程序分析和积极的重组转换等。然⽽,它的最重要的⽅⾯是它本⾝被定义为⼀种具有明确定义的语义的第⼀类语⾔。下图有个例⼦.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 对应的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是使⽤简单类型系统强制类型化的(例如,i32是⼀个32位整数,i32** 是指向32位整数的指针),并且机器的⼀些细节被抽象掉。例如,调⽤约定是通过抽象call和ret说明和明确的参数。与机器代码的另⼀个显着区别是,LLVM IR不使⽤⼀组固定的命名寄存器,它使⽤以%字符命名的⽆限临时集。
除了被实现为语⾔之外,LLVM IR实际上以三种同构形式定义:上⾯的⽂本格式,通过优化本⾝检查和修改的内存数据结构,以及⾼效和密集的磁盘上⼆进制“位码”格式。该项⽬LLVM还提供⼯具,以磁盘格式从⽂本到⼆进制转换:llvm-as⽂本汇编.ll⽂件转换成 .bc包含bitcode粘性物质从中⽂件,并llvm-dis把⼀个 .bc⽂件到⼀个.ll⽂件中。
LLVM的三端设计实现
在基于LLVM的编译器中,前端负责解析,验证和诊断输⼊代码中的错误,然后将解析的代码转换为LLVM IR(通常但不总是通过构建AST,然后将AST转换为LLVM IR)。该IR可选地通过⼀系列分析和优化过程来馈送,改进代码,然后发送到代码⽣成器中以产⽣本地机器代码,如图所⽰。这是⼀个⾮常直接的三端设计实现,但这个简单的描述掩盖了LLVM架构从LLVM IR派⽣的⼀些功能和灵活性。
特别地,LLVM IR是优化器的唯⼀接⼝。 这个就意味着你需要知道为LLVM编写⼀个前端是什么LLVM IR,它是如何⼯作,以及它期望的不变性。 由于LLVM IR具有⼀级⽂本形式,因此构建⼀个将LLVM IR作为⽂本输出的前端是可能的,也是合理的,然后使⽤Unix管道通过您选择的优化器序列和代码⽣成器发送它。
这可能是令⼈惊讶的,但这实际上是LLVM的⼀个⾮常新颖的属性和其成功在⼴泛的不同应⽤程序的主要原因之⼀。 即使是⼴泛成功和相对良好架构的GCC编译器也没有这个属性:它的GIMPLE中级表⽰不是⼀个⾃包含的表⽰。 作为⼀个简单的例⼦,当GCC代码⽣成器发出DWARF调试信息时,它返回并遍历源级“树”形式。 GIMPLE本⾝使⽤代码中的操作的“元组”表⽰,但(⾄少如GCC 4.5)仍然表⽰操作数作为回到源级树形式的引⽤。
参考资料:
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。
发表评论