引言

我们已经对程序, 指令集, 硬件有简单的认识

  • 概念上
    • 编译: 把程序的状态机翻译成ISA的状态机
    • 微结构设计: 根据ISA状态机设计一个数字电路状态机

 

ISA作为软件和硬件之间的桥梁, 对计算机系统有重要的影响

 

本次课内容: RISC-V指令集

  • 讲指令格式和语义, 不如RTFM
  • 我们来讲RISC-V指令集的设计对程序和硬件有什么影响

参考书: RISC-V官方手册 & The RISC-V Reader

真正的RISC-V手册

  • The RISC-V Instruction Set Manual
    • Volume I: Unprivileged ISA
    • Volume II: Privileged Architecture

 

《The RISC-V Reader》是一本科普读物

  • 作者来自RISC-V团队, 书的质量并不低
  • 书中介绍了很多软硬件协同工作的例子和指令集设计的考量
    • 虽然是2017年出版, 有的内容已经跟不上官方的RISC-V手册, 但仍然值得阅读学习

《RISC-V开放架构设计之道》

  • 《The RISC-V Reader》的中文译本

指令集的评价标准

1. 成本

ARM Cortex-A5 RISC-V Rocket
ISA 32位ARM-v7 64位RISC-V
微结构 顺序单发射 顺序单发射5级流水
Dhrystone性能 1.57 DMIPS/MHz 1.72 DMIPS/MHz
工艺 TSMC 40GPLUS TSMC 40GPLUS
不带缓存面积 0.27 mm2 0.14 mm2
带16KB缓存面积 0.53 mm2 0.39 mm2
动态功耗 < 0.08 mW/MHz 0.034 mW/MHz

更小的芯片 = 一个晶圆中有更多可用芯片 = 每颗芯片的成本更低

2. 简洁性

更简洁的ISA = 更小的芯片面积

  • RV32IMA: 68条
  • ARM-v7(整数计算/乘除/原子): > 278条

 

更简洁的ISA = 更简单的设计和验证 = 更小的人力成本

  • 一个反例: ARM-v7中的ldmiaeq SP!, {R4-R7, PC}指令
    • LoaD Mulpitle, Increment-Address, on EQual
    • 需要从内存中读取5个数据, 更新6个寄存器
    • 其中包括PC, 相当于间接跳转
    • 但上述操作仅当EQ条件码为1时才执行
  • 设计的复杂性: 分支预测, 条件执行, 访存异常, 数据依赖…
  • 还觉得ARM是RISC指令集吗? 😂 (ARM-v8重新设计, 去掉了这类指令)

2. 简洁性(2)

另一个反例: x86中的enter指令, 功能等价于

push ebp
mov  ebp, esp

但现代编译器基本上不会编译出enter指令 😂

  • 译码复杂
    • enter指令在现代x86处理器上译码成10~20条uop(慢, 占空间)
    • 而上述push+mov只译码成3~5条uop
  • 延迟高
    • enter在一个core2处理器上需要执行8个时钟周期
    • 上述push+mov只需要2个时钟周期(有数据依赖关系, 不能同时执行)
  • 指令调度困难
    • enter是一条指令, 没法拆开
    • 编译器/处理器可以分别调度pushmov, 提升指令执行的并行度

尝试使用enter指令

#include <stdio.h>
#include <stdint.h>

uint64_t f(uint64_t a, uint64_t b) {
  return a + b;
}

int main() {
  uint64_t sum = 0;
  for (int i = 0; i < 1000000000; i ++) {
    sum = f(sum, i);
  }
  printf("sum = %lld\n", sum);
  return 0;
}

不使用enter指令

gcc -m32 a.c && time ./a.out

使用enter指令

gcc -m32 -S a.c
vim a.s # insert 'enter $0, $0' instruction
gcc -m32 a.s && time ./a.out

3. 性能

性能 = 程序执行时间

        time      inst     cycle     time
perf = ------- = ------ * ------- * -------
        prog      prog      inst     cycle

 

给定一个程序, 性能优化的方向

  • 减小inst/prog - 编译优化, 更好的指令集设计
  • 减小cycle/inst(CPI) - 增加IPC, 体系结构优化
    • 在B阶段中介绍
  • 减小time/cycle - 增加主频, 电路关键路径优化, 后端物理设计优化

性能公式反映出计算机设计的两个经典矛盾

性能 = 程序执行时间

        time      inst     cycle     time
perf = ------- = ------ * ------- * -------
        prog      prog      inst     cycle

 

inst/prog & cycle/inst

  • CISC(复杂指令集)
    • 包含行为复杂的指令, 编译器可选出更优的指令 - inst/prog
    • 但复杂指令的执行时间较长 - cycle/inst
  • RISC(精简指令集)
    • 指令行为简单, 编译器可选方案较少 - inst/prog
    • 但简单指令的执行时间较短 - cycle/inst

性能公式反映出计算机设计的两个经典矛盾(2)

性能 = 程序执行时间

        time      inst     cycle     time
perf = ------- = ------ * ------- * -------
        prog      prog      inst     cycle

 

cycle/inst & time/cycle

  • 简单微结构设计
    • 一周期可以完成更多事情 - cycle/inst
    • 但关键路径较长 - time/cycle
  • 复杂微结构设计
    • 事情要拆分到多个周期完成 - cycle/inst
    • 但关键路径较短 - time/cycle

4. 架构和具体实现的分离

原则: 不要让指令集手册的定义约束微结构的实现

反例: MIPS的延迟槽(delay slot)

  • (简化版)问题背景: 假设CPU有取指(IFU), 译码执行(IDEXU)两个单元
    • 两个单元可以流水地工作, 即IDEXU执行指令A时, IFU取指令B; IDEXU执行指令B时, IFU取指令C…
  • 问题: 如果IDEXU执行的是跳转指令, IFU应该取哪一条指令?
    • 是紧接着的下一条指令? 还是跳转目标的指令?
      • 可以等待1周期得到跳转结果再决定, 但会引入性能损失

 

程序分析领域中的两个定义

  • 静态指令 - 程序代码中的指令
  • 动态指令 - 程序运行过程中的指令

分支延迟槽

延迟槽方案: 修改分支指令的语义, 跳转操作延迟一条指令进行

  • 跳转指令 -> 跳转指令的下一条静态指令(延迟槽指令) -> 跳转/不跳转

 

一个例子:

100: beq 200
101: add
102: xor
...
200: sub
201: j   102
202: slt

动态指令流:

  • beq指令的执行结果为跳转: 100 -> 101 -> 200
  • beq指令的执行结果为不跳转: 100 -> 101 -> 102
  • j指令: 201 -> 202 -> 102

 

 

int main () { return 0; }

看看MIPS的反汇编代码

需要软硬件协同才能工作

  • ISA层次 - MIPS指令集手册中描述了这一约定

 

  • 处理器层次 - 架构师按照这一约定设计处理器
    • 处理器不等待分支指令的执行结果, 先无条件执行延迟槽中的指令

 

  • 程序层次 - 在延迟槽中放置一条有意义的指令
    • 使得无论是否跳转, 按照这一约定的执行顺序都能正确地执行程序
    • 一般由编译器来放置

 

很巧妙!

  • 计算机各个层次(程序, ISA, 处理器)协调一下, 就能设计出性能更高的计算机系统

但历史证明这是个负担

  • 程序: 汇编代码读起来有点反人类
    • 忍忍还凑合
  • 编译器: 找不到有意义的指令, 只能填nop = 处理器执行时浪费1周期
    • 幸好大部分情况下还能找到
  • 乱序多发射深度流水的高性能处理器: 这就是来整我的!
    • 基本块的识别要把延迟槽算进去
    • 一次执行4条指令, 遇到两条分支指令和两条延迟槽, 怎么办?
    • 十几级流水(取指和执行跳转指令隔十几周期), 延迟槽只有1条指令
      • 搞十几条延迟槽指令? 编译器就要gg了

 

没办法啊, 一旦写进ISA手册, 就不能删掉 😂

  • 编译器: 为了让程序在旧处理器上跑, 只能按ISA手册要求支持延迟槽
  • 处理器: 为了在新处理器上跑旧程序, 只能按ISA手册要求实现延迟槽

5. 提升空间

  1. 可以添加指令

定长指令集, 总有一天会把操作码空间用完, 就看是哪天了

  • ARM在添加16位压缩指令时, 发现没空间了
    • 于是设计了Thumb和Thumb-2指令集
    • 为了解决操作码的二义性, 在处理器状态中添加一个模式位
  • 民间消息: MIPS经过龙芯多年扩展,传闻指令槽已经用完

 

  1. 可以自由地添加指令
  • 目前基本上只有RISC-V可以做到: 设计, 实现, 生产, 销售
    • 公司所有 vs. 开放基金会所有

6. 代码大小

  • 嵌入式处理器: 更小的代码 = 更小的存储器 = 更低的成本
    • 低端嵌入式芯片都是白菜价, 出货量高
    • 成本节约1分钱, 就是巨大的竞争力
  • 高性能处理器: 更小的代码 = 更高的缓存命中率 = 更低的功耗 & 更高的性能
    • 访问功耗: 片外DRAM > 片内SRAM

 

右图: 使用GCC编译的SPEC CPU2006基准测试的代码大小

  • x86作为变长指令集, 代码竟然比RV32GC大26%
    • 其实是历史原因造成的

x86的1字节操作码空间中的不常用指令

  • 69条肯定不常用指令(粉红)
    • 系统指令(中断和I/O), 段寄存器相关, 保存寄存器现场, BCD码计算(被历史淘汰), EFLAGS标志位设置清除, ESP寄存器相关, 循环前缀
    • 之前讨论的enter指令 😂
  • 42条不常用指令(浅紫)
    • 进位加/借位减, 溢出和奇偶标志, 数据交换, 字符串操作, 原子前缀/字符串重复前缀

 

个人观点: 总计43.36%的1字节操作码空间的使用并不合理

  • 科学的方法: 哈夫曼编码
  • 但因向前兼容, 无法更改/回收
  • Intel的架构师在设计8086时(1978年)没有考虑编码对将来的影响

7. 易于编程/编译/链接

  1. 更多的通用寄存器有利于编译器将变量分配到寄存器, 提升程序性能
  • 寄存器分配: \(\{PC_c, v_1, v_2, v_3, \dots\} \rightarrow \{R, M\}\)

 

  1. 提供相对PC的分支和数据寻址, 可生成更高质量的位置无关代码(Position-Independent Code)

动态链接库需要PIC机制的支持

  • 代码 - 需要相对PC的分支指令
    • 大部分ISA都提供
    • 例如, RISC-V的beq, x86的jmp
  • 数据 - 需要相对PC的数据寻址指令
    • 例如, RISC-V的auipc + lw, x86-64的mov %eax, 4(%rip)
    • 但x86-32和MIPS未提供相对PC的数据寻址指令, 应如何实现?
      • 可通过GDB查看库函数的指令

RISC-V指令集简介

RISC-V指令集起源

UC Berkeley团队在2010年为下一个项目选ISA时的副产品

  • x86不可能: 没有授权, 太复杂
  • ARM几乎不可能: 没有64位(当时ARM-v8还没出), 授权困难, 太复杂
  • 其他ISA也各有问题: 公司所有, 软件生态不好, 没人用…

 

没有就自己搞一套!

  • 2010年暑假, 4个人(2名教授+2名学生)花3个月设计了一套干净的ISA

目标: 通用的ISA

  • 兼容现有软件栈和编程语言
  • 本地硬件指令集, 不是虚拟机(如Java bytecode)
  • 小到MCU, 大到超算, 所有规模的处理器都适用
  • 适用于所有的实现技术, 包括FPGA, ASIC, 全定制芯片, 甚至未来的制造元件技术
  • 可以设计出所有的微结构, 包括顺序和乱序, 单发射和超标量等
  • 支持高度定制化, 成为定制加速器的基础
  • 稳定, 基础部分不会改变, 不会突然消失

RISC-V特色

  • 简单: 和商业ISA相比简单得多
  • 干净的设计
    • 没有历史包袱
    • 与微结构设计解耦
  • 模块化: 很小的标准基础ISA, 很多标准扩展
  • 为扩展性和定制化而设计
    • 变长的指令编码(如果没有仔细RTFM, 你会感到意外!)
    • 为扩展ISA预留了很多操作码空间

诀窍: 通过模块化应对不同的应用场景

4种基础整数指令集: RV32I, RV64I, RV128I, RV32E

  • RV32E是16个寄存器的RV32I变种
  • 基础指令只有约50条

 

标准扩展:

  • M - 整数乘除
  • A - 原子操作
  • F - 单精度浮点
  • D - 双精度浮点
  • G = IMAFD
  • C - 压缩指令

除了压缩指令, 上述指令均采用固定4字节的指令长度

 

根据需求自由组合:

  • 教学 - RV64IMA
  • 桌面 - RV64GC
  • 高性能 - RV64GCBV
  • 嵌入式(处理器越小越好) - RV32E
  • 嵌入式(存储器越小越好) - RV32IC
  • 还支持自定义指令 - 属于非标准扩展

常见误解: 自定义指令会导致碎片化问题

正解: 需要结合应用场景来看待

  1. 厂商针对其解决方案添加的自定义指令, 不属于标准扩展
  • 也不属于共性需求, 基金会不负责维护
  • 对其他厂商的设计者和用户不可见, 不影响其他生态
  • 公共软件不会出现面向特定需求的专用指令
    • 例如, Linux文件系统中不会出现用于控制智能台灯的自定义指令
  1. 即使智能台灯厂和智能空调厂设计的自定义指令相互冲突, 也没问题
  • 它们各自的软件都不会在对方的处理器上运行
  1. 几家厂商可联合推出一套面向智能台灯的自定义指令, 基金会也不管
  • 智能空调厂也不关心

事实: IoT领域的需求, 及其软硬件生态, 天然就是碎片化的

  • RISC-V可以自由添加自定义指令, 是迎合IoT领域的需求, 而不是缺点
  • RISC-V利用模块化特性, 巧妙地应对了IoT领域的碎片化需求

用起来像是定长的变长指令集

  • 变长指令集: 无限的操作码空间, 但译码器的设计复杂
  • 定长指令集: 有限的操作码空间, 但译码器的设计简单

 

x86和MIPS才做选择, RISC-V全都要!

  • 基础指令集和大部分标准扩展采用4字节定长指令(RVC是2字节)
  • 通过模块化, 更长的指令只会用在特定应用场景
    • 对绝大部分RISC-V处理器来说, 指令是定长的
      • 只有采用变长指令扩展的RISC-V处理器, 才需要支持变长指令
    • 不影响已经设计的RISC-V处理器

 

本质: 利用模块化特性, 对不同需求的处理器的实现细节进行隔离

RV32I指令集设计选讲

编程模型

32个通用寄存器(GPR, x0~x31) + PC

  • 零寄存器: 从x0中读出结果恒为零

 

  • RISC-V和MIPS有32个GPR, ARM-v7只有16个, x86(32位)只有8个
    • 数据更大概率留在GPR中 = 提升程序性能
  • ARM-v7和x86(32位)没有零寄存器
    • 需要通过立即数(通常至少8比特)甚至是额外的指令来获得常数0
    • 零寄存器的好处: 通过5比特(GPR编号)即可在任意指令中读取常数0
      • 可以得到更短的代码
  • ARM将PC作为通用寄存器
    • 所有可以写入GPR的指令都可能导致分支跳转
      • 分支预测器: 这是来整我的吧

指令格式

  • 6种指令格式, 长度均为32位, 简化了译码器的实现
    • 在低端处理器中, 复杂的译码器 = 提高成本
    • 在高端处理器中, 复杂的译码器 = 性能瓶颈
  • 三地址指令, 即指令操作数有3个
    • 大部分x86指令是二地址指令, 需要额外移动数据来进行三地址操作
# a = b + c;
# 假设b分配在ebx中, c分配在ecx中, 计算结果a分配在eax中
# 为了避免b或c被覆盖, 完成上述操作需要借助额外的mov指令
  mov %ebx, %eax
  add %ecx, %eax

指令格式(2)

  • 在所有指令格式中, rd, rs1, rs2总是在相同的位置
    • 实现时可节省不必要的选择器
  • MIPS则不是, 目的寄存器在inst[15:11](R型)或inst[20:16](I型)
 31  26 25 21 20 16 15 11 10  6 5   0
+------+-----+-----+-----+-----+-----+
|opcode|  rs |  rt |  rd |  sa |funct|   R-type  rd = rs op rt
+------+-----+-----+-----+-----+-----+
+------+-----+-----+-----------------+
|opcode|  rs |  rt |       imm       |   I-type  rt = rs op imm
+------+-----+-----+-----------------+

一些细心的考量

  1. 0x00000000是非法指令
  • 在x86中, 0x0000add %al,(%eax)
  • 在MIPS中, 0x00000000是空指令, 总是能成功执行
#include <stdio.h>
#include <sys/mman.h>
__attribute__((aligned(4096))) char a[1024] = {0};
int b[2] = {0};
__attribute__((noinline)) int* f() { return &b[1]; }
int main() {
  a[1022] = 0xc3; // ret in x86
  mprotect(a, 4096, PROT_READ | PROT_WRITE | PROT_EXEC);
  printf("Before calling: &a = %p, b[1] = 0x%08x, &b[1] = %p\n", a, b[1], &b[1]);
  void (*p)(int *) = (void *)a;
  p(f());
  printf("I am back: b[1] = 0x%08x\n", b[1]);
  return 0;
}
  • 常在初始化的数据区域出现
  • 防御性编程的理念: 尽早捕捉错误
gcc -O2 a.c && ./a.out
objdump -D --disassemble-zeroes a.out
riscv64-linux-gnu-gcc -O2 a.c && ./a.out
qemu-riscv64 -strace ./a.out

一些细心的考量(2)

  1. 0xffffffff是非法指令
  • 常在存储型设备访问错误时出现, 如未编程, 通信失败等
    • 在MIPS中, 0xffffffffsdc3 $31,-1(ra)

 

  1. 立即数均进行符号扩展
  • 可以直接表示负数: 0xfff解释成-1, 对软件来说比4095常用得多
  • 可以用addi来实现subi

奇怪的立即数编码方式

立即数的每个比特来源于不同类型指令的不同位置

  • 电路层面需要选择器来选择
  • 诀窍: 尽量减少每个比特的来源位置情况, 来减少选择器的输入端, 从而节省实现成本
    • imm[5]只来源于inst[25]0(U型), 只需要2选1选择器
    • imm[31]只来源于inst[31], 无需选择器, 可低延迟进行符号扩展
  • 代价: 编译器需要将一个立即数分段放入指令中, 编译效率更低(一些)
    • 但对编译完整过程来说是微不足道的, 而且这是一次性开销, 值得

U型指令与其他指令的组合

U型指令(lui/auipc)的20位立即数与I/S型/jalr的12位立即数组合

  • U + I型计算, 可得到32位常数
  • U + I型访存/S, 可访问32位地址空间中的内存区域
    • Uauipc, 可访问当前PC前后各2GB范围中的内存区域
  • U + jalr, 可跳转到32位地址空间中的代码区域
    • Uauipc, 可跳转到当前PC前后各2GB范围中的代码

立即数划分成20 + 12的考量

  • 一个观察: 在软件中, 大部分常数的绝对值要么很小, 要么很大
  • 方案: 很小的常数用12位有符号立即数即可表示, 节省的比特可以用来表示操作码(如funct3)
    • 很大的常数肯定需要两条或以上的指令
+------+-----+-----+-----------------+
|001111|00000|  rt |       imm       |  MIPS的lui指令, 浪费了rs中的5比特;
+------+-----+-----+-----------------+  RISC-V的lui指令充分利用了所有比特

访存指令

没有专门的push, pop指令, 可通过以sp作为基址寄存器来实现

 

不对齐访存(访存地址不被访存数据位宽整除)的处理

  • 方案1: 普通访存指令不支持不对齐访存, 采用专门的指令来支持
    • MIPS通过lwl + lwr指令组合来实现不对齐访存
      • 但这两条指令只写入GPR的一部分, 使得GPR的实现复杂化
  • 方案2: 支持不对齐访存
    • 如x86, 必须硬件实现, 性能高, 但较复杂

RISC-V的方案更灵活: 让执行环境来决定是否支持不对齐访存

  • 若执行环境决定支持, 又有若干方案
    • 纯硬件实现: 与x86类似
    • 纯软件实现: 抛异常, 软件用若干条lb的组合来实现
      • 只运行少数应用的嵌入式CPU可简化设计, 有机会提升主频

无条件跳转指令

MIPS有4条无条件跳转指令

+------+-----+-----+-----+-----+------+
|000000|  rs |00000|00000|00000|001000|  jr   (浪费较多比特)
+------+-----+-----+-----+-----+------+
+------+-----+-----+-----+-----+------+
|000000|  rs |00000|  rd |00000|001001|  jalr (浪费较多比特)
+------+-----+-----+-----+-----+------+
+------+------------------------------+
|000010|           offset             |  j
+------+------------------------------+
+------+------------------------------+
|000011|           offset             |  jal
+------+------------------------------+

RISC-V只需要2条

  • jal rd, imm - 返回地址保存到rd, 然后跳转到PC + imm
    • 通过rd = x0实现j
  • jalr rd, rs1, imm - 返回地址保存到rd, 然后跳转到rs1 + imm
    • 通过rd = x0imm = 0实现jr

jjr属于伪指令(pseudo-instruction), 不额外占用操作码

更多伪指令

  1. 采用x0的伪指令, 共32条
  • ret = jalr x0, x1, 0
  • nop = addi x0, x0, 0
  • neg rd, rs = sub rd, x0, rs
  • snez rd, rs = sltu rd, x0, rs
  • beqz rs, offset = beq rs, x0, offset
  1. x0无关的伪指令, 共28条
  • li rd, imm = lui + addi
  • mv rd, rs = addi rd, rs, 0
  • not rd, rs = xori rd, rs, -1
  • seqz rd, rs = sltiu rd, rs, 1
  • bgt rs, rt, offset = blt rt, rs, offset

更多伪指令可参考《RISC-V汇编语言编程手册》

系统设计和优化的法则 - 优化高频事件

  1. 对高频事件进行优化, 在系统整体上取得较好的效果
  • 零寄存器
    • 常数0出现的频率很高, 用5比特表示, 节省代码大小
    • 可以定义额外的伪指令, 节省指令的编码空间
  • 立即数进行符号扩展
    • 绝对值很小的负数也很常用

 

  1. 对低频事件进行劣化, 换取系统在其他维度的收益
  • 立即数的编码方式 - 用极低的编译开销换取电路更优的PPA
  • 立即数划分成20 + 12 - 牺牲范围中等的常数的表示效率, 换来更丰富的指令编码空间
    • 让更多的指令能用4字节表示, 宏观上也可以节省代码大小
  • 软件实现不对齐访存 - 降低不对齐访存的处理效率, 换取处理器PPA

总结

充满智慧的指令集设计

优秀的指令集设计体现了架构师对整个计算机系统的深入理解

  • 程序, 编译, 链接, 微结构…
  • 只有对计算机软硬件的每一处细节有清晰的认识, 才能
    • 全面地考虑一项技术对整个系统带来的影响
    • 客观地评价其优劣
    • 做出更合理的技术选择

 

强烈推荐大家阅读《The RISC-V Reader》和RISC-V官方手册

  • 其中阐述了RISC-V架构师为什么选择了这个, 而没有选择那个
    • 这些比指令集列表更有价值

真相: 预测未来是很困难的

无论RISC-V说自己多有前瞻性, 在历史的车轮下都会变得千疮百孔

  • RV32I很早就冻结, 有47条指令
    • 后来把fence.i和CSR指令独立成标准扩展
    • 说好的冻结之后永远不变呢 😂
      • 只不过没有对软件造成明显的影响
  • 有人研究已经冻结的RVC, 觉得现有方案不合理, 应该改进
  • 有人觉得RISC-V需要重新添加那些一开始认为不需要的指令
  • task group负责人不敢拍板, 成员一直在空对空争论不同方案的优劣
    • RVV向量扩展争论了好几年
    • David Patterson不满意, 需要拿出实测数据, 不能空想
  • 标准扩展开始多起来, 如何更好地管理?
riscv64-linux-gnu-gcc -march=rv64gc_zxxx_zxxx_zxxx_zxxx_zxxx ...