LLVM编译流程——全详解(快来看史上最细解析!!)

LLVM

什么是LLVM?

LLVM项目是一系列分模块、可重用的编译工具链。它提供了一种代码编写良好的中间表示(IR),可以作为多种语言的后端,还可以提供与变成语言无关的优化和针对多种cpu的代码生成功能。

传统编译器分为三个阶段:前端—>优化器—>后端

LLVM也分为三个阶段,但是设计上有区别,LLVM不同的就是对于不同的语言他都提供了同一种中间表示:

前端可以使用不同的编译工具对代码文件做词法分析以形成抽象语法树AST,然后将分析好的代码转换成LLVM的中间表示IR(intermediate representation);

中间部分的优化器只对中间表示IR操作,通过一系列的pass对IR做优化;

后端负责将优化好的IR解释成对应平台的机器码。LLVM的优点在于,中间表示IR代码编写良好,而且不同的前端语言最终都转换成同一种的IR。除了要生成正确的代码,它还负责利用不同寻常的特性支持的体系结构。常见的编译器后端部分包括指令选择寄存器分配指令调度

LLVM编译工具链编译流程:

词法分析:

它读取我们的代码,然后把它们按照预定的规则合并成一个个的标识 tokens。同时,它会移除空白符、注释等。最后,整个代码将被分割进一个 tokens 列表(或者说一维数组)。当词法分析源代码的时候,它会一个一个字母地读取代码,所以很形象地称之为扫描 - scans。

在go中有一个专门的文件go/token/token.go定义了所有的符号。

在go中是go/scanner包提供词法分析功能,将源代码转换为一系列的token。

1
2
3
const a = 5;
// 转换
[{value: 'const', type: 'keyword'}, {value: 'a', type: 'identifier'}, {value: '5', type: 'identifier'}]

语法分析:

将词法分析出来的数组转换成树形的形式,同时,验证语法。语法如果有错的话,抛出语法错误。当生成树的时候,解析器会删除一些没必要的标识 tokens(比如:不完整的括号),因此 AST 不是 100% 与源码匹配的。

为什么要将源码转化为AST?AST不依赖于具体的文法,不依赖于语言的细节,我们将源代码转化为AST后,可以对AST做很多的操作。

go/parser提供语法分析功能,将这些token转换为AST(Abstract Syntax Tree, 抽象语法树)

比如说一个简单的函数,javascript为例:

1
2
3
function add(a, b) {
    return a + b
}

根据语法分析之后获得tockens,进行语法分析生成AST结构,用图表示为:

IR代码生成:

代码生成器CodeGen会负责将语法树自顶向下遍历逐步翻译成LLVM IR,IR就是编译过程的前端的输出以及后端的输入。此步骤LLVM会去做些优化工作,在Xcode的编译设置里也可以设置优化的级别-01,-03,-0s等,还可以通过编写自己的pass自定制优化解决方案。

LLVM IR:生成.ll文件。

官方给出的例子:

C语言版;

1
2
3
4
5
6
7
8
9
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);
}

IR版:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
define i32 @add1(i32 %a, i32 %b) {// 全局标识符要以 `@` 字符开始。所有的函数名和全局变量都必须以 `@` 开始。
entry:
  %tmp1 = add i32 %a, %b                       // i32指的是32位整数
  ret i32 %tmp1                               // LLVM 中的局部标识符以百分号 % 开始。
}
?
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自带的MCJIT组件支持在运行时将LLVM IR生成为可执行机器码,因而可以方便的支持实现JIT编译器。

LLVM IR除了被实现为一种语言外,实际上还以三种同构形式定义:1)上面的文本格式,生成.ll文件,2)通过优化本身检查和修改的内存数据结构,易于阅读的LLVM IR汇编,生成.s文件;3)高效且密集的磁盘二进制“位码(Bitcode)”格式,生成的是.bc文件。

三者之间可以互相转化,此处借用一下网友的图,如有侵权可以联系删除:

关于LLVM向量的三个问题:

注意:向量化的操作需要机器硬件的支持!

再循环体的标量指令中,只有部分指令能够向量化,剩下的指令只能标量执行,称这部分不能向量化的指令为尾循环。

在进行 LLVM 向量化优化时主要使用以下工具。

Clang/clang++:clang/clang++是 LLVM 编译器前端,能够把 c/c++/Objective-c 语言编写的源代码转换成 LLVM 中间指令。

llvm-as:llvm 的汇编器。汇编器把人类可读的 LLVM 中间表示转换成相应的 内存中的字节码,或者磁盘上的字节码文件。

llvm-dis:llvm 的反汇编器。把 LLVM 字节码文件转换成可读的 LLVM 汇编 语言。

opt:llvm 优化器。优化器的输入是 LLVM 的 IR 文件,在 IR 文件上执行特定 的优化或者分析后,输出优化后的 IR 文件或者分析结果。

llc:llvm 静态编译器。把 LLVM IR 文件编译成一个特定的目标平台上的汇编 语言。

lli:采用即时编译的方式直接执行 LLVM 字节码格式的程序。

llvm-link:LLVM 字节码链接器。

llvm-ar:LLVM 打包器。

llvm-nm:列出 LLVM 字节码文件或者目标文件的符号表。

llvm-config:打印 LLVM 编译选项。

llvm-diff:比较 LLVM IR 文件不同。

llvm-cov:显示代码覆盖信息,是生成 profile 文件的基础。

llvm-profdata:用于处理 profile 文件。

llvm-stress:生成任意的.ll 文件,用于压力测试。

llvm-symbolizer:读取目标代码的名字和地址,打印源文件所在的行数。

llvm-dwarfdump:读取 dwarf 格式的目标码,并打印成可读信息。

bugpoint:自动 bug 测试工具。

llvm-extract:从模块中萃取函数。

llvm-bcanalyzer:字节码分析器。

filecheck:具有灵活模式的文件比较器。

tblgen:把机器平台描述转换成 c++代码。

lit:LLVM 集成测试。

llvm-build:使用 llvm-build 来描述基于 LLVM 的项目组件。

llvm-readobj:LLVM 目标文件阅读器。

LLVM优化Pass

LLVM 中的优化和分析被组织成pass结构。通过组合不同pass来完成不同的优化算法。LLVM 中的pass分为三种:分析pass和转换pass和使用pass。

分析pass只分析LLVM 中间表示,并从中得到分析结果,为其他的优化或者调试提供信息。

转换pass分为优化pass和规范化pass。这两种都会修改 LLVM 中间表示,通常这两种pass会利用分析pass分析的信息。其中优化pass主要是负责实施优化的逻辑,而规范化pass负责把 LLVM 中间码转换成一种统一的形式,在统一的形式下去执行优化操作会让优化操作更容易实现。

使用pass不属于优化pass也不属于分析pass,使用pass为 LLVM 其他工具提供服务。

LLVM 中有以下重要的优化pass。生成.ll文件以后,可以在命令行中添加以下命令选项进行优化。

adce:激进的死代码删除。

bb-vectorize:基本块向量化。

constprop:常量传播优化。

dce:死代码消除。

globaldce:全局死代码消除。

deadargelim:死变量消除。

globalopt:全局优化。

gvn::全局值命名。

inline:内联函数优化。

instcombine:冗余指令组合。

licm:循环不变代码提升。

loweratomic:把原子操作 Intrinsic 函数降低成非原子操作。

lowerinvoke:降低函数调用。

lowerswitch:降低 switch 语句,转化成分支语句。

mem2reg:把某块内存放在寄存器里。

memcpyopt:内存拷贝优化。

simplifycfg:控制流简化。

tailcallelim:尾调用消除。

更多的分析和优化参见:https://llvm.org/docs/Passes.html

在中间的IR的优化管道,会经历一系列pass,这些pass连续重写IR,以消除效率低下和形式不易转换成机器代码的形式。在某些编译器中,IR格式在整个优化流程中保持固定,而在另一些编译器中,格式或语义会更改。优化管道中的传递顺序是由编译器开发人员设计的。它的目标是在合理的时间内完成出色的工作。它会不时地进行调整,当然在每个优化级别都会运行一组不同的过程。通道设计的一些原则是最小性和正交性:每个通道应该做一件事,功能上不应有太多重叠。在实践中,有时会做出妥协。

图:编译器优化算法

左边的字母代表右边方框中的优化手段实施的编译优化阶段,同一框中的优化实施的编 译优化阶段大致相同。

字母 A 表示的方框中的优化通常施加在源代码上,或者施加在基本保留了源 代码的代码结构,比如循环结构和顺序、数组访问等高级中间代码(HIR)。在执 行这类优化的编译器中,这些优化通常在编译过程的前期执行,随着编译过程进行,代码的层次也会越来越低。 字母 B,C 表示的方框通常作用于中级(MIR)或者低级中间语言(LIR)。 字母 D 表示的方框通常作用于低级形式的代码,很多情况下需要利用机器相 关的特性来优化。 字母 E 表示的方框一般出现在目标代码代码链接时,此时代码是可重定位的目标码。