基于Verilog的五级流水线Cache与中断联合设计实战指南
2026/9/18 12:15:16 网站建设 项目流程

简介:基于Verilog带cache和中断的五级流水通用处理器的设计与实现,是一份面向计算机体系结构课程、数字电路设计入门及处理器设计课题研究的参考资料。文档系统介绍五级流水CPU的取指、译码、执行、访存、写回架构,设计了16位指令集,覆盖算数与逻辑指令、跳转指令、多重子程序调用指令和中断处理程序,能够帮助读者建立处理器开发的整体认识。在具体实现方面,文档详述了子程序调用、异常处理、数据高速缓存、结构相关处理和控制相关处理等模块的Verilog编写方法,并通过旁路技术等策略解决数据相关、控制相关与结构相关问题,展现出流水线设计中的关键优化手段。同时,基于硬件模拟软件完成处理器仿真测试,基本运算、存储访问、跳转及中断响应均可正常运行,为课程设计或毕业设计提供完整参考。资源包仅含1个PDF文件,大小961KB,已有160人学习,适合需要处理器设计和可综合代码样例的读者系统学习,尤其适合作为计算机组成原理课程的课外拓展材料。

1. 为什么是五级流水 + Cache + 中断

把 cache 直接挂上五级流水线,结构冒险立刻出现:一条 load 指令在 MEM 段访存,同周期 IF 段可能还要取指;再加上中断请求,指令生命周期里又多了“被中断”这条异常路径。很多课程作业只写到三级流水,因为五级流水真正麻烦的限制路径不是 ALU 运算,而是 Cache 缺失和中断现场保存。这篇博客适合用 Verilog 写过流水线但没打通命中率与中断联合调试的人,也适合 IC 秋招前系统整理处理器取指与访存链路的人。

2. 五级流水数据通路与冒险处理的Verilog落法

2.1 流水级边界:哪级寄存器决定性能

五级流水把指令生命周期切成取指(IF)、译码(ID)、执行(EX)、访存(MEM)、写回(WB)。每一级之间必须有流水寄存器,常见命名是 if_id、id_ex、ex_mem、mem_wb。除了数据信号,还把这段时间内需要的控制信号一起锁存,避免后面看到的是当前周期入口处的状态。

比“哪些寄存器”更关键的其实是使能与清零。使能来自流水线停顿,清零来自 flush 和 bubble。一个典型的 id_ex 寄存器如下,必须支持三种动作:正常工作、冻结、清除。

always @(posedge clk or negedge rst_n) begin if (!rst_n) begin id_ex_valid <= 1'b0; id_ex_rd <= 5'd0; end else if (stall_idex) begin id_ex_valid <= 1'b0; // 插入气泡,控制信号清掉 end else if (flush_idex) begin id_ex_valid <= 1'b0; // 冲刷误取指令 end else begin id_ex_valid <= if_id_valid; // 正常传递 id_ex_rd <= if_id_rd; end end

这里stall_idexflush_idex必须互斥,否则逻辑容易陷入到底该填 0 还是填原值的混乱。stall 针对 load-use,flush 针对分支和中断。id_ex_valid是每个流水寄存器都该有的有效位,很多调试问题最后都回到 valid 传播断了,而不是数据位传错。

2.2 数据冒险:转发单元与Load-Use停顿

数据冒险的经典判别标准是:同一个寄存器,写入还没提交,新的读就来了。为避免全停顿,几乎无条件先在 EX/MEM 和 MEM/WB 这两个源上加转发。下面的转发单元把更靠近当前执行的源优先。

module fwd_unit( input wire [4:0] ex_rs1, ex_rs2, input wire [4:0] mem_rd, wb_rd, input wire mem_regwrite, wb_regwrite, output reg [1:0] fwd_a, fwd_b ); wire fa_mem = mem_regwrite && (mem_rd != 5'd0) && (mem_rd == ex_rs1); wire fa_wb = wb_regwrite && (wb_rd != 5'd0) && (wb_rd == ex_rs1) && !fa_mem; wire fb_mem = mem_regwrite && (mem_rd != 5'd0) && (mem_rd == ex_rs2); wire fb_wb = wb_regwrite && (wb_rd != 5'd0) && (wb_rd == ex_rs2) && !fb_mem; always @(*) begin if (fa_mem) fwd_a = 2'b10; // 来自EX/MEM流水寄存器 else if (fa_wb) fwd_a = 2'b01; // 来自MEM/WB流水寄存器 else fwd_a = 2'b00; end always @(*) begin if (fb_mem) fwd_b = 2'b10; else if (fb_wb) fwd_b = 2'b01; else fwd_b = 2'b00; end endmodule

注意2'b10从 EX/MEM 结果寄存器取,2'b01从 MEM/WB 取。因为 EX/MEM 阶段的结果比 MEM/WB 阶段更新,所以当两者同时指向同一寄存器时,EX/MEM 源需要被优先判断。转发只解决 ALU 结果和 load 结果已经进入流水线但还没写寄存器堆的情况,解决不了 load 结果还没从数据 Cache 回来时下一拍就要用的情况。

Load-Use 是唯一需要硬件停顿的情况:一条 load 在 EX 阶段算出地址,下一拍地址在 MEM 访存,但下一条指令在 ID 阶段已经需要立即读数。转发来不及,必须把 PC 和 IF/ID 冻结一拍。判定条件一般写为:

wire load_use_stall = id_ex_memread && (id_ex_rd != 5'd0) && ((id_ex_rd == if_id_rs1) || (id_ex_rd == if_id_rs2)); assign stall_pc = load_use_stall; assign stall_ifid = load_use_stall; assign flush_idex = load_use_stall;

这里把 ID/EX 中 load 的写寄存器号与当前 IF/ID 译码出的所有源寄存器做比较。注意id_ex_memread必须来自 id_ex 流水寄存器,而不是 EX 阶段的原始控制信号,否则会晚一拍。控制信号与数据信号的同步是五级流水调试中最常犯的错误。

2.3 控制冒险:分支判断的所在级决定flush成本

控制冒险的解法非常多,从静态预测不跳、延迟槽到动态分支历史表。在通用处理器和可综合教学实现里,最简单且靠谱的是“预测不跳转”。这意味着装入分支则默认顺序取下一 PC,到 ID 级发现跳转再纠正。

如果把分支判断放在 ID 级,flush 只需要清掉 IF/ID 中这一条已经误取的指令,重新取跳转目标,代价是 1 拍。如果放在 EX 级,代价变成 2 拍,因为 ID 和 IF 都取了错指令。常见的通用处理器会把比较器搬到 ID 级,代价是组合路径变长,但收益很直接。

冒险类型判断条件采用的解决方式流水线代价
数据冒险ex/mem 或 mem/wb 的 rd 与 ex 的 rs 相等转发通常 0 拍
数据冒险load 的目标与后续指令源相等load-use stall1 拍
控制冒险分支目标地址与跳转条件在 ID 级得出flush IF/ID 并重定向 PC1 拍
结构冒险指令/数据同时访问同一存储器指令/数据 Cache 分离无 stall

到这里必须说明一个边界:如果后续接了 Cache,前面“访存每拍都完成”的假设失效。Cache 命中时流水线保持每周期推进;miss 时会拉低 Cache ready 信号,把前面所有阶段的 valid 都冻住。这是下一章的内容。

3. 给五级流水挂Cache:映射、写策略与访存状态机

3.1 Cache地址切分与映射方式选型

带 Cache 的处理器首先要解决两个问题:Cache 多大、替换策略怎么样。经典地址组织把 32 位地址切成 Tag、Index、Offset 三段。如果 16KB Cache、32 字节 Cache 行、4 路组相联,则 Offset 占 5 位,Index 占 log2(1024/4)=8 位,Tag 占剩下的 19 位。使用组相联而不是直接映射,是因为直接映射在访问序列里同一 Index 不同 Tag 时冲突太明显。

映射方式标签比较次数硬件成本冲突行为
直接映射1 次最低同 index 不同 tag 直接替换
组相联组内 N 路并行比较中等,要 N 路 tag SRAM冲突比直接映射小,命中率更高
全相联全 Cache 行比较最高,对标准 SRAM 不友好基本无冲突,但时序和成本难接受

选择组相联时建议从 2 路或 4 路起步,不要追求 8 路。FPGA 上 tag 比较使用组合比较器,路数翻倍,比较器面积和关键路径都会增长。替换策略用 LRU 还是伪 LRU?对小路数,用计数器实现的 PLRU 就够,几乎不亏命中率,而且电路简单。

3.2 写直达与写回:脏位带来的访存状态

数据 Cache 写策略要考虑和主存的一致性。写直达好处是永远不会出现“内存里数据是旧的”,但每条 store 都要等总线,流水线性能损失明显。写回只在脏行被替换时写。在实际项目中,写回几乎是必选,因为它能过滤高频写地址,只把替换出的整行推给主存。

写回要求 Cache 行带 dirty 标志。从 IDLE 到 REFILL 到 WRITEBACK 的状态转移因此出现。一个最小但完整的四状态 Cache 状态机会很干净:

localparam IDLE = 2'd0; localparam REFILL = 2'd1; localparam WRITEBACK = 2'd2; localparam WAIT_WB = 2'd3; reg [1:0] dc_state; reg dirty_flag; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin dc_state <= IDLE; end else case (dc_state) IDLE: begin if (req && !hit_miss) begin if (dirty_flag && replace_en) dc_state <= WRITEBACK; else dc_state <= REFILL; end end WRITEBACK: if (mem_gnt) dc_state <= REFILL; REFILL: if (mem_gnt && mem_beat_done) dc_state <= IDLE; endcase end

参数说明:hit_miss是 Tag 比较结果,1 表示命中;replace_en表示当前 set 中选中一路;mem_gnt是主存返回的握手信号。写回状态不直接回 IDLE,而是先等主存接受写数据和地址,再去申请读缺失行,避免数据总线被覆盖。FPGA 上 BRAM 读延迟固定为 1 拍,因此命中判断要在 request 发出后一拍才能稳定,设计配套状态时也要把这 1 拍算进去。

3.3 Cache与流水线的握手机制

流水线并不能像仿真玩具那样“一拍给地址,下一拍直接返回数据”。一般用 ready/valid 握手:valid=1 表示流水线想访问,ready=1 表示 Cache 本周期能完成;只有两者同时为高,这次访问才提交到下一级。miss 时 ready 拉低,流水线整体冻结。

assign dmem_req = ex_mem_valid && (ex_mem_op == OP_LD || ex_mem_op == OP_ST); assign dmem_stall = dmem_req && !dcache_ready; assign stall_pc = dmem_stall; // 冻结 PC assign stall_ifid = dmem_stall; // 冻结取指

这里dcache_ready用一拍组合输出,才能保证在最后一个可用周期时,WB 前的数据已经在总线上。注意当 stall 时,ex_mem 寄存器的内容不能覆盖为 0,否则 miss 返回后指令会丢失。所以对访存级使用“使能信号”而不是“清除信号”。很多实现卡在 cache miss 就是因为这里用了同一条 flush 线。

3.4 指令Cache与数据Cache分离

综合项目一般把指令和数据 Cache 做成两块独立存储,否则流水线在 MEM 阶段访数据时,IF 阶段无法取指,每拍都要仲裁。这种分离叫哈佛结构,对 FPGA 很友好:在 FPGA 上例化两块 BRAM,各自有独立读端口,指令路径和数据路径互不阻塞。

如果想让项目更接近真实 SoC,可以在总线层把两个 Cache 的 miss 请求汇聚到一个主存控制器,用优先级仲裁器让指令请求和数据请求分时访问同一块主存。仲裁器不需要很复杂:指令 miss 识别通常优先于数据 miss,因为流水线等待着取指,而数据 miss 不会立刻影响 IF。这个优先级策略能在不改变 Cache 逻辑的情况下显著减少取指停顿时间。

4. 中断与异常:从请求到流水线现场恢复

4.1 中断请求、异常向量与CSR的状态

在通用处理器里,中断和异常通常会统一到一个入口。指令造成的异常(非法指令、load 地址不对齐)和外部中断共用一套现场保存流程,区别只在 cause 寄存器。RISC-V 习惯的配置是:mtvec 放向量基址,mepc 放异常指令的 PC,mcause 放原因,mstatus.MIE 控制全局中断使能。

设计时不要试图在硬件里保存全部通用寄存器,那样寄存器文件需要多个写口,面积直接爆炸。常见的做法是硬件只保存 epc 和 cause,然后跳到 trap 入口,软件在入口用一条sw序列把被中断的寄存器和mepc搬到内存栈。恢复靠mret,硬件从 epc 回到断点。

always @(posedge clk or negedge rst_n) begin if (!rst_n) trap_taken <= 1'b0; else if (mem_trap_trigger) trap_taken <= 1'b1; else if (wb_valid && wb_is_mret) trap_taken <= 1'b0; end always @(posedge clk or negedge rst_n) begin if (mem_trap_trigger) begin csr_mepc <= mem_pc; // 被中断指令的 PC csr_mcause <= 32'h8000_0007; // 外部中断,最高位表示中断 end end

参数说明:mem_trap_trigger在 MEM 级才拉起,目的是实现精确异常;csr_mcause高位置 1 表示异步中断,0 表示同步异常。如果跳转目标本身 Cache miss,流水线会走正常的 refill 流程,不影响 CSR 中的 epc 内容。

4.2 精确异常与流水线冲刷点的确定

中断信号什么时候采样,直接决定整条流水线要清多少。最差的做法是在 IF 看到中断就跳,但此时 MEM 里还可能有一条未完成的 store,中断服务程序无法确定之前的指令是否执行完,这就是不精确异常。对通用处理器来说,不精确异常会让系统行为不可复现。

所以把采样点放在 MEM 级:MEM 级之前的所有指令都已进入执行阶段,MEM 后的年轻指令都有机会被丢弃。把 epc 锁存为当前 MEM 级指令的 PC,然后所有更年轻的 IF/ID/EX 瞬间变为 bubble,PC 重定向到 mtvec。这个做法的代价是中断响应延迟比 IF 级多 2 拍,但换来严格顺序语义。

assign flush_ifid = mem_trap_trigger || branch_flush; assign flush_idex = mem_trap_trigger || branch_flush; assign flush_exmem = mem_trap_trigger;

flush 之前已经写入 WB 的指令不能回滚,它们仍然正常写回寄存器,因为它们不是“未提交”。在 mepc 保存的同时,WB 路径上的寄存器写入照常进行。很多刚开始做中断的人把 flush 线一路清到 WB,结果寄存器文件被白白破坏。

4.3 现场恢复与mret时序

mret本身是一条正常流水线指令,但 CSR 的恢复要早于下一轮取指,所以必须在 MEM 或 WB 阶段把trap_taken清除,并且让下一拍 PC 使用csr_mepc。常见做法是:mret 指令进入 WB 级时,把trap_taken清除,同时把下一次 PC select 置为 CSR 值;从 ID 开始的后续指令要被清空。

如果 mret 在 EX 级判断,那么 IF/ID 里已经取了 mret 后一条指令,需要 flush 掉,代价又是一拍。有些实现用延迟槽避免这个代价,但在 RISC-V 里建议直接允许这一拍损失。中断现场的恢复代码可以只用两个信号:

wire use_mepc = wb_is_mret && wb_valid; wire pc_sel = trap_taken ? 2'b10 : use_mepc ? 2'b01 : 2'b00;

因为use_mepc在 WB 阶段,PC 重定向也在 WB 级完成,能保证 mret 后的下一条指令确实从 epc 取。这个机制特别容易出错,所以要在测试中主动构造“中断 -> 长服务程序 -> mret”的回环。

4.4 中断、Cache写回和总线握手竞争

当流水线测到中断时,MEM 级可能正处在一个 Cache refill 过程的中间。不能因为 trap 就放弃 refill:数据还没回到寄存器堆,中断会把它掩盖。稳妥的做法是让中断请求把 ready 拉低,但不打断当前状态机;等到 Cache 完成本次访问,才在下一次输入信号里采样 mem_trap_trigger。换句话说,中断只影响 PC 流,不影响访存电路的状态。

如果主存接口同时正在处理写回脏行,而此时中断到来,控制器要区分“因为写回引起的 busy”和“因为 refill 引起的 busy”。设计总线状态机时,不要用单一 busy 信号作为 trap 判断条件,而应该用“cache 保持 req”和“流水线采样点”联合判断。很多总线 hang 到头都查不到,就是因为这里把握手信号和流水线有效位混用了。

5. 边验证边改的三板斧:冒烟、对拍、波形切片

5.1 最小冒烟程序

先在测试 ROM 里放一小段程序,覆盖五级流水最基本的路径:普通指令、数据 Cache 读写、转发和中断入口。

addi a0, zero, 5 # a0 = 5 sw a0, 0(zero) # 写数据 Cache lw a1, 0(zero) # 读回 add a2, a1, a0 # 数据冒险,触发转发 ebreak # 停住

跑通的标准:PC 按顺序执行前 4 条,WB 阶段能依次看到 a0=5、a1=5、a2=10,然后停在 ebreak。波形文件中需要打开流水线各 valid 信号,确认 load-use 阻塞时 IF/ID 没有推进。

5.2 用RISC-V工具链对拍寄存器

riscv64-unknown-elf-gcc把一段 C 程序编译成二进制,再用仿真环境和软件模拟器各自运行同一镜像。把最终寄存器文件 dump 出来比对,能立即暴露出访存顺序、Cache 一致性和中断现场的错误。

make sim 2>&1 | tee sim.log gtkwave dump.vcd

比对时不要只比最后一个 PC,还要比对每个触发点上的寄存器堆快照。中断服务程序里通常会有临时寄存器,如果 mepc 恢复错了,快照会在 mret 后第一拍就开始偏离。

5.3 波形切片定位Cache与中断交互

在 gtkwave 里,不要直接把顶层全部信号 dump 出来,否则 vcd 文件增长得飞快。只选择与 Cache 状态机和中段响应相关的信号导出:dc_statetrap_takenmem_gntdcache_ready。导出后重点看中断触发时dc_state是否停在 REFILL 或 WRITEBACK 上。

如果波形里看到trap_taken拉高时dcache_ready还是 0,说明硬件没有先完成当前访存,中断处理器的现场保存会因为 load 数据未到而被覆盖。反过来,如果dc_state已经回到 IDLE 而trap_taken还没来,说明中断请求在总线上被丢了一拍。这两个界面的时序,远比 ALU 里的运算结果更容易让板子宕机。做中断与 IO 交互时,我一般会在总线接口加一个异步 FIFO 来吸收主存延迟,把总线握手和 Cache 状态机彻底解耦,这样波形切片时看到的不再是一个勉强能跑的线性流程。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询