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型指令:
| 字段 | 位数 | 含义 |
|---|---|---|
| opcode | 31:26 | 操作码,ori的opcode固定为0x0d |
| rs | 25:21 | 源寄存器1的编号 |
| rt | 20:16 | 目标寄存器编号,结果写回这里 |
| imm | 15:0 | 16位立即数 |
指令语义是: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、当前指令和写回数据。实际项目里还可以加更多的调试总线,但第一次跑通时,这几个就够定位大部分问题了。
顶层内部的主要数据流是:
- 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生成各阶段所需控制信号。对于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 endalu_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执行一百条、一千条不同的指令。