LLVM 项目博客

LLVM 项目新闻和来自底层的详细信息

LLVM MC 项目简介

LLVM 机器码 (又称 MC) 是 LLVM 的一个子项目,旨在解决汇编、反汇编、目标文件格式处理以及 CPU 指令集级别工具所涉及的许多其他相关领域中的许多问题。它是 LLVM 的一个子项目,与没有紧密集成汇编级工具的其他编译器相比,它具有许多优势。

这篇文章介绍了 MC 项目的演变过程,描述了它的几个不同方面,讨论了它为 LLVM 带来的改进/功能,最后介绍了项目的当前状态。



历史和背景


在 MC 出现之前,LLVM 已经是一个成熟的编译器系统,支持静态编译 (通过普通的汇编器) 和 JIT 编译 (直接将编码的指令字节发射到内存中),并且支持许多不同的目标和子目标。然而,尽管具有这些功能,但这些子系统的实现设计并不完美:为什么 X86 指令编码器 (JIT 使用) 必须知道如何编码 X86 代码生成器为了方便而使用的奇怪伪指令?为什么这个代码发射器特定于 JIT 使用的代码模型?

除了缺乏优雅之外,将汇编器直接集成到编译器中一直是我们的目标 (例如,参见 Bruno 的 2009 年开发者大会演讲)。这样做可以解决许多问题:对于快速编译来说,编译器仔细格式化一个巨大的文本文件,然后分叉/执行一个汇编器,然后汇编器必须词法分析/解析/验证该文件,这是一种非常浪费的做法。事实上,由于 Clang 的其他部分非常快,汇编器在 -O0 -g 下,对于 C 代码来说大约占编译时间的 20%。除了重新获得性能之外,不依赖外部汇编器在其他几个方面也很有吸引力:编译器不必担心许多不同且有错误的汇编器,这些汇编器具有类似但语法不一致的语法,Windows 用户通常没有汇编器 (并且 MASM 的功能不足以被编译器用作目标),像 FreeBSD 这样的某些系统拥有缺乏现代功能的旧版 binutils 等。

以前,我们尝试实现一个 "直接对象发射" 系统,允许代码生成器在没有汇编器的情况下写入 .o 文件,但这个项目遇到了很多问题。其中一个最大的问题是,它试图重新实现 AsmPrinter 中所有处理代码生成 IR 到机器级别构造的“降低”逻辑。这包括处理链接类型选择、段选择、常量降低和发射、调试信息和异常信息降低等的逻辑。虽然这些可能听起来并不重要,但这种逻辑非常微妙,而且 .o 文件写入器和 .s 文件写入器之间存在细微的差异 (例如,一个发射一个强符号,另一个发射一个弱符号) 是不可接受的。这个之前的项目甚至没有涉及到汇编器中有趣的方面,比如松弛支持。

最后,除了 LLVM 之前的范围之外,还有一个更大的工具链,它必须处理指令的汇编和反汇编。当一个新的指令集扩展出现时 (例如 SSE 4.2),新的指令需要添加到汇编器、反汇编器和编译器中。由于 LLVM 已经有了 JIT,它已经知道如何对指令进行编码,我们推测,从代码生成器表格中指令的文本描述中生成一个汇编器可能是合理的。能够在一个地方添加指令描述,然后一次获得汇编器、反汇编器和编译器后端支持,这将是非常棒的。

主要 MC 组件


你可以将 MC 相关的数据结构和组件分解为“对指令进行操作的”和“做其他事情的”。为了提供一致的抽象,我们引入了新的 MCInst 类来表示带有操作数的指令 (例如寄存器、立即数等),它独立于代码生成器现有的指令概念 (MachineInstr)。“其他事情”包括各种类,比如 MCSymbol (表示 .s 文件中的标签)、MCSectionMCExpr 等。你可以阅读 llvm/include/MC 目录中的头文件,了解更多关于这些的信息。

这些 MC 类位于 LLVM 系统的非常低层级,只依赖于支持库和系统库。这样做的目的是,你可以构建一个低层级的东西 (比如反汇编库),它不需要链接整个 LLVM 代码生成器 (反汇编器不需要寄存器分配器 :)。

有了这些背景知识,我将介绍 MC 项目的主要组件:指令打印器、指令编码器、指令解析器、指令解码器、汇编解析器、汇编后端和编译器集成。LLVM 已经有了这些 (除了指令解码器和汇编解析器),但现有的代码已经被重构并进行了实质性的修改。

指令打印器

指令打印器是一个非常简单的目标特定组件,它实现了 MCInstPrinter API。给定一个单一的 MCInst,它将指令的文本表示格式化并输出到一个 raw_ostream。目标可以有多个 MCInstPrinters,例如 X86 后端包含一个 AT&T 和一个 Intel 语法指令打印器。MCInstPrinters 不了解段、指令或其他类似的东西,因此它们独立于目标文件格式。

指令打印是有点繁琐的代码 (以完全正确的方式格式化所有操作数,处理各种指令语法中的不一致性等),但 LLVM 已经有一个 TableGen 后端,可以从 .td 文件中自动生成大部分代码。让一个旧式的 LLVM 目标支持这一点,需要引入一个新的 MachineInstr -> MCInst 降低过程,然后将 MCInst 传递给 InstPrinter。


指令编码器


指令编码器是另一个目标特定组件,它将 MCInst 转换为一系列字节和重定位列表,实现 MCCodeEmitter API。这个 API 非常通用,允许将生成的任何字节写入一个 raw_ostream。由于 X86 指令编码非常复杂 (又称“完全疯狂”),X86 后端使用从 .td 文件中编码的数据驱动的自定义 C++ 代码实现了这个接口。这是处理所有前缀字节、REX 字节等的唯一现实方法,它源自 X86 的旧 JIT 编码器。我们希望并期望未来 RISC 目标的编码器可以直接从 .td 文件中生成。当编译器使用它时,与指令打印器相同,会使用相同的 MachineInst -> MCInst 降低代码。


指令解析器


为了在读取 .s 文件时解析指令,目标可以为它们的语法实现词法分析器和解析器,提供 TargetAsmParser API。词法分析器主要是共享代码,它根据常见的汇编语法特征进行参数化 (例如,注释字符是什么?),但解析器是完全目标特定的。一旦指令操作码及其操作数被解析,它就会经过一个“匹配”过程,决定指定了哪个具体的指令。例如,在 X86 上,“addl $4, %eax” 是一个带有 1 字节立即数的指令,但 “addl $256, %eax” 使用 4 字节立即数字段,并表示为不同的 MCInst。

解析指令的输出是一个操作码 + 操作数列表,解析器还公开了匹配的 API。并非所有指令都可以像从 .s 文件中解析的那样进行匹配 (例如,立即数大小可能取决于两个标签之间的距离),因此指令可以保存在比 MCInst 更抽象的表示形式中,直到需要执行松弛操作时才进行。


指令解码器


正如你可能预料到的,指令解码器实现了 MCDisassembler API,并将抽象的字节序列 (使用 MemoryObject API 实现,用于处理远程反汇编) 转换为 MCInst 和大小。与之前的组件一样,目标可以实现多个不同的解码器 (X86 后端实现了 X86-16、X86-32 和 X86-64 解码器)。解码器将立即数字段转换为简单的整数立即数操作数,如果有对符号的引用,表示符号的重定位必须由解码器的客户端处理。


汇编解析器


汇编解析器处理 .s 文件中所有不是指令的指令和其它内容 (这些内容可能是通用的,也可能是特定于目标文件的)。这是知道 .word、.globl 等是什么的东西,它使用指令解析器来处理指令。汇编解析器的输入是一个 MemoryBuffer 对象 (它包含输入文件),汇编解析器为它所做的每一件事调用 MCStreamer 接口的操作。

MCStreamer 是一个非常重要的 API:它本质上是一个“汇编 API”,每个指令都有一个虚拟方法,还有一个 “EmitInstruction” 方法,它接受一个 MCInst。MCStreamer API 由 MCAsmStreamer 汇编打印器 (它实现了将这些指令打印到 .s 文件的支持,并使用指令打印器来格式化 MCInsts) 以及汇编后端 (它写入一个 .o 文件) 实现。MCStreamer API 对整个 MC 图像的有效整合是至关重要的,正如我们稍后将看到的。


汇编后端


汇编后端是 MCStreamer API 的一个实现 (以及 “MCAsmStreamer” 文本汇编代码发射器),它实现了汇编器执行的所有“困难的事情”。例如,汇编器必须执行“松弛”,这是一个处理分支缩短、像“此指令的大小取决于这两个标签之间的距离,但指令在这两个标签之间,我们如何打破循环?”等情况的过程。它将片段排列到段中,将具有符号操作数的指令解析为立即数,并将这些信息传递给特定于目标文件的代码,这些代码会写入 (例如) 一个 ELF 或 MachO .o 文件。


编译器集成


拼图的最后一块是将这一切集成到编译器中。在实践中,这意味着让编译器直接与 MCStreamer API 交谈,以便发射指令和指令,而不是发射文本文件。将所有目标、调试信息、eh 发射等转换过来是一个重大项目,而且是双重的,因为这是一个机会来修复 AsmPrinter 实现中的一系列重大设计问题 (大量复制粘贴代码等)。新系统更好地进行了分解,并直接降低到 MC 结构,比如 MCSection 和 MCSymbol,而不是传递字符串和使用其他临时数据结构。

这项工作的最终结果是,编译器后端现在调用与独立汇编解析器相同的 MCStreamer 接口来发射代码。这让我们对使用编译器后端发射文本文件 (使用 “MCAsmStreamer”) 并使用 asmparser 读取它会产生与代码生成器直接调用它们时相同的 MCStreamer 调用充满信心。

基于这些基本组件构建


现在您已经了解了 MC 生态系统的主要组件,并且您已经了解了如何使用 MCInst 和 MCStreamer 等关键数据结构在组件之间进行通信,我们可以讨论如何使用它们构建一些好东西。


反汇编库


基于此构建的一个主要高级 API 是“增强反汇编器” API,它按以下顺序使用这些组件

  1. 它使用指令解码器将内存中的机器码字节读入 MCInst 并确定指令大小。
  2. 它使用指令打印机将指令的文本形式打印到缓冲区。
  3. 它使用指令解析器重新解析文本形式以查找文本中的精确操作数边界并构建操作数的符号形式。

该库提供了许多强大的功能。Sean 在 他之前的博文 中描述了它和 X86 实现,接口位于llvm/include/llvm-c/EnhancedDisassembly.h头文件中。

您还可以通过使用 llvm-mc 工具在命令行访问一些功能

$ echo "0xCD 0x21" | llvm-mc --disassemble -triple=x86_64-apple-darwin9
int $33
$ echo "0 0" | llvm-mc --disassemble -triple=x86_64-apple-darwin9
addb %al, (%rax)
$ echo "0 0" | llvm-mc --disassemble -triple=i386-apple-darwin9
addb %al, (%eax)
$ echo "0x0f 0x01 0xc2" | llvm-mc --disassemble -triple=i386-apple-darwin9
vmlaunch


独立汇编器


如果将汇编解析器、指令解析器、汇编器后端和指令编码器组合在一起,您将获得一个传统的独立系统汇编器。llvm-mc 工具通过以下命令提供此功能

$ llvm-mc foo.s -filetype=obj -o foo.o
$ echo " invalid_inst _foo, %ebx" | llvm-mc -filetype=obj -o /dev/null
<stdin>:1:5: error: unrecognized instruction
invalid_inst _foo, %ebx
^

第二个示例表明汇编解析器提供了良好的脱字号诊断,而不仅仅是许多汇编器生成的“第 1 行解析错误”。


真正复杂的汇编“cat”


如果愿意,您可以使用独立汇编器并将 MCAsmStreamer 接口连接到汇编器正在调用的 MCStreamer。这使您可以读取 .s 文件(验证它),然后立即将其打印出来。您还可以使用 -show-encoding 和 -show-inst 选项(显示 MCInst)。为了测试目的,汇编 cat 是 llvm-mc 的默认模式,您可以像这样使用它

$ llvm-mc foo.s -o bar.s
$ echo "addl %eax, %ebx" | llvm-mc -show-encoding -show-inst
.section __TEXT,__text,regular,pure_instructions
addl %eax, %ebx ## encoding: [0x01,0xc3]
## <MCInst #66 ADD32rr
## <MCOperand Reg:29>
## <MCOperand Reg:29>
## <MCOperand Reg:27>>
$ echo "xorl _foo, %ebx" | llvm-mc -show-encoding
.section __TEXT,__text,regular,pure_instructions
xorl _foo, %ebx ## encoding: [0x33,0x1c,0x25,A,A,A,A]
## fixup A - offset: 3, value: _foo, kind: FK_Data_4

最后一个示例表明指令编码器除了指令的数据字节之外还生成了一个修正(又称重定位)。


生成汇编的编译器


现在编译器已连接到通过 MCStreamer API 发出其所有代码,我们可以连接各种流到它。为了获得“经典编译器”,我们可以将 MCAsmStreamer 连接到编译器,并从编译器中获得一个正常的 .s 文件

$ clang foo.c -S -o foo.s

这使用当前目标的 MCAsmStreamer 和指令打印机。一个有趣的观察结果是,在旧世界中,.s 文件打印在性能方面非常关键(当您没有直接 .o 文件写入支持时)。生成那个巨大的文本文件需要大量详细的格式化,并且编译器在“-O0”模式下没有执行太多其他操作,因此它占用了相当一部分编译时间。

但是,大多数构建系统(例如 makefile)以“-c”模式使用编译器,这要求 .o 文件。如果编译器支持直接写入 .o 文件,.s 路径根本就不会热,因为它没有被使用。这意味着我们可以合理地将 -S 模式设置为默认情况下发出一个包含大量有用注释的 .s 文件:此模式(例如 gcc -fverbose-asm)以某种形式得到许多编译器的支持,但大多数人不知道它的存在。直接 .o 文件写入支持的存在意味着我们可以默认情况下启用它!此外,LLVM 的这种实现非常出色,包含有关溢出、循环嵌套等的信 息。


集成汇编器


正如您可能已经猜到的那样,LLVM 也支持集成汇编器。虽然这最终将成为支持它的目标的默认设置,但现在 clang 需要您传递 -integrated-as 标志

$ clang foo.c -c -o foo.o -integrated-as

对于用户来说,这与非集成 as 支持的工作方式相同。如果您传递 -v,您将看到编译器直接发出 .o 文件,并且汇编器没有由驱动程序运行。

这很简单:汇编器后端作为 MCStreamer 提供给编译器,并且它处理系统汇编器曾经处理的各种事情。这意味着汇编器永远不必(例如)将 MCInst 打印到文本表示并重新解析它们,它只是将代码生成器正在使用的格式良好的 MCInst 直接传递给汇编器后端。

这一切都很好,除了一个问题:内联汇编。使用内联汇编,可以在源文件中包含任意汇编语言(可以选择由编译器修改以插入操作数约束信息等),我们别无选择,只能使用汇编解析器解析它。幸运的是,我们有一个很好地分解的干净系统,因此汇编处理代码只是创建一个新的临时汇编解析器来处理内联汇编,并让它与编译器正在使用的同一个 MCStreamer 实例对话。这意味着当解析内联汇编时,流的当前状态与使用系统汇编器时的状态相同。

使用集成汇编器还有其他好处:我们可以将它与 clang 诊断系统紧密集成,而不是让汇编器在后期检测错误并使用外部错误报告它。除了保持一致性之外,我们实际上可以提供有关错误发生位置的非常好的信息,将其传回原始源代码中出现问题的位置。将这些示例与使用和不使用集成汇编器的示例进行比较

$ cat t.c
int foo(int X) {
__asm__ ("frob %0" : "+r" (X));
return X;
}
$ gcc t.c
/var/folders/51/51Qw875vFdGa9KojoIi7Zk+++TM/-Tmp-//ccyXfgfZ.s:11:no such instruction: `frob %eax'
$ clang t.c
/var/folders/51/51Qw875vFdGa9KojoIi7Zk+++TM/-Tmp-/cc-4zimMn.s:15:no such instruction: `frob %eax'
clang: error: assembler command failed with exit code 1 (use -v to see invocation)
$ clang t.c -integrated-as
t.c:2:11: error: unrecognized instruction
frob %eax
^
<inline asm>:1:2: note: instantated into assembly here
__asm__ ("frob %0" : "+r" (X));
^
1 error generated.

我不知道您怎么样,但我发现知道 C 文件的第 2 行存在问题比发现已删除文件的第 15 行存在问题更有用。:) 值得指出的是,显示由汇编器解析的代码非常重要,因为在操作数替换等之后,从源代码中并不总是清楚问题是什么。

状态和开放项目!


如上所述,MC 子系统的体系结构已经相当先进,我们编写了很多代码。但是,并非所有代码都具有生产质量,并且我们还没有人员签署所有有趣的工作!截至撰写本文时,以下是高级别状态

  1. clang 方面的所有工作都已完成:驱动程序知道 -integrated-as,诊断子系统可以处理汇编器错误,代码生成器已完全切换到使用 MCStreamer 等。
  2. X86 和 ARM 反汇编器 状况良好,被认为已达到或接近生产质量。
  3. X86 指令打印机已完成 AT&T 和 Intel 语法。
  4. MC 组件的设计独立于目标文件(例如,适用于 MachO、ELF、PE-COFF 等),但只实现了 MachO(在 Mac OS/X 系统上使用)。添加对 ELF 和/或 PE-COFF 的支持应该相当简单。
  5. X86 AT&T 语法汇编解析器缺少一些重要功能,例如,向前/向后标签,支持推断指令的后缀(例如,add -> addl),宏等。但是,它足够先进,可以可靠地解析 LLVM X86 后端生成的所有内容。添加这些缺少的功能应该相当简单。
  6. X86 Intel 语法汇编解析器不存在。
  7. ARM 汇编解析器不完整(可能达到 50% 的实用性),并且指令打印器大约 50% 从旧式打印机中重构。
  8. 汇编器后端运行良好,目前尝试生成与 Mac 上的系统汇编器在位上完全相同的目标文件(以允许通过“/usr/bin/cmp”进行验证)。也就是说,它的算法并非全部经过性能优化,因此它可能可以大幅度提高速度。
  9. 旧的 JIT 代码路径仍然存在于 LLVM 中,它应该在用新的 MC 实现替换后被删除。这样做的一个好处是 JIT 将支持内联汇编 :)

如果您对工具链的这一层感兴趣,这是一个非常适合参与的领域,因为有很多小到中型的项目在等待解决。我相信这项工作的长期影响是巨大的:它允许构建新的有趣 CPU 级工具,并且意味着我们可以将新的指令添加到一个 .td 文件中,而不是必须将它们添加到编译器、汇编器、反汇编器等中。

-Chris