news 2026/9/9 0:23:50

基于Verilog的MIPS五级流水线CPU设计:冒险处理与中断嵌套实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于Verilog的MIPS五级流水线CPU设计:冒险处理与中断嵌套实战

简介:面向华中科技大学计算机组成原理课程设计的Verilog CPU流水线源码包,完整实现流水线分段、插入气泡、重定向及多级嵌套中断等功能,适合计算机专业学生、课程设计团队及硬件入门者参考。包内共345个文件,大小31.39MB,除25个Verilog源文件外,还包含综合/布局布线后的dcp文件、可烧写的bit比特流、时序报告rpt/log、工程约束xdc以及用于自动化构建的bat/tcl脚本,可快速理解从源码到比特流的完整工程结构。已有1939人学习下载。读者可在此工程基础上对照数据冒险、控制冒险的典型处理方式,掌握气泡插入与转发重定向的具体代码实现,并通过多级嵌套中断示例深化对异常响应与现场保护机制的认识;整套设计从源码、约束到时序报告均有留存,可作为课程设计报告撰写和上板调试的直接依据。 那年做计算机组成原理课程设计,我选了流水线CPU这个方向。前前后后写了将近两千行Verilog,最后交付的工程文件打包出来,文件名就是“cpu流水 verilog源码 功能包括:流水、插入气泡、重定向、多级嵌套中断”。回头再看,这门课设计最有价值的不是把代码跑通,而是把“流水线冒险”“中断嵌套”这些教科书上几页纸的概念,变成了你一眼就能看穿的时序图。这篇帖子想把我的设计思路、踩过的坑和调试方法完整写出来,给正在被流水线CPU折磨的同学一个参考。

1. 课程设计的起点:为什么是流水线CPU

1.1 指令集选择:经典MIPS是稳妥答案

选题之后第一件事是定指令集。华科的计算机组成原理课程设计,最通用的是MIPS指令子集,再其次是RISC-V。当时我选了MIPS的32位指令集,理由很朴素:教材、实验指导书、网上参考工程几乎全是MIPS,遇到问题好查;指令格式规整,opcode、rs、rt、rd、shamt、funct每个字段位置固定,译码逻辑写起来不容易错。

我最终实现的指令集不大:算术逻辑指令add、sub、and、or、slt、addiu,访存指令lw、sw,跳转指令beq、j,再加上中断必需的mfc0、mtc0和eret。这个指令集规模是课程设计最常见的难度档位——能覆盖数据冒险、控制冒险和中断处理,又不至于把工作量拖到失控。

指令集选型的另一个隐性影响是后续的中断设计。MIPS里mfc0/mtc0访问CP0协处理器寄存器,eret返回中断现场,这套机制比自造指令集要成熟得多,写汇编中断服务程序时也有现成约定可参考。

1.2 开发工具链与验证环境

工程用的是Verilog,仿真工具我当时在ModelSim和Vivado自带仿真器之间最后选了Vivado。原因倒不是ModelSim不好,而是Vivado的波形窗口、信号分组和层次化查看更顺手,流水线级间的控制信号多,分组查看能省很多时间。

验证环境是整个项目里被低估的部分。很多同学写完RTL直接拍一张波形图收工,但真正让CPU可信的前提是:有一个独立的、可重复的验证手段。我当时的做法是配合MARS模拟器跑同一段汇编程序,把寄存器堆写回结果一条条对比。MARS这类MIPS模拟器在课程设计里是通用的对照标尺,它能精确模拟每条指令对寄存器和内存的修改,流水线CPU仿真完成后对照它的运行结果,可以很快定位是第几条指令开始“走样”。

1.3 模块划分的先后顺序

我对模块的划分是这样的:

  • IFU:取指单元,维护PC,输出指令和PC+4
  • RF:寄存器堆,双读口一写口
  • ALU:算术逻辑运算
  • DM:数据存储器
  • Ctrl:主译码器,产生控制信号
  • PIPE_IF_ID、PIPE_ID_EX、PIPE_EX_MEM、PIPE_MEM_WB:四级流水线寄存器
  • Hazard:冒险检测与转发控制
  • CP0:中断控制相关寄存器

这里有一条经验:先写单周期版本,再改成流水线版本。单周期版本只需要把数据通路串起来,不含流水线寄存器,时序简单;跑通单周期后,再逐级插入流水线寄存器、加上冒险单元,调试时每次只引入一个变量,不会出现“不知道是译码错还是流水错”的玄学问题。

2. 五级流水线寄存器与控制信号传递

2.1 每一级的职责与边界

五级流水线的划分几乎是标准答案:IF取指,ID译码并读寄存器,EX执行和判断分支,MEM访存,WB写回寄存器堆。真正的难点在于流水线寄存器里到底该锁存哪些信号,以及这些信号在哪里被消费、在哪里被忽略。

以ID/EX级为例,我看到很多初学者的第一反应是“把整个控制器输出都锁存过去”,这样做不是不行,但会把流水线寄存器的位宽撑得很大。更好的思路是:每个流水线寄存器只保存本级之后仍然需要的控制信号。比如RegWrite只在WB阶段用,所以它要从ID/EX一路传到MEM/WB;而MemRead在MEM阶段用,传到EX/MEM就够了;AluOp在EX阶段用,只要锁存在ID/EX里。

我做的一张控制信号传递表,基本决定了每个流水线寄存器的位宽:

控制信号产生阶段消费阶段需要穿过哪几级
RegWriteIDWBID/EX、EX/MEM、MEM/WB
MemToRegIDWBID/EX、EX/MEM、MEM/WB
MemReadIDMEMID/EX、EX/MEM
MemWriteIDMEMID/EX、EX/MEM
AluOpIDEXID/EX
AluSrcIDEXID/EX
BranchIDEXID/EX

每次加信号时都要重复问自己一句:它需要活到哪一级?这个问题的答案就是流水线寄存器位宽的答案。

2.2 控制信号一致性的坑

控制信号在流水线里最大的坑不是位宽,而是“某一条信号在某级忘了传递”。听起来低级,实际发生频率极高,尤其当你在EX级加了一个新信号、MEM级又加了一个新信号之后,很容易在某个分支条件下漏掉对旧信号的透传。

这类bug的典型表现是:单周期仿真全对,流水线仿真跑前几条指令正常,跑到写回访存指令时寄存器堆突然没写入。排查方法没有捷径,只能打开波形图,把各级流水线寄存器的RegWrite信号拉出来,和对应指令的预期值一格一格对。我后来学乖了,在顶层文件里加了一堆wire,把这些“跨级信号”全部引到波形里,分组命名,一开仿真就能看见。

2.3 数据有效位是后续调试的救命稻草

除了控制信号,每个流水线寄存器我还多加了一个valid位。这个位表示当前级是否有一条真正有效的指令在流动。常规设计里,插入气泡时会把控制信号清零,但从经验上看,只清控制信号不够,因为后面做数据转发时,需要知道“EX/MEM级现在的结果是不是无效气泡带来的脏结果”。

valid位之后,转发逻辑和写回逻辑都多了一个判断条件:valid && regwrite,避免把一条已经作废的指令结果写进寄存器堆。这个设计在我后面处理分支冲刷和中断嵌套时帮了大忙,因为冲刷指令后流水线里会出现大量空泡,没有valid位的话,这些空泡非常容易伪装成有效结果把CPU状态带偏。

3. 冒险处理的组合拳:什么时候插气泡,什么时候做重定向

3.1 数据冒险:能转发就别停顿

数据冒险是三种冒险里最“温和”的,因为大部分情况可以通过重定向解决。重定向的基本思路是:不等到结果写回寄存器堆,而是直接从EX/MEM或MEM/WB流水线寄存器的输出端,把结果送到ID/EX级的ALU输入前。

转发逻辑需要两个比较器组。第一组判断EX/MEM级的结果能不能转发给当前ALU的rs输入:ex_mem_regwrite && ex_mem_rd != 0 && ex_mem_rd == id_ex_rs;第二组判断MEM/WB级的结果能不能转发:mem_wb_regwrite && mem_wb_rd != 0 && mem_wb_rd == id_ex_rs。两组同时命中的时候要注意优先级——EX/MEM是更新鲜的数据,所以同一条指令同时被两级转发时,必须让EX/MEM级赢。

当时的转发条件里还压着一个反直觉的细节:比较目标寄存器前先判非零。MIPS里rd=0的寄存器是硬件写死的零寄存器,写零寄存器没有副作用,转发过去反而会让后续指令误以为零寄存器被污染,进而破坏整个数据通路。

3.2 load-use冒险:唯一必须停顿的场景

转发不是万能的。上一条指令是lw,下一条指令立刻要用lw的访存结果,此时结果只存在于MEM级,而ALU在EX级就要用,怎么转发都来不及。唯一的解法是插入一个气泡,让load结果在MEM/WB级落脚,再转发给下一条指令。

气泡插入的实现是“局部冻结+整体冲刷”:检测到load-use冲突时,把IF/ID级寄存器保持不动,让已经取出的那条指令继续在ID级等待;同时把ID/EX级控制信号全部清零,防止还没读到正确数据的指令往下执行。很多人容易漏掉的是PC也要跟着停一拍,否则IF级会继续取出一条新指令,把本来打算留给下一条指令的位置挤掉。

load-use冒险的检测条件核心就是三条:id_ex_memread有效id_ex_rd不为零id_ex_rd等于if_id_rs或if_id_rt。课程设计里大部分访存密集程序会经常触发这个停顿,所以在性能上它是最需要注意的瓶颈点。

3.3 控制冒险:分支冲刷与静态预测

分支指令beq在EX阶段比较两个寄存器的值并决定是否跳转。这意味着从IF取出分支指令到EX确定跳转方向,中间已经推进了两条指令,如果跳转真的发生,这两条指令都必须被丢弃。

实现上就是flush:当EX阶段的beq判定跳转成立时,把IF/ID和ID/EX两级流水线寄存器的valid拉低,同时把PC改为分支目标地址。如果跳转不成立,则什么都不做,跟着flow继续走。

这种“默认不跳转、跳转时才冲刷”的策略叫静态预测not taken,是课程设计能接受的方案。缺点是每条跳转指令损失最多两个周期。我当时没有做更复杂的分支预测,原因是整个CPU已经塞进了中断处理逻辑,再上预测器会让时序收敛变得麻烦。如果你后续想优化性能,最简单的一步是把这个判断提前到ID级,在ID级增加比较器,把损失压到一个周期。

3.4 数据有效位在冒险控制里的优先级

转发、气泡、冲刷三种机制同时存在时,优先级必须定清楚。我的排序是:冲刷优先级最大,气泡次之,转发最后生效。因为冲刷整条流水线会立刻清掉IF/ID和ID/EX两级的有效指令,而此时ID级正在等待的load-use气泡信号很可能指向一条已经无效的指令,如果按照load-use逻辑继续插气泡,流水线就会停在一个永远等不到结果的死循环里。

排序的代码逻辑很简单:flush直接覆盖stall,stall覆盖正常推进,转发的mux则在stall/flush都无效时才按常规比较结果输出。写的时候每一层都单独判断,不要在一条if/else链里混太多条件,不然一两个晚上后自己都读不懂。

4. 多级嵌套中断的实现细节

4.1 中断入口与现场保存

中断设计是课程设计里公认的“硬骨头”。我的实现是简化的MIPS CP0方案,核心寄存器就三个:EPC保存被中断指令的地址,Cause保存中断原因,Status的低位作为全局中断使能位。

外部中断到来时,CPU要做的第一件事是保存现场。这里有一个很容易理解错的地方:大多数课程设计的流水线CPU,采用“重新执行被中断指令”的模型,即在ID阶段检测到外部中断后,把当前正在译码的这条指令地址存入EPC,然后冲刷已经取出的其他指令,PC跳转到中断入口地址。中断处理程序返回时,回到EPC对应的指令重新执行。

这样做的好处是硬件简单,不需要支持“指令已经执行一半再恢复”的复杂状态。坏处是中断响应会有几个周期的延迟,但课程设计阶段完全可以接受。

进入中断时,通用寄存器的保存是由中断服务程序自己负责的,硬件只自动保存EPC和Cause。如果需要支持多级嵌套,中断服务程序一上来必须先把EPC和Cause从CP0寄存器搬到栈里,否则第二层中断到来时,这两个寄存器会被覆盖,第一层的返回现场就丢了。

汇编层面的现场保存典型套路是这样:

interrupt_handler: mfc0 $k0, $epc addiu $sp, $sp, -8 sw $k0, 0($sp) # 保存被打断的PC mfc0 $k1, $cause sw $k1, 4($sp) # 保存中断原因 mfc0 $k0, $status ori $k0, $k0, 1 mtc0 $k0, $status # 重新打开中断使能,允许嵌套

第一层中断处理程序打开中断使能后,新的外部中断才被允许打断当前处理流程,从而形成多级嵌套。如果不开这一句,后面的中断请求只会被硬件挂起或丢弃,不支持嵌套。

我在最开始做这个设计时犯过一个非常经典的错误:在保存现场之前就打开了中断使能。结果第二层中断立刻进来,把第一层尚未保存的EPC覆盖掉,第一层恢复现场时读到的是第二层的返回地址,程序直接跑到未知地址去了。后来我把顺序固定为“先保存EPC/Cause,再开中断使能”,对接下来的调试来说算是打好了地基。

4.2 中断与流水线交互的处理

中断请求进入流水线,不能随便找一个周期就插入。因为流水线里的指令可能在EX、MEM、WB各级正在执行,如果立刻跳转,已经算完的结果可能还没来得及写回寄存器堆,后续无法撤销。

我的做法是:在ID阶段检测到有效的中断请求后,先在ID/EX级插入一个气泡,让流水线里已有的指令继续往前推进完毕,同时把IF/ID级锁存。等流水线里所有有效指令都排空,再把PC改到中断入口地址。这种方式本质上是一种“等待指令流排空”的中断响应,比强制冲刷所有级要安全得多。

这个过程中有一个优先级问题需要提前想清楚:如果同一条指令既触发异常,又有外部中断请求,谁先响应?我的约定是异常优先。因为异常往往意味着这条指令本身执行不下去,中断可以等,异常不能等。具体到RTL里,就是在ID/EX级的控制信号加一个trap标志,trap置位时不再接受外部中断的请求,直接把EPC保存为异常指令地址,跳转到异常处理入口。

4.3 中断返回:eret指令引发的连锁反应

中断返回指令eret在流水线里也是一个控制冒险源,而且要复杂得多,因为它要跳转的目标不是固定地址,也不是分支计算出来的地址,而是CP0里的EPC寄存器的值。

我的实现是把它当成一类特殊跳转:译码时识别出eret,EX阶段将EPC值作为跳转目标传给PC,同时冲刷IF/ID和ID/EX两级。由于EPC在进入eret前已经从栈里恢复回来了,这个跳转等价于“回到被打断的指令”。

这里还有一个隐蔽细节:eret指令本身可能和上一级的load-use转发冲突。例如中断服务程序末尾有一条lw从栈恢复某寄存器,紧接着就是eret,eret并不消费通用寄存器的值,所以load-use检测不会阻塞eret。这一点容易被忽视,但一旦中断服务程序的局部数据被破坏,定位这个问题会极其痛苦。

5. 功能验证与调试:如何证明CPU真的能跑

5.1 从单周期到流水线的逐级回归

我强烈建议把验证分成两个阶段。第一个阶段是单周期CPU验证,跑几个简单测试程序:加减法、访存、分支跳转。第二个阶段才是流水线验证。

流水线阶段我采用“逐级加难度”的办法:先跑不包含数据依赖的指令序列,确认各级流水线寄存器能把指令干干净净地推下去;再加入连续依赖的算术指令,确认转发逻辑工作;再加入lw后的立即使用,确认load-use停顿唤醒;最后才是分支和中断嵌套测试程序。每一步都对照MARS的寄存器快照,发现不一致就停在这一步,不往下叠加难度。

这样做最大的好处是锁定错误范围。比如跑依赖指令时出错,问题要么在转发条件,要么在valid位,要么在寄存器堆写回时序,排查面很小。

5.2 测试程序设计的三个层次

测试程序不能只靠一条一条的原子指令。我最后用的三个测试程序现在回头看很有代表性:

第一个是求最大公约数的循环程序,里面有连续的数据依赖和分支,专打转发和分支冲刷。第二个是递归求阶乘,函数调用会频繁压栈弹栈,能验证栈指针和访存时序,顺便检查addiusubu的立即数扩展。第三个是外部中断嵌套测试:主程序死循环自增一个寄存器,定时器中断触发第一层中断,第一层中断在处理过程中再次打开中断使能,随即被一个更高频率的边沿信号触发第二层中断,第二层中断处理完再返回到第一层,最后回到主循环。

第三个测试程序让我发现两个真实问题:一是多级嵌套时,第一层中断的EPC如果保存晚了会被覆盖;二是中断返回时eret的跳转目标如果出现在EX阶段,而系统中又有分支指令在后面排队,优先级处理不好会跳错地址。这些问题靠手写真值表都不如直接在仿真里观察PC的跳变轨迹来得直观。

5.3 波形调试里的几个实战技巧

波形调试是大多数同学卡壳的地方。PC信号和流水线寄存器信号成倍增长后,肉眼盯着波形图看效率极低。我后来的调试习惯是在关键位置加$display打印,而不是只看波形:

if (reset == 1'b0 && valid) begin $display("Cycle %0d: PC=%h reg_write=%h rd=%0d alu_result=%h", cycle, pc_out, regwrite, rd, alu_result); end

把每一条写回寄存器堆的指令打印出来,和MARS的寄存器快照逐行比对,定位速度比看波形快一个数量级。等打印数据一致了,再回到波形里去核实时序细节。

另一个技巧是给冒险单元的正确性做一个“旁路探针”:在数据通路上额外引出一组逻辑,无论是否发生转发,都同时输出“纯寄存器堆读取值”和“转发后的最终输入值”,一旦两者与MARS预期不符,立刻知道是转发选择错、还是寄存器堆读取错。

5.4 当时踩过的三个经典坑

第一个坑是分支冲刷漏了ID/EX级。beq在EX阶段判断跳转,按理说要同时冲刷IF/ID和ID/EX,但我只清了IF/ID,结果beq之后紧跟的算术指令在ID级已经读完寄存器、正要进入EX,冲刷信号到不了它,它还是照常执行了。定位时看到PC是对的、寄存器结果却是错的,一度以为是转发逻辑出错。

第二个坑是load-use停顿期间PC没冻结。load-use在ID/EX级插入气泡时,载荷是让IF/ID保持不变,同时PC也必须保持不变,否则下一拍会取入新指令,把IF/ID里的旧指令挤出去。当时只冻结了IF/ID忘了冻结PC,导致load目标被下下条指令覆盖,程序跑到第三轮循环才崩溃。

第三个坑是中断服务程序里寄存器压栈顺序对不上号。汇编层面保存现场时先压了$ra再压$s0,恢复现场时却先恢复$s0再恢复$ra,栈指针倒是正确的,但寄存器的值错位。这种问题靠MARS对照其实一眼就能看出来,因为所有寄存器快照都会在中断返回后和预期值差出一个固定偏移。

做完这个项目再回头看,流水线CPU的课程设计价值并不只在最终代码,而在于它逼着你同时处理数据通路、时序、异常三个维度的问题。那份zip里的Verilog源码,真正值钱的部分是那些用一晚上换来的报错信息和调试脚本。如果你也正在做类似的设计,我的建议是:先跑通单周期,再谈流水线;先保证转发正确,再追求性能;中断嵌套能不能做好,取决于你有没有一套能精确对照的验证手段,而不是取决于你手速有多快。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 0:20:56

Skills是什么?拆解AI编程中技能机制与开发实战

我第一次被问到“Skills是什么”时,场面有点尴尬。对方指着Claude Code里那个skills目录问我:这是不是某种AI插件?我说不完全是。他又问:那是不是一大段提示词?我说也不是。最后我只能告诉他:你可以把Skill…

作者头像 李华
网站建设 2026/9/9 0:15:13

DS1302 RTC芯片实战:时序、寄存器与PCB布线避坑指南

前几天帮朋友调一块数据采集板,MCU用的是GD32,外挂的RTC芯片是DS1302。代码是从网上移植的,能编译能下载,但读回来的时间要么全是0xFF,要么干脆分秒不进。折腾到半夜,最后发现问题出在时序上:命…

作者头像 李华
网站建设 2026/9/9 0:14:41

菜谱微信小程序开发实战:导航栏、支付与视频适配全解析

简介:这是一份面向微信小程序初学者的菜谱大全项目源码,以美食菜谱为业务场景,完整演示了从页面搭建到交互逻辑的小程序开发流程。资源共26个文件,压缩包仅19KB,主要包含6个js逻辑文件、5个wxss样式文件、4个wxml页面结…

作者头像 李华
网站建设 2026/9/9 0:03:38

DeepSeek Harness配置指南:通用设置与Agent预设实战

DeepSeek Harness 装好之后,有一段时间我很困惑:能打开界面、能聊天,但每次换任务都要重新解释一遍需求,模型回答风格忽冷忽热,多聊几轮就开始丢上下文。后来我把通用设置从头到尾捋了一遍,又用 Agent 预设…

作者头像 李华
网站建设 2026/9/9 0:03:33

开关电源环路裕量测试实战:相位裕量与增益裕量详解

1. 项目概述:为什么环路裕量测试是电子工程师绕不开的“体检项目”“从零开始的电子工程师生活(6)——环路裕量测试”,这个标题一出来,老电源工程师可能已经下意识摸了摸示波器探头,新同事则大概率在想&…

作者头像 李华