编译器开发是一项非常艰巨的任务。 但是,幸运的是,随着LLVM之类的项目的开发,此问题的解决方案得到了极大简化,甚至允许单个程序员创建性能接近C的新语言。由于该系统由大量代码表示并配有小型文档,因此使用LLVM变得很复杂。 为了纠正这一缺陷,我们今天出版的材料的作者将演示用Go编写的代码示例,并展示如何先使用
TinyGO编译器将其传输到
Go SSA ,然后再传输到LLVM IR。 对Go SSA和LLVM IR代码进行了稍微的编辑,为了使这些说明更容易理解,已删除了不属于此处给出的说明的内容。
第一个例子
我要在这里解析的第一个函数是一个简单的加数机制:
func myAdd(a, b int) int{ return a + b }
此功能非常简单,也许没有一件事情不容易提出。 它将转换为以下Go SSA代码:
func myAdd(a int, b int) int: entry: t0 = a + b int return t0
通过此函数表示,有关数据类型的提示位于右侧,在大多数情况下,您可以忽略它们。
这个小例子已经使您了解SSA的一个方面的要点。 即,在将代码转换为SSA形式时,每个表达式都被划分为其组成的最基本的部分。 实际上,在我们的例子中,
return a + b
命令表示两个操作:加两个数字并返回结果。
另外,在这里您可以看到程序的基本块,在此代码中只有一个块-输入(入口块)。 我们将在下面详细讨论。
Go SSA代码可以轻松转换为LLVM IR:
define i64 @myAdd(i64 %a, i64 %b) { entry: %0 = add i64 %a, %b ret i64 %0 }
您可能会注意到,尽管此处使用了其他语法构造,但该函数的结构基本上没有改变。 LLVM IR代码比Go SSA代码略强,类似于C。这里,在函数声明中,首先是对返回给它的数据类型的描述,参数的类型在参数名称之前指示。 此外,为简化IR解析,全局实体的名称前面带有
@
符号,而本地实体的名称之前则带有
%
字符(该函数也被视为全局实体)。
您需要注意的此代码的功能之一是,在创建LLVM时,将决定表示类型Go
int
的类型,该类型可以由32位或64位值表示,具体取决于编译器和编译的目的。红外线代码。 这是LLVM IR代码与平台无关的众多原因之一。 为一个平台创建的此类代码不能简单地为另一个平台采用和编译(除非您
非常谨慎地执行此任务)。
另一个值得注意的有趣点是
i64
类型不是有符号整数:就表示数字的符号而言,它是中性的。 根据指令的不同,它可以表示带符号的数字和带符号的数字。 在表示加法运算的情况下,它不起作用,因此使用带或不带符号的数字没有区别。 在这里,我想指出,在C中,有符号整数变量的溢出导致未定义的行为,因此,Clang前端在操作中添加了
nsw
(无符号包装)标志,这向LLVM表示可以从假设加法永远不会发生溢出。
这对于某些优化可能很重要。 例如,在32位平台(具有32位寄存器)上将两个
i16
值相加后,要求在相加完成后将符号扩展操作保持在
i16
范围内。 因此,考虑到寄存器的机器大小,执行整数运算通常会更高效。
现在,对于我们来说,使用此IR代码将来会发生什么并不特别有趣。 代码经过优化(但在像我们这样的简单示例中,尚未进行任何优化),然后将其转换为机器代码。
第二个例子
我们将研究的以下示例将更加复杂。 也就是说,我们正在谈论一个将整数切片求和的函数:
func sum(numbers []int) int { n := 0 for i := 0; i < len(numbers); i++ { n += numbers[i] } return n }
此代码将转换为以下Go SSA代码:
func sum(numbers []int) int: entry: jump for.loop for.loop: t0 = phi [entry: 0:int, for.body: t6] #n int t1 = phi [entry: 0:int, for.body: t7] #i int t2 = len(numbers) int t3 = t1 < t2 bool if t3 goto for.body else for.done for.body: t4 = &numbers[t1] *int t5 = *t4 int t6 = t0 + t5 int t7 = t1 + 1:int int jump for.loop for.done: return t0
在这里,您已经可以看到更多针对SSA形式的代码表示的构造。 该代码最明显的特征可能是没有结构化的流控制命令。 为了控制计算流程,只有条件跳转和无条件跳转,如果我们将此命令视为控制流的命令,则返回命令。
实际上,您可以在这里注意以下事实:该程序没有使用花括号将其划分为块(如C系列语言)。 它由类似于汇编语言的标签划分,并以基本块的形式显示。 在SSA中,基本块是连续的代码序列,从标签开始,并以完成基本块的指令(例如
return
和
jump
。
该代码的另一个有趣的细节是
phi
指令。 该说明很不寻常,可能需要一些时间才能弄清。 请记住,
SSA是静态单一分配的缩写。 这是编译器使用的代码的中间表示,其中每个变量仅分配一次。 这对于表示简单的函数(如上面显示的
myAdd
函数)非常
myAdd
,但对于更复杂的函数(如本节中讨论的
sum
函数)则不是。 特别地,在循环执行期间,变量
i
和
n
改变。
SSA使用所谓的
phi
指令(其名称取自希腊字母)绕过了对单个变量值分配的限制。 事实是,为了使代码的SSA表示形式能够像C这样的语言形成,您必须采取一些技巧。 调用该指令的结果是变量(
i
或
n
)的当前值,并且基块列表用作其参数。 例如,考虑以下说明:
t0 = phi [entry: 0:int, for.body: t6] #n
其含义如下:如果前一个基本块是一个
entry
(输入)块,则
t0
为常数
0
;如果前一个基本块是
for.body
,则需要从该块中获取值
t6
。 所有这些可能看起来都很神秘,但是由于有了这种机制,所以可以确保SSA。 从人的角度来看,所有这些都使对代码的理解变得复杂,但是每个值只分配一次的事实大大简化了许多优化。
请注意,如果您正在编写自己的编译器,则通常不必处理此类事情。 即使Clang不会生成所有这些
phi
指令,它也会使用
alloca
机制(类似于使用普通的局部变量)。 然后,当执行称为
mem2reg的LLVM优化传递时,
alloca
指令将转换为SSA形式。 但是,TinyGo从Go SSA接收输入,该输入已经很方便地已经转换为SSA形式。
这段中间代码的另一项创新是,以计算地址的操作和获得的指针的解引用操作的形式,介绍了通过索引访问切片元素的方法。 在这里,您可以看到将常数直接添加到IR代码中(例如
1:int
)。 在具有
myAdd
函数的示例中,未使用它。 现在我们已经弄清楚了这些功能,下面我们来看一下将其转换为LLVM IR形式时该代码将变成什么:
define i64 @sum(i64* %ptr, i64 %len, i64 %cap) { entry: br label %for.loop for.loop: ; preds = %for.body, %entry %0 = phi i64 [ 0, %entry ], [ %5, %deref.next ] %1 = phi i64 [ 0, %entry ], [ %6, %deref.next ] %2 = icmp slt i64 %1, %len br i1 %2, label %for.body, label %for.done for.body: ; preds = %for.loop %3 = getelementptr i64, i64* %ptr, i64 %1 %4 = load i64, i64* %3 %5 = add i64 %0, %4 %6 = add i64 %1, 1 br label %for.loop for.done: ; preds = %for.loop ret i64 %0 }
在这里,和以前一样,我们可以看到相同的结构,其中包括其他语法结构。 例如,在
phi
调用中,值和标签被交换。 但是,还有一些值得特别注意的地方。
对于初学者,在这里您可以看到功能的完全不同的签名。 LLVM不支持切片,因此,以优化的形式生成了此中间代码的TinyGo编译器将该数据结构的描述分为若干部分。 他可以将三个切片元素(
ptr
,
len
和
cap
)表示为结构(结构),但将它们表示为三个单独的实体可以进行一些优化。 其他编译器可能以其他方式显示切片,这取决于调用目标平台功能的约定。
该代码的另一个有趣功能是使用
getelementptr
(通常简称为GEP)。
该指令与指针一起使用,用于获取指向slice元素的指针。 例如,让我们将其与以下用C编写的代码进行比较:
int* sliceptr(int *ptr, int index) { return &ptr[index]; }
或具有以下等效项:
int* sliceptr(int *ptr, int index) { return ptr + index; }
这里最重要的是
getelementptr
语句
getelementptr
执行取消引用操作。 它仅基于现有指针来计算一个新指针。 它可以解释为
mul
并在硬件级别
add
。 可在
此处找到有关GEP指令的详细信息。
此中间代码的另一个有趣的功能是
icmp
指令的使用。 这是用于实现整数比较的通用指令。 该指令的结果始终是类型为
i1
的值-逻辑值。 在这种情况下,使用关键字
slt
(带符号小于)进行比较,因为我们正在比较先前由
int
类型表示的两个数字。 如果要比较两个无符号整数,则将使用
icmp
作为指令,并且比较中使用的关键字将为
ult
。 为了比较浮点数,使用了另一条指令
fcmp
,其工作方式类似。
总结
我相信,在本文中,我研究了LLVM IR的最重要功能。 当然,还有很多事情。 特别地,代码的中间表示可能包含很多注释,从而允许考虑优化过程中编译器已知的某些优化功能,这些功能不能以任何其他方式在IR中表示。 例如,这些是
inbounds
指令的入
nuw
或
nsw
和
nuw
,可以将其添加到
add
语句中。 这对于
private
关键字同样适用,它向优化器指示不会从当前编译单元外部引用由他标记的功能。 这使您可以执行许多有趣的过程间优化,例如消除未使用的参数。
您可以在
文档中阅读有关LLVM的更多信息,在开发自己的基于LLVM的编译器时经常会参考该
文档 。 这是一份
指南 ,讨论了使用非常简单的语言进行的编译器开发。 在创建自己的编译器时,这两种信息源都将派上用场。
亲爱的读者们! 您是否使用LLVM?