如何在RISC-V中添加一个指令

RISC-V支持指令的扩展,本文介绍在从添加一个完整的指令并且使用需要作出那些改动。(用户态指令)

步骤

  1. 确定这一指令的功能:计算加速/安全检查等等
  2. 确定指令的需求:需要读取几个寄存器,是否有立即数参与,最终确定指令类型(R、I、S、U)。参见指令手册2.2章Base Instruction Formats
  3. 确定指令的编码空间:是否兼容与已有指令,是否是一个新的扩展,找到一个编码空间。
  4. 硬件实现
    1. 在idecode.scala中添加指令编码,控制线(如果需要)
    2. 选择如何实现这一指令:RoCC指令添加,Rocket核心中添加或者自己增加一个单独的核心。
  5. 汇编器实现
    1. 在gnu工具链中添加汇编器的识别
    2. 在gnu工具链中添加反汇编识别
  6. 在编译器中实现
    1. 如何在编译器中使用这个新的指令

确定指令功能、需求和实现方式

确定指令需求是添加指令的第一步,如果指令设计有问题,之后的实现肯定遭遇各种问题。首先要思考以下问题:

  • 为什么要添加新的指令?
    • 用于加速:能够有多少加速效果?
      • 用RoCC指令本身就会因为无法使用旁路等机制减慢,要综合考虑加速的得失。
      • 用原有指令来组成这一功能需要多少条指令?加速的效果明显么?
      • 是否需要访存,是否需要从Cache中单独建立通路?
    • 用于添加功能:
      • 添加的功能是什么?
      • 这个功能需要哪些操作数,是否和CPU原有流水和功能有冲突?
      • 一个指令只有64bit,除去opcode的部分用于指定读写寄存器和立即数,是否够用?(等价于选用什么指令类型,如果不是规范的指令类型会在指定寄存器和立即数上更加复杂)
        • 每个寄存器需要5bit
        • 在核心中每条指令仅有两个读寄存器一个写寄存器,超过这个数量会变得复杂。
      • RoCC不能够进行控制转移(修改PC)等修改,在核心中更改的代价是否过高?
  • 硬件上如何处理这个指令?Rocket Core对于一般的指令自己处理,乘除法交给div模块处理,浮点交给fpu处理,定制指令交过RoCC模块处理。
    • RoCC是标准的扩展指令接口,经典的例子是用于sha算法加密等,这种方法优点是实现简单,完全自主,但效率比较低,不能应用旁路等。适合如下的指令:
      • 输入方法简单
      • 运算本身复杂
      • 与其他指令相关性小
      • 不产生控制流转移
    • 直接在Rocket Core中添加功能是可以效率很高的,但问题是比较复杂,你需要考虑五级流水中所有的问题。如果你的期望指令有如下的特性,那更适合直接在Rocket Core中修改:
      • 运算简单
      • 经常与前后指令产生旁路
      • 产生控制转移
      • 产生中断/异常
    • 当然,也可以像fpu一样,自己定义接口自己写,但这样一般更加适合直接用RoCC,包括定制的RoCC接口。

确定指令的编码

你需要考虑如下问题:

  • 是否要建立一个新的Extension?参见指令手册21章Extending RISC-V
  • 指令的格式是什么样的,包括几个读写寄存器和立即数?指令手册2.2章Base Instruction Formats
  • 确定编码后,在rocket-chip/src/main/scala/rocket/instructions.scala中写入你自己的指令。其中能够确定的决定指令的部分用二进制表示,表示寄存器/立即数的非确定部分用"?"表示。这样硬件上就可以给你的指令分配了一个指令空间。
  • 确定编码后,在$RISCV/riscv-gnu-toolchain/riscv-binutils-gdb/opcodes/riscv-opc.c中添加你的指令和格式,统一指令的不同格式(省略等)必须相邻。
  • $RISCV/riscv-gnu-toolchain/riscv-binutils-gdb/include/opcode/riscv-opc.h中写入你的指令mask和经过mask后的match,以及格式。如果你看不懂mask和match,你应该考虑一下你是否适合干这行。
  • 完成上述两条,你在汇编器中就给你的新指令占据了一个指令空间。分别编译硬件部分和软件部分确定你的编码空间是不冲突的。

确定控制线

一个指令在Rocke中被如何处理多数取决于decode阶段如何对这一指令解码,解码的结果是若干个控制线,这些解码表在rocket-chip/src/main/scala/rocket/idecode.scala中。这一文件中先定义了所有的控制线,然后定义了每个指令对应的控制线的解码结果。其中部分是X表示这个控制线可以是任意值。在Rocket Core中,id_ctrl/ex_ctrl/mem_ctrl/wb_ctrl就是控制线的集合,可以方便的调用任意阶段的指令解码后的控制线。

使用RoCC时的控制线设计

如果使用RoCC来处理新的指令,那么控制线基本不需要改动,因为控制线中的rocc位为真的话,Rocket Core直接将这个指令转发给RoCC处理。你只需要将读写寄存器相关的控制线确定好即可。

使用Rocket Core时的控制线设计

在Rocket Core中处理新指令时,最简单分辨你的新指令和旧指令的方法就是添加新的控制线。添加的控制线在所有旧指令中为假,在你的新指令中为真(亦或相反)。在各级流水处理时可以直接调用这条控制线来设计逻辑。

其余的控制线你仍然需要了解其意义,因为这决定了在Rocket Core中,如何读取和写入寄存器,是否调用加法器和乘法器等等,确保你认识每一条控制线和它的功能,再决定你的指令如何解码。

解码的逻辑是自动生成的,你无需关心。

硬件实现

RoCC的硬件实现

RoCC的硬件实现相对比较规范,你可以先看懂教程(忘了链接)。RoCC中有一个router,RoCC指令首先被发往这个router,router决定这一指令由哪个核心来进行处理。

简单的说,你需要实现一个自己的核心,专门处理自己的指令,并在RoCC router中识别并转发给你的核心。当计算完成后,结果返回给Rocket Core。

在这一实现中,你的实现方法是任意的,周期数也没有明确的要求,你可以随心所欲地设计自己的逻辑,尽快地得出结果并返回。

Rocket Core的硬件实现

如果你想在Rocket Core中实现,那么必须看懂rocket-chip/src/main/scala/rocket/rocket.scala。如何实现你所需的逻辑,需要明确的设计,和推敲。必须不影响其他指令的正常执行。这一方法比较麻烦,没办法直接在文章中说清。

汇编和反汇编实现

在决定编码的阶段,我们说过了需要改动$RISCV/riscv-gnu-toolchain/riscv-binutils-gdb/opcodes/riscv-opc.c$RISCV/riscv-gnu-toolchain/riscv-binutils-gdb/include/opcode/riscv-opc.h两个文件。改动正确之后,汇编器可以正确的识别新的指令并编码为二进制。幸运的是,objdump也直接可以识别新的指令。

反汇编器其实还有一个,就是spike-disas,也就是在硬件模拟的output中负责处理反汇编的小程序。这个反汇编器如何改,不知道。

编译器如何使用这一指令

至此,你的新的指令已经可以被汇编器汇编,被硬件处理了,剩下的只剩如何在编译器中使用你的指令了。

然而这可能是所有步骤中最复杂的。

gcc中

gcc中可能反而比较简单,因为gcc中经常直接使用字符串作为编译器的汇编结果输出。

LLVM中

LLVM中相对麻烦一些,你需要知道如何在后端中加入这个新指令,并在MIR中设置好何时调用这一指令。

总之,在编译器中如何使用这个指令是十分复杂的。