在 unsplash 上挑封面的时候,我情知输入 “Compile” 这样的字眼不可能会有什么答案,鬼使神差下,不知道为什么键入了 “Textile” ——纺织。是的,或许抖个机灵放几棵 “Tree” 会更贴合我的工作内容,但不可否认的是整个编译过程给我的体验就如纺线一般,层层递进,希望最终的结果也能如纺织品交错的针脚一般整齐而美丽吧。🌳
简介
LLVM是一种基于 静态单赋值(SSA) 的表示,它具有类型安全性、低级操作、灵活性,以及清晰地表示”所有”高级语言的能力。它是LLVM编译策略所有阶段中使用的通用代码表示。
因此,如果我们想要为一个语言(即使它是你自创的,比如编译比赛使用的 SysY2022 或者编译实验课使用的 SysMini)创建编译策略,一个很好的选择就是使用 LLVM IR 作为中间代码。
LLVM 表示的目标是轻量级和低层次的,同时具有表达性、类型化和可扩展性。它的目标是成为某种“通用的IR”,通过足够低的级别,高层次的思想可以清晰地映射到它(类似于微处理器是“通用的 IR ”,允许许多源语言映射到它们)。在提供类型信息的前提下,LLVM可以作为优化的目标:例如,通过指针分析,可以证明 C automatic variable 从未在当前函数之外被访问,这使得它可以提升为一个简单的 SSA 值,而不是内存位置。
编译器构成与IR
-
IR:中间表示 Intermediate Representation
-
开发编译器时通常将源代码编译为某种IR,然后再将IR翻译为目标体系结构的汇编
- 一些优化技术与目标平台无关,只需要在IR上做好优化,再翻译为不同的汇编,就能够在所有支持的体系结构上实现这种优化,减少开发工作量
m
种源语言和n
种目标平台的场景 - fromm * n
tom + n
-
常见的编译器构成:LLVM也照此结构设计
- 前端 front-end:将源语言编译到 IR
- 中端 middle-end:对IR进行优化
- 后端 back-end:将IR翻译为目标语言
-
表示形式:LLVM IR的三种完全等价的中间格式
- 在内存中的编译语言:我们无法通过文件的形式得到
- 在硬盘上存储的二进制中间语言:格式为
.bc
- 人类可读的代码语言:格式为
.ll
-
示例程序
-
源程序:
1
2
3
4
5
6
7
8
9
10int foo(int first, int second) {
return first + second;
}
int a = 5;
int main() {
int b = 4;
return foo(a, b);
} -
编译得到的 LLVM IR 代码:
1 |
|
-
简要说明
-
无用语句:删除后仍不影响格式
-
align字段:描述程序的对齐属性
-
dso_local:变量和函数的运行时抢占说明符
-
以
;
开头的字段:LLVM IR的注释
-
-
需要关注的正文内容
-
删去无关语句后得到如下代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40; 所有的全局变量都以 @ 为前缀,后面的 global 关键字表明了它是一个全局变量
@a = global i32 5
; 注意,@a 的类型是 i32* ,后面会详细说明
; 函数定义以 `define` 开头,i32 标明了函数的返回类型,其中 `foo`是函数的名字,`@` 是其前缀
; 函数参数 (i32 %0, i32 %1) 分别标明了其第一、第二个参数的类型以及他们的名字
define i32 @foo(i32 %0, i32 %1) {
; 第一个参数的名字是 %0,类型是 i32;第二个参数的名字是 %1,类型是 i32。
; 以 % 开头的符号表示虚拟寄存器,你可以把它当作一个临时变量(与全局变量相区分),或称之为临时寄存器
%3 = alloca i32 ; 为 %3 分配空间,其大小与一个 i32 类型的大小相同。%3 类型即为 i32*
%4 = alloca i32 ; 同理,%4 类型为 i32*
store i32 %0, i32* %3 ; 将 %0(i32)存入 %3(i32*)
store i32 %1, i32* %4 ; 将 %1(i32)存入 %4(i32*)
%5 = load i32, i32* %3 ; 从 %3(i32*)中 load 出一个值(类型为 i32),这个值的名字为 %5
%6 = load i32, i32* %4 ; 同理,从 %4(i32*) 中 load 出一个值给 %6(i32)
%7 = add nsw i32 %5, %6 ; 将 %5(i32) 与 %6(i32)相加,其和的名字为 %7。nsw 是 "No Signed Wrap" 的缩写,表示无符号值运算
ret i32 %7 ; 返回 %7(i32)
}
define i32 @main() {
; 注意,下面出现的 %1,%2……与上面的无关,即每个函数的临时寄存器是独立的
%1 = alloca i32
%2 = alloca i32
store i32 0, i32* %1
store i32 4, i32* %2
%3 = load i32, i32* @a
%4 = load i32, i32* %2
; 调用函数 @foo ,i32 表示函数的返回值类型
; 第一个参数是 %3(i32),第二个参数是 %4(i32),给函数的返回值命名为 %5
%5 = call i32 @foo(i32 %3, i32 %4)
ret i32 %5
}-
以 @ 开头的符号:表示全局变量,后面的 global 关键字表明了它是一个全局变量
-
以
define
开头的字段:表示函数,i32 标明了函数的返回类型,其中foo
是函数的名字,@
是其前缀 -
以 % 开头的符号:表示虚拟寄存器,你可以把它当作一个临时变量 / 局部变量(与全局变量相区分),或称之为临时寄存器
-
alloca:分配空间
-
store, load, add等:对应临时寄存器的行为,参照正常的汇编即可
-
call:调用函数
-
值得注意的地方
- 注释以
;
开头 - LLVM IR是静态类型的,在编写时每个值都有明确的类型
- 局部变量的作用域是单个函数:比如
@main
中的%1
是一个i32*
类型的地址,而@foo
中的%1
是一个i32
类型的值 - 临时寄存器(临时变量)拥有升序的名字
- 全局变量与局部变量由前缀区分,全局变量和函数名以
@
为前缀,局部变量以%
为前缀 - 大多数指令与字面意义相同,也就是前面说的 参照正常汇编即可
- 注释以
LLVM IR的结构
总体结构
- LLVM IR 文件的基本单位是
module
- 一个
module
中可以拥有多个顶层实体,比如function
和global variable
- 一个
function define
中至少有一个basicblock
- 每个
basicblock
中有若干instruction
,并且都以terminator instruction
结尾
函数定义与函数声明(Define & Declare)
函数定义
由前文的示例代码可以发现,一个基本的
main
函数定义应如下所示: -
-
1 | define i32 @main() { |
函数后也可以加上参数列表,如 foo
函数所示:
1 | define i32 @foo(i32 %a,i32 %b) { |
可以看到,一个函数定义的最简单的语法形如:
define + return value(i32) + function name(@foo) + argv[] ((i32 %a, i32 %b)) + function block({ret i32 0})
函数声明
我们在一个 module
里,如果想要调用别的模块的函数,就需要在本模块中先声明这个函数。如果要使用库函数,可能需要用函数声明的形式在生成的 .ll
文件里声明库函数的名字。
函数声明的结构是用 declare
关键词替换 define
, 并且没有函数体,如以下代码所示:
1 | declare i32 @getint() |
基本块
一个基本块是包含了若干个指令以及一个终结指令的代码序列。
基本块只会从终结指令退出,并且基本块的执行是原子性的。也就是说,如果基本块中的一条指令执行了,那么块内其他所有指令也都会执行。这个约束是通过代码的语义实现的——基本块的内部没有控制流,控制流是由多个基本块直接通过跳转指令实现的。
形象地讲,一个基本块中的代码是顺序执行的,且顺序执行的代码都属于一个基本块——包括跳转,没有分支、循环,没有函数调用,只会顺序执行。比如,在一个基本块中加入一个 if-else
语句后,代码就会变成4个基本块。
指令
指令是指 LLVM IR 中的 非分支指令 ,通常用来进行某种计算或者是访存动作,这些指令并不会改变程序的控制流,比如 load, add 等。
值得一提的是,call
指令也是非分支指令,因为在使用 call
调用函数时,我们不关心被调用函数内部的具体情况(也即被调用函数内部存在的控制流),而是只关心我们传入的参数以及被调用参数的返回值,因此这并不会影响我们当前程序的控制流。不过,存在 call
指令的代码序列不是一个基本块。
终结指令
终结指令一定位于某个基本块的末尾,反过来,每个基本块的末尾也一定是一条终结指令。终结指令决定了程序控制流的执行方向。
-
例如,
ret
指令会使程序的控制流返回到当前函数的调用者,可以理解为return
,而br
指令表示根据标识符选择一个控制流的方向,可以理解为if
-
代码实例
-
源代码:
1
2
3
4
5
6
7
8
9
10
11
12
13//if.c
int main() {
int a = getint();
int b = getint();
int c = 0;
if (a == b) {
c = 5;
} else {
c = 10;
}
putint(c);
return 0;
}- 导出并简化后的
.ll
文件:
- 导出并简化后的
-
1 | declare i32 @getint() |
此程序的控制流如图所示:
-
br
指令- 语法:
br + 标志位 + truelabel + falselabel
或者br + label
1
2
3br i1 %9, label %10, label %11 ; A
br label %12 ; B
br label %12 ; C-
形如代码 A 的用法叫做条件转移。如果标志位为 1 ,程序会跳往 truelabel 标记的基本块;如果标志位为0,程序会跳往 falselabel 标记的基本块;
形如代码 B 和 C 的用法叫做无条件转移,它会在程序运行到此处时无条件跳转到目标基本块。
类型系统
类型系统是LLVM IR中最重要的部分之一,强大的类型系统在很大程度上降低了读取和分析LLVM IR的难度,并且可以实现一些在一般的 三地址码IR 中难以实现的优化。
-
补充:三地址码
每个三地址码指令都可以被分解为四个元组(4 - tuple):
(运算符,运算对象1,运算对象2,结果)
。因为每个陈述都包含了三个操作数,需要三个地址代码,所以它被称为三地址码
Void Type
仅占位用,不代表任何值也不占任何空间。
- 语法:
1 | define void @foo(){ |
Integer Type
最简单的类型,代表了后面数字决定的位宽的类型,比如 i1
代表的就是 1bit 长的integer(我们可以看成 bool
), i32
就是 32bit 长的integer。
1 | ret i32 0 |
Label Type
标签类型,用作代码标签。
1 | br i1 %9, label %10, label %11 |
Other Types
待补充。
LLVM 中的 SSA
在 LLVM IR 中,变量是以 SSA 的形式存在的。LLVM IR 的代码有两种状态,分别是存取内存形式的 IR 以及带有 phi
指令的 SSA IR。
SSA介绍
静态单赋值(Static Single Assignment, SSA)是编译器中间表示中非常重要的概念,它是一种变量的命名约定,当程序中的每个变量都有且只有一个赋值语句时,称一个程序是 SSA 形式的。
在 LLVM IR 中,每个变量在使用前都必须先定义,且每个变量只能被赋值一次(有点像每个变量只能被初始化的意思),因此我们称 IR 是静态单赋值的。
SSA的好处
SSA 可以简化编译器的优化过程。譬如说,考虑下面这段代码:
1 | d1: y := 1 |
可以发现第一次对 y 的赋值是不必要的,毕竟在对 x 赋值时使用的 y 值是第二次赋值的结果,但是编译器必须要经过一个定义可达性(Reaching definition)分析才能做出判断。编译器的分析过程大致如下所示:
- 定义:对变量 x 进行定义的意思是在某处(可能)会给 x 赋值,比如上面的 d1 处就是一个对 y 的定义
- kill:当一个变量有了新定义后,旧有的定义就会被kill,比如上面的 d2 就 kill 了 d1 中对 y 的定义
- 定义到达某点:定义 p 到达某点 q 的意思是存在一条路径,沿着这条路径行进,p 在到达 q 点之前不会被kill
- 定义可达性(Reaching definition):a 是 b 的 Reaching definition 的意思是存在一条从 a 到达 b 的路径,沿着这条路径走可以自然得到 a 要赋值的变量的值,而不需要额外的信息
1 | d1: y1 := 1 |
显然这样得到 x 的值的路径就是唯一确定的,d2 就是 d3 的 Reaching definition ,这样的信息在编译器想要进行优化时会起到很大作用。
对于我们来说,上述的代码是一目了然的,但如果控制流再复杂一点,对于编译器来说,它便无法确定 d3 的 Reaching definition 是 d1 还是 d2 了,也不清楚 d1 和 d2 到底是谁 kill 了谁。但是,如果代码是 SSA 形式的,就会长成这样:
SSA的麻烦
面对需要利用循环实现的函数,我们的通常做法是给出临时值 tmp 来存储循环的量,但这就不可避免地会导致 tmp 被重复赋值,循环时用于计数的变量(比如 i )也会被重复赋值,在 SSA 中不合法。
解决方案
-
phi
-
给出一个这样的循环函数:
1
2
3
4
5
6
7int factorial(int val) {
int tmp = 1;
for (int i = 2; i <= val; i++) {
tmp *= i;
}
return tmp;
}
-
在 clang -O1
选项下生成此函数的 .ll
格式文件,我们会发现生成代码大致如下所示:
1 | define dso_local i32 @factorial(i32 %0) local_unnamed_addr #0 { |
phi
指令的语法是:
<result> = phi <ty> [<val0>, <label0>], [<val1>, <label1>]...
这个指令能够根据进入当前基本块之前执行的是哪个基本块的代码来选择一个变量的值,这样一来上面代码的执行过程就变成了下图所示的过程:
可以看到每个变量只被赋值一次,起到了循环递增的效果。这里也可以看出,SSA 要求的是在静态,也即仅从代码文本层面可以看出的单一赋值,而非严格的运行时只会被赋值一次。
-
alloca, load & store
-
循环函数依旧如上节所示,此时在
clang -O0
选项下生成此函数的.ll
格式文件,我们会发现生成代码大致如下所示:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31define dso_local i32 @factorial(i32 %0) #0 {
%2 = alloca i32, align 4
%3 = alloca i32, align 4
%4 = alloca i32, align 4
store i32 %0, i32* %2, align 4
store i32 1, i32* %3, align 4
store i32 2, i32* %4, align 4
br label %5
5:
%6 = load i32, i32* %4, align 4
%7 = load i32, i32* %2, align 4
%8 = icmp sle i32 %6, %7
br i1 %8, label %9, label %16
9:
%10 = load i32, i32* %4, align 4
%11 = load i32, i32* %3, align 4
%12 = mul nsw i32 %11, %10
store i32 %12, i32* %3, align 4
br label %13
13:
%14 = load i32, i32* %4, align 4
%15 = add nsw i32 %14, 1
store i32 %15, i32* %4, align 4
br label %5
16:
%17 = load i32, i32* %3, align 4
ret i32 %17
}
-
alloca
指令的作用是在当前执行的函数的栈帧上分配内存,并返回一个指向这片内存的指针,当函数返回时内存会被自动释放(一般是改变栈指针)。上述的 IR 对一些局部变量存在多次 store 操作,但这些局部变量都是存放在内存中的,而非直接作为 LLVM IR 的一个虚拟寄存器被赋值。实际上,这是借助 LLVM IR 中只要求虚拟寄存器是 SSA 形式而内存却不要求的特性开了个后门,前端可以直接把局部变量分配到内存中,放在内存里的变量不需要遵循 SSA 形式,可以经受多次定义。构造 SSA 的算法比较复杂,而且需要各种复杂的数据结构,这些因素导致在前端直接生成 SSA 形式的 IR 时非常麻烦。
基于这样的技巧,前端能够直接将变量按照 栈 的方式分配到内存中,并且这个内存里的变量不需要遵循 SSA 形式,可以被多次定义,从而避免了构造 phi
指令产生的 大量开销 。
在 LLVM 中,所有的内存访问都需要显式地调用 load / store
指令。要说明的是,LLVM 中并没有给出“取地址”的操作符。以上述生成代码中的 %3
为例,我们可以发现 %3
通过 alloca
声明,分配了一个 i32
大小的空间,此时 %3
的类型为 i32*
,代表这段空间的地址,load
再将 %3
所指向空间的内容读取出来,而 store
将新的值写入 %3
指向的空间。alloca
分配的栈变量可以进行多次存取,因此通过这三条指令我们就可以避免 phi
指令的使用。
每个可变变量都变为了栈上分配的空间,也即每个变量都变成了一条
alloca
指令每次对变量的读都变成了从内存空间的一次读,通过
load
指令实现每次对变量的写都变成了对内存空间的一次写,通过
store
指令实现获取变量的地址等价于获取内存的地址
不难发现,每次变量的存取都变成了访问内存,这会导致严重的性能问题。
-
解决方案:
mem2reg
- 它能够把
alloca
指令分配的内存变量转化为 SSA 形式的 LLVM IR 虚拟寄存器变量,并在合适的地方插入phi
指令。
- 它能够把
LLVM 官方非常支持使用上述 alloca + mem2reg
技术,clang 默认不开优化生成的就是存取内存形式的 IR。alloca
技术可以把前端从繁琐的 SSA 构造工作中解脱出来,而 mem2reg
则可以极其快速地生成 SSA 形式。这两者的结合大大提高了编译的效率。
LLVM中核心类的层次结构参考
LLVM 核心类是表示被检查或转换的程序的主要方法。LLVM 核心类定义在 include/LLVM/IR
目录的头文件中,实现在 lib/IR
目录中。因为一些历史原因,这个库被称为 libLLVMCore.so
,而不是我们可能会想到的 libLLVMIR.so
。
Type 类和 DerivedType
1 | #include "llvm/IR/Type.h" |
Type 是所有类型类的超类。每个值都有一个类型。Type本身不能直接实例化,而只能通过其子类进行实例化,某些基本类型(如 VoidType, LabelType, FloatType, DoubleType)有隐藏的子类。它们之所以被隐藏,是因为它们除了 Type 类提供的功能以外没有提供其他有用的功能,只是将自己与 Type 的其他子类区分开来。
所有其他类型都是 DerivedType 的子类。类型可以命名,但并不是必须的,在任何时候,给定的 shape 都只存在一个实例。这允许 [类型相等] 与 [类型实例的地址相等] 一起执行,也就是说,给定两个 Type* 值,如果指针相同,则类型相同。
重要的公共方法
1 | bool isIntegerTy() const: 对所有的整数类型返回真值 |
重要的派生类型DerivedType
-
IntegerType
- DerivedType 的子类,表示任意位宽的整数类型,位宽介于 1 至 800w 之间的整数都可以表示
1
- static const IntegerType* get(unsigned NumBits):获取指定位宽的一个整型
-
unsigned getBitWidth() const:获取某整型的位宽
-
SequentialType
- ArrayType 和 VectorType 的父类
1
- const Type * getElementType() const:返回序列中所有元素的类型
-
uint64_t getNumElements() const:返回序列中的元素个数
-
ArrayType
- SequentialType 的一个子类,定义了数组类型的接口
-
PointerType
- 指针类型的 Type 的子类
-
VectorType
- 向量类型的 SequentialType 的子类。VectorType 与 ArrayType 类似,但有区别,因为它是第一类类型,而 ArrayType 不是。向量类型用于向量运算,通常是整数或浮点类型的小向量
-
StructType
- 结构体类型的 DerivedType 的子类
-
FunctionType
-
函数类型的 DerivedType 的子类
-
bool isVarArg() const
-
-
const Type * getReturnType() const
-
const Type * getParamType (unsigned i)
-
const unsigned getNumParams() const
Module类
1 | #include "llvm/IR/Module.h" |
Module 类代表了 LLVM 程序中的顶级结构。LLVM模块实际上要么是原始程序的翻译单元,要么是由链接器合并的几个翻译单元的组合。Module 类跟踪一个函数列表、一个全局变量列表和一个符号表。此外,它还包含一些有用的成员函数,通过它们可以简化一些常见操作。
重要的公共成员
-
Module::Module(std::string name = “”)
- 构造一个模块很简单,可以根据翻译单元的名字选择提供给它一个名字
-
如下的转发方法使访问模块对象的函数列表的内容变得容易
-
Module::iterator
- 函数列表迭代器的类型定义
-
Module::const_iterator
-
begin(), end(), size(), empty()
-
-
同上,但是针对全局变量列表
- Module::global_iterator
- Module::const_global_iterator
- global_begin(), global_end(), global_size(), global_empty()
-
Module::FunctionListType &getFunctionList()
- 返回函数列表。当需要更新函数列表或执行一个没有转发方法的复杂操作时,这个方法就是必要的
-
Module::GlobalListType &getGlobalList()
- 同上,但是针对全局变量列表
-
SymbolTable *getSymbolTable()
- 获取此模块对符号表的引用
-
Function *getFunction(StringRef Name) const
- 在模块的符号表中查找指定的函数。如果不存在,则返回null
-
FunctionCallee getOrInsertFunction(const std::string &Name, const FunctionType *T)
- 在模块的符号表中查找指定的函数。如果不存在,则为函数添加外部声明并返回它。注意,已经存在的函数签名可能与请求的签名不匹配。因此,为了使直接将结果传递给 EmitCall (监听调用)的通用用法成为可能,返回类型是一个结构体{FunctionType *T, Constant FunctionPtr},而不是简单的带有潜在意外签名的Function
-
std::string getTypeName(const Type *Ty)
- 如果符号表中至少有一个指定类型的表项,则返回该表项。否则返回空字符串
-
bool addTypeName(const std::string &Name, const Type *Ty)
- 在符号表中插入一个表项,将Name映射到类型Ty。如果该名称已经有一个表项,则返回true, 符号表不被修改
Value类
1 | #include "llvm/IR/Value.h" |
Value 类是 LLVM Source 基类中最重要的类。它表示一种类型的值,可以作为指令的操作数使用。有许多不同类型的值,如常量、实参。即使指令和函数也是值。(万物皆为 Value )
一个特定的值可以在一个程序的 LLVM 表示中多次使用。例如,函数的传入参数(以 argument 类的实例表示)被函数中引用该参数的每条指令“使用”。为了跟踪这种关系,Value 类保存了一个正在使用它的所有 User 的列表( User 类是 LLVM 图中所有可以引用 Values 的节点的基类)。这个使用列表是 LLVM 在程序中表示 def-use 信息的方式,可以通过 use_* 方法访问,如下所示。
因为 LLVM 是一个类型化的表示,每个 LLVM 值都是类型化的,这个类型可以通过getType() 方法获得。此外,所有的 LLVM 值都可以被命名。Value的 “name” 是一个在 LLVM 代码中打印的符号字符串:
1 | %foo = add i32 1, 2 |
此指令的名称是“foo”。注意,任何值的名称都可能丢失(一个空字符串),所以名称应该只用于调试(使源代码更容易阅读,调试打印输出),它们不应该用于跟踪值或它们之间的映射。为此,使用指向值本身的指针的 std::map 。
LLVM 的一个重要方面是,SSA 变量和产生它的操作之间没有区别。因此,对指令产生的值的任何引用(例如,作为传入参数可用的值)都表示为指向表示该值的类实例的直接指针。虽然这可能需要一些时间来适应,但它简化了表示,使其更容易操作。
重要的公共成员
-
Value::use_iterator:用于 use-list 的迭代器的类型定义
-
Value::const_use_iterator
-
unsigned use_size()
-
bool use_empty()
-
use_iterator use_begin() - Get an iterator to the start of the use-list.
-
use_iterator use_end() - Get an iterator to the end of the use-list.
-
User *use_back() - Returns the last element in the list.
这些方法是访问 LLVM 中的 def-use 信息的接口。与 LLVM 中的所有其他迭代器一样,命名约定遵循 STL 定义的约定。
-
Type *getType() const
-
bool hasName() const
-
std::string getName() const
-
void setName(const std::string &Name)
-
void replaceAllUsesWith(Value *V)
-
此方法遍历一个 Value 的使用列表,将当前值的所有 Users 改为引用" V "。例如,如果你检测到一条指令总是产生一个常量值(例如通过常量折叠),你可以像这样用常量替换该指令的所有用法:
1
Inst->replaceAllUsesWith(ConstVal);
-
User类
1 | #include "llvm/IR/User.h" |
User 类是所有 LLVM 节点的公共基类,它可能引用“值”。它公开了一个“操作数”列表,这些操作数是用户所引用的所有值。User 类本身是 Value 的子类。
User 的操作数直接指向它所引用的 LLVM 值。因为 LLVM 使用静态单赋值(SSA)形式,所以只能引用一个定义,允许这种直接连接。这个连接提供了 LLVM 中的use-def信息。
Instruction类
1 | #include "llvm/IR/Instruction.h" |
指令类是所有 LLVM 指令的公共基类。它只提供了一些方法,但却是一个非常常用的类。指令类本身跟踪的主要数据是操作码(指令类型)和指令嵌入的父类 BasicBlock 。要表示特定类型的指令,需要使用指令的多个子类中的一个。
因为指令类是 User 类的子类,它的操作数可以像其他 User 类一样被访问(使用getOperand() / getNumOperands()和op_begin() / op_end()方法)。指令类的一个重要文件是 llvm/ instructions .def
文件。这个文件包含一些关于 LLVM 中各种不同类型指令的元数据。它描述了用作操作码的枚举值(如 Instruction::Add 和 Instruction::ICmp),以及实现该指令的具体子类(如 BinaryOperator 和 CmpInst )。不幸的是,在这个文件中使用宏会混淆 doxygen ,所以这些 enum 值在 doxygen 输出中不能正确显示。
Constant类及其子类
Constant 是表示不同类型常量的基类。它是 ConstantInt 、ConstantArray 等的父类,用于表示各种类型的常量。GlobalValue 也是它的一个子类,它表示全局变量或函数的地址。
GlobalValue类
1 | #include "llvm/IR/GlobalValue.h" |
全局值(全局变量或函数)是唯一在所有函数体中可见的 LLVM 值。因为它们在全局作用域是可见的,所以它们还需要与在不同翻译单元中定义的其他全局变量进行链接。为了控制链接过程,GlobalValues 知道它们的链接规则。具体来说,GlobalValues 知道它们是否具有内部链接或外部链接,这是由 LinkageTypes 枚举定义的。
如果 GlobalValue 具有内部链接(相当于C语言中的静态),它对当前翻译单元之外的代码是不可见的,并且不参与链接。如果它有外部链接,那么它对外部代码是可见的,并且参与链接。除了链接信息外,GlobalValues还跟踪它们当前属于哪个模块。
因为 GlobalValues 是内存对象,所以总是通过它们的地址来引用它们。因此,全局的 Type 始终是指向其内容的指针。在使用 GetElementPtrInst 指令时,务必记住这一点,因为必须先对该指针解引用。例如,如果您有一个 GlobalVariable (GlobalValue的子类),它是一个包含24个int的数组,类型为[24 x i32],那么 GlobalVariable 就是指向该数组的指针。虽然这个数组的第一个元素的地址和 GlobalVariable 的值是相同的,但是它们有不同的类型。GlobalVariable 的类型是[24 x i32]。第一个元素的类型是i32。因此,访问全局值需要首先使用 GetElementPtrInst 解除对指针的引用,然后才能访问其元素。这在LLVM语言参考手册中有解释。
Function类
1 | #include "llvm/IR/Function.h" |
Function 类表示 LLVM 中的单个过程。它实际上是 LLVM 层次结构中比较复杂的类之一,因为它必须跟踪大量的数据。Function 类跟踪一个基本块列表、一个形式参数列表和一个符号表。
基本块列表是 Function 对象中最常用的部分。该列表对函数中的块施加了隐式排序,这表明后端将如何布局代码。此外,第一个基本块是函数的隐式入口节点。在 LLVM 中显式地分支到这个初始块是不合法的。没有隐式的退出节点,实际上一个函数可能有多个退出节点。如果基本块列表为空,这表明该函数实际上是一个函数声明:函数的实际主体还没有被链接进去。
除了基本块列表之外,Function 类还跟踪函数接收到的形式参数列表。这个容器管理 Argument 节点的生命周期,就像基本块列表对基本块所做的那样。
符号表是一个很少使用的 LLVM 特性,它只在你必须根据名称查找值时使用。除此之外,符号表用于内部,以确保函数体中指令、基本块或参数的名称之间不存在冲突。
注意,函数是一个全局值,因此也是一个常量。函数的值是它的地址(在链接后),它保证是常量。
GlobalVariable类
1 | #include "llvm/IR/GlobalVariable.h" |
全局变量用 GlobalVariable 类表示。像函数一样,Globalvariable 也是 GlobalValue 的子类,因此总是通过它们的地址来引用(全局值必须存在于内存中,因此它们的“名称”指向它们的固定地址)。全局变量可以有一个初始值(必须是一个常量),如果它们有初始化器,它们可以被标记为“常量”(表明它们的内容在运行时永不改变)。
BasicBlock类
这个类表示代码的单个入口和单个出口部分,通常被编译器称为基本块。BasicBlock 类维护一个指令列表,这些指令构成了块的主体。与语言定义匹配,指令列表的最后一个元素总是结束符指令。
除了跟踪组成块的指令列表外,BasicBlock类还跟踪它嵌入的函数。
注意,基本块本身就是值,因为它们被像分支这样的指令引用,可以进入交换表。基本块有类型标签。
Argument类
这个 Value 的子类定义了函数传入形式参数的接口。函数维护其形式参数的列表。参数有一个指向父函数的指针。
LLVM IR 在内存中的存储方式
最重要的概念是Value、Use和User
一切皆Value
在 LLVM 架构中,几乎所有的东西都是一个 Value,我们重点关注的是这么几项:
1 | BasicBlock,Argument,User 都继承了 Value 类 |
BasicBlock 表示的是基本块类,Arugument 表示的是函数的形参,Constant 表示的是形如 i32 4
的常量,Instruction 表示的是形如 add i32 %a, %b
的指令
Value 是一个非常基础的基类,一个继承于 Value 的子类表示它的结果可以被其他地方使用。 一个继承于 User 的类表示它会使用一个或多个 Value 对象,根据 Value 与 User 之间的关系,还可以引申出 use-def 链和 def-use 链这两个概念。use-def 链是指被某个 User 使用的 Value 列表,def-use 链是使用某个 Value 的 User 列表。实际上,LLVM 中还定义了一个 Use 类,Use 就是上述的使用关系中的一个边。
-
class Value 中的 UseList 保存了使用了这个 Value 的 User 列表,对应着 def-use 关系
-
class User 中的 OperandList 保存了这个 User 使用的 Value 列表,对应着 use-def 关系
-
class Use 中的 Value,User 的引用,维护了这条边的两个结点以及使用和被使用的关系,从 User 能够通过 OperandList 找到这个 User 使用的 Value,从 Value 也能找到对应的使用这个 Value 的 User。
这里我直接偷软院编译的指导书内容吧。