Chisel实战之单周期RISC-V处理器实现(一)——需求分析和初步设计
需求分析
首先明确我们要做的是什么,这个在标题里面已经说明了,我们要做的是一个单周期RISC-V处理器。
但光是个短语不足以支撑我们开展项目,我们需要对项目目标做进一步的明确,也就是需求分析。
关于指令集架构(ISA)
设计一个处理器的依据是指令系统规范,也就是ISA的规范,不严谨地来说就是该指令集架构的机器语言的规范,即计算机软件和硬件的接口。而设计处理器是在ISA规范的基础上,对微体系结构进行设计,所以经典的教材《计算机体系结构:量化研究方法》中就将计算机体系结构描述为指令集架构(ISA)和微体系结构的结合。
因此,我们第一步就是要明确,我们这个项目支持的指令系统规范是什么!
既然要做一个RISC-V处理器,那必然是要支持RISC-V指令集,我们可以在官网找到该指令集的规范文件:
Specifications - RISC-V International (riscv.org)
指令集规范中包含了非特权指令集(当前版本规范为Unprivileged Spec v. 20191213)、特权指令集(当前版本规范为Privileged Spec v. 20211203),以及一些仍然处于其他阶段的扩展规范。
我们这个项目的目标很简单,不需要支持特权指令,更不需要支持拓展指令(比如向量拓展、位操作拓展等),仅需要支持非特权指令集就行了,而且是它的一个子集!
非特权指令集有以下基本子集:
基本集 | 说明 | 版本 | 状态 |
---|---|---|---|
RVWMO | WMO即Weak Memory Ordering,是RISC-V的内存一致性模型 | 2.0 | 正式批准 |
RV32I | 最基本的RISC-V 32位整数指令集 | 2.1 | 正式批准 |
RV64I | 最基本的RISC-V 64位整数指令集,可以看作是RV32I到64位的拓展 | 2.1 | 正式批准 |
RV32E | 面向嵌入式微控制器的基本的RISC-V 32位整数指令集,可以看作是RV32I的精简 | 1.9 | 草案 |
RV128I | 最基本的RISC-V 128位整数指令集,可以看作是RV32I、RV64I到128位的拓展 | 1.7 | 草案 |
既然是基本子集,那就必须得有一个,我们的单周期处理器是单线程的、顺序执行的,因此不需要考虑内存一致性模型。我们作为例子也只需要实现最简单的32位版本,因此考虑将RV32I作为我们项目的基本指令集。而RV32E虽然是RV32I的精简,但仍然处于草案阶段,就也不考虑了。
注意:这里的xx位指的是地址空间的位数。
还有一些拓展指令子集:
拓展集 | 说明 | 版本 | 状态 |
---|---|---|---|
M | 整数乘法、除法拓展 | 2.0 | 正式批准 |
A | 原子指令拓展 | 2.1 | 正式批准 |
F | 单精度浮点数拓展 | 2.2 | 正式批准 |
D | 双精度浮点数拓展 | 2.2 | 正式批准 |
Q | 四精度浮点数拓展 | 2.2 | 正式批准 |
C | 压缩指令拓展 | 2.0 | 正式批准 |
Counters | 计数器、定时器、性能计数器拓展 | 2.0 | 草案 |
L | 十进制浮点数拓展 | 0.0 | 草案 |
B | 位操作拓展 | 0.0 | 草案 |
J | 对动态转译语言的支持拓展,动态转译也叫JIT,此拓展用于支持动态检查和垃圾回收 | 0.0 | 草案 |
T | 事务性内存操作拓展 | 0.0 | 草案 |
P | Packed-SIMD指令拓展 | 0.2 | 草案 |
V | 向量拓展 | 0.7 | 草案 |
Zicsr | 控制和状态寄存器拓展 | 2.0 | 正式批准 |
Zifencei | 指令抓取栅栏拓展 | 2.0 | 正式批准 |
Zam | 非对齐原子内存操作拓展 | 0.1 | 草案 |
Ztso | RVTSO(Total Store Ordering)内存一致性模型拓展,是RVWMO的变体 | 0.1 | 草案 |
RV32I加上A拓展就可以支持操作系统了,但我们不需要,其他拓展更是不需要了。
我们可以根据需求选取需要的基本子集和拓展集,实现符合应用场景的处理器。由于我们的需求很简单,那么自然我们经过上面的分析,就可以得到结论:
我们只需要支持RV32I基本指令集!不需要其他任何拓展!
当然了RV32I中的指令我们也并非都需要,在后续的实现中我们还会进行少许的取舍。
关于微体系结构(Microarchitecture)
CPU的设计无非只有两部分,一个是数据通路,另一个就是逻辑控制,不管如何我们先确定我们在微体系结构上的需求。
如果你学习过《计算机体系结构:量化研究方法》或其他类似的教材,那肯定知道很多现代处理器中常见的技术,比如Cache、流水线、分支预测、SIMD等等,想想就让人害怕。不过好消息是我们这个项目暂时不涉及这些,不信我们先捋一捋:
- 内存层级方面:现代处理器都有Cache之类的东西,用来构成内存层级,然后用一些复杂的策略来保证Cache的命中率,而我们的项目中不需要内存层级,直接从内存访问指令、数据啥的就行;
- 指令集并行(ILP)方面:这里引入了流水线技术,紧接着为了解决数据冒险,引入了转发技术和动态调度技术(比如记分牌算法和Tomasulo算法),跟着一起出现的还有分支预测那些,另一方面引入了多发射技术,似乎越来越超纲了,但是我们不需要,我们是单周期CPU,没有流水线,而且我们一次只执行一条指令,也没有多发射,根本就没有指令集并行;
- 数据级并行(DLP)方面:显然我们不需要,因为我们不支持向量拓展,跳过;
- 线程级并行(TLP)方面:我们是单核CPU,不支持多线程,更不存在线程之间共享内存,也没有Cache啥的,因此我们同样也不需要这个,复杂的内存一致性模型完全不用考虑;
还有什么地址转换啥的,我们也不需要!
捋完了可以发现,我们什么优化技术都不需要用上,简简单单就实现一个朴素的单周期RISC-V处理器就行了!
初步设计
需求分析结束之后,就可以开始我们的初步设计了!
RV32I指令集分析
通过上面的分析,我们只需要把RV32I中我们需要的指令给支持了就行了,那么我们从分析RV32I中的指令开始。
RV32I指令集指令有6种类型:
其中:
- R类型即寄存器(Register)类型,有三个操作数,两个源操作数均来自寄存器(rs1和rs2),目的操作数为寄存器(rd);
- I类型即立即数(Immediate)类型,有三个操作数,两个源操作数分别来自立即数(imm)和寄存器(rs1),目的操作数为寄存器(rd);
- S类型即存储(Store)类型,有三个操作数,均为源操作数,其中寄存器rs1和立即数imm运算得到存储的地址,rs2寄存器的值为被存储的数;
- B类型即分支(Branch)类型,有三个操作数,均为源操作数,其中两个寄存器(rs1和rs2)的值用于比较,立即数imm的值为分支目的地址的偏移量;
- U类型即无符号立即数(Unsigned immediate)类型,有两个操作数,立即数imm为源操作数,rd为目的操作数,此指令用于将立即数加载到指定寄存器rd;
- J类型即跳转(Jump)类型,有两个操作数,立即数imm为源操作数,用于计算跳转目的地址,rd为目的操作数,用于记录跳转前指令的下一条指令的地址;
进一步地,我们分析RV32I中的所有指令,共计四十条,如下表所示:
指令格式是比较规整的,除了ECALL
和EBREAK
以外,均显然符合上面的六种指令格式类型。
可以按照指令的功能对指令进行分类:
- 直接跳转类:包括
JAL
和JALR
; - 条件分支类:包括
BEQ
、BNE
、BLT
、BGE
、BLTU
、BGEU
; - 加载/存储类:包括
LB
、LH
、LW
、LBU
、LHU
、SB
、SH
、SW
; - 算术逻辑运算和位运算类:包括所有加法、减法,按位与、或、异或,逻辑左移、逻辑右移、算术右移相关指令;
- 比较指令类:包括
SLTI
、SLTIU
、SLT
、SLTU
; - 其他指令类:
FENCE
、ECALL
、EBREAK
;
再依次对这几个类指令的行为进行分析:
- 直接跳转类需要对PC寄存器的值进行直接修改,同时写一个寄存器;
- 条件分支类首先需要进行比较,然后根据比较结果选择是否修改PC寄存器;
- 加载/存储类需要访问数据存储,加载只读取数据,存储只写入数据;
- 算术逻辑运算和位运算类会对操作数进行运算,然后将结果写入目的寄存器;
- 比较指令类与算术逻辑运算和位运算类一致,但操作变成了比较;
- 其他指令中,由于不需要维护内存一致性和连续性,因此我们不需要实现
FENCE
,同样,由于不涉及环境调用中断和调试调试中断,所以我们暂时也不需要实现ECALL
和EBREAK
;
数据通路和控制逻辑的初步设计
根据上面的分析,我们设计的CPU中应该至少需要包含以下组件:
- 指令内存(
MemInst
):接收一个32位的指令地址,读取出指令; - PC寄存器(
PCReg
):为指令内存提供指令地址,每个时钟周期地址都会+4,当前指令为跳转时,下一条指令为跳转目的地址,当前指令为分支指令且分支成功时,下一条指令为分支目标地址; - 通用寄存器堆(
Registers
):可读可写的寄存器,接收寄存器号,为运算单元提供操作数,接收运算结果或从数据内存读取到的值; - 数据内存(
MemData
):根据加载/存储地址,加载或存储数据,加载或存储依赖于译码器的译码; - 指令译码器(
Decoder
):对指令进行译码,解析得到立即数、操作码、寄存器号等信息; - 运算单元(
ALU
):根据操作数和操作码进行运算,运算结果写到寄存器,分支指令时将比较结果发送给PC,加载存储指令时计算地址;
这些组件只描述了数据通路,要使得CPU能正常运行,还需要良好的逻辑控制。
控制逻辑需要根据译码结果对数据通路进行控制,可能需要以下几个方面:
ctrlJump
:指令是否为跳转指令?如果是,需要给控制信号到PC,要求在下一时钟周期修改为跳转目的地址;ctrlBranch
:指令是否为分支指令?如果是,根据运算单元的比较结果(分支与否),决定是否让PC在下一个时钟周期跳转的分支目标地址;ctrlRegWrite
:指令是否需要写寄存器?如果是,将运算单元的结果或从数据内存中读取的值写入寄存器;ctrlLoad
:指令是否为加载指令?如果是,写入寄存器的值来源应该是数据内存;ctrlStore
:指令是否为存储指令?如果是,将寄存器中的值写入数据内存;ctrlALUSrc
:指令的操作数2是立即数还是寄存器值?根据此选择操作数2的值;ctrlJAL
:指令是否为JAL指令?如果是,操作数1的值应当为PC寄存器的值;ctrlOP
:为ALU指定具体的操作,加?减?或者其他啥?
这些控制信号的生成和传输我们统一由控制器(Controller)完成。
上面的说明并不详尽,只作为初步设计,但我们也很难在开始的时候就考虑到所有细节,更多的细枝末节需要在设计、调试、修改的迭代中完善,但至少上面的内容足够我们开始实现了。
最后放上初步设计的草图:
再次说明,上面的设计是不完备的,比如目前还未考虑到加载/存储时是字节、半字还是字,虽然只是一个信号的问题,但足以体现还有很多不完善的地方,在实现中迭代设计是很有必要的。接下来,我们就将基于这个不完备的设计开始我们的项目开发!