编译器后端中指令的编码是一项非常繁杂、易错的工作。需要查找大量的手册资料才能拼接出二进制序列,并且测试成本也比较高。于是研究了下,使用LLVM的模块完成指令的编码,自己的编译器这端只需要输出到符号化的汇编指令即可,大大减少了查文档拼二进制这项令人头秃的工作。
MCCodeEmitter
通过学习LLVM中X86等后端的代码,可以大概看出MCCodeEmitter的用法:
1. 先选定目标平台的Triple,创建出SubTargetInfo
2. 由此Triple创建出MCInstrInfo和MCRegInfo,分别是目标平台的指令与寄存器的信息,因为即使同为x86,也存在很多种不同的CPU特性集,这些信息的目的是保证生成指令限制在目标平台所支持的范围。
3. 创建MCContext上下文对象
4. 创建MCCodeEmitter对MCInst指令进行编码,得到二进制序列
示例代码:
InstrInfo & RegInfo
InstrInfo和RegInfo都是由tablegen工具根据对应的*.td文件生成的,如根据X86.td生成的X86Registers.inc提供了X86的寄存器信息,如X86::RAX。而X86InstrInfo则提供了X86的指令操作符的枚举列表,如X86::PUSH64r,代表PUSH一个64位寄存器的操作指令。
默认情况下tablegen生成的这些枚举都是不对外的,需要在包含这些文件前手动Define一个GET_REGINFO_ENUM和GET_INSTRINFO_ENUM来获取对应的枚举信息。
示例代码:
MCInst & MCInstBuilder
MCInstBuilder是一个Builder模式的类,用于创建一个对象化的机器指令MCInst。MCInst中包含了操作符、每个操作数等信息。将MCInst传给MCCodeEmitter进行编码,即可得到这条指令的二进制编码信息。
需要注意的是,这里的MCInst是和对应*.td文件中定义的指令的操作数对应的,而不是和目标平台的操作码、操作数对应的,因为不同的指令集可能有各种的编码方案,LLVM统一使用*.td文件对不同平台的指令重新进行了建模分类,这里的操作符枚举也是由LLVM定义的,而不是由对应平台定义的。
示例:将push rax这条指令编码为二进制
指令的tablegen描述
以上面的push rax为例,它在X86InstrInfo.td的定义如下:
这是一条非常简单的指令,它只接受一个GR64类型的$reg参数,即64位寄存器号。
但通常,使用MCInstrBuilder创建一个指令需要的参数并不和实际指令对应,这也和LLVM自己对指令的封装方式有关,例如add rbx, 1这条指令:
就需要3个操作数,其中RBX重复了2遍。同时,在X86的*.td文件中,是无法直接找到ADD64ri8这条指令的定义的,因为它是从ArithBinOp_RF这个类似于模板的类上生成出来的,而ArithBinOp_RF中又引用了BinOpRI8_RF。
到这里,就可以看到由于LLVM抽象了所有2元操作的定义,所以导致即使是单寄存器与i8的加法,也被生成为一个具有$src1、$src2和$dst这3个操作数的描述形式了。为了避免歧义,这里针对这条指令添加了一个Constraints约束,即$src1 = $dst,所以在构造时,2个寄存器必须传成相同的寄存器。
通过借助LLVM完成指令编码,虽然减少了直接查各个CPU文档带来的麻烦,但是需要非常了解LLVM对每个平台的指令建模分类形式,同时需要引用LLVM的tablegen生成的各个枚举值。由于这些*.td文件的结构不是固定的,导致LLVM的MCInst指令格式以及生成的RegInfo、InstInfo的枚举值也是高度依赖固定结构的,在编译时最好直接依赖LLVM一起编译,与最新生成的tablegen信息共同构建,从而保证这些生成的信息能够与LLVM相对应上。
后面,可能会尝试更具有通用和稳定性的方法,如自己生成成汇编描述,由LLVM完成标准的汇编描述到指令编码的转换,这样标准的汇编描述就可以作为一个稳定的结构,解偶LLVM的tablegen数据。
本文的示例代码:https://github.com/sbwtw/LLVMTest