LLVM的Go语言

编译器开发是一项非常艰巨的任务。 但是,幸运的是,随着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中,基本块是连续的代码序列,从标签开始,并以完成基本块的指令(例如returnjump

该代码的另一个有趣的细节是phi指令。 该说明很不寻常,可能需要一些时间才能弄清。 请记住, SSA是静态单一分配的缩写。 这是编译器使用的代码的中间表示,其中每个变量仅分配一次。 这对于表示简单的函数(如上面显示的myAdd函数)非常myAdd ,但对于更复杂的函数(如本节中讨论的sum函数)则不是。 特别地,在循环执行期间,变量in改变。

SSA使用所谓的phi指令(其名称取自希腊字母)绕过了对单个变量值分配的限制。 事实是,为了使代码的SSA表示形式能够像C这样的语言形成,您必须采取一些技巧。 调用该指令的结果是变量( in )的当前值,并且基块列表用作其参数。 例如,考虑以下说明:

 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编译器将该数据结构的描述分为若干部分。 他可以将三个切片元素( ptrlencap )表示为结构(结构),但将它们表示为三个单独的实体可以进行一些优化。 其他编译器可能以其他方式显示切片,这取决于调用目标平台功能的约定。

该代码的另一个有趣功能是使用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指令的入nuwnswnuw ,可以将其添加到add语句中。 这对于private关键字同样适用,它向优化器指示不会从当前编译单元外部引用由他标记的功能。 这使您可以执行许多有趣的过程间优化,例如消除未使用的参数。

您可以在文档中阅读有关LLVM的更多信息,在开发自己的基于LLVM的编译器时经常会参考该文档 。 这是一份指南 ,讨论了使用非常简单的语言进行的编译器开发。 在创建自己的编译器时,这两种信息源都将派上用场。

亲爱的读者们! 您是否使用LLVM?

Source: https://habr.com/ru/post/zh-CN451476/


All Articles