Go 视角下的 LLVM

开发编译器是一项非常困难的任务。 但是,幸运的是,随着像 LLVM 这样的项目的开发,这个问题的解决方案大大简化了,甚至允许单个程序员创建一种性能接近 C 的新语言。使用 LLVM 很复杂,因为它系统由大量代码表示,但配备的文档却很少。 为了尝试纠正这个缺点,我们今天发布的材料的翻译将演示用 Go 编写的代码示例,并展示它们如何首先被翻译成 去SSA,然后在 LLVM IR 中使用编译器 小小GO。 Go SSA 和 LLVM IR 代码已稍作编辑,删除了与此处给出的解释无关的内容,以使解释更容易理解。

Go 视角下的 LLVM

第一个例子

我要在这里查看的第一个函数是一个用于添加数字的简单机制:

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解析,全局实体的名称前面带有符号 @,并且在本地名称之前有一个符号 % (函数也被视为全局实体)。

关于这段代码需要注意的一件事是 Go 的类型表示决定 int,可以表示为 32 位或 64 位值,具体取决于编译器和编译目标,在 LLVM 生成 IR 代码时被接受。 这是 LLVM IR 代码不像许多人认为的那样独立于平台的众多原因之一。 为一个平台创建的此类代码不能简单地为另一个平台获取和编译(除非您适合解决这个问题 极其小心).

另一个值得注意的有趣点是类型 i64 不是有符号整数:它在表示数字的符号方面是中性的。 根据指令的不同,它可以表示有符号数和无符号数。 在表示加法运算的情况下,这并不重要,因此使用有符号数或无符号数没有区别。 这里我想指出的是,在C语言中,溢出有符号整型变量会导致未定义的行为,因此Clang前端为该操作添加了一个标志 nsw (无签名换行),它告诉 LLVM 它可以假设加法永远不会溢出。

这对于某些优化可能很重要。 例如,将两个值相加 i16 在 32 位平台(具有 32 位寄存器)上,在加法之后需要进行符号扩展操作以保持在范围内 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。 这些说明非常不寻常,可能需要一些时间才能理解。 记住,那个 公共福利金 是静态单一分配的缩写。 这是编译器使用的代码的中间表示,其中每个变量仅被赋值一次。 这对于表达像我们的函数这样的简单函数非常有用 myAdd如上所示,但不适合更复杂的函数,例如本节中讨论的函数 sum。 特别是,变量在循环执行期间发生变化 i и n.

SSA使用所谓的指令绕过一次分配变量值的限制 phi (其名称取自希腊字母)。 事实是,为了为 C 等语言生成代码的 SSA 表示,您必须采用一些技巧。 调用该指令的结果是变量的当前值(i или n),并使用基本块列表作为其参数。 例如,考虑以下指令:

t0 = phi [entry: 0:int, for.body: t6] #n

其含义如下:如果前一个基本块是一个块 entry (输入),然后 t0 是一个常数 0,如果之前的基本块是 for.body,那么你需要取值 t6 从这个街区。 这可能看起来很神秘,但正是这种机制使 SSA 发挥作用。 从人类的角度来看,这一切都使代码难以理解,但每个值仅分配一次的事实使许多优化变得更加容易。

请注意,如果您编写自己的编译器,通常不必处理此类内容。 即使 Clang 也不会生成所有这些指令 phi,它使用了一种机制 alloca (它类似于使用普通的局部变量)。 然后,当运行名为 LLVM 优化过程时 内存2寄存器, 指示 alloca 转换为 SSA 形式。 然而,TinyGo 接收来自 Go SSA 的输入,而 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)作为一个结构(struct),但将它们表示为三个独立的实体可以进行一些优化。 其他编译器可能以其他方式表示切片,具体取决于目标平台函数的调用约定。

该代码的另一个有趣的功能是指令的使用 getelementptr (通常缩写为 GEP)。

该指令与指针一起使用,用于获取指向切片元素的指针。 例如,我们将其与以下用 C 编写的代码进行比较:

int* sliceptr(int *ptr, int index) {
    return &ptr[index];
}

或者使用与此等效的以下内容:

int* sliceptr(int *ptr, int index) {
    return ptr + index;
}

这里最重要的是说明 getelementptr 不执行取消引用操作。 它只是根据现有指针计算一个新指针。 可以作为说明 mul и add 在硬件层面。 您可以阅读有关 GEP 指令的更多信息 这里.

该中间代码的另一个有趣的功能是指令的使用 icmp。 这是用于实现整数比较的通用指令。 该指令的结果始终是类型的值 i1 ——逻辑值。 在本例中,使用关键字进行比较 slt (符号小于),因为我们正在比较之前由类型表示的两个数字 int。 如果我们要比较两个无符号整数,那么我们会使用 icmp,比较中使用的关键字是 ult。 为了比较浮点数,使用另一条指令, fcmp,其工作方式类似。

结果

我相信在本材料中我已经涵盖了 LLVM IR 最重要的功能。 当然,这里还有很多。 特别是,代码的中间表示可能包含许多注释,这些注释允许优化过程考虑编译器已知的代码的某些特征,否则这些特征无法用 IR 表示。 例如,这是一个标志 inbounds GEP 指令或标志 nsw и nuw,可以添加到说明中 add。 关键字也是如此 private,向优化器表明它所标记的函数不会从当前编译单元外部引用。 这允许进行许多有趣的过程间优化,例如消除未使用的参数。

您可以阅读有关 LLVM 的更多信息 文件资料,您在开发自己的基于 LLVM 的编译器时会经常参考它。 这里 领导,它着眼于为一种非常简单的语言开发编译器。 在创建您自己的编译器时,这两个信息源都会对您有用。

亲爱的读者! 你使用LLVM吗?

Go 视角下的 LLVM

来源: habr.com

添加评论