五级流水CPU Verilog源码:气泡、重定向与多级嵌套中断实战

发布时间:2026/10/30 15:51:15
五级流水CPU Verilog源码:气泡、重定向与多级嵌套中断实战

简介:面向计算机科学与技术专业学生的计算机组成原理课程设计成果包,聚焦基于Verilog HDL的流水线CPU实现,适合正在做组成原理课设、需要参考可上板流水线设计的学生对照学习。工程完整覆盖五级流水分段、数据冲突时的气泡插入与重定向、多级嵌套中断的现场保存与恢复等关键机制,并配有仿真测试平台与综合实现流程,可直接用Vivado打开复现从源码到比特流的验证链路。压缩包共345个文件、约31.39MB,其中v源文件与xdc约束构成设计主体,xml、rpt、jou日志记录综合与实现过程,dcp为各阶段检查点,bit为可下载到开发板的码流,另有tcl脚本、rst复位说明与少量前端页面文件,目录结构清晰便于按模块检索。已有1949人学习下载,适合需要完整工程骨架、排错线索与上板参考的中高阶学习者。

1. 从一条指令的迟到说起:这份 CPU 流水 Verilog 源码能解决什么

仿真波形跑到第 37 个周期,load 指令后面的 add 拿到的还是旧寄存器值,问题不在 ALU,而在 load-use 冒险少插了一个气泡。这份某高校计算机组成原理课程设计的 CPU 流水 Verilog 源码,把五级流水、插入气泡、重定向和多级嵌套中断放在同一套可仿真工程里,适合刚学完数字逻辑、想把手写单周期 CPU 改成流水线的人,也适合要交课程设计、需要一份能改能验的参考实现的开发者。它不追求跑分,价值在于每条冒险通路和中断现场都有对应的信号与波形,翻车时能定位到具体流水寄存器。

2. 工程骨架与跑通路径:目录、模块边界和仿真入口

拿到源码包后先别急着改代码,先把模块边界和复位路径看清楚。流水线 CPU 最常见的翻车不是指令写错,而是stall、flush、valid这三根线的语义混了:有人把 stall 当 flush 用,结果气泡没插进去,错误路径反而被放进来。比较稳的顺序是先看顶层端口,再看流水寄存器,最后看冒险单元和中断控制器。下面这套目录组织是这类课程设计里很常见的做法,实际压缩包里的文件名可能略有差别,但职责划分基本一致。

2.1 目录结构与模块边界

我一般先按文件清单建立一张“信号地图”,不急着打开每一个.v。顶层负责把五级模块串起来,流水寄存器负责在级间保存有效位,冒险单元和重定向单元只做组合判断,中断控制器单独成模块。这样拆的好处是改冒险逻辑时不用动 ALU,调中断嵌套时也不用碰取指模块。

路径作用先看什么
rtl/cpu_top.v顶层,连接五级流水与中断控制器端口、复位、valid传播
rtl/if_stage.v取指,维护 PC 和指令存储器接口pc_f、stall_f、flush
rtl/id_stage.v译码、读寄存器堆、生成控制信号源寄存器号、立即数
rtl/ex_stage.vALU、分支比较、重定向选择转发选择器、分支结果
rtl/mem_stage.v数据访存mem_read、mem_write
rtl/wb_stage.v写回寄存器堆reg_write、目的寄存器号
rtl/pipe_regs.vIF/ID、ID/EX、EX/MEM、MEM/WB 流水寄存器valid、stall、flush
rtl/hazard_unit.vload-use 停顿、分支冲刷、中断接受冲刷冻结与清除的优先级
rtl/forward_unit.vEX 级操作数重定向MEM/WB 与 EX/MEM 的优先级
rtl/interrupt_ctrl.v多级中断优先级、嵌套深度、返回int_accept、current_prio
sim/tb_cpu_top.v顶层测试平台复位序列、中断注入
prog/inst.mem指令与数据初值定向测试程序
docs/timing.md时序与信号约定时钟沿、复位极性

这张表不是让你死记文件名,而是提醒一个设计原则:流水寄存器只负责“保存”和“清除有效位”,冒险判断尽量放在组合逻辑里,中断控制器不要直接去改 PC。PC 的保存和恢复属于 CPU 现场保护,中断控制器只给出“接受哪个中断、当前嵌套多深”。把这两件事混在一个always块里,后面加嵌套时很容易出现返回地址错一位的问题。

2.2 仿真环境与第一条波形

仿真入口我习惯做成一个薄脚本,不让测试平台依赖某个固定图形界面。命令行只需要传顶层名和测试名,编译、展开、跑波形一次完成。下面这段是工程根目录下常见的 Makefile 调用方式,具体仿真器由环境变量SIM指定,换机器时不用改测试平台。

# 在工程根目录执行;SIM 指向本机可用的 Verilog 仿真器 make clean make sim TOP=tb_cpu_top SIM="$SIM" WAVE=build/wave.vcd # 跑完后先看 build/sim.log,再看 wave.vcd 里的 pc_f 和 if_id_valid

TOP是测试平台顶层名,SIM是仿真器命令,WAVE是波形输出路径。参数不要写死在脚本里,否则换一个环境就要改文件。跑完先看日志里有没有x或z,再看波形的前 20 个周期:复位释放后pc_f应该从 0 开始按 4 递增,if_id_valid在第一个有效指令进入译码级后拉高。如果pc_f一直不动,先查时钟和复位极性;如果pc_f动了但if_id_valid一直为 0,查流水寄存器的复位是不是把有效位永久清零了。

执行步骤可以按这个顺序走:

  1. 先跑空程序,确认 PC 能走、流水寄存器能复位。
  2. 加一条add,看 WB 阶段是否写回正确寄存器。
  3. 加load后紧跟add,看 hazard 单元是否拉高stall_f和stall_d。
  4. 加一条beq,看分支成立时是否冲刷 IF/ID 和 ID/EX。
  5. 最后注入中断,先单级,再两级嵌套,观察nested_depth和返回地址。

这个顺序的好处是每一类问题都能单独定位。直接上完整测试程序,波形里全是信号,反而像看黑匣子。

2.3 从复位到第一条指令的启动序列

启动阶段最容易忽略的是中断使能。很多实现一复位就把global_ie置 1,结果测试平台还没加载完指令,外部中断就进来了,PC 被拉到中断向量,波形看起来像“取指错乱”。我一般让复位后先关全局中断,等第一条指令提交后再由软件开中断。下面这段顶层启动逻辑是常见写法,重点是复位时把所有流水有效位和中断状态一起清干净。

// cpu_top.v:复位与启动阶段的中断屏蔽 always @(posedge clk or posedge reset) begin if (reset) begin pc_f <= 32'h0000_0000; if_id_valid <= 1'b0; id_ex_valid <= 1'b0; ex_mem_valid <= 1'b0; mem_wb_valid <= 1'b0; global_ie <= 1'b0; // 启动阶段先关中断,避免取指未完成就进中断 nested_depth <= 4'd0; end else if (int_accept) begin global_ie <= 1'b0; // 进入中断后由中断控制器按优先级决定是否嵌套 end else if (eret) begin global_ie <= 1'b1; end end

pc_f是取指级 PC,复位值按指令存储器基址设置;if_id_valid到mem_wb_valid是各级有效位,必须和 PC 一起复位;global_ie是全局中断使能,启动阶段保持 0;nested_depth是中断嵌套深度,复位清零。int_accept和eret的优先级要明确:中断接受时先关全局使能,返回时再打开。注意这里的int_accept最好来自中断控制器的寄存器输出,不要用组合逻辑直接驱动,否则在时钟沿附近容易产生毛刺。

3. 五级流水核心:从 IF 到 WB 的数据通路、气泡插入与重定向

五级流水本身不复杂,难的是级间握手。IF 取指、ID 译码、EX 执行、MEM 访存、WB 写回,每一级之间都有一个流水寄存器。寄存器里除了数据,还必须带valid有效位。很多人只传指令和数据,不传有效位,结果气泡被当成一条真实指令执行,写使能乱开,寄存器堆被改得面目全非。下面按“流水寄存器、冒险检测、气泡插入、重定向、中断接入”五个位置拆,代码只保留关键片段,方便你对着自己的工程改。

3.1 流水寄存器与握手信号

先看 IF/ID 寄存器。它要同时支持三种动作:正常推进、停顿保持、冲刷清空。正常推进时把取指级的 PC 和指令打进译码级;停顿保持时输出不变,让译码级继续用旧指令;冲刷清空时把valid拉低,让后面所有写使能失效。

// pipe_if_id.v:IF/ID 流水寄存器,stall 保持,flush 清有效位 module pipe_if_id ( input wire clk, input wire reset, input wire stall, input wire flush, input wire [31:0] pc_f, input wire [31:0] instr_f, output reg [31:0] pc_d, output reg [31:0] instr_d, output reg valid_d ); always @(posedge clk or posedge reset) begin if (reset) begin pc_d <= 32'h0000_0000; instr_d <= 32'h0000_0000; valid_d <= 1'b0; end else if (flush) begin pc_d <= 32'h0000_0000; instr_d <= 32'h0000_0000; valid_d <= 1'b0; // 错误路径或中断冲刷:清有效位 end else if (!stall) begin pc_d <= pc_f; instr_d <= instr_f; valid_d <= 1'b1; // 正常推进 end // stall 为 1 时不赋值,保持原值 end endmodule

clk是单时钟,reset高有效;stall为 1 时寄存器保持,flush为 1 时清空。优先级上reset最高,flush次之,stall最后。valid_d是这条指令是否真实存在的唯一依据,后面的 ID/EX、EX/MEM、MEM/WB 都要逐级传递它。instr_d在 flush 时清零只是便于波形观察,真正起作用的是valid_d。同样的结构复制到 ID/EX 时,还要多传控制信号、源寄存器号、目的寄存器号和立即数;复制到 EX/MEM、MEM/WB 时,传 ALU 结果、写回地址和访存数据。

这里有个血泪经验:stall和flush不要共用一个信号。load-use 冒险需要“冻结 PC 和 IF/ID,同时清空 ID/EX”,如果只拉一个stall而不清 EX,气泡进不去;如果只拉flush而不冻 PC,取指级会把同一条 load 后面的指令重复取进来。正确做法是stall_f、stall_d冻结前两级,flush_e清 ID/EX 的有效位。

3.2 冒险检测:load-use 与分支冲突

冒险单元只做组合判断,输出冻结和冲刷信号。load-use 的判断条件是:EX 级是一条 load,且它的目的寄存器等于 ID 级指令的源寄存器。分支冲突的判断条件是:EX 级分支成立,此时 IF/ID 和 ID/EX 里是错误路径上的年轻指令,需要冲刷。中断接受也要冲刷,因为中断点之后取进来的指令不能提交。

// hazard_unit.v:load-use、分支冲刷、中断接受冲刷 module hazard_unit ( input wire [4:0] rs_d, input wire [4:0] rt_d, input wire [4:0] rs_e, input wire [4:0] rt_e, input wire [4:0] rd_e, input wire mem_read_e, input wire branch_taken_e, input wire int_accept, output wire stall_f, output wire stall_d, output wire flush_e, output wire flush_d ); wire load_use; assign load_use = mem_read_e && ((rd_e == rs_d) || (rd_e == rt_d)) && (rd_e != 5'd0); assign stall_f = load_use; assign stall_d = load_use; assign flush_e = load_use || branch_taken_e || int_accept; assign flush_d = branch_taken_e || int_accept; endmodule

rs_d、rt_d是译码级源寄存器号,rs_e、rt_e、rd_e是执行级寄存器号,mem_read_e表示 EX 级是 load,branch_taken_e表示分支成立,int_accept来自中断控制器。stall_f和stall_d同时拉高时,PC 和 IF/ID 保持,ID/EX 被清空,相当于在 load 和后面的 use 之间插入一个气泡。flush_d在分支和中断时清掉译码级,flush_e多一个 load-use 条件,用来把已经进入 EX 的无用指令清掉。

rd_e != 0这个条件不能省。寄存器 0 是常量零,load 到寄存器 0 不应该触发停顿。分支冲突的判断放在 EX 级,意味着分支成立时已经取了两条年轻指令,冲刷范围要覆盖 IF/ID 和 ID/EX;如果分支在 ID 级判断,冲刷范围可以小一点,但组合路径会变长。课程设计里两种都常见,关键是把冲刷范围和分支判断级对齐。

3.3 插入气泡的实现

气泡不是一条nop指令,而是一个valid=0的流水槽。ID/EX 寄存器在flush_e时要把所有写使能清零:reg_write_e、mem_write_e、mem_read_e都置 0,ALU 操作码置成无关值。这样即使后面的组合逻辑还在算,也不会写寄存器堆或存储器。

// pipe_id_ex.v:插入气泡时清控制信号,而不是只清指令字 module pipe_id_ex ( input wire clk, input wire reset, input wire flush_e, input wire valid_d, input wire [31:0] pc_d, input wire [31:0] rs_val_d, input wire [31:0] rt_val_d, input wire [4:0] rs_d, input wire [4:0] rt_d, input wire [4:0] rd_d, input wire [3:0] alu_op_d, input wire reg_write_d, input wire mem_read_d, input wire mem_write_d, output reg valid_e, output reg [31:0] pc_e, output reg [31:0] rs_val_e, output reg [31:0] rt_val_e, output reg [4:0] rs_e, output reg [4:0] rt_e, output reg [4:0] rd_e, output reg [3:0] alu_op_e, output reg reg_write_e, output reg mem_read_e, output reg mem_write_e ); always @(posedge clk or posedge reset) begin if (reset || flush_e) begin valid_e <= 1'b0; pc_e <= 32'h0000_0000; rs_val_e <= 32'h0000_0000; rt_val_e <= 32'h0000_0000; rs_e <= 5'd0; rt_e <= 5'd0; rd_e <= 5'd0; alu_op_e <= 4'd0; reg_write_e <= 1'b0; // 气泡不能写寄存器 mem_read_e <= 1'b0; mem_write_e <= 1'b0; end else begin valid_e <= valid_d; pc_e <= pc_d; rs_val_e <= rs_val_d; rt_val_e <= rt_val_d; rs_e <= rs_d; rt_e <= rt_d; rd_e <= rd_d; alu_op_e <= alu_op_d; reg_write_e <= reg_write_d; mem_read_e <= mem_read_d; mem_write_e <= mem_write_d; end end endmodule

flush_e为 1 时,valid_e和三个写使能全部清零,这是气泡的核心。pc_e、寄存器号清零只是为了波形干净,不是功能必需。alu_op_e可以置成加法,但valid_e=0后后续级不应该再根据它产生副作用。注意 EX/MEM 和 MEM/WB 也要传递valid,否则气泡到了访存级可能被当成一次真实 store,把数据写到错误地址。

实际改代码时,我建议把“气泡”和“冲刷”分开命名:flush_e用于 load-use 和分支,bubble_e用于控制冒险,虽然实现上都是清有效位,但波形里看名字能少走弯路。

3.4 重定向通路与优先级

重定向解决的是“结果还没写回寄存器堆,后面的指令就要用”的问题。EX 级操作数选择器一般有三路:00 用寄存器堆读出的值,01 用 MEM/WB 阶段写回的值,10 用 EX/MEM 阶段的 ALU 结果。优先级上 EX/MEM 高于 MEM/WB,因为 EX/MEM 更新。load 指令在 EX/MEM 阶段数据还没从存储器出来,所以 load 的 MEM 阶段不能直接转发给紧邻的 use,必须靠 load-use 停顿等一拍。

// forward_unit.v:EX 级操作数重定向,EX/MEM 优先于 MEM/WB module forward_unit ( input wire [4:0] rs_e, input wire [4:0] rt_e, input wire [4:0] rd_m, input wire [4:0] rd_w, input wire reg_write_m, input wire reg_write_w, input wire mem_read_m, output reg [1:0] fwd_a, output reg [1:0] fwd_b ); always @(*) begin fwd_a = 2'b00; fwd_b = 2'b00; // 第一优先级:EX/MEM 的 ALU 结果;load 的 MEM 阶段数据未就绪,排除 if (reg_write_m && !mem_read_m && (rd_m != 5'd0) && (rd_m == rs_e)) fwd_a = 2'b10; else if (reg_write_w && (rd_w != 5'd0) && (rd_w == rs_e)) fwd_a = 2'b01; if (reg_write_m && !mem_read_m && (rd_m != 5'd0) && (rd_m == rt_e)) fwd_b = 2'b10; else if (reg_write_w && (rd_w != 5'd0) && (rd_w == rt_e)) fwd_b = 2'b01; end endmodule

fwd_a、fwd_b分别控制 ALU 两个操作数的来源,2'b00选寄存器堆,2'b01选 MEM/WB 写回值,2'b10选 EX/MEM 的 ALU 结果。mem_read_m用来排除 load:load 在 MEM 阶段还没有数据,不能把地址当数据转发。rd_m != 0和rd_w != 0防止寄存器 0 被误转发。分支比较器也要接同样的转发选择,否则beq在 EX 级比较时可能拿到旧值,出现“寄存器里明明相等,分支却不跳”的玄学问题。

还有一个常见误用:只转发 ALU 的 A 口,不转发 B 口。ADD 指令两个源操作数都可能来自前一条指令,只改 A 口会在某些测试程序里过,换一个寄存器分配就翻车。最稳的做法是把 EX 级所有读操作数的地方都接上转发器,包括 ALU、分支比较和 store 的数据口。

3.5 多级嵌套中断的接入点

中断接入的第一原则是“在指令边界接受”,不能在一条指令执行到一半时把 PC 改掉。常见做法是在 MEM/WB 提交点判断int_accept,此时前面的指令已经确定提交,后面的年轻指令全部冲刷。中断控制器负责优先级和嵌套深度,CPU 负责保存返回 PC 和状态寄存器。下面这个控制器支持 4 个中断源,位号越大优先级越高,嵌套深度最多 8 层。

// interrupt_ctrl.v:固定优先级 + 嵌套深度 + 返回恢复 module interrupt_ctrl #( parameter N = 4, parameter DEPTH = 8 )( input wire clk, input wire reset, input wire [N-1:0] irq, // 电平中断,位号越大优先级越高 input wire global_ie, input wire int_return, // eret 指令产生 output reg int_accept, output reg [1:0] int_vector, output reg [1:0] current_prio, output reg [3:0] nested_depth ); reg [1:0] prio_stack [0:DEPTH-1]; integer i; reg found; reg [1:0] sel; // 组合逻辑:找当前允许的最高优先级中断 always @(*) begin found = 1'b0; sel = 2'b00; for (i = N-1; i >= 0; i = i - 1) begin if (!found && irq[i] && (i > current_prio)) begin found = 1'b1; sel = i[1:0]; end end end always @(posedge clk or posedge reset) begin if (reset) begin int_accept <= 1'b0; int_vector <= 2'b00; current_prio <= 2'b00; nested_depth <= 4'd0; for (i = 0; i < DEPTH; i = i + 1) prio_stack[i] <= 2'b00; end else if (int_return) begin int_accept <= 1'b0; if (nested_depth != 0) begin nested_depth <= nested_depth - 1'b1; current_prio <= prio_stack[nested_depth - 1'b1]; end end else if (global_ie && found && (nested_depth < DEPTH)) begin int_accept <= 1'b1; int_vector <= sel; prio_stack[nested_depth] <= current_prio; current_prio <= sel; nested_depth <= nested_depth + 1'b1; end else begin int_accept <= 1'b0; end end endmodule

N是中断源数量,DEPTH是硬件优先级栈深度;irq是电平中断输入;global_ie是全局使能;int_return由返回指令触发;int_accept是寄存器输出,避免组合毛刺直接冲刷流水;int_vector是当前接受的中断号;current_prio是当前正在处理的优先级;nested_depth是嵌套层数。优先级栈prio_stack保存被打断的优先级,返回时恢复。只有i > current_prio的中断才能打断当前处理,这样低优先级中断不会把高优先级服务程序打断。

CPU 侧还要配一个现场栈。int_accept有效时,把提交点之后的 PC 和状态寄存器压栈,把global_ie关掉,并冲刷 IF/ID、ID/EX;eret时弹栈恢复 PC 和状态。返回地址要保存“下一条要执行的指令”,不是当前正在执行的指令。比较稳的写法是在 MEM/WB 提交点接受中断,保存pc_wb + 4,同时冲刷所有年轻指令。嵌套时每一层都有独立的 PC 和状态栈项,nested_depth只负责优先级恢复,不要拿它当 PC 栈指针用。

4. 避坑与排查:仿真波形里最容易误判的五种现象

流水线 CPU 的调试体验很依赖波形组织。信号全加进去,一眼看过去全是跳变;信号加少了,又不知道气泡到底插没插。我的习惯是固定看五组信号:pc_f、if_id_valid、id_ex_valid、ex_mem_valid、mem_wb_valid,再加stall_f、flush_e、int_accept、nested_depth。下面五条是这类工程里出现频率很高的坑,每条按“现象、原因、解决”拆开,方便直接对照。

4.1 流水与冒险类问题

现象一:复位后 PC 正常走,但寄存器堆一直不更新。原因通常不是 ALU 坏了,而是valid没有逐级传下去,或者 WB 阶段的reg_write被复位清掉后没有再置起来。有人只在 IF/ID 寄存器里定义valid,ID/EX 和 EX/MEM 忘了传,结果气泡和真实指令在后续级看起来一模一样。解决方法是把valid当成和指令字同等重要的数据,每一级流水寄存器都显式传递;WB 写使能写成valid_w && reg_write_w && (rd_w != 0),仿真时先看valid_w再看reg_write_w,能快速区分“指令没到”还是“控制信号没开”。

现象二:load 后紧跟 add,波形里插了气泡但结果还是错。原因多半是只清了 ID/EX,没有冻结 PC 和 IF/ID。这样气泡确实进 EX 了,但取指级还在往前走,下一条指令被重复取进来,等气泡过去后执行了两次。解决方法是把stall_f和stall_d同时拉高,PC 保持、IF/ID 保持,flush_e只清 ID/EX。波形上应该看到pc_f停一拍、if_id_valid保持、id_ex_valid为 0 一拍,三个动作同时发生才算对。

现象三:分支指令跳转后,返回地址或顺序执行地址错一位。原因是分支在 EX 级判断,但冲刷范围只清了 IF/ID,没有清 ID/EX;或者 PC 更新和冲刷用了同一个时钟沿,导致年轻指令已经提交。解决方法是把分支成立时的flush_d和flush_e同时拉高,PC 选择分支目标,并且确认分支指令后面的两条年轻指令都没有写寄存器。定向测试里可以故意在分支后面放一条写寄存器指令,跑完后检查该寄存器是否被错误修改。

现象四:中断返回后重复执行同一条指令,或者跳过一条指令。原因通常是保存的 PC 不是提交点之后的 PC。有人在 IF 级看到int_accept就保存pc_f,但此时 IF 级可能已经取了年轻指令,保存的地址和实际提交点对不上。解决方法是在 MEM/WB 提交边界接受中断,保存pc_wb + 4,同时冲刷所有年轻指令;返回时用eret弹栈恢复 PC,不要把nested_depth当 PC 栈指针。嵌套场景下每一层都要有独立的 PC 栈项,返回顺序必须后进先出。

4.2 中断与综合类问题

现象五:仿真里单级中断正常,两级嵌套时低优先级中断把高优先级打断了。原因是优先级比较只用了irq的位号,没有和current_prio比较;或者int_accept是组合输出,在同一个周期里被连续触发两次。解决方法是让int_accept打一拍,比较条件写成i > current_prio,接受中断时把旧优先级压入prio_stack,返回时恢复。下面这段测试平台检查可以直接加在顶层 TB 里,专门盯嵌套深度。

// tb_cpu_top.v:最小中断嵌套检查 initial begin wait (dut.int_accept == 1'b1); @(posedge clk); if (dut.nested_depth != 4'd1) $display("FAIL: first irq depth=%0d", dut.nested_depth); wait (dut.int_accept == 1'b1); @(posedge clk); if (dut.nested_depth != 4'd2) $display("FAIL: nested irq depth=%0d", dut.nested_depth); wait (dut.eret == 1'b1); @(posedge clk); if (dut.nested_depth != 4'd1) $display("FAIL: eret depth=%0d", dut.nested_depth); end

这段检查用wait等int_accept,再等一个时钟沿看nested_depth是否按 1、2、1 变化;dut是顶层实例名,eret是返回信号。它不替代完整测试程序,但能在早期抓住“嵌套深度没加”或“返回没减”的问题。注意int_accept如果是组合信号,wait可能在同一个周期多次命中,所以控制器输出最好寄存一拍。

现象六:仿真通过,综合后中断不响应或时序违例。常见原因是int_accept组合逻辑直接驱动flush,flush又参与 PC 选择,形成组合环路;或者复位只复位了 PC,没有复位current_prio和nested_depth,综合工具把未初始化寄存器优化掉。解决方法是把中断接受打一拍,所有时序状态显式复位,组合路径里不要出现“自己影响自己”的环路。上板前先跑一遍后综合仿真,重点看复位释放后的前 10 个周期和第一次中断接受点,这两个位置最容易暴露未初始化问题。

5. 二次开发:把固定五级流水改成可配置实验平台

如果要把这份源码继续改成自己的实验平台,我建议从“参数化”和“可观测性”两个方向下手,不要一上来就加乱序执行。第一步把中断源数量N、嵌套深度DEPTH、寄存器位宽和指令存储器深度抽成参数,顶层只改参数不改端口;第二步把valid、stall、flush做成统一命名,每一级流水寄存器都输出一份调试信号,仿真时直接按级看。下面这张表是我一般会先改的几处,改动小,验证也直接。

改造点改动文件验证信号
中断源从 4 扩到 8interrupt_ctrl.v的N和向量位宽irq、int_vector、current_prio
嵌套深度可配置DEPTH参数、优先级栈nested_depth、prio_stack
增加性能计数器cpu_top.v加周期计数和停顿计数cycle_cnt、stall_cnt
分支提前到 ID 级id_stage.v、hazard_unit.vbranch_taken_d、flush_d

改中断源数量时,int_vector的位宽要跟着N变,不要固定写 2 位;改嵌套深度时,prio_stack的索引宽度要能覆盖DEPTH,否则综合工具会报越界。加性能计数器是最划算的改造:一个周期计数器加一个停顿计数器,跑完整测试程序后看stall_cnt和cycle_cnt的比值,就能判断冒险处理是不是过于保守。分支提前到 ID 级能减少冲刷损失,但 ID 级要多做一次寄存器比较,组合路径会变长,改完必须跑后综合仿真。

验证方法不要只靠一条长程序。我一般准备三组:定向测试覆盖 load-use、分支、单级中断、两级嵌套;随机指令测试

本文还有配套的精品资源,点击获取