LLVM代码生成分析杂谈

发布时间 2023-10-31 04:21:42作者: 吴建明wujianming

LLVM代码生成分析杂谈

1简介

本文提供了有关生成和编译LLVM程序集代码的其他信息。

LLVM是一个庞大而复杂的系统,用于为各种目标体系结构生成优化的机器代码。对于这个项目,将使用其功能的非常有限的子集,为了方便使用,定义了一个生成LLVM代码的简单接口,可以在示例代码的LLVM目录中找到它。有关LLVM汇编语言的详细信息,请参阅《LLVM语言参考》,网址为https://llvm.org/docs/LangRef.html。虽然不需要阅读很长的整个文档,但如果在理解某些LLVM指令如何工作时遇到困难,它是一个很好的参考。

2 LLVM API

示例代码中的llvm目录包含一个库,用于支持生成llvm程序集代码。

本节概述了该库的主要组件以及如何使用它们,但您应该查看文档齐全的接口,以全面了解该库。

2.1名称

LLVM使用各种不同类型的名称来表示事物。其中包括

1)全局参数,前缀为“@”符号。这些用于命名函数和字符串常量。

2)前缀为“%”符号的寄存器是伪寄存器,用于保存本地值。由于LLVM是SSA表示,寄存器只能分配给一次,并且必须引入φ指令才能在多个控制路径上合并它们的值。

3)变量,它是各种实体的抽象,可以作为LLVM指令的参数出现。其中包括整数常量、全局变量和寄存器。变量大致对应于SimpleAST和CFG IR的值。

4)标签是函数的本地标签,用于命名基本块的入口点。

2.2模块

LLVM模块是保存生成的代码的容器。它的内容包括组成程序的函数、运行时系统函数的外部声明以及字符串文字。

2.3功能

所有可执行代码都包含在函数中。函数由全局变量命名,并由一个或多个块组成。函数参数表示为LLVM寄存器,因此生成函数代码的第一步是将其参数映射到寄存器。

2.4砌块

LLVM块是指令的容器,生成指令的大多数操作都需要一个块作为参数。LLVM块有一个唯一的标签,以φ指令开始,然后是一系列寄存器分配。块必须由分支、条件分支或返回指令终止。请注意,LLVM块是基本块,没有内部控制流。因此,在函数的CFG片段和LLVM块之间存在一对一的对应关系。

2.5类型

LLVM的类型系统有点类似于C的类型系统,但有一些附加功能。与C一样,LLVM允许将值从一种类型强制转换为另一种类型。大多数情况下,你不应该担心演员阵容;以下第4.4节将讨论一个例外。

2.6说明

使用LLVMBlock模块生成指令。LLVM库不提供指令的公共表示形式。相反,生成指令的函数返回一个变量,该变量可以作为参数提供给其他指令。例如,下面的SML表达式为块blk中的表达式(1+2)*3生成LLVM指令:

LLVMBlock.emitMul (blk, LLVMBlock.emitAdd (blk, LLVMVar.i64const 1, LLVMVar.i64const 2), LLVMVar.i64const 3)

生成的LLVM程序集如下所示,但具有不同的寄存器名:

%r23 = add i64 1, 2

%r24 = mul i64 %r23, 3

LLVM指令的一种重要形式是φ指令,它描述连接点(即具有多个前置器的块)处的传入数据流。LLVM库支持通过抽象类型和两个函数对这些指令进行增量定义

type phi

val emitPhi : t * LLVMReg.t -> phi

val addPhiDef : phi * LLVMVar.t * t -> unit

当在控制流图中设置一个已知有多个前置器的块时,可以使用emitPhi函数为每个传入的活动变量(即相应片段的参数)分配新的φ指令,其中第一个参数是块,第二个参数是分配给参数的寄存器,结果是表示φ指令的抽象值。然后,可以通过在φ指令上调用addPhiDef函数来添加有关传入数据流的信息,在该函数中,指定流入指令的LLVM值及其来源块。

2.7路线图

LLVM库的公共接口被组织为八个模块(按字母顺序列出)。

structure LLVMBlock

定义LLVM基本块的表示形式。该模块定义了生成指令的功能。

structure LLVMFunc

定义LLVM函数的类型和操作。

structure LLVMGlobal

定义LLVM全局名称(例如,函数名称)的类型和操作。

structure LLVMLabel

定义LLVM标签的类型和操作,LLVM标签用于命名基本块。

structure LLVMModule

定义LLVM模块的类型和操作。

structure LLVMReg

定义LLVM伪寄存器的类型和操作。

structure LLVMType

定义LLVM类型的类型和操作。

structure LLVMVar

定义LLVM变量的类型和操作,这些变量描述指令的参数和结果。

3运行时数据表示

如先前文献中所述,LangF值统一表示为64位机器字。这些字要么是指向堆分配对象的指针,要么是带标记的整数。

因为LangF是一种多态语言,所以表达式中变量的类型并不总是静态确定的。因此,使用LangF值的统一表示作为单个64位机器字。这些字要么是指向堆分配对象的指针,要么是指向整数的指针。自垃圾收集器必须能够在运行时区分指针和整数,对整数使用标记表示。具体地,整数n表示为[[n]]=2n+1。将在下面的第4.2节中讨论对标记值的算术实现。

LangF运行时支持两种堆对象:元组和字符串。元组对象是一个或多个LangF值(即64位字)的序列。字符串对象有一个初始长度字段,它是一个单词,包含一个标记的整数值,指定字符串中的字符数,后面是表示字符串的字节序列。因此,字符串的第一个字符与字符串对象的基地址相差8个字节。

4代码生成

由于CFG已经是一个低级的IR,所以对LLVM的转换大多是直接的。CFG函数映射到LLVM函数,片段映射到LLVM虚拟机块。代码生成器必须跟踪CFG变量到LLVM变量的映射以及与片段参数相关联的φ节点。

除了转换为SSA和引入φ指令外,主要的复杂情况还有函数调用、整数运算和布尔值。在下面讨论每一个,以及翻译的其他方面。

4.1函数调用

函数调用,包括对LangF函数的调用以及对运行时系统函数的调用(例如,用于分配),是要生成的最复杂的LLVM指令。LLVM库支持两种类型的调用:尾部调用和非尾部调用。尾部调用是相当直接的,因为它们不涉及任何垃圾收集器元数据的记录。

非尾部调用的情况更为复杂。垃圾收集器必须检查堆栈上的帧,才能找到指向堆的指针。此活动需要生成允许收集器解析堆栈的堆栈映射。LLVM库提供了一个用于生成函数调用和必要的堆栈映射信息的函数。emitCall操作的形式为

val {ret, live} = emitCall (blk, {func = f, args = vs, live = lvs})

其中blk是当前LLVM块,f是命名函数的变量(可能是全局或寄存器),vs是保存函数参数的变量列表,lvs是函数调用后立即存在的LLVM变量列表。emitCall的结果是一个可选变量,表示函数调用(ret)的运行时结果和重命名的活动变量列表(现场直播)。代码生成器必须更新活动列表中CFG变量的绑定,以反映此重命名。请注意,对于形式为的CFG函数调用

let x=f(y,z)

live集将不包括x,因为它被let绑定杀死。LLVM使用活跃度信息来跟踪堆栈中潜在的GC根。作为CodeGenInfo.analyze函数的一部分,必须计算活跃度信息。

与垃圾收集兼容的调用在LLVM中看起来很难看,因此LLVM库隐藏了不重要的细节。

然而,在调试编译器的输出时,重要的是要了解发生了什么。请考虑以下SML代码片段:

val res = emitCall (someBlk, {

func = foo,

args = [arg1, arg2],

live = [val1, val2, val3]

})

其中val1和val3是堆指针。与此API调用关联的LLVM代码涉及多个LLVM内部函数,这些函数用于访问编译器的特殊功能。下面是该示例调用发出的代码的简化版本。

; performs the call to @foo, passing arg1 and arg2 as arguments.

%tok = call token @llvm.experimental.gc.statepoint(

_, _,

@foo,   ; function

2,       ; the arity of @foo

_,

arg1, arg2   ; list of args to @foo

_, _,

val1, val3   ; pointer values that are live

; after the call. val2 was not a pointer.

)

; retrieves the return value of the call to @foo

%retV = call @llvm.experimental.gc.result(token %tok)

; retrieves the (possibly updated) live heap pointers

%new.val1 = call @llvm.experimental.gc.result(token %tok)

%new.val3 = call @llvm.experimental.gc.relocate(token %tok, _, _)

需要注意的是,在调用@foo之后,实际值val1和val3被认为是更新的,调用之后的新值是new.val1和new.val3,并且不应该重用val1和val3。此重命名还会影响phi节点在当前连接点的位置。示例API调用将返回

{ ret = SOME retV, live = [val1’, val2’, val3’]}

其中live变量列表包含LLVM变量

%new.val1, %val2, %new.val3

4.2整数运算

回想第3节,将整数n表示为[[n]]=2n+1。在生成整数算术指令时,必须考虑此表示形式。例如,考虑添加两个带标记的整数:

[[ n ]] + [[ m ]] = (2n + 1) + (2m + 1)

= 2(n + m) + 2

= [[ n + m ]] + 1

这个推理表明,可以通过将两个整数的标记表示相加,然后减去一来实现它们的标记加法。当然,如果其中一个整数是常数,那么可以在编译时从它的文字表示中减去一。下表显示了如何实现各种整数运算的标记版本:

注意,可以使用算术右移指令来实现表达式b[[m]]/2c,并且可以通过左移一位来实现乘以2。整数比较操作可正确处理带标记的整数表示。示例代码包括一个生成标记算术运算的模块ArithGen。

4.3串操作

两个字符串基元运算符(StrSize和StrSub)很容易实现。要记住的关键是字符串对象的第一个单词就是它的长度,所以字符串中的第一个字符是从对象开始的8个字节。长度存储在标记表示中,因此StrSize只需要加载它。请小心使用i8*指针访问字符数据,以便地址算术正确。

4.4布尔值和比较

布尔值和所有LangF值一样,在运行时由一个64位的量表示,该量为1(表示false)或3(表示true)。然而,LLVM使用1位整数作为条件测试的布尔结果,并作为条件分支的参数。因此,有必要在这些表示之间进行转换。转换相当简单。要从LangF布尔值转换为1位布尔值,只需要发出与true(或3)的相等比较。走另一条路有点复杂。首先需要将类型强制转换为64位,然后左移1并加1。在C代码中,此过程将实现为

(((int64_t)b) << 1) + 1

还希望避免不必要的转换。例如,如果有LangF代码

if (x == 0) then 1 else 2

不想生成将相等测试的结果转换为LangF布尔值,然后用true测试其相等性的代码。诀窍是在生成转换时要懒惰。由于LLVM API用变量的类型来注释变量,因此我们可以编写两个函数(它们位于ArithGen结构中)

val toBool : LLVMBlock.t * LLVMVar.t -> LLVMVar.t

val toBit : LLVMBlock.t * LLVMVar.t -> LLVMVar.t

实现这些强制,然后在必要时使用它们。具体来说,当将1位值存储到内存中,将其作为参数传递给函数,或将其作为结果返回时,需要确保它具有64位表示。同样,当使用64位值作为条件分支的参数时,需要确保它具有1位表示。转换函数只在必要时注入代码,所以只需要将它们包含在适当的位置。

4.5元组分配

LangF运行时提供了一个用于分配未初始化元组对象的函数

int64_t *_langf_alloc (int32_t n);

此函数获取元组中的字数,并返回一个指向元组未初始化内存的指针。请注意,立即初始化对象的字段很重要,因为垃圾收集器会被未初始化的对象混淆。

5完整示例

回顾了闭包转换和代码生成文档中的迭代阶乘函数示例。回想一下CFG代码是

fun ifact_lab (ifact_clos, i, acc) {

let n = #1(ifact_clos)

goto frag_hdr (n, i, acc)

}

and ifact_hdr (n1, i1, acc1) {

let t1 = IntLte(i1, n1)

if t1 then goto frag1 (n1, i1, acc1)

else goto frag2 (acc1)

}

and frag1 (n2, i2, acc2) {

let t2 = IntAdd(i2, 1)

let t3 = IntMul(i2, acc2)

goto ifact_hdr (n2, t2, t3)

}

and frag2 (acc3) { ret acc3 }

为该函数生成的LLVM代码如下所示。

define i64 (i64*,i64,i64)* @ifact(i64* %clos1,i64 %i2,i64 %acc3)

gc "statepoint-example"

{

entry_0001:

%r7 = load i64, i64* %clos1

br label %ifact_hdr_0002

ifact_hdr_0002:

%n14 = phi i64 [ %n14, %frag1_0003 ], [ %r7, %entry_0001 ]

%i15 = phi i64 [ %r9, %frag1_0003 ], [ %i2, %entry_0001 ]

%acc16 = phi i64 [ %r13, %frag1_0003 ], [ %acc3, %entry_0001 ]

%r8 = icmp sle i64 %i15, %n14

br i1 %r8, label %entry_0001, label %frag1_0003

frag1_0003:

%r9 = add i64 %i15, 2

%r10 = sub i64 %i15, 1

%r11 = ashr i64 %acc16, 1

%r12 = mul i64 %r10, %r11

%r13 = add i64 %r12, 1

br label %ifact_hdr_0002

frag2_0004:

ret i64 %acc16

}

6编译生成的LLVM代码

正在编写的代码生成器将LLVM程序集代码生成到“.ll”文件中。然后,编译器再执行三个步骤来生成一个可以运行的可执行程序。

1.它使用llc(LLVM编译器)将LLVM汇编代码转换为x86-64汇编代码。

2.它修补了x86-64汇编文件,使堆栈映射可供运行时系统访问。

3.它编译打补丁的程序集文件,并将其与LangF运行时系统链接以生成可执行文件。

最后,命令“lfc.sh-dull.lf”的成功运行将产生三个输出文件:“foo.ll”、“foo.s”(x86-64程序集)和“foo”(可执行文件)。

为了使这些步骤发挥作用,必须在路径中包含llc和cc命令,并且必须在运行时目录中包含运行时系统的编译版本。“cc”命令是C编译器的标准名称,应该位于安装了命令行开发工具的任何计算机上的路径中。示例代码中的Makefile已被修改为在构建过程中编译运行库,因此编译器应该能够找到它。

6.1 llc命令

此项目所需的LLVM工具已安装在Department Linux计算机上。要将它们添加到路径中,需要运行以下shell命令:

module load clang-llvm/10.0.1

请注意,每次启动新shell时都需要运行此命令。可以通过运行以下命令来测试llc的正确版本是否在路径中

llc –version

就会报告的

LLVM (http://llvm.org/): LLVM version 10.0.1 ...

(加上许多附加信息)。

如果在自己的个人Linux或macOS机器上工作,那么可以在本地安装LLVM系统,但要注意,这是一个需要很长时间才能构建的大型系统。有关如何安装项目所需的LLVM工具的更多信息,请参考指南。