单周期CPU用Verilog实现,是计算机组成原理课程里最经典的实验项目,也是很多同学第一次完整理解“硬件是怎么跑程序”的关键一步。我自己当年做这个项目时,代码写了几百行,仿真一团乱,后来才意识到问题不在于Verilog语法,而在于没想清楚数据通路和控制器的关系。这篇文章就把我从需求拆解到仿真跑通的完整过程整理出来,包括指令集怎么选、数据通路怎么连、控制信号怎么推、测试怎么排错,希望能帮你少踩几个坑。
1. 单周期CPU到底在解决什么问题:先搞懂“一条指令”的完整旅程
1.1 为什么教学实验都喜欢做单周期CPU
很多同学一上来就问:单周期CPU是不是太简单了?实际芯片里谁还用这个?这个问题问得没错,但恰恰是“简单”才是它的教学价值所在。单周期CPU的核心理念是:一条指令从取指、译码、执行到写回,在一个时钟周期内全部完成。这个设定把CPU最核心的骨架暴露得干干净净,没有流水线冒险、没有乱序执行、没有分支预测这些现代处理器的复杂机制干扰,你只需要面对一个最本质的问题——指令到底是怎么被执行的。
做这个项目的过程,其实就是在做三件事:设计指令集、搭建数据通路、生成控制信号。这三件事互相咬合,指令集会决定数据通路怎么走,数据通路又会反过来约束控制信号的生成。等你把这三件事串起来,回头再看任何更复杂的CPU设计,都会有一种“不过是在这个骨架上加东西”的感觉。
1.2 单周期里的“周期”到底指什么
这里有个初学者特别容易混淆的点。单周期CPU不是说所有指令执行速度一样快,而是说时钟周期被设置为能容纳最慢那条指令的时间。比如load指令要走完取指、读寄存器、ALU算地址、访存、写回寄存器这五个阶段,那整个时钟周期就必须留够这个时间。而像add这种指令,它不需要访存,本来可以更快,但在单周期设计里,它也得等这个慢时钟走完。
所以说单周期CPU有一个天生的性能短板:快指令被慢指令拖累。但这并不妨碍它作为教学模型的优秀性,因为它让“时钟周期”这个概念变得非常直观——每个周期开始时,CPU从PC指向的地址取出一条指令,周期结束时,这条指令的所有效果(寄存器更新、内存写入、PC跳转)都已经完成。我用一个生活化的类比:单周期CPU就像一条单人流水线,每个工人每次只加工一件产品,从毛坯到成品全部做完才接下一单,流程简单清晰,但效率确实不高。
2. 指令集选型:为什么我推荐用MIPS32来入门
2.1 MIPS32对比RISC-V和自定指令集
做单周期CPU第一步不是连电路,而是决定支持哪些指令。这个决定至关重要,因为后面所有电路结构都跟着指令集走。现在主流选择是MIPS32、RISC-V或自定义教学指令集,我自己最推荐的是MIPS32。
原因有三个。第一,MIPS32的指令格式极其规整,所有指令都是32位,只有三种格式:R型(寄存器运算)、I型(立即数和访存)、J型(跳转),译码逻辑简单得近乎优雅。第二,寻址方式非常少,只有寄存器寻址、立即数寻址、基址寻址、PC相对寻址这几种,每种对应数据通路上的一条明确路径。第三,教学资料极其丰富,从教材到公开课到开源代码,你能找到海量参考。
RISC-V虽然现在工业界很火,但它的指令格式存在更多的变体,立即数编码在不同指令里有不同的位段安排,这会让初学者的译码逻辑复杂不少。自定义指令集虽然自由度最高,但万事都要自己定义,debug时连“该得到什么结果”都拿不准,反而不是好选择。所以我一直建议:如果你不是做研究而是学原理,MIPS32是最佳的入门选择。
2.2 我最终确定的指令子集
完整的MIPS32指令非常多,但单周期CPU不需要全部支持。我选的子集是10条指令,覆盖了R型、I型、J型三种格式的典型代表:
- R型:add(加法)、sub(减法)、and(按位与)、or(按位或)、slt(小于则置位)
- I型:addi(加立即数)、lw(读内存)、sw(写内存)、beq(相等则跳转)
- J型:j(无条件跳转)
这10条指令涵盖了寄存器运算、立即数运算、访存、分支、跳转这五大类操作,已经能够写出简单的汇编程序(比如算斐波那契数列)。选定这10条指令之后,后续的所有数据通路设计都有了明确目标——每条指令需要走哪些路径、需要哪些硬件资源,都会被清晰地推导出来。
2.3 从指令编码反推硬件需求
这里我想特别强调一个思维方法:不要先画电路再想指令,而要从指令编码反推硬件需求。拿MIPS32的R型指令举例,它的格式是:opcode占6位,rs、rt、rd各占5位,shamt占5位,funct占6位。slt这类指令需要三个寄存器操作数——两个读、一个写,这就直接决定了寄存器堆必须设计成“两读一写”的三端口结构。lw指令的格式是:opcode占6位,rs占5位,rt占5位,立即数占16位,它用rs寄存器的值加上立即数作为内存地址,再把读到的数据写入rt寄存器,这就意味着数据通路上必须有“寄存器值+立即数”的地址计算路径。这些需求不是凭空想出来的,都是从每一条指令的编码格式里挖出来的。
3. 数据通路逐块拆解:从取指到写回的完整路径
3.1 取指路径:PC与指令存储器的协同
数据通路的起点是程序计数器(PC),它保存着当前指令的内存地址。在单周期CPU里,每个时钟上升沿,PC会被更新为下一条指令的地址。最基本的情况是顺序执行:PC = PC + 4,因为每条指令占4字节。但遇到分支和跳转指令时,PC的更新逻辑会复杂一些。
指令存储器(Instruction Memory)是一个只读的存储结构,它通过PC给出的地址输出对应的32位指令。在Verilog实现里,我习惯将指令存储器定义为一个二维数组,用reg [31:0] imem [0:4095],配合$readmemh在仿真开始前把机器码文件加载进去。这里有个细节很多人容易忽视:指令存储器的输出是组合逻辑的,也就是说,只要PC地址变化,指令信号立刻跟着变化,不需要等时钟沿。这样做的好处是,紧接着的组合逻辑(译码和控制信号生成)可以在同一个周期内完成,符合单周期“一个周期内做完所有事”的设定。
3.2 寄存器堆的“读旧写新”策略
寄存器堆(Register File)是CPU里存储最频繁的部件,32个32位寄存器,其中$zero硬连线为0,写入无效。它的端口设计直接由指令需求决定:需要两个读端口(rs和rt),一个写端口(rd或rt),所以是三端口RAM结构。在Verilog里,我用两个always @(*)块做读操作,再用一个always @(posedge clk)块做写操作。
单周期CPU里有个非常经典的设计细节:寄存器的写必须发生在时钟上升沿,而读是组合逻辑实时输出。这就会产生一个很容易让人困惑的现象——如果一条指令的rd和另一条指令的rs是同一个寄存器,后一条指令在同周期内能不能读到前一条指令刚写的值?答案是读不到,因为写是边沿触发,读是电平触发,同一周期内写端口的数据只在时钟沿才被锁存,而读端口在时钟沿之前读到的是旧值。这也是为什么单周期CPU不需要像流水线CPU那样做数据前推(forwarding)。
3.3 ALU与数据存储器:运算与访存的分工
ALU(算术逻辑单元)是整个CPU的执行核心,它负责所有算术和逻辑运算。根据我选的指令集,ALU需要支持and、or、add、sub、slt这五种操作。在Verilog里,ALU是一个纯组合逻辑模块,输入两个32位操作数A和B,输入一个4位控制信号ALUControl,输出32位结果,外加一个Zero标志位。Zero标志是专门给beq指令准备的,用来判断两个寄存器是否相等。
数据存储器(Data Memory)和指令存储器不同,它既可读也可写。lw指令从数据存储器读数据,sw指令向数据存储器写数据。这里有个关键点容易搞混:ALU算出的地址被用于数据存储器的访问。所以lw/siw的数据通路是:rs寄存器的值 + 符号扩展后的立即数 → ALU → 数据存储器地址。数据存储器同样是时钟上升沿写入,读则是组合逻辑输出。
3.4 立即数扩展与分支跳转路径:容易被忽视的数据流
除了主数据通路,还有两条支路经常被初学者忽略,但恰恰是仿真出错的高发区。第一条是立即数扩展(Sign Extend)。MIPS32里,lw/sw/beq/addi的立即数都是16位有符号数,送入ALU或地址计算单元之前必须符号扩展到32位。注意是有符号扩展,也就是把最高位(第15位)复制到高16位。我见过不少同学用零扩展,结果用负数偏移访问内存时地址完全不对。
第二条是分支和跳转的PC计算路径。beq指令的跳转目标是:PC + 4 +(符号扩展立即数 << 2)。为什么要左移两位?因为指令是4字节对齐的,所以立即数表示的偏移量要乘以4。这个<<2经常被漏掉,导致分支跳转到的地址不对。j指令的跳转目标则是:PC[31:28]拼接上(26位地址 << 2)。这两条路径都需要单独的信号通路和控制逻辑,不能混入普通数据通路。
4. 控制器逻辑:每一个控制信号都是算出来的
4.1 控制信号清单:每一个都是什么意思
控制器(Control Unit)是单周期CPU里最考验逻辑推导能力的部分。它把指令的opcode和funct字段翻译成一堆控制信号,指挥数据通路里的各个组件干活。在我这个设计里,共有9个控制信号需要生成:
| 信号名 | 功能 | 置1时代表什么 |
|---|---|---|
| RegDst | 选择写回寄存器的地址来源 | 1=rd,0=rt |
| ALUSrc | 选择ALU第二个操作数来源 | 1=立即数,0=rt寄存器值 |
| MemtoReg | 选择写回寄存器的数据来源 | 1=内存读数据,0=ALU结果 |
| RegWrite | 寄存器写使能 | 1=允许写入寄存器堆 |
| MemRead | 数据存储器读使能 | 1=允许读内存 |
| MemWrite | 数据存储器写使能 | 1=允许写内存 |
| Branch | 分支指令标志 | 1=beq指令 |
| Jump | 跳转指令标志 | 1=j指令 |
| ALUOp | ALU操作码(2位) | 00=加法,01=减法,10=由funct决定 |
4.2 从指令到控制信号:真值表推导过程
每一个控制信号的值,都是由指令的opcode直接决定的。推导方法很简单:想象这条指令在数据通路上走了一遍,它需要哪些部件开、哪些部件关。我列一下我的设计里六组关键指令对应的控制信号组合,方便你对查:
- R型(add/sub/and/or/slt):RegDst=1,ALUSrc=0,MemtoReg=0,RegWrite=1,MemRead=0,MemWrite=0,Branch=0,ALUOp=10
- lw:RegDst=0,ALUSrc=1,MemtoReg=1,RegWrite=1,MemRead=1,MemWrite=0,Branch=0,ALUOp=00
- sw:RegDst=无关,ALUSrc=1,MemtoReg=无关,RegWrite=0,MemRead=0,MemWrite=1,Branch=0,ALUOp=00
- beq:RegDst=无关,ALUSrc=0,MemtoReg=无关,RegWrite=0,MemRead=0,MemWrite=0,Branch=1,ALUOp=01
- addi:RegDst=0,ALUSrc=1,MemtoReg=0,RegWrite=1,MemRead=0,MemWrite=0,Branch=0,ALUOp=00
- j:Jump=1,其余信号全部无关
在Verilog里,控制器就是一个组合逻辑块,用case(opcode)实现。每个case分支给上述9个信号赋对应的值。初学阶段我建议不要偷懒省略“无关项”的赋值,宁可多写几行,保证每个分支把所有信号都赋全,否则很容易出现仿真时信号悬空,最后查出是控制信号没有初始化。
4.3 ALUOp的二级译码:为什么需要两个阶段
初学者看到ALUOp只有2位,会疑惑:怎么控制ALU的5种操作?答案是二级译码。第一级控制单元根据opcode生成2位ALUOp,第二级ALU控制模块再根据ALUOp和6位funct联合生成4位ALUControl信号。
具体逻辑是:对于lw/sw/addi这类指令,ALUOp=00,此时ALUControl固定为add(加法)。对于beq,ALUOp=01,ALUControl固定为sub(减法),因为比较两个寄存器是否相等,本质上就是做减法看Zero标志。对于R型指令,ALUOp=10,这时ALUControl完全由funct字段决定:100000对应add,100010对应sub,100100对应and,100101对应or,101010对应slt。这个设计思路在计算机体系结构里叫“主控-从控”,好处是控制单元的译码逻辑简单清晰,R型指令的不同操作被延迟到从控单元处理。
5. Verilog编码实战:模块划分与关键代码
5.1 模块划分:不要把所有逻辑堆在一个文件里
写Verilog和写软件一样,模块划分决定了代码可维护性。我推荐的划分方式是按数据通路的自然边界拆分:
- pc_reg:程序计数器
- ins_mem:指令存储器
- regfile:寄存器堆
- alu:算术逻辑单元
- data_mem:数据存储器
- sign_extend:立即数符号扩展
- control:主控制单元
- alu_control:ALU从控制单元
- cpu_top:顶层连线
每个模块一个文件,顶层只负责实例化和连线。这样做的好处是调试时可以单独对每个模块写testbench,快速定位问题在哪一段。我早期习惯把所有逻辑写在一个超级模块里,仿真出错时要从好几百行代码里找bug,非常痛苦。分模块设计不仅是一种代码规范,更是一种调试策略。
5.2 核心模块关键代码:寄存器堆和控制器
看关键代码比看冗长代码更高效。我挑两个最容易写错的模块展示一下设计思路。
寄存器堆的三端口设计,最关键的是“读旧写新”语义的正确实现:
module regfile( input clk, input rst_n, input reg_write, input [4:0] read_addr1, // rs input [4:0] read_addr2, // rt input [4:0] write_addr, // rd/rt input [31:0] write_data, output reg [31:0] read_data1, output reg [31:0] read_data2 ); reg [31:0] regs [0:31]; integer i; always @(*) begin if (read_addr1 == 5'b0) read_data1 = 32'b0; else read_data1 = regs[read_addr1]; end always @(*) begin if (read_addr2 == 5'b0) read_data2 = 32'b0; else read_data2 = regs[read_addr2]; end always @(posedge clk or negedge rst_n) begin if (!rst_n) begin for (i = 0; i < 32; i = i + 1) regs[i] <= 32'b0; end else if (reg_write && write_addr != 5'b0) begin regs[write_addr] <= write_data; end end endmodule注意两个细节:读操作是组合逻辑,用always @(*);写操作是时序逻辑,用always @(posedge clk),并配合复位把寄存器全部清零。还有$zero寄存器(地址0)的读保护和写禁止,这是MIPS架构的硬规则,很多同学漏掉后,程序里用$zero做源寄存器时会读到脏数据。
控制器的Verilog实现如下,核心是case语句:
module control( input [5:0] opcode, output reg reg_dst, output reg alu_src, output reg mem_to_reg, output reg reg_write, output reg mem_read, output reg mem_write, output reg branch, output reg jump, output reg [1:0] alu_op ); always @(*) begin // 默认值全部赋0,避免悬空信号 reg_dst = 0; alu_src = 0; mem_to_reg = 0; reg_write = 0; mem_read = 0; mem_write = 0; branch = 0; jump = 0; alu_op = 2'b00; case (opcode) 6'b000000: begin // R型 reg_dst = 1; reg_write = 1; alu_op = 2'b10; end 6'b100011: begin // lw alu_src = 1; mem_to_reg = 1; reg_write = 1; mem_read = 1; end 6'b101011: begin // sw alu_src = 1; mem_write = 1; end 6'b000100: begin // beq branch = 1; alu_op = 2'b01; end 6'b001000: begin // addi alu_src = 1; reg_write = 1; end 6'b000010: begin // j jump = 1; end endcase end endmodule这里我最想强调的是default分支赋默认值这个习惯。Verilog的reg类型变量如果在某个分支里没有被赋值,会保持之前的值,这会生成锁存器(latch),导致仿真和综合结果不一致。提前在case之前把9个信号全部赋初值,是最稳妥的写法。
5.3 组合逻辑与时序逻辑的Verilog纪律
写CPU代码时,Verilog里最容易出的问题就是always块的赋值方式混用。我的纪律很简单:
- 时序逻辑(带
posedge clk的always块)里,全部用非阻塞赋值<= - 组合逻辑(
always @(*))里,全部用阻塞赋值= - 一个信号只能在一个always块里被赋值,绝不能出现在两个always块里
这个纪律能解决90%的仿真诡异问题。比如PC更新和指令存储器输出的交互,如果PC用阻塞赋值,可能导致取指逻辑在同周期内竞争冒险,仿真结果时好时坏。
5.4 顶层模块连线:数据通路的Verilog表达
最后是顶层模块,它把各个子模块像拼乐高一样连起来。连线时最需要注意的是几个多路选择器的控制信号来源:
// CPU顶层的核心连线(简化版) wire [31:0] pc_next, pc_plus4, pc_branch, pc_jump; wire [31:0] instr, read_data1, read_data2; wire [31:0] alu_result, memory_data, write_data; wire [31:0] sign_extended, alu_b; wire [4:0] write_addr; wire zero_flag; assign pc_plus4 = pc_current + 4; assign sign_extended = { {16{instr[15]}} , instr[15:0] }; assign alu_b = alu_src ? sign_extended : read_data2; assign write_addr = reg_dst ? instr[15:11] : instr[20:16]; assign write_data = mem_to_reg ? memory_data : alu_result; assign pc_branch = pc_plus4 + (sign_extended << 2); assign pc_jump = { pc_plus4[31:28], instr[25:0], 2'b00 }; assign pc_next = jump ? pc_jump : (branch & zero_flag ? pc_branch : pc_plus4);这里有一个关键连线值得反复琢磨:pc_next的优先级是j > beq > 顺序执行,这个优先级和指令本身的互斥性有关(没有一条指令同时是j和beq),但写成这样的优先级结构会让电路逻辑更自然。数据存储器读出的数据直接进入写回多路选择器,ALU结果也进入同一个选择器,由MemtoReg决定到底写回哪个值。
6. 测试流程与仿真排错:让CPU真正跑起来的必经之路
6.1 用testbench搭建最小验证环境
CPU设计完只能算完成了一半,另一半是证明它能跑对程序。我习惯用Icarus Verilog(iverilog)配合GTKWave做仿真,轻量且免费,适合教学场景。testbench的核心是初始化、驱动时钟、加载程序、观察波形。
一个最小testbench骨架是这样的:
module cpu_tb; reg clk = 0; reg rst_n = 0; always #5 clk = ~clk; // 10ns周期 cpu_top uut( .clk(clk), .rst_n(rst_n) ); initial begin $readmemh("program.hex", uut.imem.mem); rst_n = 0; #20 rst_n = 1; // 复位结束后开始跑程序 #500; $display("r16 = %d", uut.rf.regs[16]); $finish; end endmodule这里有个实用细节:由于所有子模块都实例化在顶层里,testbench可以用层次化引用uut.rf.regs[16]直接观察内部寄存器状态,省去设计调试接口的麻烦。写测试程序时,$readmemh加载的是十六进制机器码文件。我通常先用汇编器或手工编码把汇编转成机器码,再喂给仿真器。
6.2 由简到繁的三步测试策略
我的测试策略分三步,每一步都有明确的验证目标,不跳步。
第一步:单指令测试。每条指令单独跑一个testbench,然后观察这条指令涉及的所有信号变化。比如测lw指令,要确认PC依次加4、寄存器堆读出了正确基址、立即数被符号扩展、ALU算出的地址正确、数据存储器读出了目标数据、写回寄存器的数据正确。这步跑通了,这条指令的数据通路就基本没问题。
第二步:组合测试。把两条以上指令组合在一起跑,特别关注寄存器数据相关性。比如先执行addi $t0, $zero, 5,紧接着执行add $t1, $t0, $t0,验证第二条指令能否正确读出$t0的新值。如果读出来的是旧值,大概率是寄存器堆的写使能或写地址连接有问题。
第三步:完整小程序测试。写一段真正的程序,比如用循环和加法计算斐波那契数列前20项,跑完后观察寄存器堆里对应寄存器的值是否与预期一致。这一步能同时验证R型指令、addi、lw、sw、beq、j的协同工作,是最完整的回归测试。
6.3 我在项目里踩过的几个高频Bug
做单周期CPU时,有些Bug几乎是所有初学者都会踩一遍的,我整理了几个典型场景和排查链路。
现象一:PC永远停在0不动。排查链路:先看复位信号是否正常释放——如果rst_n一直为低,PC被持续清零。再看时钟是否在震荡——用波形图确认clk有翻转。然后看pc_plus4计算是否正确——有时是位宽不匹配导致赋值截断。最后看pc_next的连线——如果mux信号没有正确接入,pc_next可能一直等于pc_current,形成死循环。
现象二:beq指令该跳不跳。这是我最常遇见的bug,几乎每次都出在三个地方。第一,立即数扩展方向搞错——beq的立即数是有符号的,用零扩展会导致负数偏移跳到巨大的地址。第二,忘记左移两位——导致跳转目标四字节不对齐。第三,zero标志连接错误——ALU计算的是read_data1 - read_data2,如果操作数接反了,判断结果就不对。
现象三:寄存器的值怎么都写不进去。排查链路:先确认RegWrite是否为1,很多时候控制器case分支里忘记给这个信号赋值。再确认写地址来源是rd还是rt——R型指令要写rd(指令的第16~11位),lw/addi要写rt(第20~16位),如果接反了,指令执行的逻辑全乱。最后确认写数据源选对了——lw要写内存读出的数据,R型要写ALU结果,MemtoReg控制信号接反会让写进去的值完全错误。
现象四:仿真波形上出现很多高阻态X。这通常是某个模块有未初始化信号。常见原因是组合逻辑always块中有分支没赋全值,产生了latch,或者顶层某些wire没有驱动源,浮空了。出现X时不要着急改代码,先在波形图上追踪是哪个信号的源头出了状况,一层层往前查。
6.4 用断言和自动化检查提升仿真效率
当你跑完整程序时,靠人眼盯波形看几百个周期太不实际了。我强烈建议在testbench中加入自动比对机制:每跑完一段程序,用$display打印关键寄存器和内存位置的值,再和预期结果比对。我写过最简单的做法是执行结束后直接打印需要的值:
initial begin // ... 跑完程序后 $display("fib[19] = %d, expect = 4181", uut.rf.regs[31]); if (uut.rf.regs[31] == 4181) $display("TEST PASSED"); else $display("TEST FAILED"); end这种方式比肉眼盯波形高效得多。后期我甚至把每一条指令执行后受影响寄存器的值都打印出来,做成一份执行日志,和理论推导值逐行比对,能非常快地定位到第一条出错指令,然后针对性调试那一条指令的数据通路。
7. 从单周期到流水线:这条路后面还能怎么走
7.1 单周期CPU的性能瓶颈明显但很有价值
做完单周期CPU之后,你应该已经能够直观感受到它的性能瓶颈:时钟周期被最慢指令拖长,硬件资源大量时间在闲置。比如一条add指令根本不需要用数据存储器,但整个时钟周期还是按lw的最长路径来定。ALU在lw指令的访存阶段完全空闲,寄存器堆在sw指令时写端口闲置。这些资源浪费在教材里叫“硬件利用率低”,说直白点就是:很多昂贵的硬件模块,大部分时间在摸鱼。
但正是因为单周期CPU把这些问题暴露得如此清晰,你才能理解为什么现代CPU要用流水线。流水线的本质是让不同指令的不同阶段并行执行,就像工厂里的装配线,每道工序都有专门的工位,不同在制品同时处于不同工序。在这个阶段去学习流水线架构,你会有一种水到渠成的感觉,而不是背教材。
7.2 流水线化的关键改动:几个全新的挑战
把单周期CPU改成经典五级流水线(IF取指、ID译码、EX执行、MEM访存、WB写回),需要在数据通路的每个阶段之间插入流水线寄存器。这个改动看起来是在单周期数据通路上切五刀,但切完之后你会遇到三个单周期里不存在的问题。
数据冒险是第一个拦路虎。相邻指令如果存在数据依赖,比如第二条指令的源寄存器正是第一条指令的目的寄存器,而第一条指令要等写回阶段才能把值写入寄存器,第二条指令在译码阶段就会读到旧值。解决思路有前递(forwarding)和插入气泡(stall),实际设计里两者结合使用。
控制冒险来自分支指令。beq要等执行阶段算出结果才知道是否跳转,但这时后面的指令已经开始取指了,这些指令如果被判定不该执行,就要被冲洗(flush)掉。分支延迟槽是MIPS体系结构里一个有趣的折中方案。
还有一个更隐蔽的问题是结构冒险,单周期里指令存储器和数据存储器分开所以不会撞车,但在某些设计里,一个周期内既想取指又想访存就会冲突。这些问题每一个都够写一篇长文,但在理解单周期CPU的基础上,你已经完全具备了理解这些问题的知识储备。我就是从这个项目开始,分阶段做了五级流水线、Cache、中断控制器,乃至最后在FPGA上跑通了简单的操作系统微型内核。回过头看,所有进阶设计的底层逻辑,都还是当年那句朴素的原理——指令走过的每一步。
我个人的体会是,单周期CPU这个项目真正教会你的其实不是Verilog语法,也不是某条指令怎么编码,而是一种“分层拆解、逐级验证”的工程方法:先从需求推出结构,再把结构拆成模块,最后用测试证明每个模块正确。这个思路放在任何软件或硬件项目里都好用。如果你正在做这个实验,我建议你不妨多花点时间在画数据通路图上,把它画到烂熟于心再动手写代码,比直接抄代码学到的要多得多。