尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

自己动手写CPU:五级流水线与ori指令的设计实战

发布时间:2026/9/29 4:45:15

资讯中心
01
ARTICLE

自己动手写CPU:五级流水线与ori指令的设计实战

自己动手写CPU:五级流水线与ori指令的设计实战
1. 自己动手写CPU的第一课为什么选五级流水线为什么选ori写CPU这件事听起来像是芯片公司资深工程师的活儿离普通人很远。但如果你真的在学计算机体系结构或者对底层硬件有执念你会发现自己动手写一个CPU其实是一条特别值得走的路。它不是让你去流片、去做物理设计而是在逻辑层面用硬件描述语言比如Verilog把CPU的核心骨架搭出来让它能真正跑指令、算数据、控制流程。我最早有这个念头是因为看了《自己动手写CPU》这本书。书里用MIPS指令集手把手带读者从零写一个可综合的CPU并且跑在FPGA上验证。这本书的开篇第一课就是今天要聊的内容五级流水线CPU架构以及CPU的第一条指令ori即或立即数指令。为什么第一课是ori而不是更经典的add、sub或者lw/sw这个选择背后其实藏着很多讲究。ori这条指令逻辑极其简单不需要访存、不需要写回复杂结果只做一次位或运算非常适合作为流水线的Hello World。它让你先跑通整条流水线的通路——取指、译码、执行、访存、写回——然后再往里面加复杂指令这样每一步的调试范围都被控制得很小。这篇文章适合谁两类人最有收获一是正在学计算机组成原理、体系结构课程想动手验证课本知识的学生二是想入行IC设计、数字逻辑验证方向想积累一个能跑通项目的求职者。文章里我会把五级流水线的设计思路、ori指令的数据通路、Verilog实现细节、以及我踩过的坑都交代清楚。2. 五级流水线的底层逻辑以及为什么它是最适教学的架构2.1 五级流水线到底把一条指令拆成了什么流水线的核心思想是让多条指令的不同阶段并行处理。就像一家餐厅如果只有一个厨师从洗菜、切菜到炒菜全部一个人干一桌菜做完才接下一桌效率极低。流水线就好比把厨房分成配菜区、切菜区、炒菜区、装盘区多个订单同时在不同区流转虽然单个订单的完成时间没变短但整体出菜速度大幅提升。经典的MIPS五级流水线把每条指令拆成五个阶段IF取指根据程序计数器PC的值从指令存储器中取出当前指令同时计算PC4为取下一条指令做准备。ID译码解析指令读出寄存器堆中需要的源操作数并把立即数扩展成32位。EX执行执行ALU运算。对ori来说就是执行寄存器 | 立即数的按位或操作。MEM访存如果需要访问数据存储器在这个阶段完成。ori不访存但这条通路必须预留。WB写回把执行结果写回寄存器堆的目标寄存器。这五个阶段之间靠流水线寄存器IF/ID、ID/EX、EX/MEM、MEM/WB隔开。每一级寄存器的功能就是把上一阶段的结果锁存住并在下一个时钟沿传给下一阶段。这也是为什么流水线CPU能在一个时钟周期内同时让五条不同的指令处于不同阶段——它们各自占着一条流水线级互不干扰。2.2 为什么不用单周期、多周期一上来就搞流水线很多人学过单周期CPU一条指令跑完整个时钟周期所有部件只为一个指令服务控制逻辑简单但时钟频率上不去因为最慢的那条指令决定了整体周期长度。多周期CPU把指令拆成多个步骤提高了部件复用率但控制逻辑变复杂而且状态机切换有额外开销。五级流水线是这两个方案之间的甜点。它在保持数据通路相对规整的同时用流水线寄存器做切分让每一级逻辑深度都差不多时钟频率可以拉得比较高并且能直观展示指令级并行的概念。虽然现代高性能CPU的流水线早就不是五级了有的高达十几级甚至二十多级但五级流水线把所有核心概念——冒险、转发、控制冲突、分支预测——都压缩在一个仍然能看懂的范围里教学价值极高。从工程角度看五级流水线也容易在FPGA上实现、调试。每一级是一个清晰的模块信号边界明确出了问题可以逐级查看波形定位成本很低。相比那些为了极致性能做出的乱序执行、多发射架构五级流水线是你理解现代CPU的最小完备模型。2.3 流水线带来的三个经典问题心里要提前有数流水线不是免费的午餐。它一出现就带来了三类经典问题结构冒险两条指令同时想用同一个硬件资源。比如IF阶段要取指令MEM阶段要访存如果指令存储器和数据存储器是同一个物理存储就会冲突。MIPS五级流水线通常用分离的指令存储器和数据存储器来规避。数据冒险后面的指令依赖前面指令的计算结果但结果还没写回寄存器堆后一条指令在ID阶段读到的就是旧值。控制冒险遇到分支跳转指令PC要等到MEM阶段才能确定新值在此之前流水线已经预取了好几条指令这些指令如果取错了就要被冲刷掉。五级流水线的调试难点几乎全都集中在后两者。ori指令本身不产生数据冒险——它不读访存结果也不作为分支跳转——但后续章节一旦加入lw、beq等指令冒险就会集中爆发。所以第一课用ori把基础通路跑通其实是在给后面的排险工作打地基。3. ori指令详解一条最不起眼指令背后的完整数据通路3.1 ori的指令格式与语义ori的全称是Or Immediate即寄存器与立即数按位或。它的指令格式属于MIPS的I型指令字段位数含义opcode31:26操作码ori的opcode固定为0x0drs25:21源寄存器1的编号rt20:16目标寄存器编号结果写回这里imm15:016位立即数指令语义是rt rs | zero_extend(imm)。注意这里的立即数扩展是零扩展不是符号扩展。因为ori是无符号按位或立即数只表示一个16位无符号值高位全部补零。一个具体的例子ori $t0, $s1, 0x00ff就是把寄存器$s1的值和0x000000ff做按位或结果存进$t0。这条指令不访问内存、不改变PC除非后面加分支延迟槽逻辑所以流水线最顺畅。3.2 ori在流水线每一级做了什么从流水线的视角ori这条指令走过五个阶段时每一级的具体行为是IF阶段PC指向指令地址指令存储器读出ori的机器码。PC写入下一个值PC4流水线寄存器IF/ID锁存指令和PC4。ID阶段控制单元根据opcode0x0d产生控制信号。关键是RegDst目标寄存器选择、ALUSrcALU第二操作数来源、ALUOp运算类型、RegWrite写寄存器使能、MemWrite写存储器使能此处为0。从寄存器堆读出rs的值立即数做零扩展成32位。EX阶段ALU的两个输入一个来自寄存器堆读出的rs值另一个是零扩展后的立即数。ALU执行按位或得到32位结果。此时判断是否产生后续指令需要的数据冒险——ori的目标寄存器就是rt。MEM阶段ori不访存所以MemWrite和MemRead都为0结果直通。WB阶段RegWrite信号为1将ALU结果写入rt寄存器。如果后续马上有指令要读rt就会出现数据冒险需要暂停或转发。3.3 为什么第一课用ori能帮你避开半个调试地狱我当年学的时候有同学一上来就急着加lw指令结果在数据冒险、访存时序、写回冲突里挣扎了好几天。后来老师一句话点醒先把一条最简单的非访存、非跳转指令跑通建立指令进、结果出的完整链路再逐步加码。ori具备三个教学友好特质不访存避开了指令存储器与数据存储器分离的设计问题也避开了加载使用冒险load-use hazard这类需要暂停流水线的复杂情况。不跳转不用处理分支预测或控制冒险PC始终顺序加4流水线里不存在取错指令要冲刷的问题。运算极简按位或的逻辑在Verilog里用一行运算符实现不需要考虑符号位、溢出、进位等细节。这三个特质决定了用ori做启动指令你只需要关注数据通路的骨架是否搭对而不需要分心处理流水线的异常工况。等数据通路完全跑通再逐步加入算术运算、访存、分支指令每一步的调试范围都很小出问题也容易定位。4. Verilog实现从顶层模块到五级流水线的细节代码4.1 顶层模块的信号与模块划分写CPU的第一步是先把顶层模块想清楚。我从书的示例出发做了一个稍作简化的可运行版本顶层模块包含PC、指令存储器、寄存器堆、ALU、控制单元以及四组流水线寄存器。顶层信号大致如下module mips_cpu( input wire clk, input wire rst, output wire [31:0] debug_pc, output wire [31:0] debug_inst, output wire [31:0] debug_wb_data );debug_pc、debug_inst、debug_wb_data是预留的调试信号方便在仿真时直接观察PC、当前指令和写回数据。实际项目里还可以加更多的调试总线但第一次跑通时这几个就够定位大部分问题了。顶层内部的主要数据流是PC经IF/ID寄存器后变成id_pc同时指令inst也锁存进来ID阶段生成控制信号读取寄存器得到reg1_data和reg2_data这些信号再经ID/EX寄存器进入EX阶段ALU计算结果为alu_result经EX/MEM寄存器MEM阶段直通后再经MEM/WB寄存器最终写回寄存器堆。4.2 指令存储器的读时序在FPGA上实现指令存储器通常用只读存储器或者BRAM实现。仿真阶段可以用$readmemh把机器码文件加载进来。reg [31:0] inst_mem [0:63]; initial begin $readmemh(inst.hex, inst_mem); end assign inst inst_mem[pc[7:2]]; // 每条指令占4字节所以PC右移2位这里有个最常见的坑地址的字节偏移。MIPS按字节寻址PC加4表示跳过一个字4字节所以用PC作为指令存储器索引时要右移2位等价于除以4。很多初学者在这里直接写inst_mem[pc]仿真时会发现读出来的指令完全不对。我用一个8位宽的PC做演示实际32位PC时同样要右移两位。4.3 控制单元的真值表控制单元负责根据opcode生成各阶段所需控制信号。对于oriopcode为8位8h0d实际是6位但Verilog里写成8位也方便比较。always (*) begin case (opcode) 6h0d: begin // ori reg_write 1b1; alu_src 1b1; // ALU第二操作数来自立即数 alu_op 2b01; // 自定义编码表示按位或 mem_write 1b0; mem_to_reg 1b0; // 结果来自ALU不来自存储器 reg_dst 1b0; // 目标寄存器是rt不是rd branch 1b0; jump 1b0; end default: begin // 其他指令后期扩展 reg_write 1b0; alu_src 1b0; alu_op 2b00; mem_write 1b0; mem_to_reg 1b0; reg_dst 1b0; branch 1b0; jump 1b0; end endcase end很多人会问ALUOp为什么要做成编码而不是直接用或还是加的单独信号这是因为后续指令越来越多加、减、与、或、比较都需要不同的ALU操作用一个ALUOp编码再在ALU模块里译码控制和扩展都比较规整。如果每个运算都拉一根信号线控制单元的输出会很快膨胀。4.4 寄存器堆与立即数零扩展寄存器堆用一个双端口读、单端口写的简单实现。两条读端口可以让ID阶段同时读出rs和rt的值。reg [31:0] reg_file [0:31]; assign reg1_data (rs 5b0) ? 32b0 : reg_file[rs]; assign reg2_data (rt 5b0) ? 32b0 : reg_file[rt]; always (posedge clk) begin if (reg_write (wb_reg_addr ! 5b0)) reg_file[wb_reg_addr] wb_reg_data; end注意MIPS的$zero寄存器恒为0所以读地址为0时直接返回0写地址为0时不写。这个细节如果不加寄存器堆在仿真时可能会因为给$zero写入非零值而出错。立即数零扩展也在这里wire [15:0] imm16 inst[15:0]; wire [31:0] imm_ext {16b0, imm16};这行代码只有一个目的把16位立即数补零扩展到32位。很多资料都会特别强调ori用零扩展、addi用符号扩展两种扩展方式如果搞混位运算结果会完全不同。4.5 流水线寄存器的写法流水线寄存器是五级流水线的骨架每组寄存器在时钟上升沿锁存上一级的输出。以IF/ID为例always (posedge clk or posedge rst) begin if (rst) begin if_id_pc 32b0; if_id_inst 32b0; end else begin if_id_pc pc_plus4; if_id_inst inst; end end其他几组寄存器也类似分别是ID/EX、EX/MEM、MEM/WB。每个寄存器里锁存的不只是数据还有该阶段用到的控制信号。控制信号跟着数据一起流经流水线这就是所谓的控制信号与数据信号同步传递。设计时一定要确保每个控制信号只在其需要的阶段有效否则会出现某条指令的写使能漏到了后面一条指令这类难查的bug。4.6 ALU模块ALU在EX阶段执行运算对于ori只需要按位或但为了后续扩展我会先写一个支持加、减、与、或、比较的通用ALU。always (*) begin case (alu_op) 2b00: alu_result src1 src2; 2b01: alu_result src1 | src2; 2b10: alu_result src1 src2; 2b11: alu_result src1 - src2; default: alu_result 32b0; endcase endalu_op由控制单元的alu_op字段译码而来。在实际调试时可以先只保留按位或其他运算留空减少出错的维度。等技术熟练了再把其余运算补上。5. 仿真验证与调试写CPU真正花时间的环节5.1 搭建最小仿真环境写代码只是第一步真正能证明CPU活了的是仿真波形。我习惯用一个极简的testbenchmodule mips_cpu_tb; reg clk; reg rst; wire [31:0] debug_pc; wire [31:0] debug_inst; wire [31:0] debug_wb_data; mips_cpu uut( .clk(clk), .rst(rst), .debug_pc(debug_pc), .debug_inst(debug_inst), .debug_wb_data(debug_wb_data) ); initial begin clk 0; forever #5 clk ~clk; // 10ns时钟周期 end initial begin rst 1; #20; rst 0; #200; $finish; end endmodule仿真运行完重点看几个信号复位释放后PC是否从0开始、是否每个周期加4、指令存储器读出的指令是否正确、若干周期后写回数据是否等于预期值。5.2 我的第一个能跑测试程序为了验证ori我写了一段最简单的汇编ori $1, $0, 0xff # $1 0x000000ff ori $2, $1, 0x0f00 # $2 0x00000fff ori $3, $2, 0x00f0 # $3 0x00000fff这段程序特意让后面两条指令依赖前一条的目标寄存器目的是尽早暴露数据冒险。如果我的实现没有做数据转发第二条指令读$1时会因为流水线还没写回而读到旧值0最终结果变成0x0f00而不是期望的0x0fff。对应的机器码用汇编器或者手算都行写成hex文件加载到指令存储器里。测试时我习惯在testbench里加一条系统任务实时打印各个周期的PC和写回值always (posedge clk) begin if (!rst) $display(PC%h, inst%h, wb_data%h, debug_pc, debug_inst, debug_wb_data); end这样每次仿真完我能在终端直接看到指令流水的结果不用全程趴在波形图上翻。5.3 数据冒险的第一次正面交锋前面提到我用ori $2, $1, 0x0f00来测试依赖关系。这个例子极有代表性它让你直观感受到流水线并行和指令依赖之间的矛盾。第1个周期第一条ori在IF阶段。第2个周期第二条ori在IF阶段第一条到ID阶段此时读rs。如果第二条ori在ID阶段读到的是旧$1而不是第一条算出来的0xff就出错了。解决办法是数据转发。在ID/EX和EX/MEM、MEM/WB之间增加转发路径当EX阶段的ALU结果正好是后面指令ID阶段要读的寄存器时把结果直接送到EX阶段的ALU输入而不是等它走完WB写回寄存器堆再读。转发条件的核心是if (ex_mem_reg_write ex_mem_reg_addr ! 0 ex_mem_reg_addr id_ex_rs) alu_src1_forward ex_mem_alu_result;以及同理处理MEM/WB到ID/EX的转发。转发路径一旦接好本测试程序不用暂停就能正确执行。这也是MIPS五级流水线最核心的数据冒险处理手段。5.4 我踩过的三个典型坑调试CPU时犯错是常态关键是要能快速定位。我踩过的坑有不少印象最深的三个坑一PC右移位数错位。指令存储器按字存储PC按字节计数索引时要右移2位。第一次没右移读出的指令全是乱的波形上PC看起来正常但inst完全对不上。后来加了$display看到PC4时inst却不是第二条指令才反应过来。坑二控制信号没有逐级传递。设计初期我把RegWrite直接接到WB阶段而不是让它跟着流水线寄存器一路流过去。结果每条指令的写使能都提前一个周期生效后一条指令覆盖了前一条的写回。查了好久最后在时序图上逐级看控制信号才明白控制信号必须和数据一起走完整条流水线。坑三复位信号处理。复位时寄存器堆、PC、流水线寄存器全部要清零漏掉任何一个都会在复位释放后第一拍出现脏数据。一个很隐蔽的问题是如果复用BRAM作为指令存储器BRAM本身有自己的复位特性如果初始化没有处理好读取的首条指令可能是全零造成后续所有控制信号都为0仿真结果看似正常但实际CPU根本没跑起来。6. 从ori到完整MIPS指令集这个CPU后续还能怎么扩展6.1 从最简单的算术逻辑指令开始加ori跑通之后下一步自然是补齐算术和逻辑指令。建议按这种顺序扩展加法减法与、或、异或移位这些指令的运算都在ALU里改一行代码控制单元增加opcode分支即可。访存指令lw、sw引入数据存储器也带来load-use hazard需要在前面的转发逻辑上加暂停机制。分支指令beq、bne控制冒险登场需要处理跳转地址计算以及分支延迟槽或预测失败时的流水线冲刷。跳转指令j、jal、jrPC的更新逻辑更复杂调用返回需要额外支持。每加一类指令都要配套加一两个测试用例。我的原则是每个新增指令至少写三条验证指令一条普通场景、一条边界场景、一条和其他指令联合使用的场景。这样可以防止单个指令正确但组合起来管道冲突的隐性bug。6.2 数据转发、暂停、控制冲突的优先级流水线设计中最忌讳的是漫无目的地补逻辑。我建议按优先级来处理冒险问题第一优先数据转发。它能解决绝大多数寄存器数据依赖是性能损失最小的方案。第二优先暂停流水线。只有当前一条是load指令、后一条必须立即使用加载结果时才不得不暂停一个周期。第三优先分支控制。如果分支目标计算在MEM阶段那分支指令后面已经预取的两条指令都要被冲刷。可以把分支判断提前到ID阶段减少浪费的周期数。在实际设计中这三套逻辑不是独立存在的它们共用同一个流水线控制单元需要统一考虑信号优先级。比如当暂停有效时即使某条指令的数据依赖满足转发条件也要先冻结取指阶段否则指令会覆盖正在处理的数据。6.3 把这个CPU放到FPGA上跑如果你手头有FPGA开发板这是最兴奋的一步把仿真通过的CPU综合、布局布线然后下载到板子上跑流水灯或数码管。我当时用的是Xilinx Artix-7系列的板子把CPU的写回结果引到LED上用拨码开关充当部分寄存器输入按下复位键后LED按预期亮起的那一刻心里那种成就感是很直观的。上板之前有几个点要注意时钟管理开发板上的时钟频率通常是50MHz或100MHz如果CPU内部逻辑没做时序收敛可能需要用PLL分频降频。复位按钮的消抖直接用物理按键做复位容易因为抖动产生多个复位脉冲。我开始偷懒没做消抖结果按下复位后CPU的状态诡异得很加了一个简单按键消抖模块后问题解决。调试手段FPGA上可以用片上逻辑分析仪但更朴素的方案是留几个IO口观察PC、写回数据等关键信号。7. 常见问题速查表以及新手防坑指南7.1 问题速查表问题现象可能原因排查方向PC不递增一直停在0复位信号未正确释放或时钟没跑起来检查testbench复位时序、时钟极性PC正常但读出的指令始终为全0指令存储器初始化失败或地址索引错误检查$readmemh路径、PC右移位数写回数据偏大或偏小立即数扩展方式错误确认ori用零扩展addi用符号扩展后续指令读到旧值数据冒险未处理检查转发路径、EX阶段数据来源选择RegWrite信号异常控制信号未逐级传递跟踪控制信号每一级寄存器的值波形看起来正常结果却不对仿真时间不够长或复位期间已产生脏数据延长仿真时间复位期间强制清零全部寄存器7.2 写CPU必备的调试思维写CPU跟写普通软件最大的不同是你没有print可以随便插调试要借助波形、测试向量和模块化隔离。我的习惯做法是每改一个模块先单独仿真这个模块接上理想输入验证输出再放回整个CPU里跑。比如修改了ALU就先写一个ALU的独立testbench输入几组运算数和运算符检查结果是否和参考一致再进顶层。这样出了问题能很快定位到是模块自身的问题还是模块间连接的问题。还有一点机器码的生成可以手算但多了以后一定要用汇编器。手算虽然能加深对指令格式的理解但出错率太高。网上有开源的MIPS交叉汇编器把汇编代码编译出hex文件直接用$readmemh加载效率高得多。等做到后面几十条指令时手算机器码几乎是不可持续的。7.3 给新手的三个实用建议第一个建议是从小处验证不要一口气把整个CPU代码全写完再仿真。哪怕只是加了一个新控制信号也先编译、仿真一遍确认没有语法错误、波形符合预期再继续下一步。第二个建议是控制信号的命名别偷懒流水线里控制信号特别多命名如果像ctrl1、ctrl2这样调试时完全分不清是谁。我后来统一用阶段_信号名的格式比如id_reg_write、ex_alu_src这样在波形图上看到信号名就知道它属于哪一级极大节省了排查时间。第三个建议是保留一份可运行的黄金版本每次做完一个里程碑就把整个工程备份一份或者用版本管理工具打一个标签。流水线改动经常会让之前能跑的版本崩溃如果有一个稳定的基线对比改了什么导致出错会快很多。7.4 从ori第一课到真正理解CPU的设计哲学回到最开始的问题为什么要从ori开始自己写CPU因为这条指令让我第一次完整地看到一条指令在硬件上到底发生了什么。它不是抽象的概念而是一根根信号线、一个个触发器、一组组控制信号实实在在演出的过程。这门课的独特之处在于知识本身是高度结构化的但每个阶段的调试又非常考验耐心和系统思维。我的体会是写CPU最大的收获不是最后拿到了一个能跑的Verilog工程而是在这个过程中慢慢建立了对计算机系统自底向上的直觉。你开始明白软件里的每条指令在硬件层面都要经历取指、译码、执行、访存、写回这五步你开始明白为什么编译器要做指令调度——因为它在试图减少硬件层面的流水线气泡你也开始明白为什么有些代码在某些CPU上跑得快、在另一些CPU上跑得慢因为流水线的设计和冒险处理策略完全不同。这种理解是读多少本书都换不来的。如果你也想试试建议从今天这条ori开始把它跑通然后告诉自己下一步是让这个CPU执行一百条、一千条不同的指令。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。