基于LLVM的RISC-V自定义扩展指令支持方法①
2022-01-06邢明杰
王 鹏, 陈 影, 邢明杰
1(中国科学院 软件研究所, 北京 100190)
2(中国科学院 深圳先进技术研究院, 深圳 518055)
3(合肥工业大学 数学学院, 合肥 230601)
由于RISC-V指令集架构具有开源、模块化、可扩展等特性, 近年来在许多领域迅速兴起.国内外也出现了一些基于RISC-V进行指令集扩展的研究和实现.例如, 神经网络指令集扩展研究[1-4], 加密指令集扩展研究[5], 平头哥公司发布的玄铁C910处理器[6]等.对于标准指令集扩展, RISC-V社区会提供完整的工具链支持[7], 而对于非标准的自定义指令集扩展, 则意味着需要用户自己实现工具链支持.
LLVM编译框架具有模块化、可复用等特性[8-10],适合用于快速搭建原型系统和二次开发.目前LLVM社区已经对RISC-V体系结构进行支持.本文通过对LLVM现有框架进行分析, 研究在RISC-V后端对自定义扩展指令集的支持方法, 为基于LLVM基础架构的RISC-V自定义指令集扩展研究与实现提供借鉴.文章组织如下: 第1节介绍RISC-V指令集扩展, 包括标准指令集扩展和非标准的自定义指令集扩展; 第2节对LLVM框架进行分析, 重点分析现有的RISC-V体系结构相关部分; 第3节研究基于LLVM实现扩展指令支持的方法, 并以玄铁C910为例进行实现和验证;第4节给出结论与展望.
1 RISC-V指令集扩展
RISC-V指令集架构被设计成由基础整数指令集和各种扩展指令集组成.其中, 基础整数指令集非常精简(目前最新版本的RV32I仅包含40条指令), 同时功能又足以支持编译器和操作系统[7-9].扩展指令集分为标准扩展指令集和非标准扩展指令集.扩展指令集不仅支持固定宽度指令, 还可以支持可变长指令和VLIW指令.为了能够有效支持各种指令集扩展, RISC-V指令集架构在编码空间和命名约定等方面做了详细的设计和规划.
1.1 标准指令集扩展
标准指令集扩展涵盖了常用的功能支持, 并且相互之间不能存在指令编码冲突.非特权指令集使用单个字母或者以Z开头的字母组合来命名, 特权指令集则使用S (Supervisor级别)、H (Hypervisor级别)或者Zxm (Machine级别)开头的字母组合来命名.其中, 字母G用来表示通用指令集扩展组合IMAFDZicsr_Zifencei,依次表示整数、乘除法、单精度浮点、双精度浮点、控制状态寄存器访问、取指栅栏指令集.指令集名称不区分大小写, 名称之间可以使用下划线来分割, 并且后面可以有版本号信息.
RISC-V国际基金会下面设有专门的任务工作组来负责标准指令集扩展规范的制定.同时还设有工具链相关的任务工作组来负责推动开源社区工具链对标准指令集的支持, 从而对RISC-V的生态建设起到很好的支撑作用.
1.2 自定义指令集扩展
RISC-V指令集架构允许并鼓励用户根据自己的需求来定制指令集扩展.自定义的非标准指令集可以与它不支持的标准扩展或者非标准扩展之间存在指令编码冲突.不过为了减少冲突, RISC-V指令集规范也为自定义扩展指令集预留了4个主编码字段: 0b0001011(custom-0), 0b0101011 (custom-1), 0b1011011 (custom-2)和0b1111011 (custom-3).自定义扩展指令集使用以X开头的字母组合来命名.
比较有代表性的自定义指令集扩展实现是平头哥推出的玄铁C910, 一款12级超标量流水线、3发射、乱序执行的高性能64位嵌入式多集群多核RISC-V处理器.其标准指令集架构为RV64GCV, 并在此基础上增加了自定义扩展指令集和相应的控制状态寄存器,用于增强计算、存储和多核等方面的性能.扩展指令集的总体信息如表1所示.
表1 玄铁C910扩展指令集
新增指令的位宽为32位固定长度, 其指令主编码使用的是custom-0预留编码.新增控制状态寄存器的总体信息如表2所示.
表2 玄铁C910扩展寄存器
此外, 扩展指令集需要在机器模式控制状态寄存器MXSTATUS中开启扩展指令集使能位THEADISAEE的时候才能正常运用, 否则会出现非法指令异常.
2 LLVM框架分析
最新的LLVM代码已经开始对RISC-V体系结构进行支持.因此, 在LLVM中增加自定义扩展指令支持需要首先对LLVM框架[11], 特别是RISC-V相关部分有所熟悉[12].
2.1 LLVM整体框架
LLVM可以看作是一个编译基础设施, 由一系列的功能模块以及基于这些模块构建的工具集组成[13-15].其整体框架如图1所示.
图1 LLVM整体框架
LLVM主要涉及到编译器的中、后端, 其代码以模块的形式进行划分和实现, 包括中间表示、代码分析、优化和代码生成等[16-18].基于这些模块实现的工具集有优化器(opt)、生成器(llc)、汇编器(llvmmc)等[19].Clang主要涉及到编译器的前端, 也是采用类似的形式进行模块化实现, 包括抽象语法树、词法分析、语法分析、语义分析和LLVM中间代码生成等.基于这些模块实现的工具集有编译器(clang)、静态检查工具(clang-tidy)等[20].
2.2 RISC-V体系结构相关部分
为了能够支持多种目标体系结构(X86、ARM、RISC-V等), LLVM的代码结构被划分成体系结构无关部分和相关部分, 如图2所示.
图2 多目标体系结构支持框架
体系结构无关部分使用通用的算法来实现各种分析、优化以及代码生成(涉及指令选择、指令调度、寄存器分配等), 并通过抽象接口来获取体系结构相关的信息, 执行相应的处理.其中RISC-V体系结构相关信息主要包括:
1) 芯片特性.描述芯片所支持的特性、对应的命令行参数和说明信息等.
2) 寄存器信息.描述寄存器的编号、大小、存放数据类型、名称、类别、分配优先级等.
3) 指令信息.描述指令格式、编码、操作数、指令选择匹配模式、对应的汇编代码等.
4) 调用约定.描述需要被调用函数保存的寄存器列表等.
5) 调度模型.描述调度资源、指令延迟周期、对调度资源的使用情况等.
6) 处理器模型.描述处理器所支持的指令调度模型、芯片特性等.
这些信息都是通过LLVM自带的TableGen语言来编写.TableGen是一个领域专用语言, 用来帮助LLVM开发者来处理大规模的信息描述, 简化代码编写和维护工作.其语法形式借鉴了C++的类和模板, 并增加一些用于处理指令选择、指令编码的数据类型和操作.
图3给出了TableGen代码的处理流程: 在构建LLVM的时候, 用户使用TableGen编写的代码(文件名通常以td为后缀), 会先通过工具llvm-tblgen进行解析, 然后在构建目录下生成C++数据结构和代码片段(文件名通常以inc为后缀), LLVM源文件通过#include方式将这些生成的文件包含进来.
图3 TableGen代码处理流程
TableGen本身只是描述信息记录, 至于具体生成什么样的C++代码, 则需要LLVM开发者来实现相应的C++代码生成后端.除此之外, 还有一些目标体系结构抽象接口不适合使用TableGen来描述和自动生成,这部分则直接使用C++代码来实现, 主要包括:
1) 栈帧布局.处理栈空间的增长方向、地址对齐方式、局部变量地址偏移以及在函数开头和结尾处插入栈帧维护代码等.
2) 部分指令选择处理.例如指令DAG图构建过程中的类型和操作合法化、函数调用和返回的处理、特殊DAG节点的处理等.
3) 部分寄存器信息.例如获取预留寄存器列表、消除帧指针等.
4) 部分指令信息.例如判断指令是否为对栈槽进行加载或存储、对分支跳转指令的分析和处理等.
5) 汇编器和反汇编器的接口函数实现.
6) 机器代码层(MC)的处理.例如ELF文件写出、重定位信息、汇编指令打印等.
2.3 LLVM测试框架
LLVM源码包中自带的测试用例有两种: 回归测试与单元测试.其中单元测试使用Google C++测试框架编写, 用来测试LLVM的功能单元.回归测试使用LLVM测试框架编写, 用来验证特定功能点或者已经修复的问题.这些测试用例需要在每次提交代码之前运行通过, 从而避免新的改动出现回退现象.
3 扩展指令支持方法
我们可以将基于LLVM的扩展指令支持分为汇编层面支持和编译层面支持.其中, 编译层面支持是指可以将用户编写的高级语言程序转换成含有扩展指令的汇编程序或者机器指令编码.编译层面支持有两种常见的方式: 一是在高级语言中定义新的数据类型和编译器内建函数, 使得用户可以直接通过函数调用的形式来使用扩展指令; 二是通过编译优化技术将中间代码自动转换成机器特定的扩展指令.
本文主要研究汇编层面的支持方法.汇编层面支持是指可以将用户编写的含有扩展指令的汇编程序转换成机器指令编码.根据前面对RISC-V指令扩展的介绍以及LLVM框架的分析, 可以看到汇编层面支持大体需要完成如下工作:
1)定义新的芯片特性, 添加命令行选项;
2) 针对新增加的寄存器, 实现相应的寄存器信息描述以及可能涉及到的抽象接口;
3) 针对新增加的指令, 实现相应的指令信息描述以及可能涉及到的抽象接口;
4) 根据指令集扩展情况, 可能需要对汇编器和反汇编器的接口函数进行更新实现;
5) 根据指令集扩展情况, 可能需要在机器代码层增加相应的处理;
6) 编写测试用例, 对新增加的指令集扩展进行测试和验证.
接下来, 我们将以玄铁C910的扩展指令支持为例, 对主要涉及到的工作内容进行具体介绍.我们已经将完整的代码实现进行了开源, 项目地址为: https://github.com/isrc-cas/c910-llvm.
3.1 定义芯片特性, 添加命令行选项
我们在RISCV.td文件中, 通过TableGen语言来描述玄铁C910所支持的指令扩展特性.参见代码示例1,其中特性FeatureExtXuantie继承自SubtargetFeature,并通过模板参数给出名字、属性、属性值、文字描述信息.同时, 定义一个断言HasExtXuantie, 可以在指令描述中用来设置指令选择和汇编指令匹配的判断条件.
代码示例1.定义芯片特性def FeatureExtXuantie:SubtargetFeature<"xuantie", "HasExtXuantie","true", "'Xuantie' (Xuantie Custom Instructions)">;def HasExtXuantie:Predicate<"Subtarget->hasExtXuantie()">,AssemblerPredicate<"FeatureXcache">;
除此之外, 还定义了一个命名为c910的处理器模型.从而, 用户可以在汇编器llvm-mc的命令行中使用-mattr=+xuantie或者-mcpu=c910来开启对玄铁C910扩展指令的支持特性.
3.2 描述寄存器信息
由于玄铁 C910只对控制状态寄存器进行了扩展,并没有增加新的通用寄存器或者其他用来存放数据、参与寄存器分配的寄存器, 因此, 基于现有的RISC-V代码框架, 对这部分进行支持所需要做的工作比较简单.我们在RISCVSystemOperands.td文件中, 使用TableGen语言对它进行描述.代码示例2给出了部分扩展控制状态寄存器的描述, 例如, MXSTATUS寄存器继承自父类SysReg, 并通过模板参数给出它的名字和编码.
代码示例2.描述状态寄存器信息def MXSTATUS : SysReg<"mxstatus", 0x7C0>;def MHCR : SysReg<"mhcr", 0x7C1>;def MCOR : SysReg<"mcor", 0x7C2>;def MCCR2 : SysReg<"mccr2", 0x7C3>;
现有的RISC-V代码框架已经实现了SysReg的定义, 以及相应的支持.所以, 用户只需要添加一行TableGen描述即可.然后在汇编程序中, 便可以使用该寄存器的名字作为指令的符号操作数.
3.3 描述指令信息
我们以玄铁C910的位操作扩展指令EXT (寄存器连续位提取符号位扩展指令)和EXTU (寄存器连续位提取零扩展指令)为例介绍如何使用TableGen语言来描述指令信息.图4给出了这两条指令的编码格式.
图4 位操作扩展指令EXT和EXTU
其汇编语法形式如下:
1) ext rd, rs1, imm1, imm2
2) extu rd, rs1, imm1, imm2
可以看到EXT和EXTU两条指令具有相同的操作数, 只不过是12-14位的编码不同.因此, 可以将相同指令格式提取出来, 使用TableGen的class类型定义成一个模板类, 从而避免冗余的指令信息描述.图5给出了两个指令格式, 其中模板类RVInst是在RISCVInstrFormats.td文件中定义, 用来表示32位的RISC-V指令格式.目前LLVM中所有的RISC-V扩展指令都是继承自该父类.
图5 指令格式
我们参照LLVM现有的RISC-V代码框架, 新增加一个RISCVInstrFormatsC910.td文件用来定义扩展指令格式.其中模板类RVInstC910BO_1用来表示EXT和EXTU这样的位操作扩展指令格式, 它的主编码为0b0001011 (指令集规范中预留的custom-0主编码).然后新增加一个RISCVInstrInfoC910.td文件用来定义具体的扩展指令, 以及细化的指令格式模板子类.
代码示例3给出了RVInstC910BO_1的定义, 其模板参数分别为12-14位的编码, 指令的输出和输入操作数, 汇编指令字符串.然后, 在类的定义中根据这些参数来设置指令相应字段的值.具体的代码含义可以参照TableGen语言文档资料.通过TableGen提供的这种抽象和继承机制, 我们可以很方便的实现对玄铁C910扩展指令集的支持.
代码示例3.描述指令信息class RVInstC910BO_1<bits<3> funct3, dag outs, dag ins, string opcodestr, string argstr>: RVInst<outs, ins, opcodestr, argstr, [], InstFormatOther> {bits<6> imm1;bits<6> imm2;bits<5> rs1;bits<5> rd;let Inst{31-26} = imm1;let Inst{25-20} = imm2;let Inst{19-15} = rs1;let Inst{14-12} = funct3;let Inst{11-7} = rd;let Opcode = OPC_CUSTOM0.Value;}
3.4 测试验证
最后, 我们通过编写测试用例, 来验证对玄铁C910扩展指令的支持情况.参照现有测试框架, 在test/MC/RISCV目录下新增一个c910-valid.s文件用来测试有效的汇编指令, 同时新增一个c910-invalid.s文件用来测试对无效指令的错误处理.代码示例4中给出了测试用例的开头部分.
代码示例4.测试用例# RUN: llvm-mc %s -triple=riscv64 -mcpu=c910 -riscv-no-aliases -show-encoding # RUN: | FileCheck -check-prefixes=CHECK-ASM,CHECK-ASMAND-OBJ %s# CHECK-ASM-AND-OBJ: ext a0, a1, 4, 1# CHECK-ASM: encoding: [0x0b,0xa5,0x15,0x10]ext a0, a1, 4, 1
前两行是要执行的测试命令, 由RUN开头并且嵌套在代码注释中.LLVM测试工具llvm-lit会根据这些命令来调用汇编器llvm-mc, 然后将输出结果传送给检查工具FileCheck.FileCheck工具会根据注释中CHECK关键字开头的内容来对比汇编生成结果.
代码示例5是汇编指令测试用例, 有效汇编指令c910-valid.s文件中包含了新增的99条自定义玄铁C910指令集, 我们运行llvm-lit来单独测试c910-valid.s汇编文件的正确性, 运行结果输出 Expected Passes 1, 说明所有的新增自定义指令的汇编编码都是正确的.
代码示例5.汇编指令测试用例$./bin/llvm-lit -v../test/MC/RISCV/c910-valid.s-- Testing: 1 tests, single process --PASS: LLVM :: MC/RISCV/c910-valid.s (1 of 1)Testing Time: 0.30s Expected Passes : 1
代码示例6是新增寄存器测试用例, 在控制与状态寄存器文件user-csr-names.s中, 我们添加了玄铁C910扩展寄存器fxcr, 它的功能是用于浮点扩展功能开关和浮点异常累积位, 我们对新增寄存器进行指令编码,然后用llvm-mc求出fxcr的编码, 同时将寄存器别名添加到user-csr-names.s汇编文件中.
代码示例6.新增寄存器测试用例User@dacent:~/tools/c910-project/c910-llvm/test/MC/RISCV$ vim user-csr-names.s# fxcr# name# CHECK-INST: csrrs t1, fxcr, zero# CHECK-ENC: encoding: [0x73,0x23,0x00,0x80]# CHECK-INST-ALIAS: csrr t1, fxcr# uimm12# CHECK-INST: csrrs t2, fxcr, zero# CHECK-ENC: encoding: [0xf3,0x23,0x00,0x80]# CHECK-INST-ALIAS: csrr t2, fxcr# name csrrs t1, fxcr, zero# uimm12 csrrs t2, 0x800, zero
代码示例7是99条新增玄铁C910指令中ff0指令, ff0指令是快速找 0 指令, 我们用llvm-mc进行测试, 编译选项选择mcpu=c910 和mattr=+c910, 可以确定ff0指令对应的编码形式.
代码示例7.ff0指令汇编测试用例User@dacent:~/tools/c910-project/c910-llvm/build/bin$ echo "ff0 a0,a1" |./llvm-mc --triple=riscv64 -mcpu=c910 -mattr=+c910 -showencoding -show-inst.text ff0a0, a1# encoding: [0x0b,0x95,0x05,0x84]# <MCInst #399 FF0# <MCOperand Reg:11># <MCOperand Reg:12>>
代码示例8是利用llvm-mc, 在选定了编译选项是mcpu=c910 和mattr=+c910之后, 对汇编编码进行反汇编测试, 查看执行之后得出的是否是对应的ff0汇编指令.
代码示例8.ff0指令反汇编测试用例User@dacent:~/tools/c910-project/c910-llvm/build/bin$ echo"0x0b,0x95,0x05,0x84" |./llvm-mc -disassemble --triple=riscv64 -mcpu=c910 -mattr=+c910 -show-encoding -show-inst.text ff0a0, a1# encoding: [0x0b,0x95,0x05,0x84]# <MCInst #399 FF0# <MCOperand Reg:11># <MCOperand Reg:12>>
表3中列出了我们目前支持的所有新增玄铁C910指令的汇编测试, 反汇编测试, 编译选项mcpu=c910测试和无效操作数测试, 说明了新增玄铁C910自定义扩展指令集在LLVM中具有功能完备性支持.
表3 功能完备性测试
除此之外, 我们还在一个C文件test.c中, 使用内联汇编的方式编写了一条上述已定义的玄铁C910扩展指令, 使用clang编译生成汇编文件, 然后用llvm-mc将汇编文件test.s, 编译选项是mcpu=c910, 编译成目标文件, 之后可以通过反汇编来验证正确性.
代码示例9.玄铁C910新增指令内联汇编测试用例$./bin/clang --target=riscv64-unknown-elf test.c -S -o test.s$ cat test.c int main(){int a,b,c;a = 1;b = 2;asm volatile("mula %[z], %[x], %[y] ": [z] "=r" (c): [x] "r" (a), [y] "r" (b));if ( c == 0 ){
return -1;}return 0;}$./bin/llvm-mc test.s -triple=riscv64 -mcpu=c910 -show-encoding -show-inst --filetype=obj -o=test.o
4 结论与展望
本文通过对RISC-V指令集扩展和LLVM框架的分析, 给出了在LLVM中实现对RISC-V自定义指令集扩展的支持方法.结合玄铁C910的例子可以看到,在现有LLVM框架下, 对于32位指令集扩展的汇编层面支持比较容易实现.
对于其他宽度的指令扩展支持, 包括可变长指令和VLIW指令扩展的支持, 还需要做进一步的分析研究.除此之外, 对扩展指令的编译层面支持涉及到编译器的前、中和后端多个方面.后续工作中, 将重点研究这部分内容.