从零构建前的准备

设计思路

理论知识已经准备好了,接下来该开始实践了。

如何从零开始写一个CPU?

首先要明确ISA架构:我们需要支持哪些指令?RV32的拓展很多很多,比如MIAFD等等。我们可以实现一个“精简版”的RV32I指令集CPU——连里面的ecallebreak都不实现。只要满足基础的38条(甚至只支持lw/sw,不考虑非对齐访存),就能运行80%的C语言程序。至于Zicsr扩展和M扩展等,在构建完毕后再考虑。

然后,是微架构设计:是做单周期还是多周期,亦或者流水线?是做顺序还是乱序?单发射还是多发射?饭要一口口吃,步子迈太大了容易扯着蛋。因此,做一个经典的五级流水线、顺序单发射的CPU即可。

然后,保持一个清醒的头脑。即使是一个最简单的RV32ICPU,也需要不下于10个模块。所有的代码加起来可能连3000行都不到,但是其中的连线无比复杂。如果不在写之前就想好,很容易莫名其妙地丢掉一条连线、一个端口,然后看波形debug半天。

时刻要记住:切不可“只见树木、不见森林”——不建议一个一个模块写,而是创建好文件后问自己:

  • 这个模块的功能是什么?
  • 这个模块是组合逻辑电路还是时序逻辑电路?
  • 这个模块需要哪些控制信号?
  • 这个模块需要输出什么数据?输出给谁?

边考虑这些问题边创建文件,然后,类似参考书上的指导,构建数据通路。可以不写出来,但要想清楚,哪些数据会在寄存器之间流动,并被传递到下一级流水线。等每个文件的大体框架搭建完毕,再从易到难、从组合逻辑到时序逻辑完成。一级一级流水线向前推进,写完某一级后继续完成两级之间的流水线寄存器设计。

参考资料

xuanhao44 - HITSZ-miniRVCPU

开发环境

参考 VSCode配置Verilog开发环境 即可。

这里安利一下一个超级棒的的VSCode插件:Digital IDE

内部集成了一件模块测试、代码高亮与格式化、快速跳转等。虽然生成模块图的功能有些BUG,但是依旧非常强,瑕不掩瑜!

现在写Verilog都用它了。绝赞!

取指 IF级 设计

IF级取出指令,因此模块很少,只要实现PC程序计数器和IROM即可。

程序计数器 PC.sv

显然,程序计数器是时序逻辑模块。这里将PC+4的计算也合并到PC模块内。

程序计数器要干什么?既要计算出下一时刻的PC,还要考虑是否发生分支跳转——这里要接受来自EX级的跳转发生信号,因为B-Type类指令到EX级才能得出结果。

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
module PC (
input logic clk,
input logic rst_n,
// 是否要打一拍 保持PC
input logic keep_pc,
// 进行分支跳转
input logic branch_op,
// 分支跳转目标地址
input logic [31:0] branch_target,
output logic [31:0] pc_if,
output logic [31:0] pc4_if
);

logic [31:0] npc;

always_comb begin
pc4_if = pc_if + 4;
npc = branch_op ? branch_target : pc4_if;
end

always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) pc_if <= 0;
else if (keep_pc) pc_if <= pc_if;
else pc_if <= npc;
end

endmodule

指令存储器 IROM.sv

IROM即存放程序代码的Flash,只读,不依赖任何时钟信号,因此是组合逻辑模块。通常来说,IROM(指令存储器)会被放在译码级,但不自行封装时,我们也会给CPU接出instrpc到厂商的 IROM IP核。为了方便思考,此处放在IF级即可。为了便于仿真,这里自己实现一个支持加载hex文件的IROM。

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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
`timescale 1ns / 1ps

// IROM 行为模型 - 用于仿真
// 替代 Xilinx IP 核

module IROM (
input wire [13:0] a, // 地址输入 (14位 = 16K words)
output reg [31:0] spo // 数据输出
);

// 指令存储器 - 16K x 32bit
reg [31:0] rom_data[16384];

// 从文件加载指令
initial begin

integer i;
string rom_file; // 字符串缓冲区


for (i = 0; i < 16384; i = i + 1) begin
// rom_data[i] = 32'h00000013; // NOP
rom_data[i] = 32'h0d000721;
end

// 尝试从文件加载指令
// if ($value$plusargs("IROM=%s", rom_file)) begin
if (1) begin
rom_file =
// "user/data/hex/simple_test.hex"
"user/data/hex/jalr.hex"
// "user/data/hex/myFirstTest.hex"
// "user/data/hex/no_hazard.hex"
// "user/data/hex/loaduse_test.hex"
// "user/data/hex/hazard12.hex"
;
$readmemh(rom_file, rom_data);
$display("IROM: Loaded instructions from %s", rom_file);
end else begin
// 如果没有指定文件,加载默认的测试程序
$display("IROM: Loading default test program");
load_default_program();
end
end

// 读取数据 (组合逻辑)

always_comb begin
spo = rom_data[a];
end


// 加载默认测试程序
task automatic load_default_program;
begin
// 更复杂的测试程序 - 测试算术、逻辑、移位指令和数据冒险
$display("IROM: Default test program loaded");
rom_data[0] = 32'h00500093; // addi x1, x0, 5 x1 = 5
rom_data[1] = 32'h00300113; // addi x2, x0, 3 x2 = 3
rom_data[2] = 32'h002081b3; // add x3, x1, x2 x3 = x1 + x2 = 8
rom_data[3] = 32'h40208233; // sub x4, x1, x2 x4 = x1 - x2 = 2
rom_data[4] = 32'h0020f2b3; // and x5, x1, x2 x5 = x1 & x2 = 1
rom_data[5] = 32'h0020e333; // or x6, x1, x2 x6 = x1 | x2 = 7
rom_data[6] = 32'h002093b3; // sll x7, x1, x2 x7 = x1 << x2 = 40
rom_data[7] = 32'h0020c433; // xor x8, x1, x2 x8 = x1 ^ x2 = 6
end
endtask

endmodule

hex文件加载方式可以选择手动指定路径,也可以选择编译时传入,后者配合Makefile文件很方便,不过我选择DIDE的一键仿真。

IF/ID级流水线寄存器 PR_IF_IF.sv

终于到了第一道坎:两级之间的流水线寄存器。

流水线寄存器起到一个承上启下的作用,在两个阶段之间保存数据,使得指令可以连续执行。

都寄存器了,那肯定是时序逻辑模块了。

我们需要哪些信号?

首先是PCPC+4,其中PC+4因为分支跳转和jal/jalr指令的需求,要一直送到EX级。接着是当前的指令,要送入下一级的译码器。

还有什么?我们肯定需要处理流水线的竞争冒险,而不能简单打一拍——就算打一拍,也要有相应的控制信号。因此,还需要flushstall这两个控制信号,来控制IF级是否冲刷、是否停顿。

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
`include "./include/defines.svh"

module PR_IF_ID (
input logic clk,
input logic rst_n,
// 流水线控制信号
input logic flush,
input logic stall,
// IF级输入
input logic [31:0] pc_if_i,
input logic [31:0] pc4_if_i,
input logic [31:0] instr_if_i,
// IF级输出 给ID级输入
output logic [31:0] pc_id_o,
output logic [31:0] pc4_id_o,
output logic [31:0] instr_id_o,
// 判断指令是否有效
// 流水线冲刷时需要将指令置为无效
input logic instr_valid_if_i,
output logic instr_valid_id_o
);

always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
pc_id_o <= 32'b0;
pc4_id_o <= 32'b0;
instr_id_o <= 32'b0;
instr_valid_id_o <= 1'b0;
end else if (flush) begin
pc_id_o <= 32'b0;
pc4_id_o <= 32'b0;
instr_id_o <= 32'b0;
instr_valid_id_o <= 1'b0; // 冲刷时指令无效
end else if (stall) begin
pc_id_o <= pc_id_o;
pc4_id_o <= pc4_id_o;
instr_id_o <= instr_id_o;
instr_valid_id_o <= instr_valid_id_o;
end else begin
pc_id_o <= pc_if_i;
pc4_id_o <= pc4_if_i;
instr_id_o <= instr_if_i;
instr_valid_id_o <= instr_valid_if_i;
end
end

endmodule

至此,IF级完毕。