E5 功能完备的迷你RISC-V处理器
我们已经实现了sCPU, 让我们对处理器如何工作有更深入的认识. 但从实用性的角度来看, sCPU由于各种限制, 无法运行更复杂的程序. 事实上, 这些限制归根到底是因为sISA这个指令集过于简单, 例如
- PC寄存器的位宽只有8位, 这意味着, 程序最多只能包含256条指令
- GPR的位宽只有8位, 无法表示大于255的数据
- 指令的功能有限, 例如无法进行两个GPR之间的减法操作, 更不用说乘法和除法
接下来, 你将会实现一个功能完备的RISC-V处理器, 它可以运行更多程序, 甚至有潜力运行超级红白机游戏!
迷你RISC-V指令集
RISC-V是近十年流行起来的开放指令集架构, 它采用模块化的思想, 把指令划分成不同模块, 除了基础指令集RV32I, 还有各种指令扩展, 包括乘除扩展, 浮点扩展, 原子操作扩展等. 开发者可以根据自身需求选择一个或多个扩展, 也可以一个扩展都不选, 这种灵活性受到了开发者的喜爱.
RV32I共有42条指令, 通过实现RV32I, 处理器已经足够完成绝大部分的计算工作. 不过为了进一步降低开发的工作量, 我们提出了一个"迷你RISC-V"指令集minirv, 从RV32I中选出了8条指令, 用它们来替代其他RV32I指令的功能, 使得RV32I能完成的工作, minirv也能完成. 这样, 我们就不必实现完整的42条RV32I指令, 也能让处理器运行更复杂的程序了.
作为一个真实的ISA, RISC-V规范的细节有相应的官方手册来描述. 我们希望大家能养成阅读官方手册的好习惯, 因此你需要下载RISC-V的官方手册. 如果你是第一次接触ISA和处理器设计的相关知识, 你可能会感到理解官方手册的每一处细节对你来说并不容易, 不过我们将引导你从手册中寻找一些RV32I相关的关键信息.
通过RTFM初步了解RISC-V指令集
查阅RISC-V手册的目录, 你发现RV32I在哪一章进行介绍? 尝试在该章节中查阅RV32I的相关内容, 回答下列问题:
- PC寄存器的位宽是多少?
- GPR共有多少个? 每个GPR的位宽是多少?
R[0]和sISA的R[0]有什么不同之处?- 指令编码的位宽是多少? 指令有多少种基本格式?
- 在指令的基本格式中, 需要多少位来表示一个GPR? 为什么?
add指令的格式具体是什么?- 还有一种基础指令集称为RV32E, 它和RV32I有什么不同?
了解RISC-V指令集的一些细节之后, 我们就可以给出minirv这一ISA的规范了, 具体如下:
- PC初值为
0 - GPR数量与RV32E中定义的GPR数量一致
- 支持如下8条指令:
add,addi,lui,lw,lbu,sw,sb,jalr - 其他的ISA细节与RV32I相同
实现minirv的指令集模拟器
只有两条指令的minirvEMU
无论是用Logism还是RTL来实现处理器, 都需要考虑数字电路层次的细节. 而考虑到你可能是第一次接触RISC-V指令集, 直接在数字电路层次实现minirv处理器可能会有一定的挑战. 因此, 我们先来在指令集模拟器中实现上述指令. 因为在模拟器中实现这些指令通常更简单, 只需要考虑如何用C语言的语言特性来实现这些指令的行为即可, 这将有助于我们正确理解这些指令的行为. 我们称这个指令集模拟器为minirvEMU.
minirv有8条指令, 我们先实现其中的两条: addi, jalr. 首先考虑addi指令.
RTFM(2)
查阅RISC-V手册, 找到addi指令的编码和相应的功能描述. 在第34章RV32/64G Instruction Set Listings中有一些指令表, 可以帮助你查阅addi指令的编码.
针对取指过程, 你需要考虑修改M的宽度和PC寄存器的位宽. 不过, 存储器的概念在ISA层次和具体实现层次都存在, 如何通过C代码来实现ISA层次的存储器, 就成了需要考虑的问题.
RTFM(3)
为了了解RISC-V对存储器的若干约定, 你需要阅读RISC-V手册第1.4节的第一段, 从ISA的层面了解存储器的规格, 尤其是宽度的定义.
为了便于描述, 我们称RISC-V手册中的定义的存储器宽度为. 显然, 在ISA层面, PC寄存器是以为单位寻址的. 而在C代码的具体实现中, 如果M的宽度和不一致, 则不能直接用PC值对M进行寻址. 你需要思考如何在C代码的具体实现中解决这个问题.
针对译码过程, 首先考虑操作码的译码. 但由于minirv中的指令并不多, 操作码编码较为稀疏, 我们可以直接比较指令中的操作码字段是否与addi指令的编码一致, 来进行译码操作. 例如, 可以通过以下操作判断一条指令是否为addi指令:
// 以下为伪代码
is_addi = (inst[6:0] == ?) && (inst[14:12] == ?)
其中inst表示取出的指令, ?需要根据你查阅手册的结果来决定.
至于操作数的译码, 一个需要注意的是立即数. 由于指令中的立即数位宽较短, 要与位宽较长的GPR进行计算, 要先对立即数进行符号扩展.
对于GPR, 其设计思路和之前类似. 但在RISC-V中, R[0]的功能比较特殊, 你还需要考虑如何正确实现它.
针对执行过程, 目前只需要实现加法功能即可.
对于更新PC, 由于RISC-V的指令位宽与sISA不同, 因此你还需要思考如何更新PC, 才能让PC正确地指向下一条指令.
RTFM(4)
查阅RISC-V手册, 找到jalr指令的编码和相应的功能描述.
实现两条指令的minirvEMU
理解addi和jalr指令的功能后, 根据你之前设计sEMU的经验, 尝试设计一个支持这两条RISC-V指令的minirvEMU.
为了帮助你进行简单的测试, 我们准备了如下测试程序. 在下面的汇编指令中, GPR采用了ABI助记符(mnemonic), 名称更能反映其功能, 例如, 用zero表示编号为0的GPR. 汇编指令中还有a0和ra, 你可以通过解析相应的指令编码得知对应的GPR编号.
00000000 <_start>:
0: 01400513 addi a0,zero,20
4: 010000e7 jalr ra,16(zero) # 10 <fun>
8: 00c000e7 jalr ra,12(zero) # c <halt>
0000000c <halt>:
c: 00c00067 jalr zero,12(zero) # c <halt>
00000010 <fun>:
10: 00a50513 addi a0,a0,10
14: 00008067 jalr zero,0(ra)
尝试通过指令集的状态机理解这个程序的功能. 理解后, 将程序其放置在M中, 并尝试运行你的处理器, 然后检查处理器的运行结果是否符合预期.
测试addi指令
在上述测试程序中, addi指令的立即数比较小. 为了测试符号扩展的实现是否正确, 你需要让miniEMU执行一些立即数为负数的addi指令. 尝试编写若干条这种类型的addi指令, 并放置到M中, 检查你的实现是否正确.
实现完整的minirvEMU
接下来, 我们考虑如何实现minirv的剩余6条指令. RTFM后你会发现, add指令的功能与sISA中的add指令非常类似, 因此不难实现. 而对于lui指令, 则和sISA中的li指令很相似, 只不过要考虑不同类型的立即数格式.
实现完整的minirvEMU
实现add和lui指令. 实现后, 尝试编写一些简单的指令序列放置到M中, 来初步检查你的实现是否正确.
剩余的4条指令都是访存指令, 它们都需要访问存储器. 访存操作分为读内存(load)和写内存(store)两种. 在minirvEMU中, 存储器就是M. 在进一步考虑如何实现这4条访存指令前, 你还需要了解RISC-V对存储器的约定, 以及相应访存指令的具体行为.
RTFM(5)
查阅RISC-V手册, 找到lw, lbu, sw和sb这4条指令的编码和相应的功能描述. 手册中还介绍了EEI和不对齐访存的相关内容, 目前暂不使用, 因此你可以忽略这些内容.
lw指令较容易实现, 计算出访存地址后, 用其读出M中对应的数据即可. 和上文讨论的取指过程类似, M所代表的具体实现的存储器规格可能与ISA层次的存储器定义有所区别, 你需要思考如何正确地索引M. sw指令的实现和lw类似, 但还需要考虑不同类型的立即数格式.
不必考虑不对齐访存
如果访存地址addr除以访存指令的数据位宽w, 余数为0, 则表示此次访存是对齐的. 对于lw和sw, 有w = 4, 因此如果满足addr % 4 == 0(%为求余操作), 则访存是对齐的; 如果不满足, 则访存是不对齐的.
为了简化实现, 对于lw和sw指令计算出的访存地址, 我们可以假设其二进制表示的最低2位均为0. 我们提供的测试程序会保证这一性质, 因此不会出现需要访问的内容跨越了两个存储字的情况. 这样, 实现时就不需要考虑不对齐访存的情况.
感兴趣的同学可以尝试阅读手册中的相关内容.
实现完整的minirvEMU(2)
实现lw和sw指令, 然后编写一些简单的指令序列放置到M中, 同时在M中放置一些数据, 来初步测试访存指令的行为是否正确.
lbu指令只需要读出一个字节, 你需要根据具体的访存地址选择出相应的字节, 并写回目的寄存器.
实现完整的minirvEMU(3)
实现lbu指令, 并通过一些指令序列来初步检查你的实现是否正确.
Hint: 你可以先在M中放置一个4字节的数据0x12345678, 并通过lw指令读出它(假设数据位于内存地址a), 确认读出结果为0x12345678. 然后通过若干条lbu指令分别从内存地址a, a+1, a+2, a+3中读出数据, 我们预期这些lbu指令分别读出0x78(对应地址a), 0x56, 0x34, 0x12(对应地址a+3).
sb则相反, 它只需要往目标地址写入一个字节.
实现完整的minirvEMU(4)
实现sb指令, 并通过一些指令序列来初步检查你的实现是否正确.
Hint: 你可以先在M中放置一个4字节的数据0x12345678, 并通过lw指令读出它(假设数据位于内存地址a), 确认读出结果为0x12345678. 然后通过若干条sb指令分别往内存地址a+3, a+2, a+1, a+0 中写入0x90(对应地址a+3), 0xab, 0xcd, 0xef(对应地址a); 写入之前, 可以通过addi指令配合零号寄存器, 来向目的寄存器写入一个立即数, 从而实现sISA中li指令的效果. 最后再次通过lw指令读出新数据, 我们预期读出结果为0x90abcdef.
让程序决定模拟器何时结束
我们刚才是minirvEMU一直运行, 直到程序陷入一个预期的死循环, 来表示程序运行结束; 或者让minirvEMU来执行指定的指令数量后结束. 但这些做法并不具有很好的通用性: 你需要提前知道一个程序执行多少条指令才能结束, 然后把这一信息手动写入minirvEMU的代码中. 有没有方法可以在程序执行结束的时候自动结束minirvEMU呢?
Logisim作为GUI程序, 不太方便添加个性化的功能. 但minirvEMU作为一个C程序, 我们可以尝试让minirvEMU自动判断程序是否成功结束. 具体地, 我们可以添加一条ebreak指令, 并将其功能定义为程序结束. 为了实现程序结束的自动判断, 我们约定, 让minirvEMU执行ebreak后结束整个程序的执行, 并输出一些提示信息.
为了让程序遵守上述约定, 我们可以手动将ebreak指令的编码写入M中的正确位置: 关于ebreak指令的具体编码, 请RTFM; 而对于"正确的位置", 就是指程序中halt()函数的附近, 或者是你自行编写的指令序列的末尾. 例如, 你可以通过如下方式写入ebreak指令:
M[? + 0] = ?;
M[? + 1] = ?;
// ......
你需要找到正确的内存位置和ebreak指令的编码, 然后替换上述?.
实现程序结束的自动判断
根据上述约定, 在minirvEMU中添加并实现ebreak指令, 然后修改程序的指令序列, 使其在结束时执行ebreak指令. 如果你的实现正确, 你会看到程序自动结束并通过minirvEMU输出结束信息.
当然, 这个过程仍然涉及不少手动操作, 不过目前我们需要运行的程序并不多, 这些手动操作的开销尚可接受. 当需要运行的程序数量越来越多, 我们还是需要想办法实现全自动的判断方式. 我们很快就会继续讨论这个问题.
用RTL实现minirv NPC
实现minirvEMU之后, 你应该对minirv指令集的细节有清晰的认识了. 现在是时候将之前的NPC从sCPU"升级"为一个minirv处理器了.
模块化的RTL设计
和sCPU不同, 将来我们还会不断改进NPC, 在其中添加更多的功能. 因此, 我们有必要维护好NPC项目, 为将来的改进做好准备. 维护代码的一个方式就是模块化.
从指令类型来看, minirv的指令涵盖的功能包括加法, 位拼接, 访存和跳转. 我们可以根据这些功能, 结合处理器的工作流程给NPC划分模块:
- IFU(Instruction Fetch Unit): 负责根据当前PC从存储器中取出一条指令
- IDU(Instruction Decode Unit): 负责对当前指令进行译码, 准备执行阶段需要使用的数据和控制信号
- EXU(EXecution Unit): 负责根据控制信号控制ALU, 对数据进行计算
- LSU(Load-Store Unit): 负责根据控制信号控制存储器, 从存储器中读出数据, 或将数据写入存储器
- WBU(WriteBack Unit): 将数据写入寄存器, 并更新PC
你需要自行梳理出模块之间的接口. 当然, 你也可以自行决定将哪些部件放置在哪一个模块中. 一个例外是存储器, 为了方便测试, 我们不打算通过RTL来实现这个存储器, 而是用C++来实现它. 当前, 我们先考虑一种最简单的实现方式: 将存储器访问接口的信号拉到顶层, 通过C++代码来访问存储器.
while (???) {
...
top->inst = pmem_read(top->pc);
single_cycle();
...
}
你可以很容易地通过C++代码来实现一个简单的存储器.
只有两条指令的minirv NPC
实现minirvEMU时, 我们是在用C语言的特性来实现指令的执行过程. 但如果要用RTL来实现minirv NPC, 我们就需要考虑如何在电路层次用电路模块来实现指令的执行过程了. 因此, 你首先应该有一个的minirv NPC的架构图, 无论它是被画在纸上, 还是存在于你的思考过程中. 有了架构图, 要用RTL代码描述出其中每个模块的电路结构, 就很容易了.
我们先来实现一条最简单的指令: addi.
在NPC中实现addi指令
具体地, 你需要注意以下事项:
- 存储器中可以放置若干条
addi指令的二进制编码(可以利用0号寄存器的特性来编写行为确定的指令) - 由于目前未实现跳转指令, 因此NPC只能顺序执行, 你可以在NPC执行若干指令之后停止仿真
- 可以通过查看波形, 或者在RTL代码中打印通用寄存器的状态, 来检查
addi指令是否被正确执行 - 关于通用寄存器, 其电路本质是一个存储器. 为了避免选择Verilog的同学编写出不太合理的行为建模代码, 我们给出如下不完整的代码供大家补充(大家无需改动
always代码块中的内容):
module RegisterFile #(ADDR_WIDTH = 1, DATA_WIDTH = 1) (
input clk,
input [DATA_WIDTH-1:0] wdata,
input [ADDR_WIDTH-1:0] waddr,
input wen
);
reg [DATA_WIDTH-1:0] rf [2**ADDR_WIDTH-1:0];
always @(posedge clk) begin
if (wen) rf[waddr] <= wdata;
end
endmodule
- 你还需要思考如何实现0号寄存器的特性
不知道如何下手?
你很可能会遇到以下问题:
- 如何通过PC值正确地访问存储器?
- 如何在存储器中放置
addi指令? - 如何仅执行若干指令后结束仿真?
- ......
在搭建Verilator框架的时候, 我们就已经提醒过大家: 项目里面的所有细节都是和大家有关系的. 每当你觉得没有思路的时候, 这很大概率是在提醒你, 你很可能在之前的学习中有什么没做好. 相比于询问同学, 你其实更应该回顾之前的实验内容, 并尽自己最大努力理解每一处细节, 从而找到上述问题的答案.
在NPC中实现jalr指令
实现后addi和jalr指令后, 让NPC运行之前在minirvEMU上运行过的那个两条指令的测试程序, 并检查NPC的运行结果是否符合预期.
让程序决定仿真何时结束
和minirvEMU类似, 我们也可以在NPC中实现类似的功能: 如果程序执行了ebreak指令, 就通知仿真环境结束仿真.
要实现这一功能并不困难, 你首先需要在NPC中添加ebreak指令的支持. 不过, 为了让NPC在执行ebreak指令的时候可以通知仿真环境, 你还需要实现一种RTL代码和C++代码之间的交互机制. 我们借用System Verilog中的DPI-C机制来实现这一交互.
尝试DPI-C机制
阅读Verilator手册, 找到DPI-C机制的相关内容, 并尝试运行手册中的例子.
通过DPI-C实现ebreak
在RTL代码中利用DPI-C机制, 使得在NPC执行ebreak指令的时候通知仿真环境结束仿真. 实现后, 在上述程序中halt()函数的位置放置一条ebreak指令来进行测试. 如果你的实现正确, 仿真环境就无需关心程序何时结束仿真了, 它只需要不停地进行仿真, 直到程序执行ebreak指令为止.
如果你使用Chisel, 你可以借助Chisel中的BlackBox机制调用Verilog代码, 然后让Verilog代码通过DPI-C机制与仿真环境交互. 关于BlackBox的使用方式, 请查阅相关资料.
实现完整的minirv NPC
你需要实现剩下的6条minirv指令, 包括add, lui, lw, lbu, sw, sb. 其中, 前两条都是整数计算指令, 它们和sISA中的add和li指令非常类似. 你已经实现过sISA的这两条指令了, 因此这对你来说并不困难.
为了实现剩下的4条访存指令, 我们需要进行一些额外的考量. 访存指令需要访问存储器, 与取指不同, 访存指令还可能需要将数据写入存储器. 我们之前把取指的接口拉到顶层的简单实现方式, 并不能正确实现访存指令, 这是因为访存接口的信号会依赖于当前取到的指令, 而仿真环境不知道这个依赖关系的存在, 因此也无法正确处理它. 为了解决这个问题, 我们可以通过DPI-C机制来实现访存:
import "DPI-C" function int pmem_read(input int raddr);
import "DPI-C" function void pmem_write(
input int waddr, input int wdata, input byte wmask);
reg [31:0] rdata;
always @(*) begin
if (valid) begin // 有读写请求时
rdata = pmem_read(raddr);
if (wen) begin // 有写请求时
pmem_write(waddr, wdata, wmask);
end
end
else begin
rdata = 0;
end
end
extern "C" int pmem_read(int raddr) {
// 总是读取地址为`raddr & ~0x3u`的4字节返回
}
extern "C" void pmem_write(int waddr, int wdata, char wmask) {
// 总是往地址为`waddr & ~0x3u`的4字节按写掩码`wmask`写入`wdata`
// `wmask`中每比特表示`wdata`中1个字节的掩码,
// 如`wmask = 0x3`代表只写入最低2个字节, 内存中的其它字节保持不变
}
我们在这两个内存读写函数中模拟了32位总线的行为: 它们只支持地址按4字节对齐的读写, 其中读操作总是返回按4字节对齐读出的数据, 需要由RTL代码根据读地址选择出需要的部分. 这样是为了将来在实现总线的时候不必改动太多的代码. 你需要在Verilog代码中为这两个函数的调用传入正确的参数, 并在C++代码中实现这两个函数的功能. 对于取指, 你需要删除之前把信号拉到顶层的实现, 然后额外调用一次pmem_read()来实现它.
实现完整的minirv NPC
为NPC添加剩余的6条minirv指令, 并运行你之前为minirvEMU编写的测试, 判断程序是否成功结束运行.
