什么是 LLVM IR?

LLVM IR 是一种计算机程序的中间表示形式,它描述了一个程序的数据到数据的转换路径,或者说它描述的是一个程序的每一个值都是怎么来的,程序拿这些值又做了哪些事,比如说一个值是常量,比如说一个值是一个常量再经过一条加法运算指令与另一个值相加的结果,比如说一个值是来自一个函数调用语句等等,比如说一个值来自一个声明的全局变量,而声明的这个全局变量并不(一定)在当前翻译单元。

在 LLVM IR 中,几乎每个值、每条指令都是有类型的。事实上,每一个值都来自一条指令。某种意义上来说,值和指令如果不是一一对应,也是有非常强的关联的。

在 LLVM IR 中,你也可以像在 C 中那样,声明函数原型,并且调用函数,并且取得函数返回的值(如果有),而无需操心寄存器的保存、参数的入栈和读取、地址的分配、返回值的写入等细节。

由于存在着从 LLVM IR 到许多不同的目标 ISA 的编译器后端实现,所以 LLVM IR 相比汇编更加不依赖于具体机器底层知识,读起来没那么困难。比如说,同一段程序以 X86-64 作为目标机器,编译得到这样一种汇编(或者二进制),而同样一段程序再以 ARM64 作为目标机器,编译得到的汇编则和前者(X86-64 汇编)相差很大,但是同一段高级语言编写的程序的 LLVM IR 形式几乎是统一的,只受优化等级 (-Ox)和编译器版本、编译器具体实现影响。

LLVM IR 不像汇编那样翻译到二进制机器指令序列几乎只差一步,但是阅读它仍然是很有意义的,阅读它可以让我们知道编译器如何理解我们使用高级语言编写的计算机程序,比如说我们想知道一段 C 程序在编译器看来它的语义 (semantics) 是什么,就可以查看一下 LLVM IR, 并且它确实比汇编简单。

比如说,我想知道 C 编译器「如何看待」像 ++i++ 这样的语句,也可以查看一下编译得到的 LLVM IR.

一个示例

下面是一段简单的 C 语言程序,它把前 10 个正整数相加(累加),并且把累加得到的结果通过调用标准库函数 printf 转换为内存中的字符串 (类型是 char*),并且打印到进程的标准输出设备 (/dev/stdout):

#include <stdio.h>

int main()
{
    int sum = 0;
    for (int i = 1; i <= 10; ++i)
        sum += i;

    printf("%d\\n", sum);

    return 0;
}

其中,printf 函数的声明来自某个目录下的 stdio.h 文件,该目录的搜索顺序取决于编译器(准确来讲是 C 预处理器)的具体实现和具体的操作系统。

并且,printf 函数的定义来自于 C 语言标准库的实现,一个 C 语言标准库的实现实现了 C 语言标准中列出的库函数声明声明的每一个函数。

我们想查看编译器在不做任何优化措施的情况下,在成功解析代码字符串成 AST 之后,如何翻译这段程序为等价的 LLVM IR 形式:

敲这行命令:

clang -O0 -S -emit-llvm hello.c

其中,-O0 表示禁用优化,-S -emit-llvm 表示只生成 LLVM IR, 而不继续进入到目标文件生成等后续阶段,hello.c 是这段程序的源文件在当前目录下的文件名。

这行命令会在当前目录下生成一个文件名为 hello.ll 的文件,它的内容就是 hello.c 程序的等价 LLVM IR 形式,当然,这里的「等价」只是作者说的,一段程序的 LLVM IR 形式并不总是唯一的。

下面是 hello.ll 的内容(经过作者精简了一些编译器插入的元数据和注释):

@.str = private unnamed_addr constant [4 x i8] c"%d\\0A\\00", align 1

define i32 @main() {
  %1 = alloca i32, align 4
  %2 = alloca i32, align 4
  %3 = alloca i32, align 4
  store i32 0, i32* %1, align 4
  store i32 0, i32* %2, align 4
  store i32 1, i32* %3, align 4
  br label %4

4:
  %5 = load i32, i32* %3, align 4
  %6 = icmp sle i32 %5, 10
  br i1 %6, label %7, label %14

7:
  %8 = load i32, i32* %3, align 4
  %9 = load i32, i32* %2, align 4
  %10 = add nsw i32 %9, %8
  store i32 %10, i32* %2, align 4
  br label %11

11:
  %12 = load i32, i32* %3, align 4
  %13 = add nsw i32 %12, 1
  store i32 %13, i32* %3, align 4
  br label %4

14:
  %15 = load i32, i32* %2, align 4
  %16 = call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([4 x i8], [4 x i8]* @.str, i64 0, i64 0), i32 %15)
  ret i32 0
}

declare i32 @printf(i8*, ...)

在 VS Code 中,像下图这样以左右对比的方式打开这两个文件看起来会方便一些: