【计组大作业】单周期CPU

  奋战一星期,造台单周期

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
MUX32 m4(								// 是否branch型跳转
.A(PCplus4),
.B(PCplus4+{imm[29:0],2'b00}),
.switch(branch&zero),
.res(nextPC1)
);
MUX32_3 m5( // 是否J型跳转
.A(nextPC1),
.B({PCplus4[31:28],jmpInst,2'b00}), // jmpInst=instruction[25:0]
.C(ra),
.switch(jmp),
.res(nextPC2)
);

always @ (posedge clk)
if (reset)
begin
PC=0;
PCplus4=4;
end else
begin
PC=nextPC2;
PCplus4=PC+4;
end

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Instruction_Memory31_24 imem31_24(
.clka(rclk), // 读时钟频率非常高,等价于随时可读
.ena(1'b1),
.addra(PC[effiPCwidth+1:2]), // effiPCwidth为ip核的地址大小
.douta(instruction[31:24])
);
Data_Memory dmem31_24(
.clka(wclk),
.ena(1'b1),
.wea(memWrite),
.addra(DMadd[effiDATAwidth+1:2]), // effiDATAwidth为ip核的地址大小
.dina(DMdata[31:24]),
.clkb(rclk),
.enb(memRead),
.addrb(DMadd[effiDATAwidth+1:2]),
.doutb(DMres[31:24])
);

  本次实验没有实现半字、字节读写操作,但在此设计上要添加这些操作是很容易的,只需根据地址的最后 2 位模 4 的余数选择对应的 IP 核即可。

3) control

  该模块计算控制信号。使用 case 语句,对 aluop 或 funct 进行讨论即可。
  由于 R 型指令判断较为复杂,故此处再分成两个子模块,一个是通用 control 模块,计算不针对 R 型指令的控制信号;一个是 alucontrol 模块,计算针对 R 型指的控制信号。
  控制信号作用表如下:(其中 zero 不在该模块计算)

  示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
module control();
assign memRead=(opcode==6'b100011);
assign memWrite=(opcode==6'b101011);
always @ (opcode)
case (opcode)
// 讨论确定regDst,aluSrcB,memToReg,regWrite,branch,aluop,jmp,ExtOp
endcase
endmodule

module alucontrol();
always @ (aluop or funct)
if (aluop==4'b1111)
begin
casex (funct)
// 讨论确定aluCtr
endcase
end else aluCtr =aluop;
// sll,slr,sra
assign aluSrcA=(aluop==4'b1111 && (funct==6'd0 || funct==6'b000010 || funct==6'b000011));
// jr 由于jr是R型指令,故其jmp需要在这里判断
assign outjmp=(aluop==4'b1111 && funct==6'b001000) ?2'b10 :injmp ;
endmodule

4) Register

  该模块储存有 32 个常规寄存器、hi、lo,其中 31 号寄存器为 ra。
  该部分主要实现寄存器的读写,比较简单。

1
2
3
4
5
6
7
8
9
10
11
12
13
reg [width-1:0] tmp [0:31];		// 需要初始化

always @ (posedge clk) // 写
if (regWrite)
begin
tmp[addrW]=W;
hi=calcHi; // hi和lo的写入值用calcHi和calcLo表示
lo=calcLo;
end

assign A=tmp[addrA]; // 读
assign B=tmp[addrB];
assign ra=tmp[5'd31];

5) ALU

  该模块进行运算,并计算 ovf 和 zero。
  使用 case 语句,根据 aluCtr 讨论进行相应的运算即可。注意一些细节,区分有符号数和无符号数。默认是无符号数,只需用 $signed() 这个东西把变量套起来,就是有符号数了。

1
2
3
4
5
6
7
8
9
always @(A or B or aluCtr or hi or lo)
begin
ovf=0;
zero=0;
case (aluCtr)
// 讨论计算 ALUres、calcHi、calcLo
// 加法时判断ovf,减法时判断zero
endcase
end

6) MUX

  该模块提供不同型号的选择器。
  在该 CPU 中,共用到 3 种选择器:5 位三选一、32 位二选一、32 位三选一。

1
2
3
4
5
6
7
8
assign res=(switch) ?B :A ;			// 二选一

always @ (A or B or C or switch) // 三选一
case (switch)
2'b00: res=A;
2'b01: res=B;
2'b10: res=C;
endcase

7) extended

  该模块提供有/无符号立即数扩展。若有符号扩展,则 31~16 位为原数第 15 位;若无符号扩展,则 31~16 位为 0。

1
2
always @ (in or ExtOp)
out=(ExtOp) ?{{half{in[half-1]}},in} :{{half{1'b0}},in} ;

8) top

  千辛万苦来到了总模块。该模块作为数据通路,连接各子模块。具体来说,就是实例化各子模块,并通过 wire 连接起来。

1
2
3
4
5
6
7
8
9
10
11
NextPC nxtpc();
memory mem();
control ctr();
alucontrol aluct();
MUX5 m1(); // 根据regDst选择寄存器写地址
Register regfile();
extended ex();
MUX32 m2A(); // 根据aluSrcA选择ALU运算A口
MUX32 m2B(); // 根据aluSrcB选择ALU运算B口
ALU alu();
MUX32_3 m3(); // 根据memToReg选择寄存器写数据

  同时需要在 top 模块定义主时钟。输入的时钟信号是 10^8 Hz 的,此处分频做一个 1 MHz 的主时钟(还可以更快,但是懒得加速了。反正就这种玩具 CPU 的速度,四舍五入约等于没有)。而 10^8 Hz 的时钟信号正好用作内存模块的读时钟。
1
2
3
4
5
6
7
8
9
10
11
12
integer clk_cnt;
reg clk_1MHz;
always @(posedge clk)
if(clk_cnt==32'd50)
begin
clk_cnt <= 1'b0;
clk_1MHz <= ~clk_1MHz;
end else clk_cnt <= clk_cnt + 1'b1;
initial begin
clk_cnt=0;
clk_1MHz=1;
end

  该模块还需要处理一些细节,如指令为 bne 时 zero 需要取反等。

  到这里,CPU 工程就编写完成啦。

Sol 2 仿真检验

  根据实现的 34 种指令,设计出以下用于检验的 MIPS 程序。该程序有三个 Test 函数,Test1 是 ALU 测试,Test2 是内存读写测试,Test3 是控制测试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
.text
main: addi $t1, $0, 5
addi $t2, $0, 6
jal Test1 # ALU Test
jal Test2 # Memory Test
j Test3 # Jump and Branch Test

Test1: div $t2, $t1 # ALU Test
mult $t1, $t2
add $t5, $t1, $t2
addu $t6, $t1, $t2
addiu $t7, $t1, 34 # addi has already been tested
sub $t5, $t1, $t2
subu $t6, $t1, $t2
mfhi $t3
mflo $t4
and $t5, $t1, $t4
andi $t6, $t1, 30
or $t5, $t1, $t4
ori $t6, $t1, 30
xor $t5, $t1, $t4
xori $t6, $t1, 30
nor $t5, $t1, $t4
addi $t4, $0, -10
div $t4, $t1
mult $t4, $t1
slt $t5, $t1, $t2
sltu $t6, $t1, $t4
slti $t7, $t1, -1
sll $t1, $t1, 17
sltiu $t8, $t1, -1
srl $t5, $t4, 2
sra $t6, $t4, 2
sllv $t5, $t4, $t2
srlv $t6, $t4, $t2
srav $t7, $t4, $t2
jr $ra

Test2: sw $t1, 0x0($0) # Memory Test
sw $t2, 0x4($0)
lw $t5, 0x0($0)
lw $t6, 0x4($0)
jr $ra

Test3: beq $t3, $0, label1 # Jump and Branch Test
addi $t5, $0, 16
label1: bne $t1, $t2, label2
addi $t5, $0, 16
label2: beq $t1, $t2, label3
addi $t6, $0, 17
label3: bne $t3, $0, label4
addi $t6, $0, 18
label4: addi $a0, $0, 10

Exit: syscall

  使用 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
top tp(.clk(clkin),.reset(reset),.ovf(ovf));

parameter PERIOD = 10;
always begin
clkin = 1'b0;
#(PERIOD / 2) clkin = 1'b1;
#(PERIOD / 2) ;
end
initial begin
// Initialize Inputs
clkin = 0;
reset=1;
# 100;
reset=0;
end

  仿真结果如下:
  (说明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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 分频
// 同top的分频,此处分400Hz

// 位选控制
reg [3:0]wei_ctrl=4'b1110;
always @(posedge clk_400Hz)
wei_ctrl <= {wei_ctrl[2:0],wei_ctrl[3]};

// 段选控制
always @(wei_ctrl)
case(wei_ctrl)
4'b1110:duan_ctrl=data[3:0];
4'b1101:duan_ctrl=data[7:4];
4'b1011:duan_ctrl=data[11:8];
4'b0111:duan_ctrl=data[15:12];
default:duan_ctrl=4'hf;
endcase

// 解码
always @ (duan_ctrl)
case(duan_ctrl)
// 根据duan_ctrl确定7位七段管信号。
endcase

  并在 top 模块中调用。
1
2
3
4
5
6
display dsp(
.clk(clk),
.data({PC[7:0],W[7:0]}),
.sm_wei(sm_wei),
.sm_duan(sm_duan)
);

  接下来是实验结果。受篇幅限制,只展示部分指令。
  选择 0x0000001C 开始的 6 条指令,检测 ALU 运算,结果如下:(前两位是指令地址,后两位是 ALU 结果)

  对照代码详解表的检测指标,指令执行正确。
  选择 0x0000009C 开始的 11 条指令(含跳转到别处的指令),检测跳转,结果如下:

  对照代码详解表的检测指标,指令执行正确。

  至此,完成。

Sources

  点我下载