泰晓科技 -- 聚焦 Linux - 追本溯源,见微知著!
网站地址:https://tinylab.org

儿童Linux系统,可打字编程学数理化
请稍侯

RISC-V CPU 设计(1):RISC-V 指令集

BossWangST 创作于 2023/07/11

Corrector: TinyCorrect v0.1-rc3 - [codeinline urls pangu epw] Author: Fajie.WangNiXi YuHaoW1226@163.com Date: 2022/07/01 Revisor: Falcon falcon@tinylab.org Project: RISC-V CPU Design Sponsor: PLCT Lab, ISCAS

为了设计出一款基于 RISC-V 指令集的 CPU,我们必须先对 RISC-V 指令集本身进行一定的了解。本文以 RV32 为主来做介绍。

指令集概述

RISC-V 指令集是一款诞生于最近十年的,完全开源的指令集架构(ISA)。与几乎所有的旧架构不同,它的未来不受任何单一公司的浮沉或一时兴起的决定的影响(这一点让许多过去的指令集架构都遭了殃)。它属于一个开放的,非盈利性质的基金会。RISC-V 基金会的目标是保持 RISC-V 的稳定性。

模块化的 ISA

计算机体系结构的传统方法是增量 ISA,新的处理器不仅必须实现新的 ISA 扩展,还必须实现过去的所有扩展来保持向后的二进制兼容性。

RISC-V 的不同寻常之处,除了在于它是最近诞生的和开源的以外,还在于:和几乎所有以往的 ISA 不同,他是模块化的。以 RV32 为例,它的核心是一个名为 RV32I 的基础 ISA,运行着一个完整的软件栈。RV32I 是固定的,永远不会改变,而模块化则来源于可选的标准扩展,例如 RV32IMFD 就表示在基础的 RV32I 模块下,扩展出了 RV32M 乘除法,RV32F 单精度浮点和 RV32D 双精度浮点。

RV32I — RISC-V 指令集的心脏

RV32I 的指令数量不多,但是却覆盖了大多数 32 位系统中所需要的基本指令。且 RISC-V 的其他扩展模块都需要建立在 RV32I 模块的基础上才能得以扩展。

RISC-V 指令分类

RISC-V 指令集是一个精简指令集,也就意味着其所有的指令都是定长的 32 位(这里暂不考虑 RV32C 中压缩的 16 位指令模块)。其分为以下 6 种类型:

  • R 型:寄存器的取数计算(Register)image-20220629213411147
  • I 型:带有短立即数的取数计算或访存计算(Immediate)image-20220629213535844
  • S 型:访问存储器的计算(Store)image-20220629213840867
  • B 型:分支条件跳转(Branch)image-20220629214609665
  • U 型:带有长立即数的取数计算(Upper)image-20220629214804540
  • J 型:直接跳转(Jump)image-20220629214927196

可以看到:

  1. RISC-V 的指令格式设计中在 R 型提供了 3 个寄存器操作数,且保证了其他所有类型的格式中,寄存器操作数的位置都与 R 型一致,即对于 RISC-V 的所有指令,要读写的寄存器标识符总是在同一个位置,这可以便利我们的译码与取数工作:在译码之前就可以先开始访问寄存器

  2. RISC-V 中的所有立即数,都采用了符号扩展,这意味着可能成为关键路径的扩展立即数可以在译码之前就计算完毕

    • 关于符号扩展的补充说明:
    • 对于立即数,在大多数指令集(如 MIPS)中都存在着两种扩展方式:符号扩展和零扩展。其本质就是对于一个需要扩展位数的立即数,其高位填入的是立即数的符号位(符号扩展)还是填入全零(零扩展)。RISC-V 在设计的时候则完全摒弃了零扩展的方式,采用全部符号扩展,这样设计的好处就在于可以让立即数字段的扩展操作和译码操作同时进行,提高了指令运行的效率(否则,立即数的扩展操作必须等待译码操作结束,根据信号再进行扩展)。
  3. 本质上 RISC-V 的指令格式只有 4 种,其中 B 型指令的立即数字段是在 S 类型的基础上旋转了 1 位;J 型指令的直接跳转字段是在 U 型的基础上旋转了 12 位。

    • 关于旋转的补充说明:

    • 在 RISC-V 的指令集中,为了保持所有指令格式在寄存器字段位置上的一致性,设计出的 S 型指令将 12 位的立即数(imm[11:0])进行了拆分,使得 12 位立即数变成了两部分(imm[11:5] 和 imm[4:0])。如下图所示:Snipaste_2022-07-01_12-50-36

      但是在 B 型分支跳转指令中,我们实际需要的是一个 13 位的立即数,而这个立即数的 LSB(Least Significant Bit)总是为 0(本质上就是需要 12 位立即数左移 1 位)。所以从编码的角度上说,B 型指令和 S 型指令一样,只需要 12 位的立即数(将末位的 0 省略)。但是,如果 B 型指令只存储 12 位立即数,则指令执行的过程中将必须进行 12 位立即数的左移操作。左移操作对于硬件来说并不复杂或缓慢,但是却要实实在在的消耗 CPU 硬件资源;通常来说,常数量级的左移操作在芯片内并不会去使用像 ALU 模块中那样的专用移位器,而是将输入硬连线至偏移 1 位的输出线上。这样的操作虽然在时间消耗上可以忽略,但是却需要使用 CPU 硬件层面 12~32 根甚至更多的额外硬连线资源。

      那么为了让 B 型指令的执行在资源上更加节约,最大限度的减少对指令中存储的立即数字段的移位操作,并在指令格式上保持与 S 型指令的统一,B 型指令利用原本 S 型中不存储的 LSB 来存储 B 型指令中 13 位立即数中除符号位的最高位(imm[11]),这样就让 B 型指令在结构上和 S 型指令一致(寄存器字段和立即数字段的位置相同),也称此种操作叫旋转

      如此一来,当 B 型指令执行的时候,只需要读取指令中的 12 位立即数(根据格式拼接),末位添一位 0 就可以直接得到分支跳转的偏移量,而不需要占用额外的硬连线资源了。

      同理,对于 J 型指令和 U 型指令,两者唯一的差别在于 U 型指令需要将 20 位的立即数左移 12 位获得 32 位的长立即数,而 J 型指令需要将 20 位的立即数左移 1 位来获得 21 位的无条件跳转地址。如下图所示:upper-immediatejump-inst

      所以 RISC-V 同样在设计时利用了两种指令本身不需要存储末尾 bit 的特点,U 型指令存储的 20 位立即数字段,在 J 型指令中保持了末 8 位(imm[19:12])的数值格式不变,而将高 12 位填充进了无条件跳转的目标地址的低 12 位。

    • 总结:上述指令中虽然某些立即数字段的顺序比较奇怪,但是却最大程度的保持了指令类型之间格式的统一(imm[10:1] 在 4 种指令类型中位置不变,imm[19:12] 在 U/J 指令中位置不变,imm[4:1] 在 S/B 指令中位置不变)。RISC-V 通过这样的设计,让移位操作的负担留给了汇编器,更加简化了硬件层面的设计。

RV32I 的寄存器

在 RV32I 模块中,共 32 个 32 位通用寄存器,外加一个 32 位的 PC 寄存器存储着下一条指令的地址。命名上,可以将 32 个通用寄存器称为 x0~x31,但是我们也可根据不同寄存器的作用,对其重命名,如下图所示:

image-20220630135835766

RV32I 的整数计算

简单的算术指令(add,sub)、逻辑指令(and,or,xor),以及移位指令(sll,srl,sra)和其他 ISA 差不多。他们从寄存器读取两个 32 位的值,并将 32 位结果写入目标寄存器。RV32I 还提供了这些指令的立即数版本。和 ARM-32 不同,立即数总是进行符号扩展,这样子如果需要,我们就可以使用立即数表示负数,正因为如此,我们并不需要一个立即数版本的 sub。

程序可以根据比较结果生成布尔值。为了应对这种场景,RV32I 提供了一个当小于时置位的指令。如果第一个操作数小于第二个操作数,它将目标寄存器设置为 1,否则为 0。同样这个指令有一个有符号版本(slt)和无符号版本(sltu),分别用于处理有符号和无符号整数比较。

为了构造大的常量数值和链接,RV32I 提供了 lui 指令:加载立即数到高位。lui 将 20 位常量加载到寄存器的高 20 位,接着便可以使用标准的立即指令来创建 32 位常量。这样我们就可以仅使用 2 条 32 位的 RV32I 指令,构造出一个 32 位常量。

RV32I 的 Store 和 Load

存储和加载在 RISC-V 中只有唯一的寻址模式,即符号扩展 12 位立即数,将之送到基地址寄存器。与 MIPS-32 不同,RISC-V 不支持指令延迟槽,也就是 Load 指令获取的数据不一定需要等待两个指令之后才可用;对于后来出现的更长的流水线,延迟加载带来的收益会逐渐消失,因此 RISC-V 不支持延迟加载。

具体而言,在 RV32I 模块中,由于用户地址空间是小端方式按字节编址的 32 位地址,那么极限情况下 12 位立即数加上 32 位基地址寄存器的值是完全可以覆盖 32 位的用户地址空间的。如果考虑 RV64I 模块,由于寄存器也会扩展成 64 位,保证了 Load 和 Store 指令可以访问 RV64I 下 64 位用户空间的内存。

整数计算、访存的汇编代码展示

  • add, sub, slli, srai 指令演示image-20220701154420567
  • lui, slt, lw, sw 指令演示image-20220701160000855

RV32I 条件分支跳转

在 RISC-V 中,分支跳转指令去掉了 MIPS-32、Oracle SPARC 等指令集中被广为诟病的延迟分支特性等;对于条件分支,它也没有像 ARM-32 和 x86_32 那样使用条件码。条件码的存在使得大多数指令都需要隐式的设置一些额外状态,会导致乱序执行的依赖计算复杂化。最后,RISC-V 还省略了 x86_32 中的循环指令:loop,loope,loopz,loopne,loopnz。

RV32I 可以比较两个寄存器中保存的值并根据比较结果进行分支跳转,这里的比较可以是:

  • 相等:beq(equal)
  • 不相等:bne(not equal)
  • 大于等于:bge(greater or equal)
  • 小于:blt(less than)

上面的 bge 和 blt 都是进行有符号比较,RV32I 也提供了相应的无符号版本 bgeubltu。剩下的两个比较关系(大于和小于等于)则可以通过简单的交换两个操作数完成比较(x < y 表示 y > x 且 x >= y 表示 y <= x)。

由于 RISC-V 指令长度必须是两个字节的倍数(RV32C 模块中含有 16 位的短指令),所以分支跳转唯一的寻址模式就是:

  • 将指令中 12 位的立即数左移 1 位后符号扩展,得到的值到当前 PC 中,作为下一条指令的取指地址。

这也称为 PC 相对寻址,可用于位置无关的代码,简化了链接器和加载器的工作。

  • 补充说明:PC 相对寻址是如何简化链接器和加载器的工作?

    • 链接器允许各个文件独立地进行编译和汇编。这样在改动部分文件时,不需要重新编译全部源代码。链接器的作用就是把新的目标代码和已经存在的机器语言模块(如函数库)等拼接起来。下图展示了一个典型的 RISC-V 程序分配给代码和数据的内存区域:image-20220701164558893

      链接器需要调整对象文件的指令中程序和数据的地址,使之与上图中的地址相符合。那么如果输入文件中的是与位置无关的代码(PIC),则链接器的工作量就会有所降低。PIC 中所有的指令转移和文件内的数据访问都不会受到代码位置的影响。而在分支跳转指令中,跳转的目标地址是由当前 PC 与偏移量决定,这与本身代码位置无关,这一相对转移特性(PC-relative branch)使得程序更易于实现 PIC。

  • 补充说明:PC 的获取

    • 当前的 PC 可以通过 auipc(Add upper immediate to PC)指令获取,方法是将 U 立即数字段设置为 0,得到的结果就是 PC+0,即 PC 本身的值。对于 x86_32,想要读取 PC,则需要先进行函数调用(让 PC 进栈)。因此,获取当前 PC 值在 x86_32 中至少需要 1 个 store,2 个 load 和 2 个跳转指令;而 RISC-V 只需要 1 条 auipc 指令!

RV32I 无条件跳转

在 J 型指令中,RV32I 提供了 jal,jalr 两个无条件跳转指令。

  • jal:跳转并链接指令,具有双重功能。它可以将下一条指令(PC + 4)的地址保存在目标寄存器中(通常是返回地址寄存器 ra),实现过程调用的功能;同时如果使用零寄存器(x0)替换掉返回地址寄存器(ra)作为目标寄存器,就可以实现无条件跳转,因为零寄存器是硬连线至低电平无法更改的。和分支跳转一样,jal 的跳转目标地址同样是:
    • 将指令中 20 位的分支地址左移 1 位后符号扩展,得到的值到 PC 上,作为下一条指令的取指地址。
  • jalr:跳转和链接指令的寄存器版本,同样是多用途的。它可以调用动态计算出地址的函数,或者也可以实现调用返回(只需要 ra 作为源寄存器,x0 作为目标寄存器即可)。Switch 和 case 语句的地址跳转,也可以使用 jalr 指令,其中目标寄存器设为 x0。

分支跳转、无条件跳转的汇编代码展示

  • jal, bge 指令演示image-20220701161217732

RV32I 指令模块小结

通过上面的讲解,我们可以得知 RISC-V 指令集具有如下这些特性:

  • 允许 Load 和 Store 以字节、半字、字为单位的有符号或无符号值
  • 所有的算数、逻辑和移位指令都有立即数版本的指令相对应
  • 立即数总是符号扩展
  • 仅提供一种数据寻址模式(寄存器 + 立即数)和 PC 相对寻址
  • 一个指令 lui 用于加载立即数到高位,这样加载 32 位常量到寄存器只需要 2 条指令

总结

本文细致地对 RISC-V 指令集进行了概述并讲解了 RISC-V 指令集的核心模块 RV32I,从具体的指令规范角度阐释了 RV32I 的指令特点。

系列文章预告:CPU 设计中的 Verilog 语言基础简介与数字逻辑电路基本知识

参考资料

本文部分图片来自参考资料(Wiki 和 RISC-V 手册等),在此感谢原作者的辛苦工作!



Read Album:

Read Related:

Read Latest: