简介:面向计算机组成原理课程设计与CPU流水线学习者,这份华中科技大学课程设计以Verilog HDL实现五级流水处理器,覆盖取指、译码、执行、访存、写回各阶段,重点处理插入气泡、数据重定向与多级嵌套中断,适合流水线CPU设计、仿真验证及上板调试的本科生参考。压缩包共345个文件、约31.39MB,主体为25个.v源码,另有166个.xml工程配置、30个.rpt综合报告、22个.pb与12个.dcp检查点及1个.bit比特流,并含.tcl脚本、.xdc约束和测试平台文件,较完整呈现从代码编写、仿真到综合上板的工程链路。已有1949人学习下载,读者可据此梳理五级流水模块划分与冲突处理思路,对照报告排查时序及上板问题,并借鉴测试平台完成自验。
1. 一条 add 后面跟 sub 就读错:这套 CPU 流水线源码把重定向和气泡都摊开给你看
课程设计做到第五周,最卡人的往往不是 ALU 写不出来,而是把单周期改成 CPU 流水之后波形对不上号:PC 和指令错位一拍,重定向明明写了,add 后面紧跟的 sub 还是读到旧值;插入气泡的逻辑加上去了,load 后面第三条指令照样跑飞。这份 Verilog 源码把五级流水、旁路重定向、stall 气泡注入和多级嵌套中断放进同一套工程,每一段都能单独拉出来仿真对照。它适合正在做组原课设、需要一份能跑通「流水+中断」参考实现的人,也适合已经写出一版、想搞清楚转发优先级为什么这么排、中断嵌套现场往哪儿存的读者,拿波形逐拍比对。
2. 五级流水骨架:四个段寄存器到底该装哪些信号
流水线的本质是把一条指令的执行切成几段,让不同指令的不同段同时待在硬件里。道理谁都懂,落到 Verilog 就变成一堆段寄存器:装多了面积浪费、时序变差,装少了到下游发现关键信息没带过来,只能回头改端口。这套源码的骨架是经典的 IF/ID/EX/MEM/WB 五段,控制信号随指令一路「旅行」,数据通路在每一段各取所需。
2.1 段寄存器清单与 valid 位的作用
先看每个段寄存器必须携带什么。判断标准只有一条:下游哪一段要用它,就必须带过去;下游不用的,一律不带。
| 段寄存器 | 必须携带的信号 | 谁在消费 |
|---|---|---|
| IF/ID | pc、instr、valid | 译码单元、寄存器堆读地址 |
| ID/EX | rs1/rs2 的读出值与索引、立即数、alu_op、目的寄存器号、写回使能、memread/memwrite | EX 运算、转发比较、冒险检测 |
| EX/MEM | alu_result、store_data、目的寄存器号、写回使能、memread | 数据存储器、转发源 1 |
| MEM/WB | 读出的 mem_data 或 alu_result、目的寄存器号、写回使能 | 寄存器堆写口、转发源 2 |
表里最容易被漏掉的是 valid 位。很多人只搬数据不搬「这条是不是真指令」,结果气泡经过时目的寄存器号恰好等于某个真实寄存器号,转发逻辑就把气泡的垃圾值前递出去了,波形上表现为「某条指令算出来的结果莫名其妙」。valid 位的作用就是在每个消费点加一道门:ex_mem_valid && ex_mem_regwrite同时成立才允许转发。这条规则在这套源码里贯穿始终,也是后面所有排查的起点。
另一个常见争议是 pc 要不要一路带到 WB。如果只做课设要求的功能,EX 之后基本不用 pc;但一旦接入中断,EPC 的取值时机和精确异常点都依赖某条指令当前处在哪一段,把 pc 跟着带下去能省掉大量反推。我一般会在 ID/EX 和 EX/MEM 里各留一份 pc,面积代价只有几十个触发器,调试时能直接从波形读出「这条指令是从哪个地址来的」。
2.2 顶层端口约定与段寄存器的三种更新方式
顶层模块的端口要一次定死,后面 80% 的联调痛苦都来自端口改来改去。下面这份是源码里的顶层声明,指令存储器和数据存储器都放在外面,方便 testbench 直接喂数据和抓结果:
module cpu_top ( input wire clk, input wire rst_n, // 低有效异步复位 input wire [31:0] i_inst, // 指令存储器读出 input wire [31:0] i_rdata, // 数据存储器读出 input wire [5:0] i_irq, // 6 路中断请求,bit0 优先级最低 output wire [31:0] o_pc, // 当前取指地址 output wire [31:0] o_addr, // 数据存储器地址 output wire [31:0] o_wdata, output wire o_we, output wire [31:0] o_epc // 调试口:最近一次中断的返回地址 );段寄存器的更新只有三种动作,把这三种分清楚,流水线的控制逻辑就不会乱:正常推进、冻结保持、清成气泡。下面以 IF/ID 为例:
reg [31:0] if_id_pc; reg [31:0] if_id_instr; reg if_id_valid; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin if_id_pc <= 32'd0; if_id_instr <= 32'h0000_0013; // NOP if_id_valid <= 1'b0; end else if (flush) begin // 分支跳转 / 中断响应:整段冲刷 if_id_pc <= 32'd0; if_id_instr <= 32'h0000_0013; if_id_valid <= 1'b0; end else if (!stall_req) begin // 上游停顿时不更新,等价于冻结 if_id_pc <= pc; if_id_instr <= i_inst; if_id_valid <= 1'b1; end end这段代码里三个分支的优先级不能调换:复位最高,其次是 flush,最后才是正常推进。flush和stall_req同时有效时,冲刷优先——因为中断或分支已经判定要跳,此时再保持旧指令没有意义。参数上,NOP 用的是32'h0000_0013(把零加回零号寄存器),选它而不是全 0,是因为全 0 在部分译码逻辑里会被当成非法指令而触发异常,反而把中断流程搅乱。
ID/EX 的写法要单独注意,它多了一个「气泡」入口,和冻结不是一回事:
always @(posedge clk or negedge rst_n) begin if (!rst_n) begin id_ex_valid <= 1'b0; id_ex_we <= 1'b0; id_ex_rd <= 5'd0; end else if (flush || bubble_id_ex) begin // 插入气泡:段寄存器照常更新,但更新成一条无效指令 id_ex_valid <= 1'b0; id_ex_we <= 1'b0; id_ex_rd <= 5'd0; id_ex_alu_op<= 4'b0; end else if (!stall_req) begin id_ex_valid <= if_id_valid; id_ex_we <= dec_we; id_ex_rd <= dec_rd; id_ex_alu_op<= dec_alu_op; end end冻结是「这个寄存器这一拍不动」,气泡是「这个寄存器这一拍动,但动成一条空指令」。很多翻车就出在把两者混为一谈:load-use 冒险时只冻结了 PC 却没清 ID/EX,结果 EX 段把 load 指令又执行了一遍,写回口被同一个目的寄存器连写两次,波形上看是「值跳了一下又回去」。
注意:
bubble_id_ex和stall_req在同一拍同时有效是正常现象,前者清 EX 段,后者冻住 PC 和 IF/ID,两个动作必须同时发生才算一次完整的停顿。
2.3 先跑通一条指令,再往上叠功能
新手最容易犯的错是一上来就把转发、气泡、中断全打开,仿真出一堆 X 就完全不知道从哪查。我的习惯是分三层验证,每层都有一个明确的通过标准:
第一步,关掉所有旁路和中断,只留五级骨架和寄存器堆,跑一段没有相关的指令序列,比如连续几条立即数加法。通过标准是 WB 段写回的值和手算一致,且每拍 PC 递增 4。
第二步,打开转发,塞入add r1, r2, r3紧跟sub r4, r1, r5这样的紧相关。通过标准是 sub 读到的 r1 等于 add 的结果,且波形上 EX 段的选择器确实切到了旁路。
第三步,打开中断,用 testbench 在特定周期拉高某一路 irq,观察响应后 EPC 是否等于被打断指令的地址。
testbench 的骨架不需要写得多花哨,能喂指令、能看写回就够:
module tb_cpu; reg clk = 0; reg rst_n = 0; reg [31:0] inst_mem [0:255]; reg [31:0] data_mem [0:255]; wire [31:0] pc, addr, wdata; wire we; always #5 clk = ~clk; // 100MHz cpu_top u_cpu ( .clk(clk), .rst_n(rst_n), .i_inst(inst_mem[pc[9:2]]), // 字编址:地址右移 2 位 .i_rdata(data_mem[addr[9:2]]), .i_irq(6'b0), .o_pc(pc), .o_addr(addr), .o_wdata(wdata), .o_we(we), .o_epc() ); integer i; initial begin for (i = 0; i < 256; i = i + 1) begin inst_mem[i] = 32'h0000_0013; // 默认全填 NOP data_mem[i] = 32'd0; end inst_mem[0] = 32'h00A0_0093; // addi x1, x0, 10 inst_mem[1] = 32'h0140_0113; // addi x2, x0, 20 inst_mem[2] = 32'h0020_81B3; // add x3, x1, x2 #20 rst_n = 1; #200 $finish; end endmoduleinst_mem[pc[9:2]]这行索引是血泪经验:指令存储器按字节编址时,PC 每次加 4,如果直接用pc当下标,取出来的永远是隔三条的指令,波形上表现为「指令全乱但 PC 是对的」。所有存储器建模都要确认编址单位,字编址就右移 2 位,半字编址就右移 1 位。
3. 重定向网络怎么接:三条旁路、两级优先级和一处 load-use 例外
重定向(也叫旁路、forwarding)解决的是数据冒险:上一条指令的结果还在流水线里没写回寄存器堆,下一条就要读它。如果什么都不做,下一条读到的是旧值。硬件的做法是绕开寄存器堆,直接把 EX/MEM 或 MEM/WB 里的结果拽到 ALU 输入端。这部分逻辑代码量不大,但优先级排错一次,后面所有用例都会间歇性出错,属于典型的「错了也能跑,就是偶尔不对」。
3.1 转发源的三种情况与比较条件
ALU 的两个输入端各自有一个多路选择器,每个选择器通常有三个来源:
第一种,正常情况下用 ID/EX 段寄存器带过来的 rs1/rs2 读值。第二种,从 EX/MEM 段寄存器取上一条指令的 ALU 结果,这是最近的一笔数据,大多数紧相关都走这条路。第三种,从 MEM/WB 段寄存器取上上条指令的结果,用于「隔一条才用到」的场景,比如中间插了一条无关指令。
比较条件有三个必须同时满足,缺一个就会出现「本来不该转发却转了」或者「该转发没转」:
- 目标段的 valid 位为 1,气泡不能当指令;
- 目标段的写回使能为 1,只有会写寄存器的指令才产生旁路值,store 和分支不写;
- 目标寄存器号等于当前 ID/EX 段的源寄存器号,且目标寄存器号不为 0。
最后一条最容易漏。零号寄存器是硬连线的常量零,任何指令写它都应当被丢弃,如果转发逻辑把它当成普通寄存器,add x0, x1, x2之后的指令就会读到 x1+x2 而不是 0。
3.2 优先级判断与代码实现
优先级只有一句话:越新的结果越优先。EX/MEM 是上一条指令,MEM/WB 是上上条指令,两条指令都写同一个寄存器时,必须取上一条的。写成 if-else 时把 EX/MEM 放在前面即可:
reg [1:0] fwd_a, fwd_b; always @(*) begin // 默认:用 ID/EX 带下来的读值 fwd_a = 2'b00; fwd_b = 2'b00; // 第一优先级:上一条指令在 EX/MEM 段的结果 if (ex_mem_valid && ex_mem_regwrite && (ex_mem_rd != 5'd0)) begin if (ex_mem_rd == id_ex_rs1) fwd_a = 2'b10; if (ex_mem_rd == id_ex_rs2) fwd_b = 2'b10; end // 第二优先级:上上条指令在 MEM/WB 段的结果 // 注意用 else if 的语义:已被第一优先级命中的源不再被覆盖 if (!(ex_mem_valid && ex_mem_regwrite && (ex_mem_rd != 5'd0) && (ex_mem_rd == id_ex_rs1))) if (mem_wb_valid && mem_wb_regwrite && (mem_wb_rd != 5'd0) && (mem_wb_rd == id_ex_rs1)) fwd_a = 2'b01; if (!(ex_mem_valid && ex_mem_regwrite && (ex_mem_rd != 5'd0) && (ex_mem_rd == id_ex_rs2))) if (mem_wb_valid && mem_wb_regwrite && (mem_wb_rd != 5'd0) && (mem_wb_rd == id_ex_rs2)) fwd_b = 2'b01; endfwd_a的编码含义是 00 取寄存器堆读值、10 取 EX/MEM、01 取 MEM/WB。写成两段独立 if 而不是单个 if-else 链,是因为 rs1 和 rs2 可能一个命中 EX/MEM、另一个命中 MEM/WB,必须分别判断。参数上唯一的坑是id_ex_rs1和id_ex_rs2必须是 ID/EX 段寄存器里保存的源寄存器索引,而不是译码器当前输出的索引——译码器的输出在停顿期间可能已经变成下一条指令的,用它做比较会误判。
MEM/WB 这条旁路还有个细节:如果上上条指令是 load,MEM/WB 段里mem_wb_rd对应的值来自数据存储器读出,这时候旁路的是i_rdata而不是 ALU 结果。源码里用了一个二选一:mem_wb_wdata = mem_wb_memread ? mem_wb_memdata : mem_wb_alu_result,转发和写回共用这一份数据,避免两处逻辑各算一遍导致不一致。
3.3 load-use 冒险为什么必须插气泡
转发能救大部分数据冒险,唯独救不了 load 后面紧跟使用其结果的情况。原因是 load 的数据要到 MEM 段末尾才从存储器出来,而下一条指令在 EX 段开头就要用,中间隔着整整一个段,旁路再快也来不及。硬件上唯一的办法是让下一条指令在 ID 段多停一拍,等 load 走到 MEM/WB,再用旁路把值送过去。
检测条件写出来很短:
// load-use 冒险检测:只看 ID/EX 段是不是 load,且目的寄存器被 IF/ID 段用到 wire load_use = id_ex_valid && id_ex_memread && ((id_ex_rd == if_id_rs1) || (id_ex_rd == if_id_rs2)); assign stall_req = load_use; assign bubble_id_ex = load_use; // 同一拍把 EX 段打成气泡两个输出必须同时有效。stall_req冻住 PC 和 IF/ID,让 load 的下一条指令停在译码段不动;bubble_id_ex把 ID/EX 清成气泡,让 EX 段这一拍执行一条空指令。只做前一半,EX 段会把已经进入的那条指令再算一遍;只做后一半,PC 继续往前跑,被停住的指令和下一条指令会重复执行。
这里有个容易忽略的边界:如果 load 的目的寄存器是零号,检测条件里id_ex_rd == if_id_rs1可能误命中(因为很多无效指令的源寄存器索引也是 0),所以严格实现要加id_ex_rd != 5'd0。源码里保留了这条判断,代价是多一级比较,但能省掉一类极难复现的偶发错误。
还有一处判断标准值得说清楚:if_id_rs1和if_id_rs2必须从 IF/ID 段寄存器里的指令现场译码出来,而不是直接用译码器的输出。因为译码器输出可能已经被 flush 或气泡覆盖,用它判断会把不该停的指令停掉,波形上表现为「流水线莫名其妙少了一拍吞吐」。
4. 多级嵌套中断:从采样、压栈到 EPC 回填的完整时序
单级中断在课设里算送分题:来一个请求,保存 PC,跳到入口,执行完把 PC 还回去。嵌套就麻烦了:高优先级中断打断低优先级的中断服务程序时,第一层的返回地址必须还留着,否则返回时直接跳飞。这份源码用一个小型硬件栈解决现场保存,深度为 4 级,够覆盖课设里常见的两三层嵌套。
4.1 中断采样时机与优先级编码
采样时机决定一切。如果在任意拍都接受中断,很可能在一条指令执行到一半时把 PC 换掉,造成「指令执行了半条」。正确做法是只在指令的固定边界采样,常见选择是在 MEM 段末尾或 WB 段确认该指令不会再产生异常之后再响应。
优先级编码用一段组合逻辑即可,编号越小优先级越高,同时被屏蔽寄存器挡住的路不参与仲裁:
reg [2:0] irq_id; // 0 表示无中断,1~6 表示对应中断源 reg irq_take; always @(*) begin irq_id = 3'd0; irq_take = 1'b0; if (irq_en[0] && i_irq[0]) begin irq_id = 3'd1; irq_take = 1'b1; end else if (irq_en[1] && i_irq[1]) begin irq_id = 3'd2; irq_take = 1'b1; end else if (irq_en[2] && i_irq[2]) begin irq_id = 3'd3; irq_take = 1'b1; end else if (irq_en[3] && i_irq[3]) begin irq_id = 3'd4; irq_take = 1'b1; end else if (irq_en[4] && i_irq[4]) begin irq_id = 3'd5; irq_take = 1'b1; end else if (irq_en[5] && i_irq[5]) begin irq_id = 3'd6; irq_take = 1'b1; end endirq_en是每路独立的使能位,由软件通过写控制寄存器配置,和全局中断使能IE是两个层次。这种「全局开关 + 单路屏蔽」的两级结构在嵌套里很关键:进入高优先级服务程序时,把当前优先级写进屏蔽寄存器,低优先级请求就自动被挡住,不需要软件逐个人工关。
采样还要求中断请求至少保持一个完整周期,否则可能出现「请求来了但采样拍没看到」的丢中断。testbench 里拉 irq 的时候建议保持 2 到 3 个周期,模拟真实外设的脉冲宽度。
4.2 EPC 与 STATUS 的压栈逻辑
嵌套的核心是「返回地址不能只存一份」。源码里用两组寄存器堆做栈:
reg [31:0] epc_stack [0:3]; reg [31:0] stat_stack [0:3]; // 保存 IE 与当前优先级 reg [2:0] sp; // 栈指针,0 表示空 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin sp <= 3'd0; end else if (irq_take && (sp < 3'd3)) begin // 压栈:保存被打断指令的地址和当前状态 epc_stack[sp] <= intr_pc; // 下一条要执行的指令地址 stat_stack[sp] <= {ie_reg, cur_prio}; sp <= sp + 3'd1; pc <= {vec_base, irq_id, 2'b00}; // 跳转到向量入口 ie_reg <= 1'b0; // 进入服务程序先关全局中断 cur_prio <= irq_id; end else if (iret) begin // 出栈:恢复现场 sp <= sp - 3'd1; pc <= epc_stack[sp - 3'd1]; {ie_reg, cur_prio} <= stat_stack[sp - 3'd1]; end endintr_pc的取值是这段代码里最容易出错的地方。它不是当前正在执行的指令地址,而是「被打断后应该继续执行的那条指令的地址」。如果中断在 MEM 段末尾响应,此时 IF、ID、EX 段里还有三条指令在飞,EPC 必须取 ID 段那条指令的 pc,并把 IF/ID 和 ID/EX 一起冲刷掉,这样返回时才不会重复执行或者跳过指令。
vec_base是向量表的基地址,irq_id左移两位拼在后面形成入口地址,每个中断源占 4 字节,正好放一条跳转指令。这种向量式入口比统一入口加软件查询的响应快,代价是地址空间要预留出来。
栈深度 3 位指针配 4 组寄存器,意味着最多支持 4 层嵌套。深度不够时sp < 3'd3的判断会把多余的中断丢弃,这是有意的降级策略——宁可丢一次中断,也不能让栈指针回绕覆盖已经保存的现场。
4.3 嵌套时序与中断返回
嵌套是否成立,关键看ie_reg什么时候重新打开。进入服务程序时它被清 0,此时任何新中断都进不来,包括更高优先级的。如果希望高优先级能打断低优先级,就得在服务程序开头由软件重新置位ie_reg,同时硬件用cur_prio做二次过滤:只有优先级数值更小的请求才被接受。
这样设计的好处是灵活性:不需要嵌套的中断服务程序干脆不重新开中断,天然免疫打断;需要嵌套的再开,硬件负责挡低优先级。软件上的写法通常是入口处先保存用到的通用寄存器,再开中断,退出前关中断、恢复寄存器、执行 iret。
返回指令iret要注意两点。第一,它自己不能被中断打断,否则出栈到一半又来一次压栈,栈指针直接错乱,常见做法是 iret 执行期间强制屏蔽所有中断。第二,出栈和恢复 PC 必须同一拍完成,不能先出栈再跳转,中间那一拍 PC 还指着 iret 指令本身,会再执行一次。
冲刷的范围也要对齐。中断响应时要冲刷 IF/ID 和 ID/EX 两段,因为这两段里的指令都还没产生任何架构状态;EX 段的指令如果已经算出结果但没写回,可以选择让它走完再响应,这样实现简单但响应晚一拍。源码选的是「等 EX 段走完再响应」,所以取 EPC 时用的是 ID 段的 pc,这个对应关系一定要在波形上核对一遍,取错了就是返回后少执行一条指令。
5. 避坑实录:波形对不上时先查这四处
仿真跑不通的时候,最怕的是没有方向地乱改。下面四条是按出现频率排的,基本覆盖了这套流水线在联调阶段九成以上的异常,每条都按「看到什么现象、根因在哪、怎么改」写。
5.1 现象:复位释放后 PC 在 0 和 4 之间来回跳
原因通常不是 PC 寄存器本身,而是rst_n的释放时刻和时钟沿撞上了。异步复位如果直接在时钟上升沿附近撤销,触发器可能进入亚稳态,第一拍的行为不确定;如果 testbench 里#20 rst_n = 1恰好落在时钟沿上,就会看到 PC 抖两下才稳定。
解决办法是把复位释放改成同步释放:异步复位、同步撤销。加两级触发器做复位同步器,输出给全芯片用:
reg rst_sync0, rst_sync1; always @(posedge clk or negedge rst_n) begin if (!rst_n) {rst_sync1, rst_sync0} <= 2'b00; else {rst_sync1, rst_sync0} <= {rst_sync0, 1'b1}; end wire rst_n_sync = rst_sync1;排查时还有一个辅助手段:把 PC 和if_id_valid一起抓进波形,如果 PC 抖动时 valid 一直是 0,说明抖动发生在气泡阶段,不影响功能;如果 valid 是 1,那就是真的取到了错误地址,必须改复位。
5.2 现象:转发打开了,但某些指令序列结果仍然错,且只在特定排列下复现
多数情况是转发比较用错了寄存器索引。ID/EX 段寄存器里应该保存的是「译码阶段锁存下来的 rs1/rs2 索引」,但有些实现图省事,直接用译码器的实时输出做比较。一旦发生停顿,译码器的输入已经换成了下一条指令,比较结果就跟着变,转发的值也就错了。
判断方法很直接:在波形里同时看id_ex_rs1和译码器当前输出的dec_rs1,让它们跑一段带 load-use 停顿时序的程序,如果两者在停顿拍出现分歧,就说明用错了源。改法是把索引和读值一起打进 ID/EX 段寄存器,后续所有比较都只认段寄存器里的值。
顺带提一个相邻的坑:ex_mem_rd != 5'd0这条判断如果在某一处漏写,零号寄存器的写回会被转发出去,表现为「本该是 0 的源操作数变成了上一次运算结果」。这种错误在大部分程序里看不出来,只有显式对零号寄存器做运算时才会暴露,属于典型的需要定向用例才能抓到的 bug。
5.3 现象:中断返回后同一条指令执行了两次
大概率是 EPC 存的是「当前正在执行的指令地址」而不是「下一条要执行的指令地址」。中断响应时流水线里有多条指令在飞,取哪一段的 pc 决定了返回后从哪儿继续。取错一段,返回后就重复执行一条,程序计数器看着正常,但存储器写次数多了一次。
排查方法是构造一个自增循环,在循环体中间拉中断,返回后检查自增变量是不是多加了 1。定位时把 IF、ID、EX 三段的 pc 一起抓到波形上,对照中断响应那一拍三者的值,看 EPC 到底等于哪一个。定好规则之后写进注释,比如「EPC 取 ID 段 pc,同时冲刷 IF/ID 与 ID/EX」,后面所有代码都按这个规则来。
还有一个同源问题:冲刷范围少了一段。只冲刷 IF/ID 不冲刷 ID/EX,ID/EX 里那条指令会继续走到 WB 并写回寄存器堆,相当于中断前后各执行了一次,症状和 EPC 取错几乎一样,只能靠看写回使能的拍数来区分。
5.4 现象:气泡确实插了,但停顿拍数不对,有时多停一拍有时少停一拍
先确认stall_req的宽度。load-use 只应该停一拍:第一拍冻住 PC 和 IF/ID、清 ID/EX;下一拍 load 走到 MEM/WB,旁路已经能把数据送过来,就应该恢复。如果stall_req一直有效,说明检测条件用了组合环路——比如把if_id_rs1接到了译码器的输出,而译码器输入又来自被冻结的 IF/ID,形成自锁。
另一类原因是stall_req传给了不该传的段。正确的传播范围是「PC、IF/ID 冻结,ID/EX 清空」,EX/MEM 和 MEM/WB 必须继续正常推进,否则 load 自己也被停住,永远等不到数据。检查方法是在波形上数 load 指令从 ID 段到 MEM 段的拍数,正常应该是 3 拍,如果变成 4 拍以上,就是 EX/MEM 也被冻了。
提示:停顿相关的 bug 有一个共同特征——程序能跑完,结果只是偏慢或偶发错误。这类问题不要靠肉眼扫波形,直接在 testbench 里加周期计数器,把关键指令的进入拍和离开拍打印出来,比对预期值。
6. 把流水线逼到边界:五组定向用例与一份自检清单
功能跑通只是起点,能不能站住要看边界。随机指令流对流水线来说太温和,大部分冒险组合根本触发不到。我的习惯是手写五组定向用例,每组只打一个点,出问题时能立刻定位到是哪条通路。
| 用例 | 构造方式 | 关键观察点 |
|---|---|---|
| 紧相关 | add紧跟使用其结果的sub | EX 段选择器切到 EX/MEM,结果正确 |
| 隔一条相关 | 中间插一条无关指令 | 走 MEM/WB 旁路,优先级不被 EX/MEM 抢 |
| load-use | load紧跟使用其结果的指令 | PC 与 IF/ID 冻一拍,ID/EX 被清 |
| 分支冲刷 | 条件跳转成立,后跟两条指令 | IF/ID 被冲刷,跳转目标只执行一次 |
| 嵌套中断 | 低优先级服务中拉高更高优先级 | 栈指针加 2,返回两次后现场完整 |
紧相关和隔一条相关这两组要一起跑,因为它们考的是同一个选择器的两个优先级。如果只跑紧相关,把 MEM/WB 那一路删掉照样能过;只跑隔一条,把优先级顺序写反也能过。两组都过,才说明优先级逻辑是对的。
load-use 用例要特意构造「load 的结果被下一条的 rs1 和 rs2 同时使用」和「只被 rs2 使用」两种,后者用来验证比较逻辑对两个源都做了判断。我见过只判断 rs1 的实现,跑常规程序完全正常,一遇到sw类指令需要 rs2 就出错。
分支冲刷用例要观察跳转目标是否只执行一次。常见错误是冲刷晚了一拍,跳转目标已经进了 ID 段又被冲刷掉,表现为「跳过去了但第一条指令没执行」。把跳转目标的写回使能拍数数出来,和预期比对,一目了然。
嵌套中断用例最难构造,因为它要求精确控制拉高第二路中断的周期。做法是在第一层服务程序的固定位置插一条nop,在 nop 执行的那一拍拉高第二路,这样时序可复现。观察点有三个:栈指针是否从 1 变到 2,两次 iret 之后栈指针是否回到 0,以及最外层被打断的指令是否只执行了一次。
除用例之外,还有一份我每次改完流水线都会走一遍的自检清单:所有段寄存器的 valid 位是否都在复位时清零;所有转发条件是否都带了!= 0和 valid 判断;所有 Stall 是否只冻结应该冻结的段;所有 flush 是否覆盖了 IF/ID 和 ID/EX 两段;中断入口的向量地址是否按 4 字节对齐。这五条里任何一条漏掉,都可能在某组用例上偶发失败,而且往往要跑几千拍才复现一次。
从那以后我每次改完控制逻辑,都强制先跑这五组用例再看综合报告,宁可多花二十分钟仿真,也不想在板子上盯着一个每千拍错一次的结果猜原因。希望帮到你。
本文还有配套的精品资源,点击获取