LLVM芯片编译器实践示例
7.1编译器基本概念
7.1.1AI编译器绪论
芯片是一个硬件,接收的是二进制的指令,要想让自己的编程语言执行编程指令,就需要一个编译器。
这个部分的重要程度丝毫不亚于芯片本身。最近国内很多公司在做AI芯片,经常出现芯片很快就做出来了,但芯片受限于编译器无法发挥最大能效的窘境。总之,了解编译器还是很重要的。
如何用LLVM做一个最简单的编译器。万变不离其宗,其他复杂的编译器可以从这个例子上拓展。
本小节主要介绍基础知识,不需要了解细节,但是对编译器整体如何工作的要有概念。
7.1.2 LLVM的模块化编译器框架[b1] [w2]
首先一个问题要搞明白,为什么要用LLVM? LLVM的是什么?
LLVM提供了一个模块化的编译器框架,让程序员可以绕开烦琐的编译原理,快速实现一个可以运行的编译器。
常见的结构如图7.1所示。
图7.1. LLVM提供了一个模块化的编译器框架
[b3] [w4] [w5] 主要由三个部分组成。
1)前端:将高级语言(如C或者其他语言)代码转换[b6] [w7] 成LLVM定义的中间表达方式LLVM IR。例如非常有名的Clang, 就是一个将C/C++代码转换为LLVM IR[b8] [w9] 的前端。
2)中间表示:中端主要是对LLVM IR本身进行一下优化,输入是LLVM, 输出还是LLVM,主要是消除无用代码等工作,一般来讲这个部分是不需要动的,可以不管他。
3)后端:后端输入是LLVM IR,输出是机器码。通常说的编译器应该主要是指这个部分。大部分优化都从这个地方实现。
至此,LLVM架构的模块化应该说的比较清楚了。很大的一个特点是隔离了前后端。
如果想支持一个新语言,就重新实现一个前端,例如华为仓颉就有自己的前端来替换Clang。
如果想支持一个新硬件,那就重行实现一个后端,可以正确的把LLVM IR映射到自己的芯片。
接下来大致讲讲前后端的流程。
7.1.3前端在干什么
如图7.2所示,以Clang举例,前端主要实现四件事。
图7.2Clang前端四件事
经过词法分析、语法分析、语义分析、LLVM IR生产,最终将C++转化成后端认可的LLVM IR。
1)词法分析:一一取出程序中所有的词汇[b10] [w11] ,遇到不认识的字符就报错。例如将a=b+c 拆成a,= ,b ,+, c;
2)语法分析:将语法提取出来,例如,随手写了个a+b=c, 明显不符合语法规则,直接报错[b12] [w13] ;
3)语义分析:分析一下写的代码实际含义是不是正确,例如a=b+c, a,b,c有没有定义,类型是不是正确的;
4)LLVM IR生成:经过上述三步,将写的代码转化成树状描述(抽象语法树),然后再转化成IR定义的IR即可。
举个直观的例子,写的C++程序。
// add.cpp
int add(int a, int b) {
return a + b;
}
这里介绍一下生成的LLVM IR。
这里不需要看懂每个细节,知道LLVM IR类似汇编语言就[b14] [w15] 行了,专业的形式称为SSA(Static Single Assignment 静态单一分配)。
; ModuleID =
'add.cpp'
source_filename = "add.cpp"
target datalayout = "e-m:o-i64:64-f80:128-n8:16:32:64-S128"
target triple = "x86_64-apple-macosx10.15.0"
; Function Attrs: noinlinenounwindoptnonesspuwtable
define i32 @_Z3addii(i32, i32) #0 {
%3 = alloca i32, align 4
%4 = alloca i32, align 4
store i32 %0, i32* %3, align 4
store i32 %1, i32* %4, align 4
%5 = load i32, i32* %3, align 4
%6 = load i32, i32* %4, align 4
%7 = add nsw i32 %5, %6
ret i32 %7
}
7.1.4 后端在干什么
后端把LLVM转换成真正的汇编(或者机器码)。主要的流程如图7.3所示。这个要重点讲一下,因为后续就是要实现一个后端支持一个新的芯片。
图7.3后端把LLVM转换成真正的汇编(或者机器码)
7.1.5 DAG下译[b16] [w17]
这个[b18] [w19] 主要负责将LLVM IR转换为有向无环图,便于后续利用图算法优化。
例如,如图7.4所示,将下面的LLVM IR 转换成图,每个节点是一个指令。
%mul = mul
i32%a, %a
%mul = mul
i32%a, %a
[b20] %add = add
nswi32 %mul4, %mul
ret i32 %add
图7.4将LLVM IR 转换成图,每个节点是一个指令
7.1.6 DAG合法化
DAG图合法化,DAG图都是LLVM IR指令,但实际上LLVM
IR指令不可能被芯片全部支持,这个步骤就是替换这些不合法的指令。
1. 指令选择
这个步骤将LLVM IR转换成机器支持的机器DAG。
如图7.5所示,将store指令[b21] [w22] 换成机器认可的st[b23] [w24] 指令, 将16位的寄存器转向32位。一切向机器指令靠拢。
图7.5将store指令换成机器认可的st, 将16位的寄存器转向32位
2. 调度
这个步骤主要是调整指令顺序的,从有向无环图再展开成顺序的指令。
例如,如图7.6所示,把下面的指令调成这样的。
图7.6从有向无环图展开成顺序的指令
把%C的存储提前一些[b25] [w26] 并行进行,因为下一条ld[b27] [w28] 指令要用C语言表示。
3. 基于SSA的机器码优化
主要是做一些公共表达式合并/去除的操作。
4.寄存器分配[b29] [w30]
这一步就要分配寄存器了。也许大家认为[b31] [w32] 寄存器其实是可以无限用的,但实际硬件的寄存器有限的。所以得考虑寄存器数量与寄存器值的生命周期,将虚拟的寄存器替换成实际的寄存器。这个一般会用到图着色等算法,很复杂,好在LLVM都实现好了,不用再重复造轮子。
例如一个芯片,有32个可用的寄存器,如果函数使用到了64个[b33] [w34] 寄存器,剩下的就只能压入堆栈或者处于等待状态[b35] [w36] 了。
5. Prologue/Epilogue代码插入
主要是插入函数调用前的指令和函数调用后的指令,即主[b37] [w38] 要是调用前把参数存下来,调用后把结果写到固定的寄存器里。
6. 窥视孔优化
这个步骤主要是对代码进行最后一次优化[b39] [w40] ,比如把x*2换成x<1[b41] [w42] 方法。
再比如图7.7所示这样:
图7.7将两个32bit的存储[b43] [w44] 过程换成一个64bit的存储
将两个32bit的存储换成一个64bit的存储。
7. 代码发布[b45] [w46]
最后一步显然,将上述优化好的中间代码转换成真正需要的汇编,由汇编器翻译成机器码。
7.1.7 小结
本小节介绍了编译器的基本概念,以及编译过程中的大部分流程。[b47] [w48] 下一小节开始介绍如何用LLVM快捷的实现流程。LLVM的精髓就在于,不必对每一个步骤内部如何实现的彻底了解细节。只需要知道有这个LLVM就能很快攒出编译器。
7.2 从无到有开发
7.2.1 不必从头开始开发
经过了上一小节对编译基础知识的介绍,已经明白了编译器的基本步骤。如果以前对编译没概念建议简单先看看。
那么当自己做了一个芯片,如何利用LLVM从无到有的写自己的编译器呢?
现在开始回答这个问题。
由于LLVM是个开源框架,没必要从头开始。只需要确定在LLVM的框架下添加什么内容即可。这里先看个整体。
7.2.2需要添加的文件类型
LLVM基于C++写的,所以,首先,肯定要添加一堆的.cpp和.h文件。
其次,为了进一步提高编码效率,LLVM其实提供了一套目标定义(target definition)接口(td文件,td的名字就这么来的)。
写td文件,LLVM里有个组件称为tablegen,tablegen读取写的td文件自动帮转成对应的cpp文件,避免所有的cpp都要自己写。
如图7.8所示,大致的原理是这样的:
图7.8读取td文件自动转成对应的cpp文件
现在只需要明白,需要写两种[b49] [w50] 文件。
1)td文件:与架构组件相关的内容,如寄存器等,会在.td文件中编写[b51] [w52] 。
2).cpp和.h文件:其他控制性的、调用性的, 以及不好用.td文件自动生成的,就直接用.cpp文件编写。(所以若C++不熟,阅读或者写代码有难度,不妨找一本C++ PREMIER先学学)。
7.2.3 从文件角度先看整体[b53] [w54] 框架
完成一个简单的后端需要添加多少文件?
现在先看一下,做一个最简单的编译器需要添加什么内容。先看个整体,里面有什么内容。
现在先给后端起个名字,且名字前缀为xxx[b55] [w56] 。xxx能够较为明显的区分写的内容和LLVM自带的内容。
如图7.9所示就是一个简单的LLVM后端需要写的所有文件。不要看到这个地方就被劝退了,其实有些文件内容不多的,一共也就几千行代码。
图7.9一个简单的LLVM后端需要写的所有文件
下面列出了按功能分类的各个文件是如何用的。这里主要看看要写几个方面的内容。
1.芯片总体架构
xxx.h:定义顶层的类。
xx[b57] [w58] x.td:所有td文件的入口,整体芯片各种特性的开关。
xxxTargetMachine.cpp(.h)[b59] [w60] :目标芯片的定义,生成TargetMachine对象。
xxxMCTargetDesc.cpp/h:定义了xxx的各种信息接口。
xxxBasedInfo.h:定义了芯片常见的特性以及指令类型。
xxxTargetInfo.cpp:将target注册到LLVM系统里。
xxxSubtarget.cpp/h:芯片子系列的定义。
2.寄存器描述
xxxRegisterInfo.cpp/h:寄存器信息的底层。
xxxSERegisterInfo.cpp/h:寄存器相关的具体实现。
xxxRegisterInfo.td:具体寄存器的定义。
3.指令相关
1)指令描述
xxxAnalyzeImmediate.cpp/h:处理立即数指令。
xxxInstFormats.td:指令类型定义,I,J等类型。
xxxInstInfo.cpp/h:指令信息的顶层。
xxxSEInstInfo.cpp/h:指令信息的具体实现。
xxxInstInfo.td:各条指令的描述,逐条写。
2)指令处理
第一,LLVM IR 到LLVM DAG:
xxxISelLowering.cpp/h
xxxSEISelLowering.cpp/h
第二,LLVM DAG 到Machine DAG:
xxxISESelDAGToDag.cpp/h
xxxISelDAGToDag.cpp/h
3)指令调度
xxxSchedule.td:指令调度需要的信息。
4.堆栈管理
xxxFrameLowering.cpp/h:堆栈的顶层。
xxxSEFrameLowering.td:具体的堆栈操作,包括压栈退栈等操作。
5.函数管理
xxxCallingConv.td:处理函数返回值存储的问题。
xxxMachineFunctionInfo.cpp/h:函数处理顶层。
6.汇编及输出
xxx[b61] [w62] ASMPrinter.cpp/h:汇编输出顶层。
xxxInstPrinter.cpp/h:实现指令部分的汇编打印。
xxxMCInstLowering.cpp/h:指令内部表示到汇编映射。
xxxSEMCInstLower.cpp/h:指令映射的具体实现。
xxxTargetObjectFile.cpp/h:定义了ELF文件相关内容。
xxxMCAsmInfo.cpp/h:定义一些打印ASM需要的格式信息。
划重点,一共需要写六个方面!
1)芯片总体的架构。
2)寄存器的描述。
3)指令相关的描述。
4)堆栈的管理。
5)函数的管理。
6)汇编和其他输出的管理。
上面实现了最基本的功能,运用熟练以后还可以自己添加其他功能。不需要搞清楚每个文件具体是干什么的,后续返回来看就能明白,会逐一来细讲。
7.2.4从类继承与派生角度看整体[b63] [w64] 框架
上面以文件角度看可能对于熟悉C++的来讲还不够脉络清晰,换个角度看世界。
既然以类角度看,那就需要来个抓手。如图7.10所示,在编译器中,这个抓手其实是xxxSubtarget类。
图7.10在编译器中 xxxSubtarget类抓手
有了这个类,大部分的资源都能通过指针访问到。同时,其他类也通过指向Subtarget的指针获得了访问其他信息的接口。
总而言之, Subtarget类是一个接口类。实现了资源的互通调用。这样其他类的关系就有了方向了。一般来讲,要先在代码中找到subtarget的指针[b65] [w66] ,然后通过subtarget访问其他[b67] [w68] 模块。
td文件通过tablegen生成对应的xxx[b69] [w70] Geninfo类[b71] [w72] ,然后通过派生合入自己写的类。最后Subtarget通过指针访问之。
划重点:
1)Subtarget类是接口类。
2)td文件通过tablegen生成[b73] [w74] 类,然后通过派生合入写的类。
实际上是解决LLVM如何用起来这个问题的开始。觉得开源项目要想做的快,一定是要先看森林后看树叶的。否则很容易陷入开源代码的汪洋大海中毫无方向。这里从两个角度介绍了宏观的框架,然后再转到最后完成的目标[b75] [w76] ,属于介绍LLVM的整体。从下一小节开始,逐个方向介绍如何一步步组合起来编译器。
7.3 芯片的整体架构部分
介绍了从宏观上看LLVM需要补充什么内容,从开始逐个讲各部分需要的代码。
首先介绍芯片的整体架构部分。这一部分可能稍微有点儿枯燥,没有讲寄存器和指令那么的清晰明了,但它确实是LLVM编译器的入口,首先就要完成这一部分代码, 所以需要耐心看完。
[b77] [w78] 7.3.2 xxx.h类型文件
这个文件是要完成的第一个文件,其实就是声明了两个类,方便后续引用。
namespace llvm{
class xxxTargetMachine;
class FunctionPass;
}
后续可以把全局的宏定义写到这个里面。
7.3.3 xxx.td类型文件
接下来写xxx.td。emmm 这个文件主要定义整体架构层面的内容。
1.定义一些子目标特征
例如定义两个特征, 分别支持slt和cmp指令。
def
FeatureCmp: SubtargetFeature<"cmp", "HasCmp",
"true", S
"Enble
'cmp' instructions.">;
def
FeatureSlt: SubtargetFeature<"slt", "HasSlt",
"true",
"Enble
'slt' instructions.">;
然后为了简便[b79] [w80] ,定义这些特征的集合。
def
Feature32II: SubtargetFeature<"xxx32II",
"xxxArchVersion",
"xxx32II", "xxx32II ISA Support",
[FeatureCmp,
FeatureSlt]>;
例如定义xxx32II特征,包含cmp和slt[b81] [w82] 。
这部分其实是利用tablegen来完成的,基类是SubtargetFeature。后续可以确定某个Subtarget类带[b83] [w84] 不带某个特征。
至于这个基类SubtargetFeature, [b85] [w86] 显然用的是LLVM提供的接口。
class
SubtargetFeature<string n, string a, string v, string d,
list<SubtargetFeature> i = [ ]> {
string Name = n;
string Attributes = a;
string Value = v;
string Desc =d;
list<SubtargetFeature> Implies = i;
}
没有太多内容,就是定义了一下名字,描述之类的。
2. 定义一些处理器
然后就是定义几个。这个处理器带了某个特性。
class
Proc<string Name, list<SubtargetFeature> Features>
: Processor<Name, xxxGenericItineraries,
Features>;
def:
Proc<"xxx32I", [Featuresxxx32I]>;
def: Proc<"xxx32II",
[Featuresxxx32II]>;
例如上面就定义了两个Subtarget处理器,一个含有Feature[b87] [w88] xxx32I特性,另一个含有Feature[b89] [w90] xxx32II特征。
这里把架构定义出来。
def xxx: Target {
// 按照以前的方法定义xxxInstrInfo: InstrInfo
let
InstructionSet = xxxInstrInfo;
}
7.3.4 xxxTargetMachine.cpp/h类型文件
1.定义target类
这部分h文件描述TargetMachine类。这个类包含了对各个subtarget类的映射[b91] [w92] 。
从LLVMTargetMachine里继承出xxxTargetMachine类。
class
xxxTargetMachine: public LLVMTargetMachine {
bool isLittle;
std::unique ptr<TargetLoweringObjectFile>
TLOF;
xxxSubtargetDefaultSubtarget;
mutable
StringMap<std::unique_ptr<xxxSuntarget>>SuntargetMap;
其实可以理解为对Subtarget的管理,提供了接口,可以方便地拿出需要的s[b93] [w94] ubtarget。
当然,为了使用方便,还能用xxxTargetMachine,可以分别派生出大、小端的[b95] [w96] 类。
classxxxTargetMachine: public
xxxTargetMachine
2. 注册target类
在C文件里,比较重要的是需要调用一个LLVM的库函数,把写的类注册给LLVM。
extern "C" void LLVMInitialxxxTarget {
// 注册目标
// 小端目标机器
[b97] [w98] RegisterTargetMachine<xxxTargetMachine>
Y(getThexxxTarget( ));
}
3. 实现Pass的配置[b99] [w100]
此处在C文件里直接实现xxxPassConfig类,继承自TargetPassConfig, 用来配置Target的Pass。(什么是Pass:
就是一个处理操作,例如程序选择就是一个Pass)
class
xxxPassConfig: public TargetPassConfig{
public:
TargetPassConfig(TM, PM) { };
bool
addInstSelector( ) override {
addPass(createxxxSEISelDAG(getxxxTargetMachine(
), detOptarget);
return false;
}
例如上面的代码就是重载了addInstSelector,
注册了自己写的指令选择器。
7.3.5 xxxMCTargetDesc类文件[b101] [w102]
这部分代码正式生成Target对象,并把各种类都注册给LLVM。具体注册了些什么,可参考cpp文件。
目标对象主要[b103] [w104] 在LLVMInitializexxxTarget机器码里:
extern
"C" void LLVMInitializexxxTarget(){
Target &ThexxxTarget = getThexxxTarget();
//Target &ThexxxTarget = getThexxxTarget();[b105] [w106]
for(Target *T: {&ThexxxTarget}){
// 注册机器汇编代码信息
RegisterMCAsmInfoFn
X(*T, createxxxMCAsmInfo);
//注册机器码指令信息TargetRegistry::RegisterMCInstrInfo(*T, createxxxMCInstrInfo);
//注册机器码注册信息
TargetRegistry::RegisterMCRegInfo(*T,
createxxxMCRegisterInfo);
//注册机器码子目标信息
TargetRegistry::RegisterMCSubtaregetInfo(*T,
createxxxMCSubtargetInfo);
//注册机器码指令分析器
TargetRegistry::RegisterMCInstrAnalysis(*T,
createxxxMCInstrAnalysis);
//注册机器码指令输出
TargetRegistry::RegisterMCInstPrinter(*T,
createxxxMCInstrInfo);
}
把Target, Asminfo, Inst, Reg,
Subtarget等类全部注册给LLVM。
7.3.6 xxxbaseInfo类型文件
这是个.h文件,倒是比较简单。定义了两个枚举类型,一个是TOF(Target Operand Flag),即目标操作数标志
enumTOF {
MO_NO_FLAG,
}
另一个是指令编码类型。
[b107] [w108] enum {
// 伪指令:这表示一条伪指令或尚未实现的指令。代码生成是非法的,但在中间实//
现阶段是可以容忍的 Pseudo
= 0,
// FrmR:这张表格是用于R格式的说明
FrmR
= 1,
// FrmI:这张表格是用来说明格式I的
FrmI
= 2,
// FrmJ: This form is for instructions ofr
the format J.
FrmJ
= 3,
}
现在没什么其他内容了,比较简单。
7.3.7 xxxTargetInfo类型文件
这个文件简单,主要就是实现一个函数getTheTarget[b109] [w110] ,获取到Target类。
Target &llvm::getThexxxTarget(){
static
Target ThexxxTarget;
return
ThexxxTarget;
}
7.3.8 xxxSubtarget类型文件
emmm 然后来到了最重要的一个类。这个类前面讲过,这是个接口类,主要定义了一系列Subtarget[b111] [w112] 对外的接口。
这个地方Subtarget[b113] [w114] 有哪些在td文件中定义过[b115] [w116] ,这里创建一个枚举类型[b117] [w118] , 显然,td文件要与 .h文件对上。
enumxxxArchEnum{
xxx32I,
xxx32II,
};
所以可以看出,td文件和cpp/.h文件是要配合使用的,两个剥离开了理解是非常痛苦的一件事儿。这里类里定义了一堆借口。
const
xxxInstrInfo *getInstrInfo()
const overide { return InstrInfo.get();
}
const
TargetFrameLowering *getFrameLowering()
const override { return
FrameLowering.get();
}
const
xxxRegisterInfo *getRegisterInfo()
const override { return
&InstrInfo->getRegisterInfo();
}
const
xxxTargetLowering *getFrameLowering()
const override { return TLInfo.get();
}
例如上面这样的,返回的全是各类的指针。
7.3.9 几个容易混淆的概念
上面这部分代码重点已经讲完了。可能看到这里有几个点有疑惑,这里需要解释一下。
Target和Subtarget[b119] [w120] 的区别:Target说的是一类芯片,例如ARM是一个Target, SubTarget是具体的一个芯片,例如ARM的M3。不同的芯片有不同的特性[b121] [w122] [b123] [w124] ,有些支持slt指令[b125] [w126] , 有些不支持。把这些特性总结成特征, 然后定义各种Subtarget[b127] [w128] 即可。否则岂不是要写若干编译器吗?
Target和TargetMachine区别:Target是信息层面的类,比如Target是什么,有哪些特征?TargetMachine是操作层面的类,用于管理subtarget[b129] [w130] 的。
7.3.10 小结
从LLVM森林看到LLVM树木的内容。介绍了如何在LLVM上定义出架构,方便后续添加枝叶进去。可能有点晦涩,可对照代码来读。
下一小节介绍如何添加寄存器信息进去。
7.4寄存器信息
7.4.1 寄存器概述
本小节介绍寄存器。处理器就是通过指令对寄存器里的值进行各种操作[b131] [w132] 。
先来看看,要在LLVM中描述寄存器需要哪些内容。
寄存器描述:
xxxRegisterInfo.cpp/h:寄存器信息的底层。
xxxSERegisterInfo.cpp/h:寄存器相关的具体实现。
xxxRegisterInfo.td:具体寄存器的定义。
7.4.2 xxxRegisterinfo.td
首先介绍td[b133] [w134] 格式的文件。这个才是描述寄存器的主力。td[b135] [w136] 文件最终会通过tablegen生成xxxGenRegisterInfo, 供后续合入到Registerinfo类中去。
这里面还是比较简单的,首先继承一个寄存器基类,生成16bit[b137] [w138] 参数。
class
xxxReg<bits<16> Enc, string n>: Register<n> {
// 对于CMakeLists.txt中的tablegenablegen( ...
-gen-emitter)
let HWEncoding = Enc;
let Namespace = "xxx";
}
然后派生出两种类型的寄存器:通用寄存器和C0寄存器。[b139] [w140]
// 两种寄存器类型
class
xxxGPRReg<bits<16>Enc, string n>: xxx<Enc, n>;
class
xxxC0Reg<bits<16>Enc, string n>: xxx<Enc, n>;
接下来定义所有寄存器[b141] [w142] 。
//定义全部寄存器
let
Namespace = "xxx" in {
//@General Purpose Registers
def ZERO: xxxGPRReg<0,
"zero">, DwarfRegNum<[0]>;
def AT: xxxGPRReg<1, "1">,
DwarfRegNum<[1]>;
def V0: xxxGPRReg<2, "2">,
DwarfRegNum<[2]>;
def V1: xxxGPRReg<3, "3">,
DwarfRegNum<[3]>;
def A0: xxxGPRReg<4, "4">,
DwarfRegNum<[4]>;
def A1: xxxGPRReg<5, "5">,
DwarfRegNum<[5]>;
def T9: xxxGPRReg<6, "6">,
DwarfRegNum<[6]>;
def T0: xxxGPRReg<7, "7">,
DwarfRegNum<[7]>;
def T1: xxxGPRReg<8, "8">,
DwarfRegNum<[8]>;
def S0: xxxGPRReg<9, "9">,
DwarfRegNum<[9]>;
def S1: xxxGPRReg<10, "10">,
DwarfRegNum<[10]>;
def GP: xxxGPRReg<11, "11">,
DwarfRegNum<[11]>;
def FP: xxxGPRReg<12, "12">,
DwarfRegNum<[12]>;
def SP: xxxGPRReg<13, "13">,
DwarfRegNum<[13]>;
def LR: xxxGPRReg<14, "14">,
DwarfRegNum<[14]>;
def SW: xxxGPRReg<15, "15">,
DwarfRegNum<[15]>;
}
再然后定义寄存器组。
//def
Register Groups
def
CPURegs: RegisterClass<"xxx", [i32], 32, (add
// Reserved
ZERO, AT,
// Return Values and Arguments
V0, V1, A0, A1,
// Not preserved across procedure calls
T9, T0, T1,
// Callee save
S0, S1,
// Reserved
GP, FP,
SP, LR, SW)>;
//
@Status Registers class
def
SR: RegisterClass<"xxx", [i32], 32, (add SW)>;
//
@Co-processor 0 Registers class
def
C0Regs: RegisterClass<"xxx", [i32], 32, (add PC, EPC)>;
def
GPROt: RegisterClass<"xxx", [i32], 32, (add(sub CPURegs, SW)>;
def
C0Regs: RegisterClass<"xxx", [i32], 32, (add HI, LO)>;
例如C0 寄存器组是一个32位宽的寄存器组,是3[b143] [w144] 2位对齐的,包含了PC和EPC。
寄存器组相当于把单个寄存器变成了二维数组,可以拼起来访问。
寄存器中的.td[b145] [w146] 文件里目前就含这么多内容,这是不是也不复杂?
7.4.3 xxxRegisterInfo类型文件
很显然,这个[b147] [w148] xxxRegisterInfo是xxx中寄存器的基类,td文件[b149] [w150] 通过xxxGenRegisterInfo合入进来。
classxxxRegisterInfo: publicxxxGenRegisterInfo{
然后定义了一堆寄存器操作的特殊函数。可以选择性的重载一些。具体能重载什么,看官方文档,一大堆。
例如,重载一个位反序的函数。[b151] [w152]
LLVM: llvm::TargetRegisterInfo Class Referencellvm.org/doxygen/classllvm_1_1TargetRegisterInfo.html#a8681f09dd6db9839e0cdf1155312c451
BitVectorgetReservedRegs(constMachineFunction&MF)
constoverride;
}
其他函数也大致如此,不再赘述。
需要说明的是,在这个TargetRegisterInfo类[b153] [w154] 里写了一个eliminateFrameIndex的成员函数。这部分到后续堆栈管理时一起讲。
7.4.4xxxSERegisterinfo类型文件
这个文件非常简单。主要就是继承上面的类,多实现了个成员函数intRegClass。
class
xxxxRegisterInfo: public xxxRegisterInfo {
public:
xxxRegisterInfo(const
xxxSubtarget&Suntarget);
const TargetRegisterClass
*intRegClass(unsigned Size) const override;
};
}
//
end namespace llvm
这里[b155] [w156] 没有什么实质性的内容,类程序定义用来[b157] [w158] 追踪注册类[b159] [w160] 的。
本小节比较简单以及短小。讲了LLVM中的寄存器描述,可以看到这一部分的c++描述其实不多,大多是.td文件完不成的任务采用C ++去做,大多数的寄存器信息都记录在了.td文件中。搞懂了.td文件基本就无障碍了。下一小节来讲指令的描述。
7.5指令描述的.td文[b161] [w162] 件
7.5.1 指令相关概述
本小节来到了指令相关的部分,这部分内容是编译器最重要的信息。
先来看看这部分主要有哪些内容。
[b163] [w164] 由于指令描述部分太过重要,因此分7.5节和7.6节进行介绍,本节先介绍指令描[b165] [w166] 述的.td文件。
7.5.1 xxxInstFormats.td类型文件
1. 指令类型的例子
首先从简单的开始介绍,既然说指令,那么常见的指令类型应该有所了解?
比如,如图7.11所示,MIPS或者RISC的指令,对编译器来讲,最重要的信息是一个指令里有几个操作数。
图7.11 指令类型与操作数
2. Formats文件定义
首先把类定义出来。
class
Format<bits<4> val>
{
bits<4> Value = val;
}
def
Pseudo: Format<0>;
def
FrmA: Format<1>;
def
FrmL: Format<2>;
def
FrmJ: Format<3>;
def
FromOther: Format<4>;
//
Instruction w/ aCustomformat
然后定义指令中通用的基类。
//
Generic xxx Format
class
xxxInst<dag outs, dag ins, string asmStr, list<dag> pattern,
InstrItinClassitin,
Format f>: Instruction
{
// Inst and Size: for tablegen( ...
-gen-emitter) and
// tablegen( ... -gen-disassembler) in CMakeLists.txt
field bits<32> Inst;
Format Form = f;
let Namespace = "xxx";
let Size = 4;
bits<8>Opcode = 0;
//Top 8 bits are the opcode field
let Inst{31-24} = Opcode;
let OutOperandList = Outs;
let InOperandList = ins;
let AsmString = asmStr;
let Pattern = pattern;
let Itinerary = itin;
//Attributes specific to xxx instructions
bits<4>FormBits = Form.Value;
let TSFlags{3-0} FormBits;
let DecoderNamespace = "xxx";
field bits<32>SoftFail = 0;
}
上面直接继承了LLVM的指令基类,指定参数。还顺手把opcode的位宽给指定了一下。
然后就是几个类的定义,都继承上面这个基类。
class
FA<bits<8> op, dag outs, dag ins, string asmStr,
list<dag> pattern,
InstrItinClassitin>
:xxxInst<outs, ins, asmStr, pattern, itin, FrmA>
{
bits<4> ra;
bits<4> rb;
bits<4> rc;
bits<12>shamt;
let Opcode = op;
let Inst{23-20} = ra;
let Inst{19-16} = rb;
let Inst{15-12} = rc;
let Inst{11-0} = shamt;
}
A型的如上面所示,L,J型类似,就是指定一下不同比特代表的是什么。
另外,还定义了伪指令的类,这些指令不会出现在最后的机器码里,仅仅用在中间处理上。
//
xxx Pseudo Instructions Format
class
xxxPseudo<dag outs, dag ins, string asmstr, list<dag> pattern,
InstrItinClassitin
= IIPseudo>
:
xxxInst<outs, ins, asmstr, pattern, itin, Pseudo>{
let
isCodeGenOnly = 1;
let
isPseudo = 1;
}
7.5.2 xxxInstInfo.td类型文件
上面定义了指令类型,具体支持的指令在这个文件里来定义。
1. 定义返回值结点[b167] [w168]
首先,定义一个返回值结点。在DAG中,指令就是结点。
// 返回
def
xxxRet: SDNode<” xxxISD:: Ret”, SDTNode,
[SDNPHasChain, SDNPOptInGlue, SDNPVariadic]>;
可以看到,定义一个返回结点,名字是xxxISD:[b169] [w170] :Ret,SDTNone指的是无类型要求,后面三个是参数。
例如SDNPVariadic表示允许可变参数。然后把这个结点的类型指定一下。
def SDT_xxxRet: SDTypeProfile<0, 1,
[SDTCisInt<0>]>;
表示这个结点有0个结果(都返回了,显然没有输出结果),1个操作数,这个操作数类型是整数的(SDTCisInt<0>)。
2. 定义操作数
然后定义一堆操作数。
// Signed
Operand
def simm16:
Operand<132>{
let DecoderMethod =
"DecodeSimm16";
}
def shamt:
Operand<132>;
def uimm16:
Operand<i32>{
let PrintMethod =
"printImsignedImm";
}
// Address
operand
def mem:
Operand<iPTR> {
let PrintMethod =
"printImsignedImm";
let MIOperandInfo = (ops CPURegs, simm16);
let EncoderMethod =
"getMemEncodeing";
}
比如定义为有符号数、无符号数[b171] [w172] 等。同时,制定了各操作数的打印方式。let
PrintMethod = "xx" 后面的xx在cpp文件里实现的打印方法。
同时,此处还定义了一下转换函数。
//
Transformation Function: get the higher 16 bits.
def HI16:
SDNodeXForm<imm, [{
return getIMM(N, (N->getZExtValue( )
>> 16) & 0xffff);
}]>;
例如,取立即数的高16位。这种内容.td文件[b173] [w174] 没有语法描述,所以干脆就用c++函数写出来了。
3. 定义指令的具体形式
// Arithmetic and logical instructions with 3
register operands
class ArithLogicR<bits<8> op, string
instr_asm, SDNodeOpNode,
InstrItinClassitin, RegisterClass RC, bit isComm =
0>
: FA<op, (outs GPROut: $ra), (ins RC: $rb, RC:
$rc),
!strconcat(instr_asm, “\t$ra, $rb, $rc”),
[(set GPROut: $ra, (OpNode RC: $rb, RC: $rc))],
itin>{
let shamt = 0;
let isCommutable = isComm;
let isReMaterializable = 1;
}
通过上面定义的格式, 派生出指令的具体形式。
例如通过FA,制定其打印形式 \t$ra, $rb, $rc。在DAG图上表现为输出为ra寄存器,输入为rb,rc寄存器。
7.5.3 按个定义指令
然后就该按个把指令定义出来了。
defADDu: ArithLogicR<0x11,
addu, add, IIAlu, CPURegs, 1>;
defADDu: ArithLogicR<0x12,
subu, sub, IIAlu, CPURegs>;
例如上面形式,定义了一个ADDu,采用的是ArithLogicR指令,opcode是0x11, 名字是addu, SDNODE是add。将指令绑定到硬件的ALU单元, 操作的寄存器组是CPURegs, ADDu是可执行的,SUBu最后需要转化一下。
有了这些信息,编译器基本就可以编译指令了。
7.5.4 定义指令的自动转换
包括了自动将小的立即数操作转换为加法等(从L型转换为了A型,不一定发生,但是要告诉编译器能转换)。
// Immediates
def: Pat<i32
immSExt16: $in), (ADDiu, ZERO, imm: $in)>;
还包括了一些指令的别名。
def: xxxInstAlias<”move $dst, $src”,(ADDuGPROut: $dst,
GPROut: $src, ZERO), 1>;
例如,move实际上实现的时候,这是使用ADDu+ 0来完成。
7.5.5 小结
本节重点讲了描述指令的.td文件,该文件的编写在编译器中占了很大的工作量,但其实也不难理解, 因为它给出了最基础的用法。[b175] [w176]
7.6 描述指令的.cpp文件[b177] [w178]
7.6.1指令描述
xxxAnalyzeImmediate.cpp/h:处理立即数指令。
xxxInstFormats.td:指令类型定义,I,J等类型。
xxx.InstInfo.cpp/h:指令信息的顶层。
xxxSEInstInfo.cpp/h:指令信息的具体实现。
xxxInstInfo.td:各条指令的描述,逐条写。
现在来接着讲,指令的.cpp文件[b179] [w180] 描述里面有什么内容。
7.6.2 xxxInstr[b181] [w182] Info.cpp/.h[b183] [w184] 类型文件
这个头文件[b185] [w186] 主要是指令类。由于大部分内容都在td文件里定义里,这个里面就简单的几个函数定义。
void
storeRegToStackSlot(MachineBasicBlock&MBB,
MachineBasicBlock::
iterator MBBI,
Register SrcReg, bool
iskill, int FrameIndex,
const TargetRegisterClass
*RC,
const TargetRegisterInfo
*TRI)
const override{
storeRegToStack(MBB,
MBBI, SrcReg, isKill, FrameIndex, RC, TRI, 0);
}
void
loadRegFromStackSlot(MachineBasicBlock&MBB,
MachineBasicBlock::
iterator MBBI,
Register DestReg, int
FrameIndex,
const
TargetRegisterClass *RC,
const
TargetRegisterInfo *TRI)
const override{
loadRegToStack(MBB,
MBBI, DestReg, FrameIndex, RC, TRI, 0);
}
void
storeRegToStackStack(MachineBasicBlock&MBB,
MachineBasicBlock::
iterator MI,
Register SrcReg, bool
iskill, int FrameIndex,
const
TargetRegisterClass *RC,
const
TargetRegisterInfo *TRI,
int64_t offset)
const = 0;
void
loadRegFromStackSlot(MachineBasicBlock&MBB,
MachineBasicBlock::
iterator MI,
Register DestReg, int
FrameIndex,
const
TargetRegisterClass *RC,
const
TargetRegisterInfo *TRI,
int64_t offset)
const = 0;
这几个定义主要[b187] [w188] 包括对寄存器存取堆栈[b189] [w190] 的操作,此处定义成了纯虚函数,后续在SE(SEInstInfo.cpp)中实现,其他所有内容已经都在td文件里描述过了。
7.6.3 xxxSEInstInfo.cpp/h类型文件
SE中实现了上述几个存取堆栈的操作。
void
xxxSEInstInfo::
storeRegToStack(MachineBasicBlock&MBB,
MachineBasicBlock::iterator I,
Register SrcReg, bool isKill,
int FI,
const TargetRegisterClass *RC,
const TargetRegisterInfo *TRI,
int64_t Offset) const {
DebugLoc
DL;
MachineMemOperand
*MMO = GetMemOperand(MBB, FI, MachineMemOperand:: MOStore);
unsigned Opc = 0;
Opc =
xxx::ST;
assert(Opc&& "Register class not
handled!");
BuildMI(MBB,
I, DL, get(Opc)).addReg(SrcReg, getKillRegState(isKill))
.addFrameIndex(FI).addImm(Offset).addMemOperand(MMO);
}
这里直接调用了BuildMI的成员函数addReg, 将SrcReg给存到堆栈里面去。
7.6.4 xxxAnalyzement[b191] [w192] diate.cpp/h类型文件
这个文件里主要是一堆关于立即数的调用函数。具体的函数如下面代码所示。
// AddInstr - 将I添加到SeqLs中的所有指令序列
void AddInstr(InstSeqLs&SeqLs, const &I);
// GetInstSeqLsAddiu - 获取结束ADDiu以加载即时Imm的指令序列
void GetInstSeqLsADDiu(uint64_t Imm, Unsigned RemSize,
InstSeqLs&SeqLs);
// GetInstSeqLsORi - 获取结束ORI以加载即时Imm的指令序列
void GetInstSeqLsORi(uint64_t Imm, Unsigned RemSize,
InstSeqLs&SeqLs);
// GetInstSeqLsSHL - 获取以SHL结尾的指令序列以加载即时Imm
void GetInstSeqLsSHL(uint64_t Imm, Unsigned RemSize,
InstSeqLs&SeqLs);
// GetInstSeqLsSeqLs - 获取指令序列以加载即时Imm
void GetInstSeqLs(uint64_t Imm, Unsigned RemSize,
InstSeqLs&SeqLs);
// RepalceADDiuSHLWithLUi - 获取指令序列以加载即时Imm
void RepalceADDiuSHLWithLUi(InstSeqLs&Seq);
// GetShortestSeq - 在SeqLs中查找最短的指令序列,并在Insts中返回
void GetShortestSeq(InstSeqLs&SeqLs, InstSeq&Insts);
可以将一些不支持的操作转换为支持的操作。比如把ADDiu和SLH打包成LUI。处理非常大的立即数是有用。
addiu¥1, $zero, 8;
shl$1, $1, 8;
addiu$1, $1, 8;
addiu$sp,$sp, $1;
比如处理时将上面的指令替换成下面的指令
lui¥1, 32768;
addiu$1, $1, 8;
addu$sp,$sp, $1;
可以在一定程度上提升运行效率[b193] [w194] 。其实,这些指令主要[b195] [w196] 体现在堆栈[b197] [w198] 非常大,sp指针的跳转比较费事。Sp指令逻辑[b199] [w200] 比较复杂,具体的实现是递归的。
内容就这么多,其实讲得有点粗略,感兴趣的话直接深入代码看。指令这一部分重头戏都在td上,其他cpp文件更多的是给td文件打了个补丁,比如堆栈的操作,立即数的操作等。总之,每个函数也不必全搞懂,知道每个文件都是干什么的就达到基本目标了,后续如果要用的话再详细读代码。