资讯动态

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

发布时间:2026/9/9 0:23:54 来源:尧图企业网站定制
简介面向华中科技大学计算机组成原理课程设计的Verilog CPU流水线源码包完整实现流水线分段、插入气泡、重定向及多级嵌套中断等功能适合计算机专业学生、课程设计团队及硬件入门者参考。包内共345个文件大小31.39MB除25个Verilog源文件外还包含综合/布局布线后的dcp文件、可烧写的bit比特流、时序报告rpt/log、工程约束xdc以及用于自动化构建的bat/tcl脚本可快速理解从源码到比特流的完整工程结构。已有1939人学习下载。读者可在此工程基础上对照数据冒险、控制冒险的典型处理方式掌握气泡插入与转发重定向的具体代码实现并通过多级嵌套中断示例深化对异常响应与现场保护机制的认识整套设计从源码、约束到时序报告均有留存可作为课程设计报告撰写和上板调试的直接依据。 那年做计算机组成原理课程设计我选了流水线CPU这个方向。前前后后写了将近两千行Verilog最后交付的工程文件打包出来文件名就是“cpu流水 verilog源码 功能包括流水、插入气泡、重定向、多级嵌套中断”。回头再看这门课设计最有价值的不是把代码跑通而是把“流水线冒险”“中断嵌套”这些教科书上几页纸的概念变成了你一眼就能看穿的时序图。这篇帖子想把我的设计思路、踩过的坑和调试方法完整写出来给正在被流水线CPU折磨的同学一个参考。1. 课程设计的起点为什么是流水线CPU1.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输出指令和PC4RF寄存器堆双读口一写口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/WBMemToRegIDWBID/EX、EX/MEM、MEM/WBMemReadIDMEMID/EX、EX/MEMMemWriteIDMEMID/EX、EX/MEMAluOpIDEXID/EXAluSrcIDEXID/EXBranchIDEXID/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里rd0的寄存器是硬件写死的零寄存器写零寄存器没有副作用转发过去反而会让后续指令误以为零寄存器被污染进而破坏整个数据通路。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直接覆盖stallstall覆盖正常推进转发的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寄存器的值。我的实现是把它当成一类特殊跳转译码时识别出eretEX阶段将EPC值作为跳转目标传给PC同时冲刷IF/ID和ID/EX两级。由于EPC在进入eret前已经从栈里恢复回来了这个跳转等价于“回到被打断的指令”。这里还有一个隐蔽细节eret指令本身可能和上一级的load-use转发冲突。例如中断服务程序末尾有一条lw从栈恢复某寄存器紧接着就是ereteret并不消费通用寄存器的值所以load-use检测不会阻塞eret。这一点容易被忽视但一旦中断服务程序的局部数据被破坏定位这个问题会极其痛苦。5. 功能验证与调试如何证明CPU真的能跑5.1 从单周期到流水线的逐级回归我强烈建议把验证分成两个阶段。第一个阶段是单周期CPU验证跑几个简单测试程序加减法、访存、分支跳转。第二个阶段才是流水线验证。流水线阶段我采用“逐级加难度”的办法先跑不包含数据依赖的指令序列确认各级流水线寄存器能把指令干干净净地推下去再加入连续依赖的算术指令确认转发逻辑工作再加入lw后的立即使用确认load-use停顿唤醒最后才是分支和中断嵌套测试程序。每一步都对照MARS的寄存器快照发现不一致就停在这一步不往下叠加难度。这样做最大的好处是锁定错误范围。比如跑依赖指令时出错问题要么在转发条件要么在valid位要么在寄存器堆写回时序排查面很小。5.2 测试程序设计的三个层次测试程序不能只靠一条一条的原子指令。我最后用的三个测试程序现在回头看很有代表性第一个是求最大公约数的循环程序里面有连续的数据依赖和分支专打转发和分支冲刷。第二个是递归求阶乘函数调用会频繁压栈弹栈能验证栈指针和访存时序顺便检查addiu和subu的立即数扩展。第三个是外部中断嵌套测试主程序死循环自增一个寄存器定时器中断触发第一层中断第一层中断在处理过程中再次打开中断使能随即被一个更高频率的边沿信号触发第二层中断第二层中断处理完再返回到第一层最后回到主循环。第三个测试程序让我发现两个真实问题一是多级嵌套时第一层中断的EPC如果保存晚了会被覆盖二是中断返回时eret的跳转目标如果出现在EX阶段而系统中又有分支指令在后面排队优先级处理不好会跳错地址。这些问题靠手写真值表都不如直接在仿真里观察PC的跳变轨迹来得直观。5.3 波形调试里的几个实战技巧波形调试是大多数同学卡壳的地方。PC信号和流水线寄存器信号成倍增长后肉眼盯着波形图看效率极低。我后来的调试习惯是在关键位置加$display打印而不是只看波形if (reset 1b0 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源码真正值钱的部分是那些用一晚上换来的报错信息和调试脚本。如果你也正在做类似的设计我的建议是先跑通单周期再谈流水线先保证转发正确再追求性能中断嵌套能不能做好取决于你有没有一套能精确对照的验证手段而不是取决于你手速有多快。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价