引言

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

  • 概念上
    • 编译: 把程序的状态机翻译成ISA的状态机
    • 微结构设计: 根据指令集状态机设计一个数字电路状态机
  • 代码实现上
    • YEMU模拟器: 用C语言实现ISA状态机
    • Verilator仿真器: 用C语言实现数字电路状态机

 

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

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

参考书: 虚假的RISC-V手册

虚假的RISC-V手册 ❎

真正的RISC-V手册

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

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

指令集的评价标准

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是一条指令, 没法拆开
    • 编译器/处理器可以分别调度两条指令, 提升指令执行的并行度

3. 性能

性能 = 程序执行时间

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

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

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

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

性能 = 程序执行时间

        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

需要软硬件协同才能工作

  • MIPS指令集手册中描述了这一约定
  • 架构师按照这一约定设计处理器
    • 处理器不等待分支指令的执行结果, 先无条件执行延迟槽中的指令
  • 编译器负责在延迟槽中放置一条有意义的指令
    • 使得无论是否跳转, 按照这一约定的执行顺序都能正确地执行程序

 

很巧妙!

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

但历史证明这是个负担

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

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

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

5. 提升空间

可以添加指令

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

 

可以自由地添加指令

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

6. 代码大小

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

 

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

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

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

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

 

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

  • 但为了向前兼容, 无法更改/回收

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

  • 更多的通用寄存器有利于编译器将变量分配到寄存器, 提升程序性能
    • 寄存器分配: {PC_c, v1, v2, v3, ...} -> {R, M}

 

  • 提供相对PC的分支和数据寻址, 可生成更高质量的位置无关代码(Position-Independent Code)
    • 动态链接库需要PIC机制的支持
    • 大部分ISA都提供与相对PC的分支指令
    • 但x86(32位)和MIPS未提供相对PC的数据寻址指令
      • 它们应该如何获取当前指令的PC?
        • Hint: 利用函数调用

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, 一个通用(General-purpose)的组合
  • C - 压缩指令

 

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

根据需求自由组合

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

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

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

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

 

事实: IoT的软硬件生态本来就是碎片化的

  • RISC-V可以自由添加自定义指令, 是迎合IoT领域的需求, 而不是缺点

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

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

 

大人才做选择, 小孩全都要!

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

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

RV32I指令集设计选讲

编程模型

32个通用寄存器(GPR) + PC

  • R[0]中读出结果恒为零

 

  • 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则不是
 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
+------+-----+-----+-----------------+

一些细心的考量

  • 0x000000000xffffffff是非法指令
    • 前者常在初始化的数据区域出现
    • 后者常在存储型设备访问错误时出现, 如未编程, 通信失败等
    • 在x86中, 0x0000add %al,(%eax)
    • 在MIPS中, 前者是空指令, 后者是sdc3 $31,-1(ra)
    • 防御性编程的理念: 尽早捕捉错误

 

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

奇怪的立即数编码方式

你很可能会觉得立即数的编码方式匪夷所思

  • 需要从硬件实现角度来理解

对于生成的立即数, 其中每个比特来源于不同类型指令的不同位置

  • 因此需要通过选择器来生成立即数的每个比特
  • 诀窍: 通过尽量减少每个比特的来源位置情况, 来减少选择器的输入端, 从而节省实现成本
    • imm[5]只来源于inst[25]0(U型), 只需要2选1选择器
    • imm[31]只来源于inst[31], 无需选择器, 可低延迟进行符号扩展
  • 代价: 编译器生成指令时需要更长时间, 但这是一次性开销, 值得

U型指令

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

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

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

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

访存指令

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

 

不对齐访存的处理

  • 方案1: 普通访存指令不支持不对齐访问, 采用专门的指令来支持
    • MIPS通过lwl + lwr指令组合来实现不对齐访存
      • 但这两条指令只写入GPR的一部分, 使得GPR的实现复杂化
  • 方案2: 支持不对齐访问
    • 如x86, 必须硬件实现, 性能高, 但较复杂
  • RISC-V的方案更灵活: 让执行环境来决定是否支持不对齐访存
    • 若执行环境决定支持, 又有若干方案
      • 纯硬件实现: 与x86类似
      • 纯软件实现: 抛异常, 软件用若干条lb的组合来实现

无条件跳转指令

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

总结

充满智慧的指令集设计

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

  • 程序, 编译, 链接, 微结构…

 

强烈推荐大家阅读《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 ...