原作:http://eli.thegreenplace.net/2012/11/24/life-of-an-instruction-in-llvm/
作者:Eli Bendersky
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作为LLVM的前端,负责把C、C++以及ObjC源代码转换到LLVMIR。Clang的复杂性主要来自正确地解析及分析C++语义;一个简单C层面操作的流程实际上相当简单。
Clang的解析器从输入构建出一棵抽象语法树(Abstract Syntax Tree,AST)。AST是Clang各部分进行交易的主要“货币”。至于我们的除法操作,在AST中创建了一个BinaryOperator节点,带有“操作符类型”BO_div(要检查由Clang产生的AST,以-cc1 -ast-dump选项编译源文件)。然后Clang的代码生成器从这个节点继续产生一个sdiv LLVM IR指令,因为这是一个有符号整数类型的除法。
这里是为这个函数产生的LLVM IR(为了清除碎片,我通过opt –mem2reg | llvm-dis运行这个IR):
define 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采用了某些C预处理器技巧来尽量减少代码重复。更多详情,请参考文件include/llvm/Instruction.def以及它在LLVM源代码中各处的使用)。像其他指令,它可以被LLVM分析及转换遍处理。至于针对SDiv的一个特殊例子,看一下SimplifySDivInst。因为贯穿LLVM“中间(middle-end)”层,指令保持在IR形式,我不想花太多时间讨论它。要目击下一个化身,我们需要看一下LLVM代码生成器。
代码生成器是LLVM最复杂的部分之一。它的任务是将相对高级、目标无关的LLVM IR降级到低级的、目标相关的“机器指令”(MachineInstr)。在通向MachineInstr的路上,一个LLVMIR指令经过了一个“选择DAG节点(selection DAG node)”化身,这是我下面要讨论的。
SelectionDAG(这里DAG意指有向无环图(Directed Acyclic Graph),它是LLVM代码生成器用来表示各种操作,连同它们所产生及消耗值的一种数据结构)节点由“服务于”SelectionDAGISel的SelectionDAGBuilder类创建,SelectionDAGISel是指令选择的主要基类。SelectionDAGIsel仔细检查所有的IR指令,并对它们调用分发器SelectionDAGBuilder::visit。处理SDiv指令的方法是SelectionDAGBuilder::visitSDiv。它从DAG请求带有操作码ISD::SDIV的一个新SDNode,它成为该DAG中的一个节点。
这个方式构建的初始的DAG仍然只是部分目标相关。在LLVM术语里,它被称为“非法的”——目标机器可能不直接支持它包含的类型;它包含的操作也是这样。
可视化DAG有几个方式。一是向llc传递-debug标记,这将创建该DAG在整个选择阶段的一个文本形式的转储。另一个方式是传递其中一个-view选项来转储并显示该图的一个实际图形(更多细节在code generator docs)。这里是我们SDiv代码在DAG中相关的部分,就在该DAG产生之后(sdiv节点在底下):
在SelectionDAG机件正式从DAG节点产生机器指令前,这些节点经历了其他几个转换。最重要的有类型与操作合法化步骤,它使用目标机器特定的钩子把所有的操作与类型转换为该目标机器确实支持的对象。
X86的除法指令(idiv用于有符号操作数)同时计算该操作的商与余数,并把它们保存在两个不同的寄存器。因为LLVM的指令选择区分这样的操作(称为ISD::SDIVREM)与仅计算商的除法(ISD::SDIV),在目标是x86时,我们的DAG节点在DAG合法化阶段被“合法化”。下面是这个过程。
代码生成器用来向通用的目标无关算法传递目标特定信息的一个重要接口是TargetLowering。目标机器实现这个接口来描述LLVM IR指令如何被降级为合法的SelectionDAG操作。这个接口x86的实现是X86TargetLowering(它是LLVM中最可怕的(有争议)单个代码片段)。在其构造函数中,它标记哪些操作需要被操作合法化“展开”,而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替代。这是一个有趣的例子,展示在selection DAG形式下,一个操作可以经历的转换。
代码生成过程的下一步(代码生成器在主要步骤间,比如合法化与选择,执行DAG优化。知道这些优化是重要且有趣的,但因为它们作用在selection DAG节点上,并返回它们,这不是本文的关注点)是指令选择。LLVM提供一个由TableGen产生的、通用的基于表的指令选择机制。不过,许多目标后端选择在它们的SelectionDAGISel::Select实现里定制代码,来手工处理某些指令。然后通过调用SelectCode,其他指令被送往这个自动生成的选择器。
X86后端手动地处理ISD::SDIVREM以顾全某些特殊的情形及优化。在这一步创建的DAG节点是一个MachineSDNode,一个SDNode保存构建一个实际机器指令所要求信息的派生类,但仍然保持DAG节点形式。在我们的例子里,这时实际的X86指令码——X86::IDIV32r被选中。
这时我们拥有的代码仍然表示为一个DAG。但CPU不能执行DAG,它们执行一个线性指令序列。调度步骤的目标是通过向其操作(节点)赋予一个次序来线性化这个DAG。最简单的做法是拓扑排序这个DAG,但LLVM的代码生成器采用更聪明的启发式(比如寄存器降压)来尝试并产生一个会导致更快代码的安排。
每个目标有一些钩子,可以实现来影响完成调度的方式。不过我不打算展开这个话题。
最后通过InstrEmitter::EmitMachineNode转换SDNode,调度器向一个MachineBasicBlock发出一组指令。 现在指令具有MachineInstr形式(从现在起称为“MI形式”),DAG可以被摧毁了。
我们可以通过-print-machineinstrs选项调用llc来检查这一步产生的机器指令,并且看到输出的第一部分写着“指令选择后(After instruction selection)”:
# 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= COPY %EDX; GR32:%vreg2
%vreg1= COPY %ESI; GR32:%vreg1
%vreg0= COPY %EDI; GR32:%vreg0
%vreg3= ADD32rr %vreg0 , %vreg1, %EFLAGS ; GR32:%vreg3,%vreg0,%vreg1
%EAX= COPY %vreg3; GR32:%vreg3
CDQ %EAX, %EDX , %EAX
IDIV32r %vreg2, %EAX, %EDX , %EFLAGS , %EAX , %EDX ; GR32:%vreg2
%vreg4= COPY %EAX; GR32:%vreg4
%EAX= COPY %vreg4; GR32:%vreg4
RET
# End machine code for function foo.
注意到代码中提及的输出是SSA形式的,我们可以看到某些使用到的寄存器是“虚拟”寄存器(比如%vreg1)。
除了某些定义良好的例外之外,从指令选择器产生的代码是SSA形式的。特别的,它假定具有无限的“虚拟”寄存器可操作。这当然不是真的。因此,代码生成的下一步是调用一个“寄存器分配器”,它的任务是,从目标机器的寄存器池,选择物理寄存器替换虚拟寄存器。
上面提及的例外也同样重要且有趣,因此让我们对它们多讨论一些。
在某些架构上,某些指令要求固定的寄存器。一个好榜样是我们的x86除法指令,它要求它的输出必须在EDX与EAX寄存器里。指令选择器知道这些限制,因此在上面的代码里我们可以看到,IDIV32r的输入是物理寄存器,而不是虚拟寄存器。这个赋值由X86DAGToDAGISel::Select完成。
寄存器分配器照管所有非固定的寄存器。在SSA形式的机器指令上有更多一些的优化(及伪指令展开)步骤,但我准备跳过这些。同样,我不准备讨论寄存器分配后执行的步骤,因为这些不会改变操作所呈现的基本形式(这时候是MachineInstr)。如果你对此感兴趣,看一下TargetPassConfig::addMachinePasses。
现在我们已经把原始的C函数翻译到MI形式——一个填满了指令对象(MachineInstr)的MachineFunction。这个时刻,代码生成器完成了它的工作,我们可以发出代码。在当前的LLVM里,有两个方式可以做到。一个是(遗留的,legacy)JIT,它向内存直接发送随时可运行的可执行代码。另一个是MC,它是一个雄心勃勃的目标文件与汇编框架(object-file-and-assembly),几年前已是LLVM的一部分,以替换之前的汇编生成器。目前MC用于所有的(或至少重要的)LLVM目标机器汇编与目标文件的生成。MC还启动了“MCJIT”,这是一个基于MC层的JIT框架。这是为什么我将LLVM的JIT模块称为遗留(legacy)的原因。
我将先稍介绍一下旧的JIT,然后转到MC,它更为有趣些。
实现JIT发出代码所执行的遍序列(The sequence of passes to JIT-emit code)由LLVMTargetMachine::addPassesToEmitMachineCode定义。它调用,定义了执行本文到现在所提及的大部分工作所要求的所有遍的,addPassesToGenerateCode——把IR转换到MI形式。接下来,它调用addCodeEmitter,这是一个将MI转换为实际机器代码的目标机器特定的遍。因为MI已经非常低级,把它们转换到可运行的机器代码相当简单(在这里,当我谈到“机器代码”时,我是指在一个缓存里的实际字节,它代表CPU可以运行的编码形式的指令。一旦发出结束,JIT指引CPU从这个缓存执行代码)。对此,用于x86的代码位于lib/Target/X86/X86CodeEmitter.cpp。对于我们的除法指令,这里没有特殊的处理,因为它封包的MachineInstr已经包含了指令的操作码与操作数。在emitInstruction中,它与其他指令一起被一般化地处理。
在LLVM被用作静态编译器时(比如,作为clang的部分)。MI被下传到MC层,这里处理目标文件的生成(它也可以产生文本形式的汇编文件)。MC有很多可说,但这将需要另一篇文章。LLVM博客的这篇博文是一个好的参考。我将关注单条指令所经过的路径。
LLVMTargetMachine::addPassesToEmitFile负责定义产生一个目标文件所要求的动作序列(the sequence of actions required to emit an object file)。实际的MI到MCInst转换由AsmPrinter接口的EmitInstruction完成。对于x86,由X86AsmPrinter::EmitInstruction实现这个方法,而它把这个工作委托给X86MCInstLower类。类似于JIT,对于我们的除法指令,在这里也没有特殊的处理,它与其他指令一起被一般化地处理。
通过向llc传入-show-mc-inst,我们可以查看产生的MC级指令,连同实际的汇编代码:
foo: # @foo
# BB#0: # %entry
movl %edx, %ecx # ##> leal (%rdi,%rsi), %eax # ######> cltd #idivl %ecx # #> ret #.Ltmp0:.size foo, .Ltmp0-foo目标文件(或汇编代码)的生成由实现MCStreamer接口来完成。目标文件由MCObjectStreamer产生,根据实际的目标文件格式,MCObjectStreamer被进一步派生。例如,ELF的生成实现在MCELFStreamer里。一个MCInst在这些streamer中穿行的大致路径是MCObjectStreamer::EmitInstruction,后接一个特定格式的EmitInstToData。二进制形式指令的最终生成显然特定于目标机器。由MCCodeEmitter接口处理(如X86MCCodeEmitter)。 然而LLVM余下的代码通常是费解的,因为它必须分离目标机器无关与目标机器特定的功能,MC更具挑战性,因为它增加了另一个维度——不同的目标文件格式。因此某些代码是完全通用的,一些代码是格式相关的,而一些代码是目标机器相关的。
汇编器与反汇编器
MCInst是经过深思熟虑的,非常简单的表示。它尝试尽可能排除语义信息,仅保留指令的操作码与操作数列表(及用于汇编器诊断的源代码位置)。如LLVM IR,它是一个带有多种可能编码格式的内部表示。最显著的两个是汇编(如上所示)与二进制目标文件。
llvm-mc是一个使用MC框架来实现汇编器与反汇编器的工具。在内部,MCInst是用来在二进制与文本格式间转换的表示。这时,这个工具不关心是哪个编译器产生了汇编/目标文件。