☰
自己动手写CPU:五级流水线与ori指令的设计实战
2026/9/29 4:45:09 网站建设 项目流程

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的值,从指令存储器中取出当前指令,同时计算PC+4,为取下一条指令做准备。
  • 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固定为0x0d
rs25: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写入下一个值PC+4,流水线寄存器IF/ID锁存指令和PC+4。
  • ID阶段:控制单元根据opcode=0x0d产生控制信号。关键是RegDst(目标寄存器选择)、ALUSrc(ALU第二操作数来源)、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、当前指令和写回数据。实际项目里还可以加更多的调试总线,但第一次跑通时,这几个就够定位大部分问题了。

顶层内部的主要数据流是:

  1. PC经IF/ID寄存器后变成id_pc,同时指令inst也锁存进来;
  2. ID阶段生成控制信号,读取寄存器,得到reg1_data和reg2_data;
  3. 这些信号再经ID/EX寄存器进入EX阶段,ALU计算结果为alu_result;
  4. 经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生成各阶段所需控制信号。对于ori,opcode为8位8'h0d(实际是6位,但Verilog里写成8位也方便比较)。

always @(*) begin case (opcode) 6'h0d: begin // ori reg_write = 1'b1; alu_src = 1'b1; // ALU第二操作数来自立即数 alu_op = 2'b01; // 自定义编码,表示按位或 mem_write = 1'b0; mem_to_reg = 1'b0; // 结果来自ALU,不来自存储器 reg_dst = 1'b0; // 目标寄存器是rt,不是rd branch = 1'b0; jump = 1'b0; end default: begin // 其他指令后期扩展 reg_write = 1'b0; alu_src = 1'b0; alu_op = 2'b00; mem_write = 1'b0; mem_to_reg = 1'b0; reg_dst = 1'b0; branch = 1'b0; jump = 1'b0; end endcase end

很多人会问:ALUOp为什么要做成编码,而不是直接用"或"还是"加"的单独信号?这是因为后续指令越来越多,加、减、与、或、比较都需要不同的ALU操作,用一个ALUOp编码再在ALU模块里译码,控制和扩展都比较规整。如果每个运算都拉一根信号线,控制单元的输出会很快膨胀。

4.4 寄存器堆与立即数零扩展

寄存器堆用一个双端口读、单端口写的简单实现。两条读端口可以让ID阶段同时读出rs和rt的值。

reg [31:0] reg_file [0:31]; assign reg1_data = (rs == 5'b0) ? 32'b0 : reg_file[rs]; assign reg2_data = (rt == 5'b0) ? 32'b0 : reg_file[rt]; always @(posedge clk) begin if (reg_write && (wb_reg_addr != 5'b0)) 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 = {16'b0, imm16};

这行代码只有一个目的:把16位立即数补零扩展到32位。很多资料都会特别强调,ori用零扩展、addi用符号扩展,两种扩展方式如果搞混,位运算结果会完全不同。

4.5 流水线寄存器的写法

流水线寄存器是五级流水线的骨架,每组寄存器在时钟上升沿锁存上一级的输出。以IF/ID为例:

always @(posedge clk or posedge rst) begin if (rst) begin if_id_pc <= 32'b0; if_id_inst <= 32'b0; 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) 2'b00: alu_result = src1 + src2; 2'b01: alu_result = src1 | src2; 2'b10: alu_result = src1 & src2; 2'b11: alu_result = src1 - src2; default: alu_result = 32'b0; endcase end

alu_op由控制单元的alu_op字段译码而来。在实际调试时,可以先只保留按位或,其他运算留空,减少出错的维度。等技术熟练了,再把其余运算补上。

5. 仿真验证与调试:写CPU真正花时间的环节

5.1 搭建最小仿真环境

写代码只是第一步,真正能证明CPU"活了"的,是仿真波形。我习惯用一个极简的testbench:

module 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,看到PC=4时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、jr:PC的更新逻辑更复杂,调用返回需要额外支持。

每加一类指令,都要配套加一两个测试用例。我的原则是:每个新增指令至少写三条验证指令,一条普通场景、一条边界场景、一条和其他指令联合使用的场景。这样可以防止"单个指令正确,但组合起来管道冲突"的隐性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执行一百条、一千条不同的指令。

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

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

立即咨询