编写一个LLVM后端

本文翻译自 LLVM 官方的一篇教程:https://releases.llvm.org/10.0.0/docs/WritingAnLLVMBackend.html#instruction-scheduling
该文档需要有一定的 LLVM 和 编译原理的基础。
LLVM目前的更新很活跃,请注意跟踪项目最新变更

1 介绍

这篇文章描述了如何编写一个用于将LLVM中间表示(IR)转换成特定目标机器上的代码或其他编程语言的编译器后端的技术。作用于特定目标机器的代码可以使汇编码形式,也可以是用于JIT编译器的二进制码形式。

LLVM的后端特点是目标无关代码生成器,它可以输出多种不同类型目标CPU的代码,包括X86、PowerPC、ARM以及SPARC等。后端也会被用来生成如SPU一类的元胞处理器(Cell processor)或者是一些GPU上的计算内核。

这篇文章专注在的路径是在release版本LLVM源码路径下的llvm/lib/Target,尤其是专注于举例如何编写一个为SPARC目标平台的静态编译器(也就是发射汇编码),因为SPARC架构有非常标准的特性,比如RISC架构指令集以及常规的调用约定。

1.1 目标读者

本文目标读者是任何需要编写LLVM后端来为特定软硬件目标生成代码的人。

1.2 预先阅读

以下资料必须提前阅读了解:

  • LLVM Language Reference Manual:这是一个介绍LLVM汇编语言的参考手册
  • The LLVM Target-Independent Code Generator:一个描述用于翻译LLVM中间表示到特定目标机器代码需要使用的类结构和算法的指南。需要特别注意代码生成阶段(pass)的内容:指令选择,调度和队列化(Formation),SSA级优化,寄存器分配,Prolog和Epilog代码插入,后机器代码优化,以及代码发射。
  • TableGen:这个文档描述了TableGen(tblgen)引用如何管理领域特定信息来支持LLVM代码生成。TableGen从一个特殊的目标描述文件(.td后缀)中读入输入信息,然后生成c++代码,用于代码生成。
  • Writing an LLVM Pass:汇编输出是一个FunctionPass,另外还有几个SelectionDAG的处理步骤。

另外,为了支持SPARC案例相关的信息,你需要有一份 The SPARC Architecture Manual, Version 8 来作为参考。更多关于ARM架构指令集的信息,需要参考 ARM Architecture Reference Manual 。有关于GNU汇编器格式的说明,参考 Using As ,特别是汇编代码输出的部分, Using As 中包含了一个目标机器相关特性的清单。

1.3 基本步骤

编写一个编译器后端来将LLVM的IR转换为特定目标的代码(如硬件机器或其他语言),需要以下步骤:

  • 创建一个TargetMachine的子类,用来描述你的目标机器的特性。拷贝已经存在的其他特定后端中的TargetMachine和头文件;比如拷贝SparcTargetMachine.cpp和SparcTargetMachine.h,但是要修改文件名为你自己的目标。类似的,也要把文件内容中的space都改成你的目标名称。
  • 需要描述目标机器的寄存器集。依赖于目标相关的RegisterInfo.td文件作为输入,使用TableGen来生成有关寄存器定义、寄存器别名和寄存器类的代码。你也可能编写一些额外的代码,通过实现继承TargetRegisterInfo类的子类来表示有助于寄存器分配和寄存器间交互的信息。
  • 需要描述目标机器的指令集。依赖于目标相关的TargetInstrFormats.td文件和TargetInstrInfo.td文件作为输入,使用TableGen来生成有关目标的指令集信息。你也可能编写一些额外的代码,通过实现TargetInstrInfo类的子类来表示目标机器的一些机器指令。
  • 需要描述将LLVM IR从一个DAG描述的指令转换成原生特定机器指令的选择和转换。依赖于目标相关的TargetInstrInfo.td文件作为输入,使用TableGen生成有关描述模式匹配和指令选择的信息。编写XXXISelDAGToDAG.cpp文件中代码(XXX表示目标平台名称)来描述模式匹配和DAG到DAG的指令选择。另外也要完成XXXISelLowering.cpp文件中代码,来替代或移除一些SelectionDAG中不支持的操作和数据类型。
  • 需要为汇编输出模块编写代码,从而可以将LLVM IR转换为与你目标机器平台匹配的GAS格式的输出。你应该会在目标相关的TargetInstrInfo.td中增加对指令汇编格式的约定。同事还需要完成继承AsmPrinter类的之类,它被用来实现LLVM IR到汇编格式的转换,另外还有个辅助的继承类TargetAsmInfo的之类。
  • 可选部分,可以支持子目标平台(subtarget)你可以编写一个继承自TargetSubtarget类的之类,通过命令行参数-mcpu=和-mattr=可以指定针对特定子目标平台和部分特性的编译选项。
  • 可选部分,增加一个JIT支持,创建一个机器码输出,你需要编写一个继承自TargetJITInfo类的子类,用来发射二进制机器码到内存中。

在cpp和h文件中,首先需要为这些方法占位,然后再逐步实现它们。最初,你可能不知道这些类需要哪些私有成员,以及哪些子类需要被创建。

1.4 预备步骤

为了创建你的编译器后端,你需要创建和修改一些文件,这里简单讨论了一下。但是真正的操作,你必须要参考 LLVM Target-Independent Code Generator 文档中的描述来逐步进行。

首先,你应该在 lib/Target 目录下创建一个你自己目标名称的子目录,用来存放所有的和你目标相关的文件。如果你的目标叫做Dummy,需要创建的目录就是 lib/Target/Dummy。

在这个目录下,需要创建一个CMakeLists.txt文件,你可以简单的从其他的后端路径下复制该文件,然后直接修改,至少需要将 LLVM_TARGET_DEFINITIONS 变量修改了。对应的library可以叫做LLVMDummy(你可以参考MIPS后端)。另一种方式是,你可以区分LLVMDummyCodeGen和LLVMDummyAsmPrinter这两个为不同的库,后者需要实现在 lib/Target/Dummy下一级的子目录中(你可以参考PowerPC后端)。

需要注意,这两种命名方式是硬编码在llvm-config中的。使用其他的命名方式会让llvm-config无法正常工作,并在llc中产生很多的链接错误。

为了让你的目标真的做什么事情,至少你需要实现TargetMachine的子类,这个实现是在 lib/Target/DummyTargetMachine.cpp中完成的,但任何在该目录下的其他文件都应该能正常编译和工作。为了实现LLVM的目标无关的代码生成工作,你需要实现所有当前机器平台后端需要做的事情,实现一个继承自LLVMTargetMachine的子类(如果是从零开始创建目标平台,实现TargetMachine的子类)。

为了能让LLVM可以编译和链接你的目标,你需要指定参数-DLLVM_EXPERIMENTAL_TARGETS_TO_BUILD=Dummy来运行cmake,这将能够在不必将目标添加在其他目标的列表前,就构建你的目标。

一旦你的目标后端稳定了,你可以将其增加在LLVM_ALL_TARGETS变量中,这个变量位于最外层的CMakeLists.txt中。

2 目标机器

LLVMTargetMachine被设计作为一个基类来完成LLVM目标无关代码生成任务。这个类需要被继承并实现其定义的虚函数。LLVMTargetMachine是TargetMachine的一个子类,其在 include/llvm/Target/TargetMachine.h 中被实现,同时TargetMachine类还处理大量和命令行参数有关的内容(TargetMachine.cpp)。

为了定义一个继承自LLVMTargetMachine类的目标平台特定的子类,首先需要复制一个已经存在的TargetMachine的源文件和头文件,然后你应该将其命名为与你特定后端相关的名字,比如对于SPARC平台来说,将其命名为 SparcTargetMachine.h和SparcTargetMachine.cpp文件。

对于一个目标机器XXX,实现的XXXTargetMachine必须通过一些方法来访问到对应后端的各种组件。这些方法被命名为 get*Info,比如能够获得指令集信息的getInstrInfo,获得寄存器集信息的getRegisterInfo,获得帧信息的getFrameInfo等。XXXTargetMachine还需要实现getDataLayout方法,用来访问对应这个目标特殊的数据特性,比如数据类型的空间占用和对齐要求。

举个例子,对于SPARC目标来说,头文件SparcTargetMachine.h 中声明了很多get*Info和getDataLayout方法的原型,这些方法返回的是对应的类成员对象。

namespace llvm {

class Module;

class SparcTargetMachine : public LLVMTargetMachine {
	const DataLayout DataLayout;
	SparcSubtarget Subtarget;
	SparcInstrInfo InstrInfo;
	TargetFrameInfo FrameInfo;

protected:
  virtual const TargetAsmInfo *createTargetAsmInfo() const;
  
public:
  SparcTargetMachine(const Module &M, const std::string &FS);
  
  virtual const SparcInstrInfo *getInstrInfo() const {return &InstrInfo; }
  virtual const TargetFrameInfo *getFrameInfo() const {return &FrameInfo; }
  virtual const TargetSubtarget *getSubtargetImpl() const {return &Subtarget; }
  virtual const TargetRegisterInfo *getRegisterInfo() const {
    return &InstrInfo.getRegisterInfo();
  }
  virtual const DataLayout *getDataLayout() const { return &DataLayout; }
  static unsigned getModuleMatchQuality(const Module &M);
  
  // Pass Pipeline Configuration
  virtual bool addInstSelector(PassManagerBase &PM, bool Fast);
  virtual bool addPreEmitPass(PassManagerBase &PM, bool Fast);
};

} // end namespace llvm

这就包括:

  • getInstrInfo()
  • getRegisterInfo()
  • getFrameInfo()
  • getDataLayout()
  • getSubtargetImpl()

对于另外一些目标,你还可以支持如下的方法:

  • getTargetLowering()
  • getJITInfo()

一些架构,比如GPU等,并不支持跳转到程序任意位置,执行分支任务需要屏蔽执行并使用循环体周围的特殊指令实现循环。为了避免CFG修改引入不可约束的控制流无法被硬件处理,目标必须在初始化时调用setRequiresStructuredCFG(true)。

另外,XXXTargetMachine的构造函数需要应该指定一个特殊的TargetDescription字符串,用来决定该目标平台的data layout,包括指针的占用内存大小、对齐以及大小端信息。比如,SPARCTargetMachine中的构造函数包括如下信息:

SparcTargetMachine::SparcTargetMachine(const Module &M, const std::string &FS)
  : DataLayout("E-p:32:32-f128:128:128"),
    Subtarget(M, FS), InstrInfo(Subtarget),
    FrameInfo(TargetFrameInfo::StackGrowsDown, 8, 0) {
}

连接符-区分了data layout字符串的不同部分:

  • 大写的E表示这是大端目标数据模型,而小写的e则表示是小端模型;
  • p:以及后续的几个值,表示指针的信息,包括占用空间大小,ABI的对齐要求和优先对齐。如果后边只有2个数字,则第一个数字是指针的占用空间大小,第二个数字同时表示两种对齐情况。
  • 然后后边的一个字符,可能是fiva等,分别表示浮点数、整数、向量和整体,然后后边的数据格式意义与指针类型是基本一致的。

3 目标注册

你还需要将你的目标通过TargetRegistry接口来注册,从而其他的LLVM工具可以在运行时来查找和使用你的目标。TargetRegistry接口可以直接使用,但是大多数后端都会额外有一些帮助模版来辅助注册。

所有的目标都需要声明一个全局的Target对象,这将被用来在注册时表示目标。然后,在目标的TargetInfo库中,目标需要定义对象并使用RegisterTarget模版接口来注册对象。比如对于Sparc目标的注册代码如下:

Target llvm::getTheSparcTarget();

extern "C" void LLVMInitializeSparcTargetInfo() {
  RegisterTarget<Triple::sparc, /*HasJIT=*/false>
    X(getTheSparcTarget(), "sparc", "Sparc");
}

这将允许TargetRegistry通过名字或目标标识来查找目标。另外,大多数目标还会注册一些其他会在单独的库中使用的特性。这些注册步骤是独立的,因为一些工具可能只需要目标中的部分特性,比如说JIT代码生成的库就不需要汇编输出的特性,以下是一个Sparc中注册汇编输出功能的代码:

extern "C" void LLVMInitializeSparcAsmPrinter() {
  RegisterAsmPrinter<SparcAsmPrinter> X(getTheSparcTarget());
}

更多的信息请参考 “llvm/Target/TargetRegistry.h”

4 寄存器集合寄存器类别

(译注:本节及后文将原文中Register Set译为寄存器集合,将Register Class译为寄存器类别。)

你应该接下来描述表示目标机器的寄存器文件的简单类结构。这个类被称为 XXXRegisterInfo,这个类中的寄存器文件数据会被用来做寄存器分配等工作,它同事也描述了寄存器之间的关系。

你也需要定义一些寄存器类别来分类相关的寄存器。一个寄存器类别中的寄存器应该对于一些指令有着相同的行为。典型的例子是包括所有整形寄存器的类别、浮点型寄存器的类别和向量寄存器的类别。寄存器分配允许一个指令使用某个特定寄存器类别中的寄存器来完成相同的指令功能。寄存器类别会给这些指令分配虚拟寄存器,同时也会在寄存器分配阶段分配真实的寄存器。

大多数寄存器相关的代码,比如寄存器定义、别名以及类别,都是在TableGen的 XXXRegisterInfo.td文件中完成的,这个文件会生成 XXXGenRegisterInfo.h.inc 和 XXXGenRegisterInfo.inc。另外一些代码需要手动在 XXXRegisterInfo 结构中实现。

4.1 定义一个寄存器

在 XXXRegisterInfo.td文件中,习惯性先定义目标机器的寄存器。Register类(在Target.td中定义)用来为每个寄存器定义对象,参见下边代码。其中的参数 n 是指寄存器的名字。基本 Register 对象没有子寄存器,也没有特殊的别名。

class Register<string n> {
  string Namespace = "";
  string AsmName = n;
  string Name = n;
  int SpillSize = 0;
  int SpillAlignment = 0;
  list<Register> Aliases = [];
  list<Register> SubRegs = [];
  list<int> DwarfNumbers = [];
}

例如,在 X86RegisterInfo.td 文件中,使用Register类完成寄存器定义的一个例子:

def AL : Register<"AL">, DwarfRegNum<[0, 0, 0]>;

这行代码定义了寄存器 AL 并且指定了 Dwarf 中寄存器编号,这个编号会被如 gcc,gdb 或其他调试信息工具来识别寄存器。对于 AL 寄存器 来说,DwarfRegNum 使用了一个由 3 个值组成的数组,用来表示 3 种不同的模式:第一个元素是针对 X86-64,第二个元素是用于 X86-32 中的异常处理(exception handling),第三个元素是通用值。如果指定 -1 则表示 gcc 的值未定义,如果指定 -2 则表示寄存器值是非法的。

对于之前的 td 文件中的描述,TableGen 工具会在 X86RegisterInfo.inc 中生成如下的 c++ 代码:

static const unsigned GR8[] = { X86::AL, ... };
const unsigned AL_AliasSet[] = { X86::AX, X86::EAX, X86::RAX, 0 };
const TargetRegisterDesc RegisterDescriptors = {
  ...
  { "AL", "AL", AL_AliasSet, Empty_SubRegsSet, Empty_SubRegsSet, AL_SuperRegsSet },
  ...
}

TableGen 会生成针对每个寄存器的 TargetRegisterDesc 对象。这个对象在 include/llvm/Target/TargetRegisterInfo.h 中定义,它的结构如下:

struct TargetRegisterDesc {
  const char *AsmName;              // Assembly language name for the register
  const char *Name;                 // Printable name for the reg (for debugging)
  const unsigned *AliasSet;         // Register Alias Set
  const unsigned *SubRegs;          // Sub-register set
  const unsigned *ImmSubRegs;       // Immediate sub-register set
  const unsigned *SuperRegs;        // Super-register set
}

TableGen 使用 td 文件来决定寄存器名称(AsmName 和 Name 部分)和与其他寄存器的关系。在这个例子中,还定义了寄存器 AX,EAX 和 RAX 并互相作为别名,所以 TableGen 生成了一个以 null 结尾的数组(AL_AliasSet)来保存寄存器别名集合。

Register 类也会作为更加复杂的寄存器类的基类,在 Target.td 文件中,Register 类作为 RegisterWithSubRegs 类的基类,后者被用来定义需要指定特殊子寄存器的寄存器,定义如下:

class RegisterWithSubRegs<string n, list<Register> subregs> : Register<n> {
  let SubRegs = subregs;
}

在 SparcRegisterInfo.td 文件中,还有SPARC 特殊使用的寄存器类,如 SparcReg,它以 Register 作为基类,并衍生出更多子类,如 Ri,Rf 和 Rd。SPARC 寄存器由 5 个 ID 数字来识别,这个在不同的子类中是相同的,他们使用 let 表达式来覆盖在父类中初始化时定义的值。

class SparcReg<string n> : Register<n> {
  field bits<5> Num;
  let Namespace = "SP";
}
// Ri - 32-bit integer registers
class Ri<bits<5> num, string n> : SparcReg<n> {
  let Num = num;
}
// Rf - 32-bit floating-point registers
class Rf<bits<5> num, string n> : SparcReg<n> {
  let Num = num;
}
// Rd - Slots in the FP register file for 64-bit floating-point values
class Rd<bits<5> num, string n, list<Register> subregs> : SparcReg<n> {
  let Num = num;
  let SubRegs = subregs;
}

在 SparcRegisterInfo.td 文件中,使用这些子类来完成寄存器定义,比如:

def G0 : Ri< 0, "G0">, DwarfRegNum<[0]>;
def G1 : Ri< 1, "G1">, DwarfRegNum<[1]>;
...
def F0 : Rf< 0, "F0">, DwarfRegNum<[32]>;
def F1 : Rf< 1, "F1">, DwarfRegNum<[33]>;
...
def D0 : Rd< 0, "F0", [F0, F1]>, DwarfRegNum<[32]>;
def D1 : Rd< 2, "F2", [F2, F3]>, DwarfRegNum<[34]>;
...

最后两个寄存器(D0 和 D1)是双精度的浮点寄存器,他们由两个单精度浮点子寄存器组成。除别名之外,子寄存器和父寄存器的关系也存在于寄存器的 TargetRegisterDesc 字段中。

4.2 定义一个寄存器类别

(译注,原文 Register Class 想表达的是寄存器的集合,这里译作寄存器类别,去 C++中的类做区别)

寄存器类别的类 RegisterClass(在 Target.td 中定义)被用来定义一个表示一组相关寄存器的集合的对象,同时用来定义寄存器的默认分配顺序。目标描述文件 XXXRegisterInfo.td 使用 Target.td 来构造寄存器类别,该类的定义如下:

class RegisterClass<string namespace, list<ValueType> regTypes, int alignment, dag regList> {
  string Namespace = namespace;
  list<ValueType> RegTypes = regTypes;
  int size = 0;  // 位为单位的溢出长度,设为 0,由 tblgen 工具设定
  int Alignment = alignment;
  
  // CopyCost 是在两个寄存器间复制值的成本
  // 默认是 1,表示用 1 条指令完成
  // 设定为负数表示复制值非常困难或无法实现
  int CopyCost = 1;
  dag MemberList = regList;
  
  // 这个类别的子类别
  list<RegisterClass> SubRegClassList = [];
  
  code MethodProtos = [{}];  // 任意代码
  code MethodBodies = [{}];
}

定义一个 RegisterClass 的对象,需要给定 4 个参数:

  • 第一个参数是命名空间;
  • 第二个参数是一个寄存器类型值的列表,这些类型在 include/llvm/CodeGen/ValueTypes.td 中定义。已定义的类型包括整数类型(i16, i32, 用于布尔型的i1等),浮点类型(f32, f64),向量类型(比如 v8i16 表示 8 * i16 的向量)。同一个寄存器类型中的所有的寄存器必须有相同的 ValueType,但是一些寄存器可能在不同的配置下存储不同类型的向量数据。比如,一个能够存放 128 位数据的向量寄存器,既可以保存 16 个 8 位的整形元素,也可以保存 8 个 16 位的整形或 4 个 32 位的整形元素(译注:所以这个参数用列表来指定不同的可能的类型)。
  • 第三个参数是这个 RegisterClass 对象特定的对齐长度,当它们做 store 和 load 操作时,这个参数会被用到。
  • 第四个参数,指定了这个类别中有哪些寄存器。如果没有指定可选的分配顺序,则这个参数中的顺序还同时表示寄存器分配时的顺序。简单的例子如(add R0, R1, ...),更加复杂的一些例子可查看 include/llvm/Target/Target.td。

在 SparcRegisterInfo.td 文件中,定义了三个寄存器类别的类对象,分别是 FPRegs,DFPRegs,IntRegs。这三个寄存器类别对象的第一个参数(命名空间)指定为“SP”。FPRegs 定义了一组保存 32 位单精度浮点数的寄存器集合(F0 到 F31);DFPRegs 定义了一组保存 16 位双精度浮点数寄存器集合(D0-D15)。实现代码如下:

// F0, F1, F2, ..., F31
def FPRegs : RegisterClass<"SP", [f32], 32, (sequence "F%u", 0, 31)>;

def DFPRegs : RegisterClass<"SP", [f64], 64, 
                            (add D0, D1, D2, D3, D4, D5, D6, D7, D8, 
                                 D9, D10, D11, D12, D13, D14, D15)>;

def IntRegs : RegisterClass<"SP", [i32], 32, 
                            (add L0, L1, L2, L3, L4, L5, L6, L7, 
                                 I0, I1, I2, I3, I4, I5, 
                                 O0, O1, O2, O3, O4, O5, O7, 
                                 G1,
                                 // 不分配的寄存器:
                                 G2, G3, G4,
                                 O6, // 栈指针
                                 I6, // 帧指针
                                 I7, // 返回地址
                                 G0, // 常数 0
                                 G5, G6, G7 // 内核保留
                             )>;

将 SparcRegisterInfo.td 作为 TableGen 的输入,会生成多个输出文件,这些文件可以在你的代码中被调用。SparcRegisterInfo.td 首先生成 SparcGenRegisterInfo.h.inc,这个文件可以包含(included)到你的 SPARC 寄存器实现的头文件(SparcRegisterInfo.h)中。在SparcGenRegisterInfo.h.inc 文件中,定义了一个新的结构,SparcGenRegisterInfo,它使用 TargetRegisterInfo 作为基类,同样的,会根据 td 文件中的指定区分类型:DFPRegsClass,FPRegsClass 和 IntRegsClass。

另外,SparcRegisterInfo.td 还会输出 SparcGenRegisterInfo.inc,可以包含到(included)SparcRegisterInfo.cpp 最下边,后者是寄存器的实现代码文件。下边代码展示了生成的整数寄存器的内容和对应的类。IntRegs 的寄存器顺序和 td 文件中的定义时保持一致。

// 整数寄存器类别
static const unsigned IntRegs[] = {
  SP::L0, SP::L1, SP::L2, SP::L3, SP::L4, SP::L5,
  SP::L6, SP::L7, SP::I0, SP::I1, SP::I2, SP::I3,
  SP::I4, SP::I5, SP::O0, SP::O1, SP::O2, SP::O3,
  SP::O4, SP::O5, SP::O7, SP::G1, SP::G2, SP::G3,
  SP::G4, SP::O6, SP::I6, SP::I7, SP::G0, SP::G5,
  SP::G6, SP::G7,
};

// IntRegsVTs 寄存器类别类型
static const MVT::ValueType IntRegsVTs[] = {
  MVT::i32, MVT::Other
};

namespace SP {    // 寄存器类别的实例
  DFPRegsClass    DFPRegsRegClass;
  FPRegsClass     FPRegsRegClass;
  IntRegsClass    IntRegsRegClass;
  ...

  static const TargetRegisterClass* const IntRegsSubRegClasses [] = {};

  static const TargetRegisterClass* const IntRegsSuperRegClasses [] = {};
  
  ...
    
  IntRegsClass::IntRegsClass() : TargetRegisterClass(IntRegsRegClassID,
                                                     IntRegsVTs, IntRegsSubclasses,
                                                     IntRegsSuperclasses, IntRegsSubRegClasses,
                                                     IntRegsSuperRegClasses, 4, 4, 1,
                                                     IntRegs, IntRegs + 32) {}
}

寄存器分配会避免使用保留寄存器,被调用函数保存的寄存器在所有可分配寄存器都被使用完之前不会被使用。大多数情况下这都是正常的,但在一些特殊情况下,可能需要手动指定分配顺序。

4.3 实现一个 TargetRegisterInfo 的子类

寄存器的这一部分,最后一步是手动编写 XXXRegisterInfo 的代码,这一部分会实现 TargetRegisterInfo.h 中描述的接口。这些函数如果没有被重写(overridden),会返回 0,NULL 或 false。以下列出了一部分 SPARC 后端在 SparcRegisterInfo.cpp 中重写的函数接口:

  • getCalleeSavedRegs:该函数返回一个被调用函数保存寄存器的列表,预期被用于调用栈帧偏移。
  • getReservedRegs:返回物理寄存器编号的序号列表,表示那些被保留的寄存器。
  • hasFP:返回一个布尔型,表示函数具有专用栈帧寄存器。
  • eliminateCallFramePseudoInstr:如果调用帧需要设置或销毁伪指令,这个函数会清除它们。
  • eliminateFrameIndex:从使用抽象帧索引的指令中清除它们。
  • emitPrologue:插入 prologue 代码。
  • emitEpilogure:插入 epilogure 代码。

(译注:这些函数的具体功能可参见代码)

5 指令集

在代码生成的早期阶段,LLVM IR 格式代码被转换为 SelectionDAG 格式,其中的节点SDNode 包含有目标平台的指令信息。一个 SDNode 具有一个操作码,还有操作数,类型要求和属性,这些属性比如有描述这个节点是可交换的(commutative),或者描述这个节点是一个 load 操作。不同的操作节点类型在 include/llvm/CodeGen/SelectionDAGNodes.h 中描述(NodeType 类型的枚举属于 ISD 命名空间)。

TableGen 使用以下列出的 td 文件来生成指令定义的代码:

  • Target.td:这里定义了主要的基本类型,比如 Instruction, Operand, InstrInfo 等;
  • TargetSelectionDAG.td:被 SelectionDAG 指令选择生成器使用,包含有一些 SDTC 开头的类(这些类是 selectionDAG 类型约束),以及定义 SelectionDAG 节点(比如 imm, cond, bb, add, fadd, sub 等),还有 pattern 的基础类支持(比如 Pattern, Pat, PatFrag, PatLeaf, ComplexPattern 等)。
  • XXXInstrFormats.td:目标平台相关的指令 pattern 定义。
  • XXXInstrInfo.td:目标平台相关的指令模板、条件编码、指令实现等。根据具体的架构区别,这个文件会有不同的命名,比如对于带 SSE 指令的 Pentium 架构,这个文件被命名为 X86InstrSSE.td,对于带 MMX 指令的 Pentium 架构,这个文件为 X86InstrMMX.td。(译注:对于不那么复杂的架构,可以不修改名称)。

另外还有和平台相关的 XXX.td 文件,该文件包含了其他的各种 td 文件,但其内容与子目标直接相关。

你应该完成一个精确的特定平台下的 XXXInstrInfo 的类(译注:这里存疑,这个类默认应该是 TableGen 生成的),用来表示目标机器支持的指令。XXXInstrInfo 中包含有一个 XXXInstrDescriptor 的对象数组,每个对象描述一个指令。这个对象中包含有:

  • 操作编码的标记名称
  • 操作数的数量
  • 隐式使用的寄存器和定义的寄存器的列表
  • 目标无关的属性(如内存操作,是否可替换等)
  • 目标相关的标记

Instruction 类(在 Target.td 中定义)经常会被先继承为更复杂的 Instruction 子类,其定义如下:

class Instruction {
  string Namespace = "";
  dag OutOperandList;    // 包含有 MI def 操作数列表的 dag 结构
  dag InOperandList;     // 包含有 MI use 操作数列表的 dag 结构
  string AsmString = ""; // 汇编文件中的指令表示
  list<dag> Pattern;     // 这条指令的 dag patter
  list<Register> Uses = [];
  list<Register> Defs = [];
  list<Predicate> Predicates = [];  // 指令选择中的谓词部分
  ...
}

SDNode中包含有平台相关的指令的描述对象,这些指令的定义在 XXXInstrInfo.td 中定义。硬件架构手册中有关于指令对象描述信息的说明(比如对于 SPARC 平台的是 SPARC Architecture Manual)。

架构手册中一条简单的指令,可能会依赖于操作数的差异,被扩展为多条指令。比如,手册中描述了一条 add 指令,因为 add 指令可能的操作数是寄存器或者立即数,所以在 LLVM 后端平台中会有两个指令,分别是 ADDri 和 ADDrr。

你应该为没个指令类别定义 class,然后为每个不同的操作码定义子类,同时指定合适的参数(比如固定的编码部分和可变的部分)。另外还需要指定指令中寄存器占用的位是哪些,这些也会被编码,还有指令在输出汇编格式时如何被打印。

在 SPARC Architecture Manual, Version 8 中,描述了架构主要有三种 32 位格式的指令,第一种格式是 CALL 指令,第二种格式是分支、条件指令以及 SETHI 指令,第三种格式是其他普通指令。

每一类指令格式都有一个对应的类,在 SparcInstrFormat.td 中定义。InstSP 是其他指令类的基类。其他的基类都是某种特殊格式下的结构:比如 F2_1 被用于 SETHI 指令,F2_2 被用于分支指令。另外还有三个基类:F3_1 被用于寄存器与寄存器的操作,F3_2 被用于寄存器与立即数的操作,F3_3 被用于浮点操作。SparcInstrInfo.td 中同样为合成指令(synthetic instructions)增加了基类(Pseudo)。

SParcInstrInfo.td 中主要由这些指令和操作数的定义组成。举一个例子,下边代码中,描述了 LDrr 这个指令,它是一个从通过寄存器指定访问的内存中 load 一个 32 位数据到寄存器的指令。

def LDrr : F3_1 <3, 0b000000, (outs IntRegs:$dst), (ins MEMrr:$addr),
                 "ld [$addr], $dst",
                 [(set i32:$dst, (load ADDRrr:$addr))]>;

第一个参数,3(0b11),表示这个指令所在分类的操作码;第二个指令,0b000000,表示这个指令特殊的操作码。第三个参数,(outs IntRegs:$dst),是输出位置,在这里是一个寄存器操作数,IntRegs 的定义在寄存器的 td 中;第四个参数,(ins MEMrr:$addr),是一个地址操作数,MEMrr 在 SparcInstrInfo.td 中靠前的位置定义:

def MEMrr : Operand<i32> {
  let PrintMethod = "printMemOperand";
  let MIOperandInfo = (ops IntRegs, IntRegs);
}

第五个参数是一个字符串,它表示汇编输出的样式,也可以暂时留空,让汇编输出器(addembly printer)接口来实现。第六个参数,也是最后一个参数,是一个 pattern,这个参数用来在 SelectionDAG 指令选择阶段做指令匹配。参考:The LLVM Target-Independent Code Generator,这个参数在下一部分指令选择时再介绍。

指令类不会根据不同类型的操作数来重载,所以需要根据操作数为不同的寄存器、内存或立即数类型来分别定义指令类。比如,再针对从立即数指定访问的内存中 load 一个 32 位数据到寄存器的指令,LDri ,定义如下:

def LDri : F3_2 <3, 0b000000, (outs IntRegs:$dst), (ins MEMri:$addr),
                 "ld [$addr], $dst",
                 [(set i32:$dst, (load ADDRri:$addr))]>;

但是,如果反复的写这些相似的指令,会有大量重复的冗余代码。在 td 文件中,可以通过 multiclass 关键字来同时一次性定义多个指令类(再通过 defm 来同时定义这些指令类的指令)。比如,在 SparcInstrInfo.td 中,F3_12 是个 multi

  • 5
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值