LLVM 中指令的生命周期
LLVM 是一款复杂的软件。理解其工作原理有很多途径,但没有一个简单。我最近不得不深入研究一些我不熟悉的 LLVM 领域,这篇文章是我探索结果之一。
我的目标是跟踪“指令”在经过 LLVM 多个编译阶段时的各种表现形式,从源语言中的语法结构开始,直到被编码为输出目标文件中的二进制机器码。
这篇文章本身不会教你如何使用 LLVM。它假设你已经对 LLVM 的设计和代码库有一定的了解,并且省略了许多“显而易见”的细节。请注意,除非另有说明,否则此处的信息适用于 LLVM 3.2。LLVM 和 Clang 都是快速发展的项目,未来的更改可能会导致这篇文章的部分内容不再准确。如果你发现任何差异,请告诉我,我会尽力修正它们。
输入代码
我想从一开始就开始这个探索过程 - C 源代码。以下是我们将要使用的简单函数
int foo(int aa, int bb, int cc) {
int sum = aa + bb;
return sum / cc;
}
这篇文章的重点将是除法运算。
Clang
Clang 作为 LLVM 的前端,负责将 C、C++ 和 ObjC 源代码转换为 LLVM IR。Clang 的主要复杂性来自正确解析和语义分析 C++ 的能力;对于简单的 C 级操作,流程实际上非常简单。Clang 的解析器从输入中构建抽象语法树 (AST)。AST 是 Clang 各个部分处理的主要“货币”。对于我们的除法运算,一个BinaryOperator节点在 AST 中创建,包含BO_div“运算符种类”。Clang 的代码生成器然后从节点中发出一个sdivLLVM IR 指令,因为这是一个有符号整数类型的除法。
LLVM IR
以下是为该函数创建的 LLVM IRdefine i32 @foo(i32 %aa, i32 %bb, i32 %cc) nounwind {
entry:
%add = add nsw i32 %aa, %bb
%div = sdiv i32 %add, %cc
ret i32 %div
}
在 LLVM IR 中,sdiv是一个BinaryOperator,它是Instruction的子类,具有操作码SDiv。与其他指令一样,它可以由 LLVM 分析和转换过程处理。对于针对SDiv的特定示例,请查看SimplifySDivInst。由于在整个 LLVM“中间层”中,指令都保持其 IR 形式,所以我不会花太多时间讨论它。要见证它的下一种表现形式,我们将不得不看看 LLVM 代码生成器。
代码生成器是 LLVM 最复杂的部分之一。它的任务是将相对高级的、与目标无关的 LLVM IR 降低为低级的、与目标相关的“机器指令”(MachineInstr)。在前往MachineInstr的过程中,LLVM IR 指令会经过一个“选择 DAG 节点”表现形式,这正是我接下来要讨论的内容。
选择 DAG 节点
选择 DAG 节点由SelectionDAGBuilder类创建,它“为”SelectionDAGISel服务,它是指令选择的主要基类。SelectionDAGISel遍历所有 IR 指令,并调用其上的SelectionDAGBuilder::visit调度程序。处理SDiv指令的方法是SelectionDAGBuilder::visitSDiv。它从 DAG 请求一个新的SDNode,操作码为ISD::SDIV,它成为 DAG 中的一个节点。以这种方式构建的初始 DAG 仍然只是部分依赖于目标。在 LLVM 术语中,它被称为“非法” - 它包含的类型可能不被目标直接支持;它包含的操作也是如此。
有两种方法可以可视化 DAG。一种是将-debug标志传递给llc,这会导致它在所有选择阶段创建 DAG 的文本转储。另一种是传递一个-view选项,这会导致它转储和显示图形的实际图像(更多详细信息请参阅 代码生成器文档)。以下是 DAG 的相关部分,显示了我们的SDiv节点,在 DAG 创建后立即(sdiv节点在底部)
在SelectionDAG机制实际从 DAG 节点发出机器指令之前,它们会经过其他一些转换。最重要的是类型和操作合法化步骤,这些步骤使用特定于目标的挂钩将所有操作和类型转换为目标实际支持的操作和类型。
在 x86 上将“合法化” sdiv 为 sdivrem
x86 的除法指令(idiv用于带符号的操作数)计算运算的商和余数,并将它们存储在两个独立的寄存器中。由于 LLVM 的指令选择区分了此类操作(称为ISD::SDIVREM)和仅计算商的除法(ISD::SDIV),当目标是 x86 时,我们的 DAG 节点将在 DAG 合法化阶段“合法化”。以下是其发生方式。代码生成器用于将特定于目标的信息传达给通常与目标无关的算法的一个重要接口是TargetLowering。目标实现此接口来描述如何将 LLVM IR 指令降低为合法的SelectionDAG操作。此接口的 x86 实现是X86TargetLowering。在它的构造函数中,它标记了哪些操作需要通过操作合法化“扩展”,以及ISD::SDIV是其中之一。以下是代码中的一个有趣的评论
// Scalar integer divide and remainder are lowered to use operations that
// produce two results, to match the available instructions. This exposes
// the two-result form to trivial CSE, which is able to combine x/y and x%y
// into a single instruction.
当SelectionDAGLegalize::LegalizeOp在 SDIV 节点上看到Expand标志时,它会用ISD::SDIVREM替换它。这是一个有趣的例子,展示了操作在选择 DAG 形式中可能发生的转换。
以下是合法化后的 DAG 的相关部分
指令选择 - 从 SDNode 到 MachineSDNode
代码生成过程的下一步是指令选择。LLVM 提供了一个通用的基于表的指令选择机制,该机制借助 TableGen 自动生成。但是,许多目标后端选择在他们的SelectionDAGISel::Select实现中手动编写代码以处理某些指令。然后,其他指令通过调用SelectCode.发送到自动生成的选取器ISD::SDIVREMX86 后端手动处理以处理一些特殊情况和优化。在此步骤中创建的 DAG 节点是MachineSDNodeSDNode,它是的子类,它保存了构建实际机器指令所需的信息,但仍然以 DAG 节点形式。此时,实际的 X86 指令操作码被选中 -X86::IDIV32r
在本例中。
调度和发出 MachineInstr我们目前拥有的代码仍然以 DAG 的形式表示。但 CPU 不会执行 DAG,而是执行线性指令序列。调度的目标是通过为 DAG 的操作(节点)分配顺序来线性化 DAG。最简单的方法是按拓扑顺序对 DAG 进行排序,但 LLVM 的代码生成器采用了巧妙的启发式方法(如寄存器压力降低)来尝试生成一个调度,该调度将产生更快的代码。
每个目标都有一些挂钩,它可以实现这些挂钩来影响调度的执行方式。但是,我在这里不会深入讨论这个主题。最后,调度器将指令列表发出到MachineBasicBlock中,使用InstrEmitter::EmitMachineNodeSDNode从MachineInstr进行转换。此处的指令采用
形式(从现在开始称为“MI 形式”),DAG 可以被销毁。llc我们可以通过调用,使用-print-machineinstrs
# After Instruction Selection:
# Machine code for function foo: SSA
Function Live Ins: %EDI in %vreg0, %ESI in %vreg1, %EDX in %vreg2
Function Live Outs: %EAX
BB#0: derived from LLVM BB %entry
Live Ins: %EDI %ESI %EDX
%vreg2<def> = COPY %EDX; GR32:%vreg2
%vreg1<def> = COPY %ESI; GR32:%vreg1
%vreg0<def> = COPY %EDI; GR32:%vreg0
%vreg3<def,tied1> = ADD32rr %vreg0<tied0>, %vreg1, %EFLAGS<imp-def,dead>; GR32:%vreg3,%vreg0,%vreg1
%EAX<def> = COPY %vreg3; GR32:%vreg3
CDQ %EAX<imp-def>, %EDX<imp-def>, %EAX<imp-use>
IDIV32r %vreg2, %EAX<imp-def>, %EDX<imp-def,dead>, %EFLAGS<imp-def,dead>, %EAX<imp-use>, %EDX<imp-use>; GR32:%vreg2
%vreg4<def> = COPY %EAX; GR32:%vreg4
%EAX<def> = COPY %vreg4; GR32:%vreg4
RET
# End machine code for function foo.
标志,并查看第一个输出,其中显示“指令选择后”来检查在此步骤中发出的机器指令。请注意,输出提到代码处于 SSA 形式,我们可以看到,一些正在使用的寄存器是“虚拟”寄存器(例如).
%vreg1
寄存器分配 - 从 SSA 到非 SSA 机器指令除了某些明确定义的例外情况外,从指令选择器生成的代码都处于 SSA 形式。特别是,它假设它有一组无限的“虚拟”寄存器来处理。当然,事实并非如此。因此,代码生成器的下一步是调用“寄存器分配器”,其任务是用目标的寄存器库中的物理寄存器替换虚拟寄存器。
上面提到的例外情况也很重要且有趣,所以让我们详细讨论一下。某些体系结构中的一些指令需要固定寄存器。一个很好的例子是 x86 中的除法指令,它要求其输入位于 EDX 和 EAX 寄存器中。指令选择器了解这些限制,因此正如我们在上面的代码中看到的,IDIV32r的输入是物理寄存器,而不是虚拟寄存器。此分配由.
X86DAGToDAGISel::SelectMachineInstr完成。寄存器分配器负责处理所有非固定寄存器。在 SSA 形式的机器指令上还有一些优化(和伪指令扩展)步骤,但我会跳过这些。类似地,我不会讨论在寄存器分配后执行的步骤,因为这些步骤不会更改操作出现的基本形式(.
,此时)。如果您有兴趣,请查看
# After Virtual Register Rewriter:
# Machine code for function foo: Post SSA
Function Live Ins: %EDI in %vreg0, %ESI in %vreg1, %EDX in %vreg2
Function Live Outs: %EAX
0B BB#0: derived from LLVM BB %entry
Live Ins: %EDI %ESI %EDX
16B %ECX = COPY %EDX
64B %EAX = LEA64_32r %EDI, 1, %ESI, 0, %noreg
96B CDQ %EAX, %EDX, %EAX
112B IDIV32r %ECX, %EAX, %EDX, %EFLAGS, %EAX, %EDX
160B RET %EAX
# End machine code for function foo.
TargetPassConfig::addMachinePasses
以下是寄存器分配后转储的 MI发出代码因此,我们现在已经将原始的 C 函数转换为 MI 形式 - 一个MachineInstrMachineFunction,其中充满了指令对象(
)。这是代码生成器完成其工作,我们可以发出代码的点。在当前的 LLVM 中,有两种方法可以做到这一点。一种是(遗留的)JIT,它直接将可执行的、可运行的代码发出到内存中。另一种是 MC,它是一个雄心勃勃的目标文件和汇编框架,已成为 LLVM 的一部分已有几年,取代了之前的汇编生成器。MC 目前用于所有(或至少是重要的)LLVM 目标的汇编和目标文件发出。MC 还支持“MCJIT”,这是一个基于 MC 层的 JIT 框架。这就是为什么我将 LLVM 的 JIT 模块称为遗留的原因。我首先会简要介绍一下遗留的 JIT,然后转向 MC,MC 更加普遍地有趣。JIT 发出代码的传递序列由addPassesToGenerateCode,它定义了将 IR 转换为 MI 形式所需的所有过程,而这正是本文前面大部分内容所讨论的。接下来,它会调用addCodeEmitter,这是一个特定于目标的传递,用于将 MI 转换为实际的机器代码。由于 MI 已经非常底层,因此将它们转换为可运行的机器代码相当简单。用于此目的的 x86 代码位于lib/Target/X86/X86CodeEmitter.cpp中。对于我们的除法指令,这里没有特殊处理,因为MachineInstr它包含在内,已经包含了它的操作码和操作数。它在emitInstruction.
MCInst
中与其他指令一起进行通用处理。当 LLVM 用作静态编译器(例如作为clang的一部分)时,MI 会传递到 MC 层,该层处理对象文件的输出(它也可以输出文本汇编文件)。关于 MC 可以说很多,但那需要单独的文章。一个好的参考是 LLVM 博客的这篇文章。我将继续关注单个指令所经历的路径。LLVMTargetMachine::addPassesToEmitFile负责定义输出对象文件所需的步骤序列。实际的 MI 到MCInst转换是在EmitInstruction中完成的,它是AsmPrinter接口的一部分。对于 x86,此方法由X86AsmPrinter::EmitInstruction实现,它将工作委托给X86MCInstLower类。与 JIT 路径类似,此时我们的除法指令没有特殊处理,并且与其他指令一起进行通用处理。
通过传递-show-mc-inst和-show-mc-encoding到llc,我们可以看到它创建的 MC 级指令及其编码,以及实际的汇编代码。
foo: # @foo
# BB#0: # %entry
movl %edx, %ecx # encoding: [0x89,0xd1]
# <MCInst #1483 MOV32rr
# <MCOperand Reg:46>
# <MCOperand Reg:48>>
leal (%rdi,%rsi), %eax # encoding: [0x8d,0x04,0x37]
# <MCInst #1096 LEA64_32r
# <MCOperand Reg:43>
# <MCOperand Reg:110>
# <MCOperand Imm:1>
# <MCOperand Reg:114>
# <MCOperand Imm:0>
# <MCOperand Reg:0>>
cltd # encoding: [0x99]
# <MCInst #352 CDQ>
idivl %ecx # encoding: [0xf7,0xf9]
# <MCInst #841 IDIV32r
# <MCOperand Reg:46>>
ret # encoding: [0xc3]
# <MCInst #2227 RET>
.Ltmp0:
.size foo, .Ltmp0-foo
对象文件(或汇编代码)的输出是通过实现MCStreamer接口来完成的。对象文件由MCObjectStreamer输出,它根据实际的对象文件格式进一步子类化。例如,ELF 输出在MCELFStreamer中实现。一个MCInst流经流媒体的粗略路径是MCObjectStreamer::EmitInstruction,后面跟着一个特定于格式的EmitInstToData。指令以二进制形式的最终输出当然特定于目标。它由MCCodeEmitter接口处理(例如X86MCCodeEmitter)。虽然在 LLVM 代码的其余部分通常很棘手,因为它必须在目标独立和目标特定功能之间进行区分,但 MC 更加具有挑战性,因为它添加了另一个维度——不同的对象文件格式。因此,某些代码是完全通用的,某些代码是格式相关的,而某些代码是目标相关的。
汇编器和反汇编器
一个MCInst是一个故意非常简单的表示。它试图尽可能地减少语义信息,只保留指令操作码和操作数列表(以及用于汇编器诊断的源位置)。与 LLVM IR 一样,它是一个内部表示,具有多种可能的编码。最明显的两种是汇编(如上所示)和二进制对象文件。llvm-mc是一个使用 MC 框架实现汇编器和反汇编器的工具。在内部,MCInst是用于在二进制和文本形式之间转换的表示形式。此时,该工具不关心哪个编译器生成了汇编/对象文件。
结论
呈现像 LLVM 这样的复杂系统的“大局”视图并不容易。我希望本文能成功地提供有关 LLVM 内部工作原理的一些线索,以便为进一步探索提供帮助。
[这篇文章以略微扩展的形式重新发布 从此处]