2026/10/30 3:08:27

Verilog五级流水线CPU源码解析:冒险处理、数据重定向与嵌套中断

Verilog五级流水线CPU源码解析:冒险处理、数据重定向与嵌套中断 简介面向计算机科学与技术专业学生与Verilog硬件设计初学者这份华中科技大学计算机组成原理课程设计资料以流水线CPU上板实现为核心覆盖取指、译码、执行、访存、写回五级流水以及插入气泡、数据重定向、多级嵌套中断等关键机制可用于课程设计复现、仿真验证与FPGA上板参考。压缩包共345个文件约31.39MB包含Verilog源码、XML与RPT报告、DCP与BIT综合实现产物、TCL脚本及仿真/工程配置等便于按工程、源码、报告和上板结果分层查阅。已有1949人学习下载读者可据此梳理流水线冲突处理与中断嵌套的实现思路结合测试平台观察波形、核对综合报告并借鉴工程目录组织和调试记录完成自己的CPU流水线验证。1. 从一条卡在 EX 段的 addi 说起这套 Verilog 流水线源码能补哪块拼图仿真跑到第 37 个周期一条本该写回$t1的addi卡在 EX 段不动波形上 PC 纹丝不动、IF/ID 的指令却还在往后爬——第一次做流水线的人基本都会撞上这一幕。这套某高校计算机组成原理课程设计的 Verilog 源码把这类画面背后的东西摊开得更彻底五级流水、load-use 与分支的气泡插入、EX/MEM 与 MEM/WB 两条数据重定向通路以及中断服务程序还能被更高优先级再打断一次的多级嵌套中断。课设要交可综合代码的能直接拿来对照工作几年后想补一遍底层、把“转发”和“冲刷”真正搞明白的开发者也能拿它当参照。工程是纯 Verilog 可综合风格没有塞外部 IP仿真器和综合工具都能直接吃进去。2. 五级划分与流水寄存器一条指令从取指走到写回要过几道寄存器流水线 CPU 的麻烦从来不在单个模块里而在模块之间那排寄存器上。少打一拍后面所有通路全部错位多打一拍延迟又白搭。所以看这套源码我建议先花二十分钟把cpu_top.v里的连线理顺再去翻具体的 ALU 和控制单元——顺序反过来很容易陷进某个模块出不来回头还是不知道数据从哪儿来。2.1 顶层与五级划分cpu_top 里的那根主干线源码的五级是教科书式的 MIPSIF 取指 → ID 译码 → EX 执行 → MEM 访存 → WB 写回级与级之间卡着if_id、id_ex、ex_mem、mem_wb四个流水寄存器每个模块一个.v文件。顶层的职责其实很单一把下一拍 PC 的选择、各级流水寄存器的使能和冲刷、以及组合逻辑的例化接起来不在顶层做任何运算。// cpu_top.v节选—— 顶层只做连线和下一拍 PC 的选择 module cpu_top ( input wire clk, input wire rst_n, input wire [3:0] ext_int, // 4 路外部中断源bit0 优先级最高 output wire [31:0] pc_debug ); // ---- 流水控制信号 ---- wire stall_if; // 停顿PC 与 IF/ID 保持 wire flush_if_id; // 冲刷分支成立或中断进入时清空 wire flush_id_ex; // 插气泡清空 ID/EX 的控制字 wire branch_taken; // 分支在 EX 级判定成立 wire int_enter, eret; // 中断进入 / 中断返回 // ---- 各级之间的数据 ---- wire [31:0] pc, pc_next, pc_branch, epc; assign pc_next int_enter ? epc : branch_taken ? pc_branch : stall_if ? pc : pc 32d4; pc u_pc (.clk(clk), .rst_n(rst_n), .pc_next(pc_next), .pc(pc)); imem u_imem (.addr(pc), .instr(instr)); if_id u_if_id (.clk(clk), .rst_n(rst_n), .stall(stall_if), .flush(flush_if_id), .pc_in(pc), .instr_in(instr), ...); id u_id (...); id_ex u_id_ex (.clk(clk), .rst_n(rst_n), .flush(flush_id_ex), ...); ex u_ex (...); ex_mem u_ex_mem (...); mem u_mem (...); mem_wb u_mem_wb (...); wb u_wb (...); assign pc_debug pc; endmodulepc_next这一串三目嵌套就是整条流水线的红绿灯中断进入优先级最高直接跳epc分支成立跳目标地址停顿保持当前 PC其余情况顺序 4。这里的优先级顺序不是随便排的中断请求可能在分支的同一条指令上到达谁先谁后决定了返回地址保存的是哪一条改代码时动这一行要格外小心。还有一个由“分支放在 EX 级判定”带来的连带成本分支成立的那一拍PC 已经往前多取了指令所以flush_if_id要清掉 IF/ID 里的错指令flush_id_ex要把 ID/EX 的控制字清成 nop一口气废掉两条。这套源码没做分支预测就是老老实实“判到了再冲刷”逻辑干净、好验证缺点也明摆着——每次跳转固定浪费两个周期。后面第 6 章要扩展的话这是第一个可以下手的地方。2.2 控制字的流水化为什么控制器要一路跟到 WB单周期 CPU 里控制器只活在一个时钟周期内流水线不行。ID 级译出来的regwrite、memtoreg、memwrite要到 EX、MEM、WB 才真正派上用场中间隔着两三拍只能跟着流水寄存器一起打拍传下去。这套源码把控制信号打包成一个 9 位控制字塞进id_ex然后一级一级往下搬。// id_ex.v节选—— 控制字随数据一起进流水寄存器 module id_ex ( input wire clk, rst_n, flush, input wire [31:0] pc_in, rs_val_in, rt_val_in, imm_in, input wire [4:0] rs_in, rt_in, rd_in, input wire [8:0] ctrl_in, // 来自 control.v 的 9 位控制字 output reg [31:0] pc_out, rs_val_out, rt_val_out, imm_out, output reg [4:0] rs_out, rt_out, rd_out, output reg [8:0] ctrl_out ); always (posedge clk or negedge rst_n) begin if (!rst_n) begin {pc_out, rs_val_out, rt_val_out, imm_out} 0; {rs_out, rt_out, rd_out, ctrl_out} 0; end else if (flush) begin ctrl_out 9b0; // 全零 一条 nop不写寄存器、不写存储器 end else begin pc_out pc_in; rs_val_out rs_val_in; rt_val_out rt_val_in; imm_out imm_in; rs_out rs_in; rt_out rt_in; rd_out rd_in; // 目的寄存器此时已由 id 级选好 rt 或 rd ctrl_out ctrl_in; end end endmoduleflush置位时只清ctrl_out不清数据通路上的rs_val_out、imm_out这是省逻辑的写法——气泡指令不会用到这些值清了纯属多一路扇出。这个取舍本身不算错但后面避坑那章会提到它埋的一个雷。控制字的位定义在源码里是固定的读其余模块之前最好先把它抄在纸上位名称作用[8]regwrite允许写寄存器堆[7]memtoregWB 级写回数据取自存储器而非 ALU[6]memwrite允许写数据存储器[5]memread读数据存储器[4]branch分支类指令标志送 EX 级比较[3]alusrcALU 第二操作数选立即数[2:0]aluopALU 运算类型加、减、与、或、小于置位2.3 目录结构与文件职责对照工程文件是典型的“一个模块一个文件”没有按级合并成大文件翻起来对新手比较友好。我第一次打开时按目录扫了一遍大致长这样文件职责阅读时该盯的点cpu_top.v顶层连线、下一拍 PC 选择三个控制信号的优先级pc.v程序计数器复位值、中断入口的载入路径imem.v指令存储器$readmemh预装程序的初始化if_id.v/id_ex.v/ex_mem.v/mem_wb.v四级流水寄存器stall与flush行为的差异id.v译码、读寄存器值、立即数符号扩展目的寄存器选rt还是rdcontrol.v主控制器控制字位定义、非法指令的默认值regfile.v32×32 寄存器堆写优先、$0恒为零ex.vALU、分支比较、重定向前插前插的优先级alu.v算术逻辑单元减法与slt的实现方式mem.v数据存储器读写时序、字对齐wb.v写回数据选择memtoreg的二选一hazard_unit.vload-use 冒险检测和分支、中断信号的冲突forwarding_unit.v旁路控制优先级与$0保护int_ctrl.v中断优先级与现场栈嵌套层数上限2.4 命名与位宽约定读代码前先记住的几条规矩源码里跨模块的信号统一带级名前缀id_ex_rt、ex_mem_rd、mem_wb_regwrite看到名字基本能判断它是哪一拍的值。寄存器堆的两路输出叫rs_val、rt_val而编号叫rs、rt——这个区分看着啰嗦但非常必要。血泪经验把rs编号当数值接进 ALU 的事故我见过不止一次波形上表现为 ALU 结果永远等于 0 到 31 之间的某个小数字一眼就能认出来。位宽方面PC 和所有数据通路统一 32 位寄存器编号 5 位立即数扩展后 32 位写存储器时按字32 位寻址地址的低两位在mem.v里被直接截掉。3. 从零跑通iverilog GTKWave 下的一次完整仿真拿到源码包最怕的是“打开就懵”所以第一步永远是把仿真单独跑起来别碰综合。这套工程没有绑定任何厂商工具命令行下用开源工具链就能完整验证。3.1 工具链与最小可跑环境需要的东西不多一个 Icarus Verilogiverilog加vvp负责编译仿真一个 GTKWave 看波形。Linux 上直接包管理器装Windows 上装好之后确认iverilog -V能出版本号就行。源码里没有用到 SystemVerilog 的独有语法-g2012只是为了兼容个别always_comb写法用-g2005一般也能过如果报错就加-g2012。# 一次性把所有源文件编进来注意别漏 hazard_unit / forwarding_unit / int_ctrl iverilog -g2012 -o sim_cpu \ tb_cpu.v cpu_top.v pc.v imem.v if_id.v id.v id_ex.v \ ex.v ex_mem.v mem.v mem_wb.v wb.v regfile.v alu.v \ control.v hazard_unit.v forwarding_unit.v int_ctrl.v vvp sim_cpu # 跑仿真生成 wave.vcd gtkwave wave.vcd # 看波形如果你用的综合工具是 Vivado 那一类做法是把所有.v加进工程、把tb_cpu.v设成 simulation-only然后跑行为仿真。要注意imem.v里用initial块调$readmemh装程序这段代码只对仿真有效综合时会被忽略上板后会读出一堆零——这是很多人第一次上板时 PC 一路 0 的直接原因。3.2 testbench程序怎么装进指令存储器imem.v里会给存储器留一个数组仿真开始时用$readmemh从十六进制文件里读指令。文件里每行一条 32 位指令用 8 个十六进制字符表示行尾可以带//注释。想验证“load-use 必须插气泡”最省事的办法就是手写一小段程序让lw后面立刻跟上一条用到结果的指令。timescale 1ns/1ps module tb_cpu; reg clk 0, rst_n 0; reg [3:0] ext_int 4b0000; wire [31:0] pc_dbg; cpu_top u_cpu (.clk(clk), .rst_n(rst_n), .ext_int(ext_int), .pc_debug(pc_dbg)); always #5 clk ~clk; initial begin $readmemh(prog.hex, u_cpu.u_imem.mem); // 预装程序路径按实际改 #20 rst_n 1; // 复位拉高两个周期后再放 #3000 $finish; end // 固定周期触发中断先来一路再在服务程序执行中加一路更高优先级 initial begin #900 ext_int 4b0001; #100 ext_int 4b0011; #300 ext_int 4b0000; end initial begin $dumpfile(wave.vcd); $dumpvars(0, tb_cpu); end endmodule配套的prog.hex可以极小四条指令足够看出问题20080008 // addi $t0, $zero, 8 - $t0 8 ac080000 // sw $t0, 0($zero) - 存储器[0] 8 8c090000 // lw $t1, 0($zero) - $t1 8要等一拍 01295020 // add $t2, $t1, $t1 - $t2 16紧跟 lw必须被 stall参数说明u_cpu.u_imem.mem是层次化路径直接访问子模块内部数组写 testbench 时最容易在这里拼错名字#20 rst_n 1里的 20 对应两个时钟周期复位太短会让寄存器堆的初值捕捉不稳定。运行vvp之后如果终端没有任何$display输出说明程序跑完了但没有打印这时可以自己往wb.v里加一句$display(cycle%0d pc%h rd%0d data%h, ...)比对着波形找一整天高效得多。3.3 波形上的六个观察点程序跑起来不代表流水线是对的。我一般在 GTKWave 里固定加六组信号逐一确认观察点该看到什么看不到说明什么pc每拍稳定 4只有分支或中断时跳变PC 逻辑被 stall 信号误触发if_id_instrstall 那拍保持不变if_id的使能接反了id_ex_ctrl_out气泡那拍全 0flush_id_ex没接到 ID/EXforward_a/forward_b相关指令间隔 12 条时出现10或01forwarding_unit的比较条件漏了$0branch_taken与flush_if_id同拍拉高分支在 ID 级判定接错了级epc与nest_level中断后nest_level加一返回后减一现场栈的出入栈顺序搞反了3.4 综合与上板前的两项检查仿真过了再谈综合。第一项检查是组合环ex.v里的前插逻辑如果写成“ALU 输出又反馈回来参与下一拍选择”综合工具会报出 combinational loop 警告这种环在仿真里可能只是延迟变大上板后行为直接跑飞。第二项是锁存器推断always (*)块里只要有分支没赋到某个输出就会推出一堆 latch时序彻底不可控综合报告里搜latch能一眼看到。提示综合前把tb_cpu.v从工程里排除否则$dumpfile、$finish这类系统任务会直接报错实在要用得用ifdef SIM包起来。4. 核心机制拆解气泡插入、数据重定向与多级嵌套中断三个模块撑起了这套源码的含金量也正好对应三个最容易翻车的机制。分开讲但心里要清楚它们的输出都汇到同一根pc_next和同一组流水使能上。4.1 冒险检测单元气泡到底插在哪一拍load-use 冒险的本质是lw的数据要等到 MEM 级结束才拿得到而紧跟其后的指令在 EX 级就要用中间只隔了一拍前插也救不回来。唯一的办法是让后面那条指令在 ID 级多停一拍等lw走到 WB再靠前插把值补进去。// hazard_unit.v —— 只在 load-use 时停一拍 module hazard_unit ( input wire [4:0] if_id_rs, input wire [4:0] if_id_rt, input wire [4:0] id_ex_rt, // lw 的目的寄存器是 rt input wire id_ex_memread, // ID/EX 级是否为 lw output reg stall_if, // 保持 PC 与 IF/ID output reg flush_id_ex // 清空 ID/EX插入气泡 ); always (*) begin if (id_ex_memread ((id_ex_rt if_id_rs) || (id_ex_rt if_id_rt))) begin stall_if 1b1; flush_id_ex 1b1; end else begin stall_if 1b0; flush_id_ex 1b0; end end endmodule逻辑说明检测对象是“ID/EX 级正在执行的指令”和“IF/ID 级刚取到的指令”之间的一对相邻关系所以比较的是id_ex_rt和if_id_rs/rt。stall_if拉高时 PC 不递增、IF/ID 不更新两条指令原地等flush_id_ex拉高时 ID/EX 的控制字清零等于往 EX 级送进一个什么都不做的气泡。参数说明id_ex_rt之所以是rt而不是rd是因为 MIPS 的lw把目的寄存器编在rt字段如果后来扩展到 R 型指令也要检测就得改成rd前提是你的译码器把目的寄存器统一切换到了rd。另外这里没有排除$0理论上lw $zero, ...会白白停顿一拍功能不错但浪费周期加个id_ex_rt ! 5d0就能省掉。注意如果同时把stall_if和flush_if_id接到if_id模块一定要在模块内部定好优先级。分支成立那一拍如果恰好也在停顿谁先谁后直接决定取到的是目标地址还是垃圾指令。4.2 重定向网络三条旁路的来源与去向前插解决的是“值已经有了只是还没写回寄存器堆”这个时间差。这套源码做了两条主通路EX/MEM 的结果直接喂回 EX 级输入端最近的一条MEM/WB 的结果作为备选。第三条是给sw准备的——要写进存储器的数据也可能来自刚从 MEM/WB 出来的前一条指令不能从寄存器堆里读旧值。// forwarding_unit.v —— 旁路选择编号越大越优先 module forwarding_unit ( input wire [4:0] id_ex_rs, id_ex_rt, input wire [4:0] ex_mem_rd, input wire ex_mem_regwrite, input wire [4:0] mem_wb_rd, input wire mem_wb_regwrite, output reg [1:0] forward_a, // ALU 第一操作数00 寄存器堆 / 01 MEM/WB / 10 EX/MEM output reg [1:0] forward_b // ALU 第二操作数编码同上 ); always (*) begin forward_a 2b00; forward_b 2b00; if (ex_mem_regwrite (ex_mem_rd ! 5d0) (ex_mem_rd id_ex_rs)) forward_a 2b10; // EX/MEM 最新优先 else if (mem_wb_regwrite (mem_wb_rd ! 5d0) (mem_wb_rd id_ex_rs)) forward_a 2b01; if (ex_mem_regwrite (ex_mem_rd ! 5d0) (ex_mem_rd id_ex_rt)) forward_b 2b10; else if (mem_wb_regwrite (mem_wb_rd ! 5d0) (mem_wb_rd id_ex_rt)) forward_b 2b01; end endmodule逻辑说明if / else if的写法本身就是优先级——EX/MEM 离得近值更新必须压过 MEM/WB。如果两条通路同时命中同一个源寄存器选远的那个就是典型错误波形上表现为add的结果差了整整一代数据间隔足够远就对间隔近就错。参数说明ex_mem_rd和mem_wb_rd是各级挑起写回任务的目的寄存器编号regwrite用来排除sw、beq这类不写寄存器的指令。! 5d0这一句不能省$0恒为零但前插会把它替换成某条指令的运算结果于是$0被“写”进一个非零值——这种 bug 在测试程序里只在你恰好用$0作源操作数时才发作非常隐蔽。到了ex.v里这两组选择信号会被展开成三选一// ex.v节选—— 前插的落地写法 always (*) begin case (forward_a) 2b10: alu_a ex_mem_alu_result; // 上一条指令的结果 2b01: alu_b_src mem_wb_data; // 上上条的结果 default: alu_a id_ex_rs_val; // 正常从寄存器堆读 endcase end // sw 的写数据同样要走一遍前插否则写进存储器的是旧值 assign store_data (forward_b 2b10) ? ex_mem_alu_result : (forward_b 2b01) ? mem_wb_data : id_ex_rt_val;4.3 多级嵌套中断进入、压栈、再次进入与返回“多级嵌套”是这个工程里最有意思的部分中断服务程序执行到一半来了一个优先级更高的中断请求CPU 要能挂起当前服务程序、保存现场、跳进新的服务程序返回时再一层一层退回去。硬件上靠一个深度为 4 的现场栈实现栈里存返回地址和被屏蔽的优先级。// int_ctrl.v节选—— 优先级编码 现场栈 module int_ctrl ( input wire clk, rst_n, input wire [3:0] ext_int, // 4 路中断源bit0 最高 input wire instr_done, // 当前指令执行完可安全响应 input wire eret, // 中断返回指令译码结果 input wire [31:0] pc_next, output reg int_enter, output reg [31:0] epc_out, output reg [1:0] nest_level ); reg [31:0] epc_stack [0:3]; reg [3:0] mask_stack [0:3]; reg int_req; reg [1:0] int_vec; // 优先级编码bit0 最高低编号优先 always (*) begin int_req 1b0; int_vec 2d0; if (ext_int[0]) begin int_req 1b1; int_vec 2d0; end else if (ext_int[1]) begin int_req 1b1; int_vec 2d1; end else if (ext_int[2]) begin int_req 1b1; int_vec 2d2; end else if (ext_int[3]) begin int_req 1b1; int_vec 2d3; end if (nest_level 2d3) int_req 1b0; // 栈满不再响应新中断 end always (posedge clk or negedge rst_n) begin if (!rst_n) begin nest_level 2d0; int_enter 1b0; epc_out 32d0; end else if (int_req instr_done) begin epc_stack[nest_level] pc_next; // 保存返回地址 mask_stack[nest_level] ext_int; // 保存当前屏蔽状态 epc_out pc_next; nest_level nest_level 2d1; int_enter 1b1; end else if (eret nest_level ! 2d0) begin nest_level nest_level - 2d1; epc_out epc_stack[nest_level - 2d1]; int_enter 1b0; end else begin int_enter 1b0; end end endmodule逻辑说明中断请求是组合逻辑判出来的但只在instr_done有效时才真正受理——这一点很关键若在指令执行中途跳走EX/MEM 里的状态就丢了。进入时把pc_next存进栈那是下一条还没取到的指令地址返回时正好从那里继续。返回路径把nest_level减一后取上一层栈顶cpu_top看到int_enter就把pc_next切到epc。参数说明mask_stack目前只是存下来备查真正实现“服务程序执行期间响应更高优先级”靠的是比较int_vec与当前层的屏蔽字——源码里这段判断写在中断服务程序的软件部分硬件只负责压栈和跳转。nest_level 2d3是栈满保护去掉这一句第五次嵌套会把前四层现场全覆盖掉现象是返回时 PC 跳到某个完全无关的地址。这套机制和常见 RTOS 的“中断嵌套计数器”是一个思路只是这里用硬件栈替代了软件变量。4.4 分支冲刷与中断冲刷共用一条通路flush_if_id和flush_id_ex这两个信号分支和中断都要用。源码的做法是在顶层把两个来源或起来assign flush_if_id branch_taken | int_enter; assign flush_id_ex hazard_stall | branch_taken | int_enter;或逻辑本身没问题问题在于误解“冲刷等于停顿”。分支成立时是冲刷、PC 跳目标load-use 时是停顿、PC 原地。两者都让 IF/ID 不更新但pc_next的取值完全不同。把这两个概念混在一起写波形上看起来都能“停住”功能却差了两个周期调试时会怀疑人生。我自己的习惯是在顶层给这两个信号起完全不同的前缀名stall_*和flush_*并且严禁在别的模块里用同一个变量名。5. 避坑与排查五类反复出现的翻车现场这些不是我凭空想出来的是照着波形一条一条对出来的。每一条都按“现象 → 原因 → 解决”写遇到时直接对照。5.1 现象仿真一开始所有信号全是 X原因rst_n复位时间不够或者寄存器堆、流水寄存器没有复位分支。Verilog 里的reg初值是 X要靠initial或复位赋初值而mem.v里的存储器数组默认也是 X读一个未初始化的地址数据通路立刻污染。解决在 testbench 里至少给复位留两个完整时钟周期#20起步检查每个always (posedge clk or negedge rst_n)是否都有if (!rst_n)分支存储器数组在initial里用for循环全部清零而不是只清前几个。还有一点容易忽略if_id、id_ex里的控制字复位值必须是 0否则复位期间会冒出一条“幽灵指令”去写寄存器。5.2 现象分支指令后面那条指令被执行了原因分支判定放在 EX 级成立时 PC 已经往前取了至少一条错指令而flush_if_id没有接到或者接到了但优先级低于stall_if。解决确认flush_if_id真的连到了if_id的flush端口在顶层明确优先级——冲刷应当压过停顿。另外分支的目标地址要在本文还有配套的精品资源点击获取