如何自主开发一个LLVM后端

发布时间 2023-04-15 05:06:10作者: 吴建明wujianming

如何自主开发一个LLVM后端

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

参考文献链接

https://blog.csdn.net/SiberiaBear/article/details/106869378

https://zhuanlan.zhihu.com/p/149599651

https://releases.llvm.org/10.0.0/docs/WritingAnLLVMBackend.html#instruction-scheduling

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++代码,用于代码生成。
这篇文章我做了翻译,也融入了自己的一些认识,可做参考:

P2Tree:有关于TableGen的简单介绍

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个数字,则第一个数字是指针的占用空间大小,第二个数字同时表示两种对齐情况。
然后后边的一个字符,可能是f,i,v,a等,分别表示浮点数、整数、向量和整体,然后后边的数据格式意义与指针类型是基本一致的。
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 是个 multiclass,它内部定义了两个指令类:

multiclass F3_12 <string OpcStr, bits<6> Op3Val, SDNode OpNode> {
def rr : F3_1 <2, Op3Val,
(outs IntRegs:$dst), (ins IntRegs:$b, IntRegs:$c),
!strconcat(OpcStr, " $b, $c, $dst"),
[(set i32:$dst, (OpNode i32:$b, i32:$c))]>;
def ri : F3_2 <2, Op3Val,
(outs IntRegs:$dst), (ins IntRegs:$b, i32imm:$c),
!strconcat(OpcStr, " $b, $c, $dst"),
[(set i32:$dst, (OpNode i32:$b, simm13:$c))]>;
}
这样,我们就可以使用 defm 关键字来同时定义多个指令类,比如 XOR 和 ADD 指令,比如下边代码,定义了 4 条指令,分别是 XORrr, XORri, ADDrr, ADDri:

defm XOR : F3_12<"xor", 0b000011, xor>;
defm ADD : F3_12<"add", 0b000000, add>;
SparcInstrInfo.td 文件同样定义了条件码(condition codes),条件码会在分支指令中作为跳转依据。以下代码定义了 SPARC 中使用的条件码位信息,比如,第 10 位表示整形数大于比较状态,第 22 位表示浮点数大于比较状态:

def ICC_NE : ICC_VAL< 9>; // 整形不等于
def ICC_E : ICC_VAL< 1>; // 整形等于
def ICC_G : ICC_VAL<10>; // 整形大于
...
def FCC_U : FCC_VAL<23>; // 浮点型未排序
def FCC_G : FCC_VAL<22>; // 浮点型大于
def FCC_UG : FCC_VAL<21>; // 浮点型未排序或大于
注:Sparc.h 中也定义了一些条件码相关的枚举类型,要确保其与这里的类型保持一致,比如 SPCC::ICC_NE = 9, SPCC::FCC_U = 23。

5.1 指令操作数映射
代码生成器后端会映射指令操作数到指令的编码域(field)中。操作数按照定义的顺序分配到指令中未绑定的编码域,而当他们被分配值时会被绑定。比如说,在 Sparc 后端中,定义 XNORrr 指令,它具有 3 个操作数:

def XNORrr : F3_1<2, 0b000111,
(outs IntRegs:$dst), (ins IntRegs:$b, IntRegs:$c),
"xnor $b, $c, $dst",
[(set i32:$dst, (not (xor i32:$b, i32:$c)))]>;
SparcInstrFormats.td 中展示了 F3_1的基类 InstSP:

class InstSP<dag outs, dag ins, string asmstr, list<dag> pattern>
: Instruction {
field bits<32> Inst;
let Namespace = "SP";
bits<2> op;
let Inst{31-30} = op;
dag OutOperandList = outs;
dag InOperandList = ins;
let AsmString = asmstr;
let Pattern = pattern;
}
InstSP 中的 op 字段没有绑定。

class F3<dag outs, dag ins, string asmstr, list<dag> pattern>
: InstSP<outs, ins, asmstr, pattern> {
bits<5> rd;
bits<6> op3;
bits<5> rs1;
let op{1} = 1;
let Inst{29-25} = rd; // 目的操作数
let Inst{24-19} = op3;
let Inst{18-14} = rs1; // 第一个源操作数
}
F3 中对 op 域做了绑定,并且定义了 rd, op3 和 rs1 域。F3 类型格式的指令会绑定 rd, op3 和 rs1。

class F3_1<bits<2> opVal, bits<6> op3val, dag outs, dag ins,
string asmstr, list<dag> pattern>
: F3<outs, ins, asmstr, pattern> {
bits<8> asi = 0;
bits<5> rs2;
let op = opVal;
let op3 = op3val;
let Inst{13} = 0;
let Inst{12-5} = asi;
let Inst{4-0} = rs2; // 第二个源操作数
}
F3_1中绑定了 op3,并定义了 rs2 域。F3_1类型格式的指令会绑定 rd, rs1, rs2。也就是前边 XNORrr 定义时绑定的$dst,$b,和$c操作数,分别对应 rd, rs1, rs2。

5.1.1 指令操作数命名映射
TableGen 还会生成一个 getNamedOperandIdx() 的函数,这个函数用来在 MachineInstr 中,以操作数的 TableGen 名字作为输入,查找对应序号。在一个指令的 TableGen 定义中设置 UseNamedOperandTable 位,会将其操作数增加在一个位于 llvm::XXX:OpName 命名空间中的枚举中,另外还会在 OperandMap 表中增加一个针对操作数的入口,从而可以被 getNamedOperandIdx() 引用。

int DstIndex = SP::getNamedOperandIdx(SP::XNORrr, SP::OpName::dst); // => 0
int BIndex = SP::getNamedOperandIdx(SP::XNORrr, SP::OpName::b); // => 1
int CIndex = SP::getNamedOperandIdx(SP::XNORrr, SP::OpName::c); // => 2
int DIndex = SP::getNamedOperandIdx(SP::XNORrr, SP::OpName::d); // => -1
...
在 OpName 枚举中的入口根据 TableGen 中的定义依次设定,所以小写的操作数就会有小写的入口名。

为了将这个 getNamedOperandIdx() 函数增加到你的后端中使用,你需要在 XXXInstrInfo.cpp 和 XXXInstrInfo.h 中定义一些预处理宏:

XXXInstrInfo.cpp:

#define GET_INSTRINFO_NAMED_OPS
#include "XXXGenInstrInfo.inc"
XXXInstrInfo.h:

#define GET_INSTRINFO_OPERAND_ENUM
#include "XXXGenInstrInfo.inc"

namespace XXX {
int16_t getNamedOperandIdx(uint16_t Opcode, uint16_t NamedIndex);
}
5.1.2 指令操作数类型
TableGen 还会生成一个枚举结构,其中包括了所有后端定义的已命名操作数类型,所在的命名空间是 llvm::XXX::OpTypes。一些通用的立即数类型(比如 i8, i32, i64, f32, f64)被定义在统一的 include/llvm/Target/Target.td 文件中,对每个后端的OpTypes 枚举都可用。同时,枚举中仅仅包含已命名的操作数类型,匿名类型会被忽略。比如,X86 后端中定义了 brtarget 和 brtarget8,这两个操作数类型是 Operand 类的实例化对象:

def brtarget : Operand<OtherVT>;
def brtarget8 : Operand<OtherVT>;
那么,在枚举结构中是:

namespace X86 {
namespace OpTypes {
enum OperandType {
...
brtarget,
brtarget8,
...
i32imm,
i64imm,
...
OPERAND_TYPE_LIST_END
}
}
}
为了能使用这个枚举结构,你需要定义一个预处理宏:

#define GET_INSTRINFO_OPERAND_TYPES_ENUM
#include "XXXGenInstrInfo.inc"
5.2 指令调度
可以通过 MCDesc::getSchedClass() 方法来查看指令行程(itinerary)。对应的值在 TableGen 生成的 XXXGenInstrInfo.inc 中定义为枚举结构,所在的命名空间是 llvm::XXX::Sched。调度类的名字等同于 XXXSchedule.td 中的定义。

调度模型由 TableGen 中的 SubtargetEmitter 使用 CodeGenSchedModels 类来生成。这与机器平台资源使用的行程方法不同。utils/schedcover.py 这个工具可以用来决定哪些指令可以被调度模型所涉及(covered)。第一步是使用以下指令来生成输出文件,然后调用 schedcover.py 来处理输出文件:

$ <src>/utils/schedcover.py <build>/lib/Target/AArch64/tblGenSubtarget.with
instruction, default, CortexA53Model, CortexA57Model, CycloneModel, ExynosM3Model, FalkorModel, KryoModel, ThunderX2T99Model, ThunderXT8XModel
ABSv16i8, WriteV, , , CyWriteV3, M3WriteNMISC1, FalkorWr_2VXVY_2cyc, KryoWrite_2cyc_XY_XY_150ln, ,
ABSv1i64, WriteV, , , CyWriteV3, M3WriteNMISC1, FalkorWr_1VXVY_2cyc, KryoWrite_2cyc_XY_noRSV_67ln, ,
...
为了获取生成调度模型的调试输出信息,使用以下命令(指定 target 路径并设定 subtarget-emitter debug 选项):

$ <build>/bin/llvm-tblgen -debug-only=subtarget-emitter -gen-subtarget \
-I <src>/lib/Target/<target> -I <src>/include \
-I <src>/lib/Target/<src>/lib/Target/<target>/<target>.td \
-o <build>/lib/Target/<target>/<target>GenSubtargetInfo.inc.tmp \
> tblGenSubtarget.dbg 2>&1
其中 build 是构建目录, src 是源码目录, target 是目标名称。可以在构建中捕获 TableGen 命令,通过在以下命令的输出中搜索 llvm-tblgen 的输出内容,从而再次检查以上命令:

$ VERBOSE=1 make ...
5.3 指令相关映射
这个 TableGen 特性被用于相关的指令间。当你有多个指令格式需要在指令选择之后互相转换,那么可以使用这个特性。这个特性受 XXXInstrInfo.td 文件中目标相关指令的相关模型(relation models)的驱动。相关模型使用 InstrMapping 类作为基类。TableGen 解析所有的相关模型,并使用确定的信息生成指令的相关映射。相关映射和能够调用它们的函数一起被发射到 XXXGenInstrInfo.td 文件中。更多关于这个特性的信息,请参考:How To Use Instruction Mappings

5.4 实现一个 TargetInstrInfo 的子类
最后一步是在 XXXInstrInfo 中硬编码一些代码,来实现 TargetInstrInfo.h 中的接口描述。这些函数除非被重写,否则均返回 0 或布尔类型,又或者直接 assert。以下是被 SPARC 后端重写实现的函数的列表,定义在 SparcInstrInfo.cpp 中:

isLoadFromStackSlot:如果某个指令是从栈槽中直接 load,则返回目的寄存器的值和栈槽的栈帧下标。
isStoreToStackSlot:如果某个指令是直接 store 入栈槽,则返回目的寄存器的值和栈槽的栈帧下标。
copyPhysReg:在一对物理寄存器之间复制值。
storeRegToStackSlot:将一个寄存器值 store 入栈槽。
loadRegFromStackSlot:从栈槽中 load 值到一个寄存器。
storeRegToAddr:将一个寄存器值 store 入内存。
loadRegFromAddr:从内存中 load 值到一个寄存器。
foldMemoryOperand:尝试合并特殊操作数的一些 load 和 store 指令。
5.5 分支折叠和 If 约定
将指令合并或者消除不可达指令可以提高程序性能。在 XXXInstrInfo 中的 AnalyzeBranch 方法可以实现测试分支条件指令,并移除无效指令。它从一个机器基本块(MBB, machine basic block)的末尾开始查找可能的性能提升方式,比如分支折叠和 If 约定。BranchFolder 和 IfConverter 方法(位于 lib/CodeGen 路径下的 BranchFolding.cpp 和 IfConversion.cpp 文件中)调用 AnalyzeBranch 方法来优化表示这些指令的控制流图结构。

可以使用 AnalyzeBranch 的多种实现(ARM, Alpha, X86)来作为你自己的 AnalyzeBranch 实现。SPARC 没有实现一个能用的 AnalyzeBranch,所以使用 ARM 的实现,描述如下。

AnalyzeBranch 返回一个布尔类型值,并有 4 个参数:

MachineBasicBlock &MBB:输入要检查的 MBB 块;
MachineBasicBlock *&TBB:将会返回的 MBB 块,对于一个条件分支,TBB 返回为真的块;
MachineBasicBlock *&FBB:和 TBB 类似,FBB 返回为假的块;
std::vector &Cond:评估条件分支中条件的操作数列表;
在以下简单的例子中,如果一个块到结束时没有分支,它就会传递到下一个块,TBB 和 FBB 不会被特殊指定,均返回 NULL。AnalyzeBranch(ARM 版本)的开头如下:

bool ARMInstrInfo::AnalyzeBranch(MachineBasicBlock &MBB,
MachineBasicBlock *&TBB,
MachineBasicBlock *&FBB,
std::vector<MachineOperand> &Cond) const
{
MachineBasicBlock::iterator I = MBB.end();
if (I == MBB.begin() || !isUnpredicatedTerminator(--I))
return false;
如果一个块的结尾是一个非条件分支指令,那么 AnalyzeBranch 会返回这个分支跳转的块到 TBB 中:

if (LastOpc == ARM::B || LastOpc == ARM::tB) {
TBB = LastInst->getOperand(0).getMBB();
return false;
}
如果一个块的结尾有两个非条件分支指令,那么第二个分支就是不可达的。这种情况下,会移除第二个分支指令,并将跳转的块返回到 TBB 中:

if ((SecondLastOpc == ARM::B || SecondLastOpc == ARM::tB) &&
(LastOpc == ARM::B || LastOpc == ARM::tB)) {
TBB = SecondLastInst->getOperand(0).getMBB();
I = LastInst;
I->eraseFromParent();
return false;
}
如果一个块的结尾有一条条件分支指令且条件值为 false,则会被传递到下一个后继块。AnalyzeBranch 将分支跳转的块范湖到 TBB,并且将操作数返回到 Cond。(译注:这里有点怪怪的,没太看懂)

if (LastOpc == ARM::Bcc || LastOpc == ARM::tBcc) {
TBB = LastInst->getOperand(0).getMBB();
Cond.push_back(LastInst->getOperand(1));
Cond.push_back(LastInst->getOperand(2));
return false;
}
如果一个块的结尾包含一个条件分支和一个确定的非条件分支指令,AnalyzeBranch 会将条件分支条件为 true 的跳转块放到 TBB 中返回,将非条件分支跳转的块(也就是条件分支为 false 时会跳转的块)放到 FBB 中返回。条件分支的操作数放到 Cond 参数返回。

unsigned SecondLastOpc = SecondLastInst->getOpcode();

if ((SecondLastOpc == ARM::Bcc && LastOpc == ARM::B) ||
(SecondLastOpc == ARM::tBcc && LastOpc == ARM::tB)) {
TBB = SecondLastInst->getOperand(0).getMBB();
Cond.push_back(SecondLastInst->getOperand(1));
Cond.push_back(SecondLastInst->getOperand(2));
FBB = LastInst->getOperand(0).getMBB();
return false;
}
对于最后两种情况(结束是一条条件分支或一条条件分支和一条非条件分支),Cond 返回的操作数可以传递给其他指令的方法,来创建新的分支或其他操作。AnalyzeBranch 需要辅助函数 RemoveBranch 和 InstrBranch 来处理一些操作。

AnalyzeBranch 中,当处理正确时,返回 false(译注:LLVM 中很多函数都是这样的),仅仅当遇到无法处理的情况时,比如块结束时有 3 个终止符或遇到无法处理的终止符时 ,会返回 true。

6 指令选择
LLVM 使用 SelectionDAG 来表示 LLVM IR 指令,故而 SelectionDAG 的节点应当用来表示原生的目标指令。在代码生成时,指令选择阶段将非目标相关的 DAG 节点描述的指令转换为目标相关的原生指令。这一部分代码在 XXXISelDAGToDAG.cpp 中描述,通过模式匹配来完成从 DAG 到 DAG 的指令选择工作。另外可选的是,可以定义个阶段(pass)来实现分支指令的类似 DAG 到 DAG 的指令选择工作(在 XXXBranchSelector.cpp 中描述)。之后,由 XXXISelLowering.cpp 中的代码来替换或删除不支持的操作和数据类型(也就是合法化)。

TableGen 为指令选择生成的代码主要在以下的 td 文件中描述:

XXXInstrInfo.td:包括目标相关指令的定义,会被生成 XXXGenDAGISel.inc,而这个 inc 被 XXXISELDAGToDAG.cpp 使用;
XXXCallingConv.td:包括目标架构支持的调用和返回值约定描述,会被生成 XXXGenCallingConv.inc,这个 inc 被 XXXISelLowering.cpp 使用;
(译注:以上列表中的描述实际上不是必然的,你可以在任意 C++ 源代码中引用这些 inc 文件)

指令选择阶段的实现必须包括一个头文件,声明 FunctionPass 类或者其子类。在 XXXTargetMachine.cpp 中,需要使用 PassManager 将当前的指令选择加入当阶段序列(queue of passes)中。

LLVM 的静态编译器(llc)是一个可视化 DAG 内容非常棒的工具。使用 llc 加上特殊的命令行参数,就可以显示在不同阶段 SelectionDAG 的状态。具体描述参考:SelectionDAG Instruction Selection Process。

为了描述指令选择器的行为,你需要为 lowering LLVM 代码 到 SelectionDAG 而指定 pattern。pattern 放在 XXXInstrInfo.td 文件中指令定义的最后一个参数中(译注:不是绝对的,参见具体后端)。比如,在 SparcInstrInfo.td 中,定义寄存器 store 操作的指令,最后一个参数描述了 pattern。

def STrr : F3_1<3, 0b000100, (outs), (ins MEMrr:$addr, IntRegs:$src),
"st $src, [$addr]", [(store i32:$src, ADDRrr:$addr)]>;
其中 ADDRrr 是一个 memory mode,也在这个文件中定义:

def ADDRrr : ComplexPattern<i32, 2, "SelectADDRrr", [], []>;
ADDRrr 的定义依赖自 SelectADDRrr,后者是一个定义在指令选择器中的函数(比如定义在 SparcISelDAGToDAG.cpp 中)。

在 lib/Target/TargetSelectionDAG.td 文件(注,最新的工程中,这个文件位于 include/llvm/Target/TargetSelectionDAG.td,包括下边的定义,也已更新,这里不做修改)中,DAG 的操作符 store 定义如下:

def store : PatFrag<(ops node:$val, node:$ptr),
(st node:$val, node:$ptr), [{
if (StoreSDNode *ST = dyn_cast<StoreSDNode>(N))
return !ST->isTruncatingStore() &&
ST->getAddressingMode() == ISD::UNINDEXED;
return false;
}]>;
XXXInstrInfo.td 也生成 SelectCode 方法 (位于 XXXGenDAGISel.inc),这个方法被用来在为指令匹配合适的处理方法时被调用。在上边的例子中,SelectionCode 调用 Select_ISD_STORE 来处理 ISD::STORE 操作码:

SDNode *SelectCode(SDValue N) {
...
MVT::ValueType NVT = N.getNode()->getValueType(0);
switch (N.getOpcode()) {
case ISD::STORE: {
switch (NVT) {
default:
return Select_ISD_STORE(N);
break;
}
break;
}
...
}
...
}
STrr 的 pattern 被匹配后,会在 XXXGenDAGISel.inc 中为 Select_ISD_STORE 创建处理 STrr 的代码,该文件内也会生成 Emit_22 方法,来配合完成选择过程:

SDNode *Select_ISD_STORE(const SDValue &N) {
SDValue Chain = N.getOperand(0);
if (Predicate_store(N.getNode())) {
SDValue N1 = N.getOperand(1);
SDValue N2 = N.getOperand(2);
SDValue CPTmp0;
SDValue CPTmp1;

// Pattern: (st:void i32:i32:$src,
// ADDRrr:i32:$addr)<<P:Predicate_store>>
// Emits: (STrr:void ADDRrr:i32:$addr, IntRegs:i32:$src)
// Pattern complexity = 13 cost = 1 size = 0
if (SelectADDRrr(N, N2, CPTmp0, CPTmp1) &&
N1.getNode()->getValueType(0) == MVT::i32 &&
N2.getNode()->getValueType(0) == MVT::i32) {
return Emit_22(N, SP::STrr, CPTmp0, CPTmp1);
}
...
6.1 SelectionDAG合法化阶段
合法化阶段用于将DAG转换为使用目标本身支持的类型和操作。你需要在 XXXTargetLowering 中增加实现代码,从而将原生不支持的类型和操作转换为支持的对应类型和操作。

XXXTargetLowering 类的构造函数中,首先使用了 addRegisterClass 方法来指定哪些类型是支持的、哪些寄存器类与之相配合。寄存器类代码的设计是 TableGen 使用 XXXRegisterInfo.td 文件生成的,生成文件是 XXXGenRegisterInfo.h.inc。比如,SparcTargetLowering 类的构造函数开头代码为:

addRegisterClass(MVT::i32, SP::IntRegsRegisterClass);
addRegisterClass(MVT::f32, SP::FPRegsRegisterClass);
addRegisterClass(MVT::f64, SP::DFPRegsRegisterClass);
你可以到 ISD 命名空间中检查节点类型(位于 include/llvm/CodeGen/SelectionDAGNodes.h),并判断目标原生支持的类型有哪些。对于原生不支持的操作,需要在 XXXTargetLowering 类中的构造函数中增加回调函数,进而指令选择阶段知道根据这种情况来特殊处理。TargetLowering 类的回调函数有如下一些:(在 llvm/Target/TargetLowering.h 中声明,译注:这个位置也发生了变更)

setOperationAction:通用操作
setLoadExtAction:Load 扩展操作
setTruncStoreAction:截断 Store 操作
setIndexedLoadAction:序列 Load (Indexed Load)
setIndexedStoreAction:序列 Store (Indexed Store)
setConvertAction:类型转换操作
setCondCodeAction:条件码相关
注:旧一些的版本中,使用 setLoadXAction 代替 setLoadExtAction,并且 setCondCodeAction 可能不支持。请自己检查自己的 LLVM 版本是否支持这些操作。

这些回调函数用来决定一个操作在特定类型下如何做,所有的 case 中,第三个参数是一个 LegalAction 类型,这是一个枚举,包括枚举值:Promote, Expand, Custom, Legal(译注:还有个 LibCall)。在SparcISelLowering.cpp 中包含了全部的情况。

6.1.1 Promote
可以将一个原生不支持的类型 Promote 到一个更大的但支持的类型。比如,SPARC 不支持符号扩展的布尔型(i1 type),所以在 SparcISelLowering.cpp 中,以下代码,指定在 load 之前,使用 Promote 将 i1 type 转为更大的类型。

setLoadExtAction(ISD::SEXTLOAD, MVT::i1, Promote);
6.1.2 Expand
可以将一个原生不支持的类型 Expand,而不是 Promote,使用其他操作的组合来实现功能。比如,SPARC 中浮点的正弦和余弦运算可以通过展开成多个指令完成,代码如下:

setOperationAction(ISD::FSIN, MVT::f32, Expand);
setOperationAction(ISD::FCOS, MVT::f32, Expand);
6.1.3 Custom
对于一些操作,简单的类型 Promote 或类型 Expand 可能不适用。这是,就需要实现一些特殊的 intrinsic 函数。

比如,如果一个常数需要做特殊处理,或者一个操作需要 spill 和 restore 寄存器到栈,需要寄存器分配器协助。

在 SparcISelLowering.cpp 中,如下代码,展示了一个从浮点到有符号整型的类型转换,首先调用setOperationAction 并指定 Custom 值:

setOperationAction(ISD::FP_TO_SINT, MVT::i32, Custom);
然后,在LowerOperation 方法中,对于每个 Custom操作,对应一个条件 case。代码如下,会调用到 LowerFP_TO_SINT 函数:

SDValue SparcTargetLowering::LowerOperation(SDValue Op, SelectionDAG &DAG) {
switch(Op.getOpcode()) {
case ISD::FP_TO_SINT: return LowerFP_TO_SINT(Op, DAG);
...
}
}
最后,这个 LowerFP_TO_SINT 需要被实现,实现代码大致如下:

static SDValue LowerFP_TO_SINT(SDValue Op, SelectionDAG &DAG) {
assert(Op.getValueType() == MVT::i32);
Op = DAG.getNode(SPISD::FTOI, MVT::f32, Op.getOperand(0));
return DAG.getNode(ISD::BITCAST, MVT::i32, Op);
}
6.1.4 Legal
Legal 这个枚举值只是表示一种操作是原生支持的,是默认的情况,所以其很少被使用。在 SparcISelLowering.cpp 中,CTPOP 这个操作(计算一个整型值中位为1的数量)只在 SPARC V9 中原生支持。接下来的代码表示在非 V9 的其他 SPARC 平台上通过 Expand 来实现这个操作:

setOperationAction(ISD::CTPOP, MVT::i32, Expand);
...
if (TM.getSubtarget<SparcSubtarget>().isV9())
setOperationAction(ISD::CTPOP, MVT::i32, Legal); // 如果是 V9 那么就原生支持
6.2 调用约定
为了能够支持目标相关的调用约定,XXXCallingConv.td (译注,原文为XXXGenCallingConv.td应有误,已修正)文件中调用了一些在 lib/Target/TargetCallingConv.td (更新位于 include/llvm/CodeGen 中)文件内定义的接口,比如 CCIfType 和 CCAssignToReg。TableGen 使用 XXXCallingConv.td 来生成 XXXGenCallingConv.inc 文件,这个文件被 XXXISelLowering.cpp 文件所包含与使用。在 TargetCallingConv.td 中有一些接口功能有:

参数分配的顺序
参数和返回值的存放位置(栈或寄存器)
哪些寄存器用来分配
调用方还是被调用方展开栈。
以下的例子展示了使用 CCIfType 和 CCAssignToReg 接口。如果 CCIfType 值为 true (这表示当前的参数是 f32 或 f64),进而会触发动作。当前的处理情况是使用 CCAssignToReg 将参数的值分配给靠前的未分配的寄存器,比如 R0 或 R1。

CCIfType<f32, f64], CCAssignToReg<[R0, R1]>>
在 SparcCallingConv.td 文件中包含有目标相关的返回值调用约定的描述(RetCC_Sparc32)以及一个通用的C标准调用约定的描述(CC_Sparc32)。前者的定义如下,它指示了哪些寄存器被用作特殊的标量返回类型。包括单精度浮点型将返回值到寄存器 F0,双精度浮点型会返回值到寄存器 D0,而32位整型会返回到寄存器 I0 或 I1。

def RetCC_Sparc32 : CallingConv<[
CCIfType<[i32], CCAssignToReg<[I0, I1]>>,
CCIfType<[f32], CCAssignToReg<[F0]>>,
CCIfType<[f64], CCAssignToReg<[D0]>>
]>;
而 CCIfCC 这个接口尝试将给定的名称与当前调用约定做匹配,如果名称匹配当前的调用约定,这调用指定的操作。下边是 X86的例子 (位于 X86CallingConv.td ),如果使用了 Fast 调用约定,则 RetCC_X86_32_Fast 将会被调用。 如果使用 SSECall 调用约定,则 RetCC_X86_32_SSE 将会被调用。

def RetCC_X86_32 : CallingConv<[
CCIfCC<"CallingConv::Fast", CCDelegateTo<RetCC_X86_32_Fast>>,
CCIfCC<"CallingConv::X86_SSECall", CCDelegateTo<RetCC_X86_32_SSE>>,
CCDelegateTo<RetCC_X86_32_C>
]>;
其他的一些类似的接口还有:

CCIf :如果匹配 predicate,执行动作。
CCIfInReg < action>:如果参数被标记为 inreg 属性,执行动作。
CCIfNest < action>:如果参数被标记为 nest 属性,执行动作。
CCIfNotVarArg < action>:如果当前函数没有可变长参数表,执行动作。
CCAssignToRegWithShadow :和 CCAssignToReg 类似,但是还包括一个寄存器的 shadow 列表。
CCPassByVal :以指定最小的 size 和 align 向栈槽赋值。
CallingConv <[action]>:为每一个支持的调用约定做定义。
(译注:以上这些接口我都没遇到过,所以不好做解释)

7 汇编输出
在 code emission 阶段,代码生成器可能需要使用一个 LLVM 的 pass 来处理汇编输出工作(汇编的输出是可选的)。你需要实现 assemble printer 的代码,将 LLVM IR 转换成 GAS 的汇编格式,主要步骤有:

定义你的目标平台支持的所有汇编语法字符串,将他们与 XXXInstrInfo.td 中的指令定义相对应。TableGen 将会为你生成这些映射 (XXXGenAsmWriter.inc 文件),实现了一个 位于 XXXAsmPrinter 类的 printInstruction 成员函数。
编写 XXXTargetAsmInfo.h 文件,包含了 XXXTargetAsmInfo 类的最基本声明信息,这个类是 TargetAsmInfo 的子类。
编写 XXXTargetAsmInfo.cpp 文件,包含了目标相关的 TargetAsmInfo 的属性信息和一些方法的实现。
编写 XXXAsmPrinter.cpp 文件,实现 AsmPrinter 类,完成转换的主要代码。
在 XXXTargetAsmInfo.h 文件对 XXXTargetAsmInfo.cpp 通常不怎么重要,并且其实 XXXTargetAsmInfo.cpp 中也只有一小部分声明来覆盖父类 TargetAsmInfo.cpp 中的一些属性,比如在 SparcTargetAsmInfo.cpp 中:

SparcTargetAsmInfo::SparcTargetAsmInfo(const SparcTargetMachine &TM) {
Data16bitsDirective = "\t.half\t";
Data32bitsDirective = "\t.word\t";
Data64bitsDirective = 0; // .xword is only supported by v9.
ZeroDirective = "\t.skip\t";
CommentString = "!";
ConstantPoolSection = "\t.section \".rodata\".#alloc\n";
}
在 X86 的汇编输出实现(X86TargetAsmInfo)中,覆盖了默认的 ExpandInlineAsm 方法。

目标相关的 AsmPrinter 的实现位于 XXXAsmPrinter.cpp 中,定义了 AsmPrinter 类来完成转换工作。必须包含下列头文件:

#include "llvm/CodeGen/AsmPrinter.h"
#include "llvm/COdeGen/MachineFunctionPass.h"
MachineFunctionPass 是 FunctionPass 的子类,AsmPrinter 被注册为一个 MachineFunctionPass,它会首先调用 doInitialization 来设置 AsmPrinter。在 SparcAsmPrinter 中,一个 Mangler 对象被实例化来处理变量名。

在 XXXAsmPrinter.cpp 中,必须实现 runOnMachineFunction 方法(在 MachineFunctionPass 中声明),在MachineFunctionPass 方法中,runOnFunction 方法调用了 runOnMachineFunction。目标相关的信息主要在 runOnMachineFunction 中有差别,通常会调用这些函数:

调用 SetupMachineFunction 来初始化。
调用 EmitConstantPool 来输出 spill 到内存的常量。
调用 EmitJumpTableInfo 来输出当前函数的跳转表。
输出当前函数的标签(Label)。
输出当前函数的代码内容,包括基本块的标签和里边的汇编指令(使用 printInstruction 实现)。
在 XXXAsmPrinter 中还需要使用 XXXGenAsmWriter.inc 中的函数,后者中包含了 printInstruction 的实现,该函数会调用以下函数:

printOperand
printMemOperand
printCCOperand
printDataDirective
printDeclare
printImplilcitDef
printInlineAsm
这些函数的实现在 AsmPrinter.cpp 中,通常来说都是可以直接适用的,不需要子类 XXXAsmPrinter 覆盖。

printOperand 方法中有个非常长的 switch/case 结构来匹配不同类型的操作数:寄存器、立即数、基本块、外部符号、全局地址、常量池索引、跳转表索引。而对于包含内存操作数的指令,会使用 printMemOperand 方法来生成合适的汇编输出形式。同理,像 printCCOperand 函数会输出合理的条件码操作数。

doFinalization 函数会在 XXXAsmPrinter 中覆盖定义,将会在 assembly printer 完成工作后被调用。在 doFinalization 内,全局符号和常量将会被输出。

8 子目标平台支持
Subtarget(以下翻译成 子目标平台)被用于在对某种给定的芯片型号所对应的指令集进行代码生成使用。比如,LLVM 的 SPARC 实现中包括有 3 种主要的 SPARC 微处理器架构版本:Version 8 (V8,32 位架构),Version 9 (V9,64 位架构)以及 UltraSPARC 架构。V8 有 16 位双精度浮点寄存器,也可以被用作 32 位的单精度浮点或 8 位的四精度浮点寄存器。V8 是大端架构。V9 有 32 位双精度浮点寄存器,也可以被用作 16 位的四精度浮点寄存器,但是不能被用作单精度浮点寄存器。UltraSPARC 架构在 V9 的指令集上做了扩展。

如果需要子目标平台,你应该实现一个目标相关的 XXXSubtarget 的类,通过命令行参数 -mcpu= 和 -mattr= 可以访问到这个类的功能。

TableGen 使用 Target.td 和 Sparc.td 中的内容来生成 SparcGenSubtarget.inc 文件。在 Target.td 中,定义了 SubtargetFeature 接口,如下边代码。前 4 个 string 参数是特征名称、属性集、属性的值和特性的描述,第五个参数是一个隐含属性的列表,默认是空。

class SubtargetFeature<string n, string a, string v, string d,
list<SubtargetFeature> i = []> {
string Name = n;
string Attribute = a;
string Value = v;
string Desc = d;
list<SubtargetFeature> Implies = i;
}
在 Sparc.td 文件中,使用 SubtargetFeature 定义了一些类型:

def FeatureV9 : SubtargetFeature<"v9", "IsV9", "true",
"Enable SPARC-V9 instructions">;
def FeatureV8Deprecated : SubtargetFeature<"deprecated-v8",
"V8DeprecatedInsts", "true",
"Enable deprecated V8 instructions in V9 mode">;
def FeatureVIS : SubtargetFeature<"vis", "IsVIS", "true",
"Enable UltraSPARC Visual Instruction Set extensions">;
另外在 Sparc.td 中,使用 Proc 类来定义一些特殊的 SPARC 子类型的特征描述:

class Proc<string Name, list<SubtargetFeature> Features>
: Processor<Name, NoItineraries, Features>;

def : Proc<"generic", []>;
def : Proc<"v8", []>;
def : Proc<"supersparc", []>;
def : Proc<"sparclite", []>;
def : Proc<"f934", []>;
def : Proc<"hypersparc", []>;
def : Proc<"sparclite86x", []>;
def : Proc<"sparclet", []>;
def : Proc<"tsc701", []>;
def : Proc<"v9", [FeatureV9]>;
def : Proc<"ultrasparc", [FeatureV9, FeatureV8Deprecated]>;
def : Proc<"ultrasparc3", [FeatureV9, FeatureV8Deprecated]>;
def : Proc<"ultrasparc3-vis", [FeatureV9, FeatureV8Deprecated, FeatureVIS]>;
通过这两个 td 文件生成的 SparcGenSubtarget.inc 中,通过枚举值来识别特性,用常数数组表示 CPU 特性和子平台类型。ParseSubtargetFeatures 方法用来解析指定子平台的特征字符串。

这个 inc 文件应该被 SparcSubtarget.td 中包含,目标相关的一些实现放在 XXXSubtarget 类方法中,如下代码注释描述:

XXXSubtarget::XXXSubtarget(const Module &M, const std::string &FS) {
// Set the default features
// Determine default and user specified characteristics of the CPU
// Call ParseSubtargetFeatures(FS, CPU) to parse the features string
// Perform any additional operations
}
9 JIT支持
JIT (Just-In-Time,翻译为即时编译)的代码生成是目标平台可选的一个功能,它可以发射二进制形式的机器码和其他辅助信息并写入内存。JIT 的实现主要有以下几个步骤:

编写 XXXCodeEmitter.cpp 文件来包含一个 machine function pass 来将目标机器指令转换为可重定位机器码。
编写 XXXJITInfo.cpp 文件来实现目标相关代码生成行为的 JIT 接口,比如发射机器码和桩(stubs)。
修改 XXXTargetMachine,使之可以通过调用它的 getJITInfo 接口函数来得到 TargetJITInfo 对象。
有多种不同的实现来编写 JIT 支持代码。比如,TableGen 和目标描述文件可以被用来自动化的创建 JIT 代码生成器,但这不是必须的选择。对于 Alpha 和 PowerPC 机器架构,TableGen 生成 XXXGenCodeEmitter.inc 文件,其中包含了机器指令的二进制编码,可以通过 getBinaryCodeForInstr 方法来访问这些编码。但其他的 JIT 实现不是这样的。

XXXJITInfo.cpp 和 XXXCodeEmitter.cpp 文件必须包含 llvm/CodeGen/MachineCodeEmitter.h 这个头文件,头文件中定义了 MachineCodeEmitter 的类,其中实现了很多用于将数据写到输出流的回调函数。

9.1 机器码输出
在 XXXCodeEmitter.cpp 文件中,目标相关的部分在 Emitter 类中被实现为一个 function pass (是 MachineFunctionPass 的子类)。目标相关的 runOnMachineFunction 实现(由 MachineFunctionPass 中的 runOnFunction 调用)迭代 MachineBasicBlock 并调用 emitInstruction 来处理每一条指令,并发射二进制码。emitInstruction 的实现非常大,主要结构是一个 switch-case,用来选择指令类型,这些指令类型在 XXXInstrInfo.h 中定义。比如,在 X86CodeEmitter.cpp 中,emitInstruction 方法的实现部分如下:

switch (Desc->TSFlags & X86::FormMask) {
case X86II::Pseudo: // for not yet implemented instructions
... // or pseudo-instructions
break;
case X86II::RawFrm: // for instructions with a fixed opcode value
...
break;
case X86II::AddRegFrm: // for instructions that have one register operand
... // added to their opcode
break;
case X86II::MRMDestReg:// for instructions that use the Mod/RM byte
... // to specify a destination (register)
break;
case X86II::MRMDestMem:// for instructions that use the Mod/RM byte
... // to specify a destination (memory)
break;
case X86II::MRMSrcReg: // for instructions that use the Mod/RM byte
... // to specify a source (register)
break;
case X86II::MRMSrcMem: // for instructions that use the Mod/RM byte
... // to specify a source (memory)
break;
case X86II::MRM0r: case X86II::MRM1r: // for instructions that operate on
case X86II::MRM2r: case X86II::MRM3r: // a REGISTER r/m operand and
case X86II::MRM4r: case X86II::MRM5r: // use the Mod/RM byte and a field
case X86II::MRM6r: case X86II::MRM7r: // to hold extended opcode data
...
break;
case X86II::MRM0m: case X86II::MRM1m: // for instructions that operate on
case X86II::MRM2m: case X86II::MRM3m: // a MEMORY r/m operand and
case X86II::MRM4m: case X86II::MRM5m: // use the Mod/RM byte and a field
case X86II::MRM6m: case X86II::MRM7m: // to hold extended opcode data
...
break;
case X86II::MRMInitReg: // for instructions whose source and
... // destination are the same register
break;
}
这些 case 的具体实现经常是先发射操作码的编码,再发射操作数的编码,根据操作数类型的不同,可以调用 helper 方法来处理不同的操作数。比如,在 X86CodeEmitter.cpp 中,X86II::AddRegFrm case 中,通过 emitBytes 发出的第一个数据是添加寄存器操作数的操作码。然后发射操作数,MO1。helper 方法,比如 isImmediate, isGlobalAddress,isExternalSymbol,isConstantPoolIndex 或者 isJumpTableIndex 来决定操作数的类型。(X86CodeEmitter.cpp 文件中的类也有一些私有方法,比如 emitConstant,emitGlobalAddress,emitExternalSymbolAddress,emitConstPoolAddress 和 emitJumpTableAddress,这些方法用来实际将数据发射到输出流。)

case X86II::AddRegFrm:
MCE.emitByte(BaseOpcode + getX86RegNum(MI.getOperand(CurOp++).getReg()));

if (CurOp != NumOps) {
const MachineOperand &MO1 = MI.getOperand(CurOp++);
unsigned Size = X86InstrInfo::sizeOfImm(Desc);
if (MO1.isImmediate())
emitConstant(MO1.getImm(), Size);
else {
unsigned rt = Is64BitMode ? X86::reloc_pcrel_word
: (IsPIC ? X86::reloc_picrel_word : X86::reloc_absolute_word);
if (Opcode == X86::MOV64ri)
rt = X86::reloc_absolute_dword; // FIXME: add X86II flag?
if (MO1.isGlobalAddress()) {
bool NeedStub = isa<Function>(MO1.getGlobal());
bool isLazy = gvNeedsLazyPtr(MO1.getGlobal());
emitGlobalAddress(MO1.getGlobal(), rt, MO1.getOffset(), 0,
NeedStub, isLazy);
} else if (MO1.isExternalSymbol())
emitExternalSymbolAddress(MO1.getSymbolName(), rt);
else if (MO1.isConstantPoolIndex())
emitConstPoolAddress(MO1.getIndex(), rt);
else if (MO1.isJumpTableIndex())
emitJumpTableAddress(MO1.getIndex(), rt);
}
}
break;
之前的例子中,XXXCodeEmitter.cpp 使用变量 rt (RelocationType 的枚举)来处理重定位地址(比如PIC 的全局地址)。RelocationType 的枚举在 XXXRelocations.h 文件中定义,并被用于 relocate 方法(在 XXXJITInfo.cpp 中定义)中来重写全局符号的引用(也就是写入重定位信息)。

比如,X86Relocations.h 中指定了一下的重定位类型。这 4 中类型都会将重定位值添加到内存中。对于 reloc_pcrel_word 和 reloc_picrel_word 这两种,还会有一个额外的初始调整。(译注:原文这里没有展开讲)

enum RelocationType {
reloc_pcrel_word = 0, // add reloc value after adjusting for the PC loc
reloc_picrel_word = 1, // add reloc value after adjusting for the PIC base
reloc_absolute_word = 2, // absolute relocation; no additional adjustment
reloc_absolute_dword = 3 // absolute relocation; no additional adjustment
}
9.2 目标平台JIT信息
XXXJITInfo.cpp 文件中实现了目标平台相关的 JIT 指令生成功能,比如发射机器码和桩。一个最小的实现需要以下几部分:

getLazyResolverFunction:初始化 JIT,给定目标一个用于编译的函数;
emitFunctionStub:会返回一个指定回调函数的地址;
relocate:改变全局引用的地址,需要 relocation type 的参与;
函数桩的包装(wrapper)回调函数,在最初还不知道目标时使用;
getLazyResolverFunction 需要被继承实现。它将输入参数放入全局的 JITCompilerFunction 并返回一个回调函数,该回调函数会被用作一个函数包装。 对于 Alpha 目标平台(在 AlphaJITInfo.cpp 中),getLazyResolverFunction 的实现如下:

TargetJITInfo::LazyResolverFn AlphaJITInfo::getLazyResolverFunction( JITCompilerFn F) {
JITCompilerFunction = F;
return AlphaCompilationCallback;
}
对于 X86 架构,getLazyResolverFunction 实现的更为复杂一些,因为它会返回一个更加复杂的回调函数,比如会包含 SSE 指令 和 XMM 寄存器。

回调函数初始化保存和之后还原被调用者寄存器值、输入参数以及栈和返回地址。回调函数需要底层的功能来访问寄存器或栈,所以它通常和汇编器一起实现。

 

参考文献链接

https://blog.csdn.net/SiberiaBear/article/details/106869378

https://zhuanlan.zhihu.com/p/149599651

https://releases.llvm.org/10.0.0/docs/WritingAnLLVMBackend.html#instruction-scheduling