东北大学计组课设:Verilog实现单周期与多周期CPU
发布时间:2026/9/2 7:57:32来源:尧图网络
简介面向东北大学计组课程设计完整提供5条指令单周期CPU、20条指令单周期CPU以及单发射五级流水线CPU的Verilog实现与验证工程涵盖从基础指令集、数据通路搭建到流水线冲突处理的设计进阶。资源共1499个文件包含v/vhdl源文件、do仿真脚本、mif存储器初始化文件、xdc约束文件等压缩包约251MB可在Vivado中直接打开工程进行仿真、综合与上板验证目录按任务阶段划分便于定位。除主体代码外还附带大量仿真波形、综合报告、日志及辅助脚本便于对照调试理解数据通路、控制信号与流水线相关机制。目前已有474人学习适合正在完成课设、需要参考完整CPU实现或排查流水线冲突问题的学生。内容按任务模块组织从补充缺失代码、修正已有错误到五级流水设计逐层递进能帮助读者快速定位各阶段设计要点并完成仿真与验收。 本来这篇博客应该写在课设验收完的那天晚上结果一拖就是半个学期。去年年底做东北大学计组课设从一头雾水到把5条指令CPU、20条指令CPU、多周期CPU三版代码全部调通整个过程踩了不少坑也摸清了很多教科书上写不明白的细节。这两天整理文件的时候翻出当时的设计文档和仿真波形觉得还是应该把完整思路和可用的Verilog代码梳理出来。无论你是正在做课设、还是想搞懂CPU到底怎么在硬件上跑起来这篇都值得你看完。1. NEU计组课设的验收标准与我的整体设计路线先说清楚东北大学计组课设的范围。课程设计通常要求基于FPGA开发板实验课一般用Xilinx Artix-7或Nexys系列也有人用Altera使用Verilog HDL实现一个可运行指令序列的CPU。三个梯度对应三种难度5条指令CPU是最基础的验证的是单周期数据通路20条指令CPU需要挂更多指令类型既包括算术逻辑运算也要求支持访存和跳转多周期CPU则把控制逻辑彻底改写成有限状态机。先说结论我是按“单周期打基础多周期做架构升级”的顺序推进的。先写好5条指令的单周期版本并成功跑通理解清楚每一类指令在数据通路上的流向再在这个基础上去扩展20条指令集。完成20条指令后再做多周期改造把单周期中所有组合逻辑驱动的信号拆到状态机里这是最顺的学习路径。如果一上来就写多周期状态机的分支和寄存器控制信号会让你直接懵掉。具体准备工作建议分三块。第一开发环境我当时用的是Vivado 2019.1新版本也没问题重点是仿真工具——XSim就够用不需要额外装ModelSim但ModelSim的波形界面确实好看点。第二实验板型号和引脚绑定文件这个直接问实验课老师要或者找上届学长的工程文件里扒别自己对着原理图数引脚。第三确认实验板上的时钟频率多数板子是100MHz后面设计计时器分频时要用到。2. 5条指令CPU指令集规划与单周期数据通路的一次到位2.1 指令集怎么选五条背后各有各的使命我选的5条指令分别是add、andi、lw、sw、beq。这个组合不是随便挑的它覆盖了四类核心指令格式add rd, rs, rtR型运算指令数据从寄存器堆到ALU再写回寄存器堆andi rt, rs, immI型立即数运算指令需要立即数扩展lw rt, offset(rs)I型访存指令读存储器数据到寄存器sw rt, offset(rs)I型访存指令写寄存器数据到存储器beq rs, rt, offsetI型分支指令改变PC流向看起来只有五条但单周期数据通路需要的所有元素都齐了取指、译码、执行、访存、写回每类操作都有代表。就算选sub、or、j那些也完全可以关键是确保每条指令对应的控制信号组合能覆盖住数据通路的所有控制点。2.2 数据通路设计核心就是数据往哪儿走单周期CPU的整体结构是PC指向指令存储器取出的指令送到译码模块译出的控制信号分别控制寄存器堆、ALU、数据存储器、写回选择器同时计算下一周期的PC值。这部分的原理大家都学过我直接给出一段能用的核心代码。五条指令CPU的顶层数据通路我写成这样// 单周期CPU顶层模块5条指令 module cpu_single_5_inst( input wire clk, input wire rst_n, output wire [31:0] pc_out, output wire [31:0] inst_out ); // 内部信号 wire [31:0] pc_next; wire [31:0] pc_plus_4; wire [31:0] pc_branch; wire [31:0] inst; wire [31:0] reg_data1, reg_data2; wire [31:0] alu_result; wire [31:0] mem_data; wire [31:0] write_data; wire reg_write_en; // 寄存器写使能 wire alu_src; // ALU第二操作数来源寄存器/立即数 wire mem_write_en; // 存储器写使能 wire mem_to_reg; // 写回数据来源ALU结果/存储器 wire branch; // 分支指令标志 wire [2:0] alu_control; // ALU控制信号 wire zero_flag; // PC逻辑复位清零上升沿更新 reg [31:0] pc_reg; always (posedge clk or negedge rst_n) begin if (!rst_n) pc_reg 32b0; else pc_reg pc_next; end assign pc_out pc_reg; assign pc_plus_4 pc_reg 32d4; assign pc_branch pc_plus_4 {{16{inst[15]}}, inst[15:0]} 2; // 分支选择beq且zero为1则跳转否则顺序执行 assign pc_next (branch zero_flag) ? pc_branch : pc_plus_4; // 指令存储器组合逻辑读取 // 此处使用阻塞赋值避免在仿真中产生读取延迟 reg [31:0] inst_mem [0:63]; initial begin // 测试程序初始化后续用 $readmemh 代替 inst_mem[0] 32b000000_00001_00010_00011_00000_100000; // add r3, r1, r2 end assign inst inst_mem[pc_reg[7:2]]; assign inst_out inst; // 寄存器堆同步写、组合读 reg [31:0] reg_file [0:31]; integer i; always (posedge clk or negedge rst_n) begin if (!rst_n) begin for (i 0; i 32; i i 1) reg_file[i] 32b0; end else if (reg_write_en (inst[15:11] ! 5b0)) reg_file[inst[15:11]] write_data; end assign reg_data1 reg_file[inst[25:21]]; assign reg_data2 reg_file[inst[20:16]]; // ALU用功能码和指令共同决定运算类型 assign alu_result (alu_control 3b000) ? (reg_data1 alu_src_b) : (alu_control 3b001) ? (reg_data1 - alu_src_b) : (alu_control 3b010) ? (reg_data1 alu_src_b) : (alu_control 3b011) ? (reg_data1 | alu_src_b) : (alu_control 3b100) ? (reg_data1 alu_src_b) : 32b0; assign zero_flag (alu_result 32b0); // 数据存储器 reg [31:0] data_mem [0:63]; always (posedge clk) begin if (mem_write_en) data_mem[alu_result[7:2]] reg_data2; end assign mem_data data_mem[alu_result[7:2]]; // 写回数据选择 assign write_data mem_to_reg ? mem_data : alu_result; endmodule这段代码的核心就是PC计算和数据通路的流向。要特别注意的是data_mem[alu_result[7:2]]这种取地址方式——因为MIPS按字节寻址而我们的存储深度是64个32位字所以地址线的低2位可以丢弃用[7:2]做字地址索引。很多人仿真时存储器读数据一片未知多半是这个地址下标的位宽没对齐。2.3 5条指令的单周期控制单元真值表直接硬写控制单元是整个CPU的“调度中心”。5条指令的控制信号不多可以直接按表硬编码指令reg_writealu_srcmem_writemem_to_regbranchalu_controladd10000000andi11000001lw11010000sw01100000beq00001001控制信号的生成用case指令最直观。注意在组合逻辑中用case时一定要给每个输出赋默认值或者在所有分支里都写出每个信号的取值否则综合时会生成电平敏感的锁存器latch。这是很多课设工程第一次综合出infrastructure警告的根源。// 5条指令CPU控制单元 module control_5_inst( input wire [5:0] opcode, input wire [5:0] funct, output reg reg_write_en, output reg alu_src, output reg mem_write_en, output reg mem_to_reg, output reg branch, output reg [2:0] alu_control ); always (*) begin // 默认值清零防止锁存器 reg_write_en 1b0; alu_src 1b0; mem_write_en 1b0; mem_to_reg 1b0; branch 1b0; alu_control 3b000; case (opcode) 6b000000: begin // R型指令看funct case (funct) 6b100000: begin // add reg_write_en 1b1; alu_control 3b000; end endcase end 6b001100: begin // andi reg_write_en 1b1; alu_src 1b1; alu_control 3b001; end 6b100011: begin // lw reg_write_en 1b1; alu_src 1b1; mem_to_reg 1b1; alu_control 3b000; end 6b101011: begin // sw alu_src 1b1; mem_write_en 1b1; alu_control 3b000; end 6b000100: begin // beq branch 1b1; alu_control 3b001; // 减法判断zero end default: begin // 未知指令所有信号保持默认值 end endcase end endmodule这套控制逻辑跑起来后用一组“从存储器读两个数做加法写回寄存器”的测试程序就能验证整条通路。这一步通了20条指令和后面的多周期才有地基。3. 扩展到20条指令控制信号量爆炸与冒险处理的必经之路3.1 20条指令集的构成和新增难点5条指令的CPU跑通之后扩展20条指令并不是单纯的“多写几个case分支”它牵涉到数据通路本身是否需要改动的问题。我设计的20条指令包含R型算术/逻辑运算add、addu、sub、and、or、xor、nor、slt、sllI型立即数运算addi、addiu、andi、ori、xori、sltiI型访存与分支lw、sw、beq、bneJ型跳转j这里面的关键突破点有两个。第一是跳转指令j的出现让PC的下一值来源从“顺序4”和“分支目标”两路变成了三路必须在PC选择器上新增一个跳转入口。第二是bne带来分支条件的多样化zero标志不好使了需要把ALU结果和零比较器解耦合直接比较两个寄存器是否相等。这两个改动单周期数据通路上要加一组多路选择器和一个判断逻辑。改完后的PC计算逻辑如下// 支持j跳转的PC生成逻辑 wire [31:0] pc_jump; assign pc_jump {pc_plus_4[31:28], inst[25:0], 2b00}; // j指令跳转的最高优先级 assign pc_next jump ? pc_jump : (branch_taken ? pc_branch : pc_plus_4);注意j指令的跳转地址是“当前PC4的高4位 指令低26位 2个0”。实际操作时很多人写{pc_reg[31:28], inst[25:0], 2b00}在PC还没有加4之前就拼接这在指令位于非对齐地址时会出错。稳妥的写法是从pc_plus_4里取高4位。3.2 控制信号不再是“真值表”能管住的规模20条指令下控制信号的数量没有暴涨但每条指令的组合复杂度上来了。管好它们的方法是把ALU控制信号和指令译码解耦。我在20条指令版本里新增了alu_op2位作为控制单元传给ALU控制模块的中间信号ALU控制模块再根据alu_op和funct共同决定具体的运算选择。alu_op含义对应的指令类型00加法lw/sw/addi使用访存、立即数加法01减法/比较beq/bne/slt使用分支、比较10由funct码决定R型指令使用R型算术逻辑运算11立即数逻辑运算andi/ori/xori使用立即数逻辑运算这种分层设计的好处是以后再加指令时控制单元只管判断“这属于哪一类”不关心具体的ALU运算细节具体的运算由ALU控制模块去查funct。这样代码维护成本低很多不会因为多加一条R型指令就要改动一堆控制信号的取值。3.3 单周期跑20条指令时的冒险问题很多同学做到20条指令时就发现虽然功能上能跑但分支指令之后的那些指令在流水线视角下“多执行了”。单周期CPU虽然每条指令一拍完成但分支是否跳转的结果要在当前周期末尾才确定因此分支指令的下一条指令的取指地址其实是在分支结果出来之前算的——这就是控制冒险的雏形。我的处理方式在单周期版本里接受这个现象但为了测试方便用软件办法规避。在分支指令后面加一条nop或者在测试程序里把分支目标之后的指令安排好确保即使顺序取指也没有副作用。这个问题的彻底解决要等做完多周期CPU之后用状态机的PC写使能来控制取指时机。另外lw指令在单周期里虽然拍内完成但如果后面紧跟一条使用该加载值的指令在实际硬件上是没问题的因为单周期一条指令只有一个时钟周期所有操作都在同一拍内组合完成天然不存在数据冒险。这也是单周期结构相对简单的根本原因——所有逻辑都在一拍内稳定下一拍上升沿统一写入。4. 多周期CPU的状态机设计一条lw指令串起全局控制逻辑4.1 为什么多周期CPU必须换成状态机控制多周期CPU的核心思想是把每条指令拆到多个时钟周期完成每个周期只做一小步操作。这样带来的直接好处是硬件资源可以复用。指令存储器和数据存储器可以合并为一个存储器ALU也可以在不同周期干不同的事。代价是控制逻辑从“组合逻辑真值表”变成了“有限状态机”——你必须保证每个状态里每一个控制信号的值都是确定且正确的。5条指令的CPU可以直接扩展成多周期版本的起点。课程设计里常见的多周期CPU状态划分是IF取指PC PC 4ID译码读寄存器堆计算分支目标EX执行ALU计算有效地址或运算结果MEM访存读/写数据存储器WB写回把结果写进寄存器堆看到没有这类比的是经典五级流水。但多周期CPU和流水线CPU最大的区别在于流水线是每个周期都在同时跑五条指令的不同阶段而多周期CPU同一时间只有一条指令在“行走”它在状态机里一步一步走完五个状态后才取下一指。4.2 状态机控制的核心哪些寄存器需要写使能多周期版本里我最开始犯的错就是设计状态机时只盯着“当前状态是什么”而忽略了“每个状态里寄存器堆的写使能是什么”。这个在仿真中表现为状态机确实在跑但寄存器值永远是未知态。排查了半天才发现寄存器堆的写使能信号没有在WB状态里拉高。下面给出多周期CPU控制单元的核心状态机。以lw指令为例它要经历全部五个状态// 多周期CPU控制单元状态机重点状态以lw为例 module multi_ctrl_fsm( input wire clk, input wire rst_n, input wire [5:0] opcode, input wire [5:0] funct, output reg pc_write, // PC写使能 output reg mem_write_en, // 存储器写使能 output reg ir_write, // 指令寄存器写使能 output reg reg_write_en, // 寄存器写使能 output reg [1:0] alu_op, // ALU操作码 output reg alu_src_a, // ALU输入A来源PC/寄存器 output reg alu_src_b, // ALU输入B来源寄存器/立即数/ 4 output reg [1:0] reg_dst, // 写寄存器地址来源 output reg [1:0] mem_to_reg // 写回数据来源 ); localparam IF 4d0, ID 4d1, EX 4d2, MEM 4d3, WB 4d4; reg [3:0] state, next_state; always (posedge clk or negedge rst_n) begin if (!rst_n) state IF; else state next_state; end always (*) begin // 默认信号值防止锁存器 pc_write 1b0; mem_write_en 1b0; ir_write 1b0; reg_write_en 1b0; alu_op 2b00; alu_src_a 1b0; alu_src_b 1b0; reg_dst 2b00; mem_to_reg 2b00; next_state state; case (state) IF: begin // 取指PC写使能拉高IR写使能拉高 pc_write 1b1; ir_write 1b1; next_state ID; end ID: begin // 译码根据opcode决定下一步去EX还是跳转处理 case (opcode) 6b100011, 6b101011, 6b000000, 6b001100, 6b001101, 6b001010, 6b001000, 6b001001: next_state EX; 6b000100: next_state EX; // beq在EX中判断并更新PC default: next_state EX; endcase end EX: begin // 执行ALU运算根据指令类型产生不同控制信号 case (opcode) 6b100011: begin // lw alu_src_a 1b1; // 寄存器 alu_src_b 1b1; // 立即数 alu_op 2b00; // 加法计算地址 next_state MEM; end 6b101011: begin // sw alu_src_a 1b1; alu_src_b 1b1; alu_op 2b00; next_state MEM; end 6b000000: begin // R型 alu_src_a 1b1; alu_src_b 1b0; // 第二个操作数来自寄存器 alu_op 2b10; next_state WB; end // 其他指令类似 default: next_state WB; endcase end MEM: begin if (opcode 6b101011) begin // sw写存储器 mem_write_en 1b1; next_state WB; // 实际上sw不需要WB可返回IF end else if (opcode 6b100011) begin // lw读存储 mem_to_reg 2b01; next_state WB; end else next_state WB; end WB: begin // lw、R型指令写回寄存器 if (opcode ! 6b101011 opcode ! 6b000100) reg_write_en 1b1; next_state IF; end endcase end endmodule这段状态机写出来后我就发现一个规律多周期CPU的“状态”本质上是把单周期里所有同时发生的控制信号拆成了时间序列。比如lw指令在单周期里译码同时给ALU和存储器发信号在多周期里则分成EX才给ALU信号、MEM才给存储器信号。控制逻辑因此更清晰但也更容易遗漏。4.3 多周期里指令存储器和数据存储器的分时复用多周期CPU为了省硬件资源通常把指令存储器和数据存储器合并成一个带写使能的统一存储器。这时IF状态里进行的读操作是“从PC地址读指令”MEM状态里的读操作是“从ALU结果地址读数据”。二者不会冲突因为不同周期但要注意的是统一存储器必须支持“同一周期读指令或读数据”不需要同时读两路所以用单口RAM就能实现。但大多数实验板的Block RAM是同步读写的而单周期CPU里指令存储器是组合逻辑读取的这个差异在我们把仿真代码往板上烧的时候会变成一个大坑多周期版本中IF状态读指令时如果指令存储器是同步读那么取指拿到的数据会晚一个周期导致PC已经更新但指令还没出来。解决方法有两种一是把统一存储器设计成“异步读、同步写”二是把状态机整体往后挪拍。我的做法是前者用组合逻辑assign实现读端口只在写端口加时钟控制。这样可以避免多周期状态机整体时序重构省掉大量调试时间。5. 完整代码的调试实录与板级验证常见坑5.1 仿真波形怎么看照着我这张信号表查跑仿真时不要上来就看整个波形图我按照“从源头到终点”的顺序建立了一套检查套路先看clk和rst_n是否正常翻转、复位是否在起始阶段拉低再看pc_out是否按4递增或者跳转时是否变化到正确目标然后看inst_out是否读到了你memory里预置的机器码接着看reg_data1、reg_data2是否与指令的rs、rt吻合再看alu_result是否符合预期运算最后看write_data和寄存器堆的写端口如果你的仿真波形在某个环节出现高阻态z或者未知态x先用这个顺序定位。其中最常见的还是寄存器堆复位信号没接好导致初始值未知以及存储器初始化文件没加载成功。Vivado里用$readmemh(init.hex, inst_mem)加载测试程序时文件路径必须放在仿真工程目录下否则会报文件打开失败。很多人把hex文件放在源码目录里就以为能读到结果仿真跑出来全是未知指令。以下是一个完整的测试程序例子我会把它写成hex格式供$readmemh加载// 测试程序: 计算 r15, r23, r3 r1 r2, 再存到内存地址0 // 每条指令32位一行一个word用十六进制表示模拟MIPS指令编码 00000201 // addi r1, zero, 5 (r1 5) 00200301 // addi r2, zero, 3 (r2 3) 00221820 // add r3, r1, r2 (r3 r1 r2) ac030000 // sw r3, 0(r0) (mem[0] r3)指令编码这里最容易出错。手工编码时先把MIPS指令格式拆开addi是opcode001000rs00000rt00001立即数5所以十六进制为0x20210005add是opcode000000rs00001rt00010rd00011funct100000所以十六进制是0x00221820。写hex文件时一行一个word低位在前还是高位在前视你的存储设计而定我统一采用大端格式即第一个字节是最高8位。5.2 上板实测Vivado综合通过不等于功能正确仿真完全正常后上板调试才是真正的试金石。我遇到过一个典型的“仿真通过但上板失败”问题分支跳转在百兆赫兹下会偶发跳错目标。分析之后发现问题出在PC计算路径太长——beq的跳转目标要等寄存器读出、ALU计算、zero判断全部完成才能生成这个组合链路在100MHz时钟下已经逼近时序余量极限。解决方式是给PC生成逻辑打一拍即把pc_next的结果先存到一个中间寄存器pc_next_r中下一拍再用。代价是整体会多出一个周期的分支延迟但好处是时序收敛了。这里要给个实验板相关的重要提示如果板子上有按键复位务必检查复位信号是否做了异步复位、同步释放处理或者至少保证按下复位时不会瞬间弹跳。我自己有一次就是复位按键抖动导致状态机在IF和ID之间乱跳上板运行结果时好时坏最后查了半小时才意识到是复位电路的毛刺问题。5.3 跑完项目后我才想明白的几个“为什么”回头看整个课设有几个问题是我做完之后才真正想明白的写在这里供参考。第一个为什么多周期CPU的PC写使能要单独控制因为PC不是每个时钟周期都必须更新的。在状态机跑到ID、EX、MEM、WB时PC的值要保持不变不能让它继续递增或跳转。所以必须用pc_write信号门控PC寄存器的更新只有IF状态才打开。这个设计在单周期CPU里不存在因为单周期只有一拍PC每拍都更新。第二个为什么多周期CPU比单周期CPU更省资源不是它省了ALU的调用次数而是它复用了硬件。单周期20条指令CPU里一个指令存储器、一个数据存储器、一个ALU以及每个控制信号的专用选择器多周期CPU里指令和数据共用一块存储器ALU在取指阶段算PC4在执行阶段算地址或运算控制信号的选择器也少了几路。做资源占用对比时多周期版本通常能比单周期版本少用30%左右的LUT和寄存器。第三个为什么课设要求做5条、20条、多周期三档而不是直接做多周期因为这个三档其实是教学上的“递进式难度”。5条是让你理解数据通路的最小可行化20条是让你摸清控制信号的浓度和多样性多周期是逼你把所有组合逻辑结构转换为时序化状态思维。直接做多周期的人多半会在状态机分支设置上花掉比调试电路更多的时间。6. 代码资源的使用建议和后续扩展方向最后说一下代码使用的问题。这份工程文件里包含三个版本的完整源码cpu_single_5_inst.v、cpu_single_20_inst.v和cpu_multi_cycle.v每个版本都带对应的测试bench文件和初始化hex文件。想真正吃透建议按这个顺序做第一遍不要直接抄而是对着代码把数据通路图画出来。我就是在代码旁边配了每个模块的端口连接关系表自己画了一遍才彻底搞清楚每个信号的来龙去脉。第二遍修改指令集——把5条里的andi换成sub添加一条j指令再仿真看控制信号变化。这样你就知道哪些改动会牵一发而动全身哪些改动只是加个case分支。第三遍再上板跑跑通后尝试自己添加一条课设要求之外的指令比如lhu、sb这类半字或字节访存指令你会经历一个完整的“指令集设计—数据通路改造—控制信号扩展—验证测试”循环。几个可以直接扩展的方向供参考一是加上简单的数据冒险处理逻辑虽然多周期不需要但如果你后续做流水线CPU会很需要二是加上中断和异常处理这是多周期CPU课设的进阶方向三是加上Cache模型在存储器和CPU之间加一级小容量高速缓存这基本属于毕设难度了。如果你用这份代码交了课设建议至少改动以下地方把指令集换成自己命名的自定义指令把存储深度改一下把信号命名风格统一并且设计一个和课程中不同的测试程序。这样既能确保答辩时讲得清楚也能避免和网上其他版本重复。毕竟课设的核心目的是吃透原理代码本身只是一个实现载体。// 多周期CPU顶层模块骨架可直接扩展 module cpu_multi_cycle_top( input wire clk, input wire rst_n, output wire [31:0] pc_out, output wire [31:0] alu_out, output wire reg_write_out ); wire [31:0] inst; wire [31:0] pc; wire [31:0] alu_result; wire pc_write, ir_write, mem_write_en, reg_write_en; wire [1:0] alu_op, reg_dst, mem_to_reg; wire alu_src_a, alu_src_b; // 统一存储器 reg [31:0] unified_mem [0:255]; // 指令寄存器 reg [31:0] ir_reg; always (posedge clk or negedge rst_n) begin if (!rst_n) ir_reg 32b0; else if (ir_write) ir_reg unified_mem[pc[9:2]]; end assign inst ir_reg; // 数据通路与控制单元连接... // 此处的pc_reg、寄存器堆、ALU实现与单周期类似但受状态机控制的写使能信号驱动 endmodule写到这里这个课设的核心要点基本都覆盖了。回想从最开始对着实验指导书发懵到最后能独立调通三个版本CPU最大的收获不是代码本身而是建立了一种“从指令视角看硬件”的思维方式。希望这篇整理能帮你少走几步弯路也希望你别只复制代码——花一个晚上把状态图和数据通路亲手画一遍那种收获比交一份作业大得多。本文还有配套的精品资源点击获取
网站建设高端定制企业官网