LLVM芯片编译器实践示例

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] 
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文件打了个补丁,比如堆栈的操作,立即数的操作等。总之,每个函数也不必全搞懂,知道每个文件都是干什么的就达到基本目标了,后续如果要用的话再详细读代码。
 
 
posted @   吴建明wujianming  阅读(230)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· .NET10 - 预览版1新功能体验(一)
历史上的今天:
2023-03-22 特斯拉FSD技术优化改进分析
2022-03-22 台积电2nm与3nm制程
2020-03-22 视觉SLAM技术应用
2020-03-22 Waymo的自主进化
2020-03-22 自动驾驶传感器产业链
2020-03-22 激光雷达目标检测
2020-03-22 嵌入式系统综述
点击右上角即可分享
微信分享提示