ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Verilog实现单周期CPU:数据通路与控制器设计实战指南

Verilog实现单周期CPU:数据通路与控制器设计实战指南 单周期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选择写回寄存器的地址来源1rd0rtALUSrc选择ALU第二个操作数来源1立即数0rt寄存器值MemtoReg选择写回寄存器的数据来源1内存读数据0ALU结果RegWrite寄存器写使能1允许写入寄存器堆MemRead数据存储器读使能1允许读内存MemWrite数据存储器写使能1允许写内存Branch分支指令标志1beq指令Jump跳转指令标志1j指令ALUOpALU操作码2位00加法01减法10由funct决定4.2 从指令到控制信号真值表推导过程每一个控制信号的值都是由指令的opcode直接决定的。推导方法很简单想象这条指令在数据通路上走了一遍它需要哪些部件开、哪些部件关。我列一下我的设计里六组关键指令对应的控制信号组合方便你对查R型add/sub/and/or/sltRegDst1ALUSrc0MemtoReg0RegWrite1MemRead0MemWrite0Branch0ALUOp10lwRegDst0ALUSrc1MemtoReg1RegWrite1MemRead1MemWrite0Branch0ALUOp00swRegDst无关ALUSrc1MemtoReg无关RegWrite0MemRead0MemWrite1Branch0ALUOp00beqRegDst无关ALUSrc0MemtoReg无关RegWrite0MemRead0MemWrite0Branch1ALUOp01addiRegDst0ALUSrc1MemtoReg0RegWrite1MemRead0MemWrite0Branch0ALUOp00jJump1其余信号全部无关在Verilog里控制器就是一个组合逻辑块用case(opcode)实现。每个case分支给上述9个信号赋对应的值。初学阶段我建议不要偷懒省略“无关项”的赋值宁可多写几行保证每个分支把所有信号都赋全否则很容易出现仿真时信号悬空最后查出是控制信号没有初始化。4.3 ALUOp的二级译码为什么需要两个阶段初学者看到ALUOp只有2位会疑惑怎么控制ALU的5种操作答案是二级译码。第一级控制单元根据opcode生成2位ALUOp第二级ALU控制模块再根据ALUOp和6位funct联合生成4位ALUControl信号。具体逻辑是对于lw/sw/addi这类指令ALUOp00此时ALUControl固定为add加法。对于beqALUOp01ALUControl固定为sub减法因为比较两个寄存器是否相等本质上就是做减法看Zero标志。对于R型指令ALUOp10这时ALUControl完全由funct字段决定100000对应add100010对应sub100100对应and100101对应or101010对应slt。这个设计思路在计算机体系结构里叫“主控-从控”好处是控制单元的译码逻辑简单清晰R型指令的不同操作被延迟到从控单元处理。5. Verilog编码实战模块划分与关键代码5.1 模块划分不要把所有逻辑堆在一个文件里写Verilog和写软件一样模块划分决定了代码可维护性。我推荐的划分方式是按数据通路的自然边界拆分pc_reg程序计数器ins_mem指令存储器regfile寄存器堆alu算术逻辑单元data_mem数据存储器sign_extend立即数符号扩展control主控制单元alu_controlALU从控制单元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 5b0) read_data1 32b0; else read_data1 regs[read_addr1]; end always (*) begin if (read_addr2 5b0) read_data2 32b0; 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] 32b0; end else if (reg_write write_addr ! 5b0) 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 2b00; case (opcode) 6b000000: begin // R型 reg_dst 1; reg_write 1; alu_op 2b10; end 6b100011: begin // lw alu_src 1; mem_to_reg 1; reg_write 1; mem_read 1; end 6b101011: begin // sw alu_src 1; mem_write 1; end 6b000100: begin // beq branch 1; alu_op 2b01; end 6b001000: begin // addi alu_src 1; reg_write 1; end 6b000010: 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], 2b00 }; 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 Verilogiverilog配合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语法也不是某条指令怎么编码而是一种“分层拆解、逐级验证”的工程方法先从需求推出结构再把结构拆成模块最后用测试证明每个模块正确。这个思路放在任何软件或硬件项目里都好用。如果你正在做这个实验我建议你不妨多花点时间在画数据通路图上把它画到烂熟于心再动手写代码比直接抄代码学到的要多得多。
返回列表