RV32I 指令格式
图中显示了六种基本指令格式,分别是:
- 用于寄存器-寄存器操作的 R 类型指令,
- 用于短立即数和访存 load 操作的 I 型指令,
- 用于访存 store 操作的 S 型指令,
- 用于条件跳转操 作的 B 类型指令,
- 用于长立即数的 U 型指令
- 用于无条件跳转的 J 型指令。
[! note] B 类型和 J 类型指令 分支指令(B 类型)的立即数字段在 S 类型的基础上旋转了 1 位。跳转指令(J 类型)的直接字段在 U 类型的基础上旋转了 12 位。因此,RISC-V 实际上只有四种基本格式, 但我们可以保守地认为它有六种格式。
指令格式能从一些方面说明 RISC-V 更简洁的 ISA 设计能提高提高性能功耗 比。
- 首先,指令只有六种格式,并且所有的指令都是 32 位长,这简化了指令解码。ARM-32,还有更典型的 x86-32 都有许多不同的指令格式,使得解码部件在低端实现中偏昂贵,在中高端处理器设计中容易带来性能挑战。
- 第二,RISC-V 指令提供三个寄存器操作数,而不是像 x86-32 一样,让源操作数和目的操作数共享一个字段。当一个操作天然就需要有三个不同的操作数,但是 ISA 只提供了两个操作数时,编译器或者汇编程序程序员就需要多使用一条 move(搬运)指令,来保存目的寄存器的值。
- 第三,在 RISC-V 中对于所有指令,要读写的寄存器的标识符总是在同一位置,意味着在解码指令之前,就可以先开始访问寄存器。在许多其他的 ISA 中,某些指令字段在部分指令中被重用作为源目的地,在其他指令中又被作为目的操作数(例如,ARM-32 和 MIPS-32)。因此,为了取出正确的指 =令字段,我们需要时序本就可能紧张的解码路径上添加额外的解码逻辑,使得解码路径的时序更为紧张。
- 第四,这些格式的立即数字段总是符号扩展,符号位总是在指令中最高位。这意味着可能成为关键路径的立即数符号扩展,可以在指令解码之前进行。
[! warning] 非法指令 为了帮助程序员,所有位全部是 0 是非法的 RV32I 指令。因此, 试图跳转到被清零的内存区域的错误跳转将会立即触发异常,这可以帮助调试。
类似地,所有位全部是 1 的指 令也是非法指令,它将捕获其他常见的错误,诸如未编程的非易失性内存设备、断开连接的内存总线或者坏掉的内存芯片。
极度精简、独特的 RV32I 指令集
为了给 ISA 扩展留出足够的空间,最基础的 RV32I 指令集只使用了 32 位指令字中的编码空间的不到八分之一。架构师们也仔细挑选了 RV32I 操作码,使拥有共同数据通路的指令的操作码位有尽可能多的位的值是一样的,这简化了控制逻辑。最后,当我们看到,B 和 J 格式的分支和跳转地址必须向左移动 1 位以将地址乘以 2,从而给予分支和跳转指令 更大的跳转范围。RISC-V 将立即数中的位从自然排布进行了一些移位轮换,将指令信号的扇出和立即数多路复用的成本降低了近两倍,这也简化了低端实现中的数据通路逻辑。
有什么不同之处?在这一章和后面的章节的结束部分,我们将描述 RISC-V 与其他指令集的不同之处。这种对比通常是描述相比于其他指令集,RISC-V 少了什么。省略什么特性和包括什么特性一样,都能体现架构师的精心设计。
ARM-32 指令集 12 位的立即字段不仅仅是一个常量,而是一个函数的输入,此函数 根据 12 位立即数的输入来产生一个常量:8 位被零扩展到全宽度,然后被循环右移。右移的位数是 12 位立即数中剩余 4 位的值乘 2。设计者希望在 12 位中编码更多有用的常数来减少执行指令的数量。在大多数指令格式中,ARM-32 也将十分宝贵的四位编码空间拿出来专门用于条件执行。这些条件执行指令不仅使用频率低而且增加了乱序处理器的复杂性。
[! note] 乱序执行处理器 这是一种高速的、流水化的处理器。它们一有机会就执行指令,而不是在按照程序顺序。
这种处理器的一个关键特性是寄存器重命名,把程序中的寄存器名称映射到大量的内部物理寄存器。条件执行的问题是不管条件是否成立,都必须给这些指令中的寄存器分配相应的物理寄存器。但内部物理寄存器的可用性是影响乱序处理器的关键性能资源。
RV32I 寄存器
除此之外,还有一个独立地 32 位寄存器——PC。
为了满足汇编语言程序员和编译器编写者,RV32I 有 31 寄存器加上一个值恒为 0 的 x0 寄存器。与之相比,ARM-32 只有 16 个寄存器,x86-32 甚至只有 8 个寄存器。
有什么不同之处?为常量 0 单独分配一个寄存器是 RISC-V ISA 能如此简单的一个很大的因素。第 3 章的第 36 页的图 3 给出了许多 ARM-32 和 x86-32 的原生指令操作,这两个指令集中没有零寄存器。我们可以用 RV32I 指令完成功能相同的操作,只需使用零寄存器作为操作数。
程序计数器(PC)是 ARM-32 的 16 个寄存器之一,这意味着任何改变寄存器的指令都有可能导致分支跳转。PC 作为一个寄存器使硬件分支预测变得复杂,因为在典型的 ISA 中,仅 10%-20%的指令为分支指令,而在 ARM-32 中,任何指令都有可能是分支指令。而分支预测的准确性对于良好的流水线性能至关重要。另外将 PC 作为一个寄存器也意味着可用的通用寄存器少了一个。
RV32I 整数计算
简单的算术指令(add, sub)、逻辑指令(and, or, xor),以及移位指令 (sll, srl, sra)和其他 ISA 差不多。他们从寄存器读取两个 32 位的值,并将 32 位结果写 入目标寄存器。RV32I 还提供了这些指令的立即数版本。和 ARM-32 不同,立即数总是进行符号扩展,这样子如果需要,我们可以用立即数表示负数,正因为如此,我们并不需要一个立即数版本的 sub。
程序可以根据比较结果生成布尔值。为应对这种使用场景下,RV32I 提供一个当小于时置位的指令 (set less than, slt)。
- 如果第一个操作数小于第二个操作数,它将目标寄存器设置为 1,否则为 0。
- 不出所料,对这个指令,有一个有符号版本(slt)和无符号版本(sltu),分别用于处理有符号和无符号整数比较。
- 相应的,上述两条指令也有立即数版本的(slti,sltiu)。 正如我们将要看到的,虽然 RV32I 分支指令可以检查两个寄存器之间的所有关系,但一些条件表达式涉及多对寄存器之间的关系。对于这些表达式,编译器或汇编语言程序员可以将 slt 以及与或异或等逻辑指令组合使用来解决更复杂的条件表达式。
整数计算指令 lui、auipc 主要用于构造大的常量数值和链接。
- 加载立即数到高位(lui)将 20 位常量加载到寄存器的高 20 位。接着便可以使用标准的立即指令来创建 32 位常量。这样子,仅使用 2 条 32 位 RV32I 指令,便可构造一个 32 位常量。
- 向 PC 高位加上立即数(auipc)让我们仅用两条指令,便可以基于当前 PC 以任意偏移量转移控制流或者访问数据。
- 将 auipc 中的 20 位立即数与 jalr 中 12 位立即数的组合,我们可以将执行流转移到任何 32 位 PC 相对地址。
- 而 auipc 加上普通加载或存储指令中的 12 位立即数偏移量,使我们可以访问任何 32 位 PC 相对地址的数据。
有什么不同之处?
- 首先,RISC-V 中没有字节或半字宽度的整数计算操作。操作始终是以完整的寄存器宽度。内存访问需要的能量比算术运算高几个数量级。因此低宽度的数据访问可以节省大量的能量,但低宽度的运算不会。ARM-32 具有一个不寻常的功能,对于大多数算术逻辑运算中的一个操作数,你可以选择对它进行移位。尽管这些指令的使用频率很低,但它使数据路径和数据通路更加复杂。与此相对的是,RV32I 提供了单独的移位指令。
- RV32I 也不包含乘法和除法,它们包含在可选的 RV32M 扩展中(参见第 4 章)。与 ARM-32 和 x86-32 不同,即使处理器没有添加乘除法扩展,完整的 RISC-V 软件栈也可以运行,这可以缩小嵌入式芯片的面积。MIPS-32 汇编程序可能用一系列移位以及加法指令来替换乘法,以提高性能,这可能会使程序员看到处理器执行了汇编程序中没有的指令,进而造成混淆。RV32I 可以忽略了这些特性:循环移位指令和整数算术溢出检测,这两个 特性都可以用若干条 RV32I 指令来实现(参见第 2.6 节)。
[! note] xor 指令可以进行花式操作 可以在不使用中间寄存器的情况下交换两个值!此代码交换 x1 和 x2 的值: 提示:异或操作是可交换的 (a ⊕ b = b ⊕ a),可结合的 ((a⊕b)⊕c = a⊕(b⊕c)),是它自己的逆操作 (a⊕a = 0),并且有一个单位元 (a⊕0 = a)。
xor x1, x1, x2 # x1’ == x1^x2, x2’ == x2 xor x2, x1, x2 # x1’ == x1^x2, x2’ == x1’^x2 == x1^x2^x2 == x1 xor x1, x1, x2 # x1” == x1’^x2’ == x1^x2^x1 == x1^x1^x2 == x2, x2’ == x1
不论这个操作多么奇妙,RISC-V 充足的寄存器使得编译器通常可以找到一个临时寄存器,因此很少使用这个异或的交换操作。
Load/Store
除了提供 32 位字(lw,sw)的加载和存储外,RV32I 支持加载有符号和无符号字节和半字(lb,lbu,lh,lhu)和存储字节和半字(sb,sh)。
- 有符号字节和半字符号扩展为 32 位再写入目的寄存器。
- 即使是自然数据类型更窄,低位宽数据也是被扩展后再处理,这使得后续的整数计算指令能正确处理所有的 32 位。
- 在文本和无符号整数中常用的无符号字节和半字,在写入目标寄存器之前都被无符号扩展到 32 位。
加载和存储的支持的唯一寻址模式是符号扩展 12 位立即数到基地址寄存器,这在 x86-32 中被称为位偏移寻址模式。
有什么不同之处?
- RV32I 省略了 ARM-32 和 x86-32 的复杂寻址模式。另外,ARM-32 提供的寻址模式并非适用于所有数据类型,但 RV32I 寻址不会歧视任何数据类型。RISC-V 可以模仿某些 x86 寻址模式。例如,将立即数字段设置为 0 即与 x86 中的寄存器间接寻址效果相同。
- 与 x86-32 不同,RISC-V 没有特殊的堆栈指令。将 31 个寄存器中的某一个作为堆栈指针,标准寻址模式使用起来和压栈(push)和出栈(pop)类似,并且不增加 ISA 的复杂性。
- 与 MIPS-32 不同,RISC-V 不支持延迟加载(delayed load)。与 延迟分支的设计相似,为了更好的适应五级流水线,MIPS-32 重新定义了 load 指令的语义,load 上来的数据在 load 指令两个指令后才可用。但是对于后来出现的更长的流水线,延迟加载带来的收益逐渐消失,因此 RISC-V 不支持延迟加载。
- 虽然 ARM-32 和 MIPS-32 要求存储在内存中的数据,要按照数据的自然大小进行边界对齐,但是 RISC-V 没有这个要求。移植旧的代码有时需要未对齐的访问。对于不对齐访问,一种选择是在基础 ISA 中禁止不对齐访问,然后提供一些单独的指令用于不对齐访问,例如 MIPS-32 中的 Load Word Left 和 Load Word Right。然而,这会使寄存器访问变得复杂,因为 lwl 并且 lwr 需要对寄存器进行部分写,而不是简单地对寄存器进行完整的写。支持不对齐访问的,另一种方法就是让普通的加载和存储指令支持不对齐访问,这简 化了整体设计。
[! note] RISC-V 采用小端序 RISC-V 选择了小尾端字节序,因为它在商业上占主导地位:所有 x86-32 系统,Apple iOS, 谷歌 Android 操作系统和微软 Windows for ARM 都是低字节优先序。由于字节顺序仅在同 时以按字访问和按字节访问同一份数据时才会有影响,字节序只会影响很少一部分的程序员。
条件分支
RV32I 可以比较两个寄存器并根据比较结果上进行分支跳转。比较可以是:相等 (beq),不相等 (bne),大于等于(bge),或小于(blt)。最后两种比较有符号比 较,RV32I 也提供相应的无符号版本比较的:bgeu 和 bltu。剩下的两个比较关系(大于和小于等于)可以通过简单地交换两个操作数,即可完成比较。因为 x < y 表示 y > x 且 x ≥ y 表示 y ≤ x。
[! note] 边界检查 bltu 允许使用单个指令检查有符号数组的边界,因为任何负索引都将比任何非负边界更大!
由于 RISC-V 指令长度必须是两个字节的倍数——关于可选的双字节指令,请参考第 七章——分支指令的寻址方式是 12 位的立即数乘以 2,符号扩展它,然后将得到值加到 PC 上作为分支的跳转地址。PC 相对寻址可用于位置无关的代码,简化了链接器和加载器的工作(第 3 章)。
有什么不同之处?
- 如上所述,RISC-V 去掉了 MIPS-32,Oracle SPARC 等指令集中被广为诟病的延迟分支特性等。
- 对于条件分支,它还没有像 ARM-32 和 x86-32 那样使用条件码。条件码的存在使得大多数指令都需要隐式设置一些额外状态,这使乱序执行的依赖计算复杂化。
- 最后,它省略了 x86-32 中的循环指令:loop,loope,loopz,loopne, loopnz。
[! note] 获取 PC 的值 当前的 PC 可以通过将 auipc 的 U 立即数字段设置为 0 来获得。对于 x86-32,要想读取 PC,你需要先进行函数调用,(这样子可以将 PC 推入堆栈); 然后被调用的函数可以从堆栈中读 取刚被压栈的 PC,最后将 PC 值返回给调用者(需要再弹出堆栈)。
因此,或许当前的 PC 至少需要 1 个 store,2 个 load 和 2 个跳转!
无条件跳转
跳转并链接指令(jal)具有双重功能。
- 若将下一条指令 PC + 4 的地址保存到目标寄存器中,通常是返回地址寄存器 ra,便可以用它来实现过程调用。
- 如果使用零寄存器(x0)替换 ra 作为目标寄存器,则可以实现无条件跳转,因为 x0 不能更改。
- 像分支一样,jal 将其 20 位分支地址乘以 2,进行符号扩展后再添加到 PC 上,便得到了跳转地址。
跳转和链接指令的寄存器版本(jalr)同样是多用途的。
- 它可以调用地址是动态计算 出来的函数,
- 或者也可以实现调用返回(只需 ra 作为源寄存器,零寄存器(x0)作为目的寄存器)。
- Switch 和 case 语句的地址跳转,也可以使用 jalr 指令,目的寄存器设为 x0。
有什么不同之处?RV32I 避开了错综复杂的程序调用指令,例如 x86-32 的进入和离开指令,或 Intel Itanium,Oracle SPARC 和 Cadence Tensilica 中的寄存器窗口。
杂项
控制状态寄存器指令 (csrrc、csrrs、csrrw、csrrci、csrrsi、csrrwi),使我们可以轻松地访问一些程序性能计数器。对于这些 64 位计数器, 我们一次可以读取 32 位。这些计数器包括了系统时间, 时钟周期以及执行的指令数目。
在 RISC-V 指令集中,
- ecall 指令用于向运行时环境发出请求,例如系统调用。
- 调试器使用 ebreak 指令将控制转移到调试环境。
- fence 指令对外部可见的访存请求,如设备 I / O 和内存访问等进行串行化。外部可见指对处理器的其他核心、线程,外部设备或协处理器可见。
- fence.i 指令同步指令和数据流。在执行 fence.i 指令之前,对于同一个硬件线程,RISC-V 不保证用存储指令写到内存 指令区的数据可以被取指令取到。 第 10 章介绍 RISC-V 系统指令。
有什么不同之处?
- RISC-V 使用内存映射 I/O 而不是像 x86-32 一样,使用 in,ins, insb,insw 和 out,out,outsb 等指令来进行 I/O。
- 为支持字符串处理,RISC-V 实现了字节存取,而不是像 x86-32 那样实现了 rep,movs,coms,scas,lods 等 16 条特殊的 字符串处理指令。
多个指令集比较
图 2.5 显示了我们的基准测试——用 C 实现的插入排序。图 2.6 是一个表,它总结了在编译到不同 ISA 后, 插入排序的指令数和字节数。
图 2.8 至 2.11 显示了插入排序编译生成的 RV32I,ARM-32,MIPS-32 和 x86-32 的汇编代码。尽管强调简单性,RISC-V 版本使用相同数目或更少的指令,并且不同架构的代码大小非常接近。在此示例中,RISC-V 的比较、执行分支指令和 ARM-32 和 x86-32 中花 式繁多的寻址模式以及入栈出栈指令一样,能够节省大量指令。