奋战一星期,造台单周期
Task
实现基于MIPS 指令集的单周期CPU,可执行如下34 种指令:
用 vivado 编写 CPU 工程,编写完成后,通过运行一份包含所有指令的汇编代码(将二进制代码储存于指令内存中),观察仿真信号及 FPGA 显示来检验 CPU 的正确性。
Sol
当你读这篇 blog 的时候,你应当熟悉单周期 CPU 的工作流程、数据通路。这里给放一个简化版的数据通路图:
Sol 1 CPU设计
Sol 1.1 设计总述
设计 CPU 应将 CPU 视作一个大的模块,自顶向下进行分解。
首先,确定其输入输出。由于 CPU 主要与内存进行数据交换,因此对外输入输出非常少。输入:时钟信号 clk、reset;输出:overflow 信号,若烧写至 FPGA 则增加 ALU 运算结果、当前指令等需要显示的信息。
然后,设计 CPU 的工作流程。根据单周期 CPU 的工作流程,当时钟上升沿到来时,一个新的周期开始,依次执行下列操作:
根据该工作流程,可以将 CPU 下分为几个子模块:
Sol 1.2 实现细节
代码其实很好写的,写 verilog 就跟写高级语言一样。
此处只放简略代码或伪代码,详细代码见最后下载。
约定一个周期由时钟上升沿开始,时钟由高电平到低电平。
1) NextPC
该模块储存有指令寄存器 PC,负责计算下一条 PC。
每当时钟上升沿到来,PC+=4。待控制信号准备好后,通过 branch 和 zero 决定是否进行 branch 型跳转,再根据 jmp 决定是否进行 J 型跳转。共 2 次经过选择器。第二次选择时有 j、jr 两种类型,故使用三选一选择器,jmp 使用 2 位信号。
1 | MUX32 m4( // 是否branch型跳转 |
2) memory
该模块负责管理内存,包括指令内存和数据内存。
该部分使用系统 IP 核实现,指令内存用单口 ROM,数据内存用双口 RAM(一个口读,一个口写)。
为了方便并行读写 1 个 word(4 byte)的数据,使用 8 位宽度的 IP 核,实例化 4 次,分别代表一个 word 的 31~24位、23~16位、15~8位、7~0 位。该 word 的 4 份内存的地址高 30 位相同,因此实际操作中,使用实际地址的高 30 位作为每个 IP 核的地址。
1 | Instruction_Memory31_24 imem31_24( |
本次实验没有实现半字、字节读写操作,但在此设计上要添加这些操作是很容易的,只需根据地址的最后 2 位模 4 的余数选择对应的 IP 核即可。
3) control
该模块计算控制信号。使用 case 语句,对 aluop 或 funct 进行讨论即可。
由于 R 型指令判断较为复杂,故此处再分成两个子模块,一个是通用 control 模块,计算不针对 R 型指令的控制信号;一个是 alucontrol 模块,计算针对 R 型指的控制信号。
控制信号作用表如下:(其中 zero 不在该模块计算)
示例代码如下:
1 | module control(); |
4) Register
该模块储存有 32 个常规寄存器、hi、lo,其中 31 号寄存器为 ra。
该部分主要实现寄存器的读写,比较简单。
1 | reg [width-1:0] tmp [0:31]; // 需要初始化 |
5) ALU
该模块进行运算,并计算 ovf 和 zero。
使用 case 语句,根据 aluCtr 讨论进行相应的运算即可。注意一些细节,区分有符号数和无符号数。默认是无符号数,只需用 $signed()
这个东西把变量套起来,就是有符号数了。
1 | always @(A or B or aluCtr or hi or lo) |
6) MUX
该模块提供不同型号的选择器。
在该 CPU 中,共用到 3 种选择器:5 位三选一、32 位二选一、32 位三选一。
1 | assign res=(switch) ?B :A ; // 二选一 |
7) extended
该模块提供有/无符号立即数扩展。若有符号扩展,则 31~16 位为原数第 15 位;若无符号扩展,则 31~16 位为 0。
1 | always @ (in or ExtOp) |
8) top
千辛万苦来到了总模块。该模块作为数据通路,连接各子模块。具体来说,就是实例化各子模块,并通过 wire 连接起来。
1 | NextPC nxtpc(); |
同时需要在 top 模块定义主时钟。输入的时钟信号是 10^8 Hz 的,此处分频做一个 1 MHz 的主时钟(还可以更快,但是懒得加速了。
1 | integer clk_cnt; |
该模块还需要处理一些细节,如指令为 bne 时 zero 需要取反等。
到这里,CPU 工程就编写完成啦。
Sol 2 仿真检验
根据实现的 34 种指令,设计出以下用于检验的 MIPS 程序。该程序有三个 Test 函数,Test1 是 ALU 测试,Test2 是内存读写测试,Test3 是控制测试。
1 |
|
使用 Mars 编辑器可以方便地导出指令代码。16 进制代码及测试详解如下表:
不过 Mars 导出的指令代码是一个 text 的,而我们实例化了 4 次 ROM,因此需要写一个简单的脚本程序,做成 4 个 ROM 初始化文件,分别代表 31~24 位、23~16 位、15~8 位、7~0 位。
然后编写简单的仿真模块。vivado 中默认的周期是 1 ns,为了与 Basys3 板对应,需要分频形成一个 10^8 Hz 的时钟作为 CPU 的输入时钟信号(再由 top 自行分频形成 1 Mz 的主时钟)。
1 | top tp(.clk(clkin),.reset(reset),.ovf(ovf)); |
仿真结果如下:
(说明1:W 为寄存器写数据、A 为寄存器 A 口、B 为寄存器 B 口、aluA 为 ALU 运算 A 口、aluB 为 ALU 运算 B 口、memres 为内存读取结果、zerobne 为 zero^(opcode==bne) )
(说明2:乘除法的计算结果储存在 calcHi 和 calcLo,周期结束时才写入 hi 和 lo)
篇幅限制,就只截两张图好了。
对照代码详解表的检测指标,所有指令执行正确。
Sol 3 FPGA检验
这种玩具 CPU 真的有上板子的必要么。。
在 Basys3 板上,ovf 信号用一个 LED 灯显示,数码管一共 4 个,前两个显示 PC 最后两位,后两个显示 ALU 运算结果最后两位。
数码管需要添加一个 display 模块,该模块分四部分:分频、位选控制(即 0111 循环计数)、段选控制(根据位选,选择对应的数据段)、解码(将数据转化为 7 段管信号)。
1 | // 分频 |
并在 top 模块中调用。
1 | display dsp( |
接下来是实验结果。受篇幅限制,只展示部分指令。
选择 0x0000001C 开始的 6 条指令,检测 ALU 运算,结果如下:(前两位是指令地址,后两位是 ALU 结果)
对照代码详解表的检测指标,指令执行正确。
选择 0x0000009C 开始的 11 条指令(含跳转到别处的指令),检测跳转,结果如下:
对照代码详解表的检测指标,指令执行正确。
至此,完成。