ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从零写Verilog CPU:五级流水线第一条ori指令实现详解

从零写Verilog CPU:五级流水线第一条ori指令实现详解 1. 为什么拿ori当CPU的第一条指令1.1 从零开始写CPU别一上来就啃复杂指令我一直觉得想真正理解计算机组成原理光看书是远远不够的。书上的数据通路图画得再漂亮寄存器传输级RTL代码一写就原形毕露。所以这个系列我打算带着大家从零开始用Verilog手写一个支持五级流水线的简易CPU而第一课就一句话让CPU成功跑通第一条指令ori。为什么选ori而不是add、lw这些更“常见”的指令原因很朴素ori是一条典型的I型指令它的操作数里同时包含寄存器、立即数和目标寄存器能够一次性把“读寄存器、扩展立即数、运算、写寄存器”这四个CPU最核心的动作全走一遍。更重要的是ori不访存、不写内存天然避开了流水线里最麻烦的load-use冒险非常适合当第一块敲门砖。先把这条指令从取指到写回的整个数据通路跑通后面再扩展lw、sw、beq、jal的时候你心里就有了一张清晰的地图。顺带交代一下项目环境。我没用任何开发板纯Verilog iverilog仿真配合GTKWave看波形。为什么这么干因为写CPU的第一阶段最要紧的是把逻辑理清楚而不是纠结片上资源。用仿真器跑你能把每一拍的数据变化看得明明白白等逻辑无误了再上FPGA板子也不迟。1.2 ori指令的本质一条“多功能”的I型指令ori的全称是Or Immediate语义非常直白把寄存器rs的值和16位立即数做按位或运算结果写入寄存器rt。对应的MIPS指令格式长这样位域31:2625:2120:1615:0字段opcodersrtimmediateori的opcode是6b001101rs和rt都是5位寄存器编号立即数是16位。比如ori $t0, $s1, 0x00ff含义是$t0 $s1 | 0x000000ff。这里的坑在于立即数在参与运算前要零扩展不是符号扩展。因为ori是无符号的按位或高16位补0就行。很多新手第一次写CPU会在扩展逻辑上踩坑——把{16b0, immediate}和{{16{immediate[15]}}, immediate}搞混结果算出来的数据完全不对。我在后文调试部分会专门讲这个。另外注意一个细节ori的写入目标是rt字段不是rd。这一点和R型指令比如add $rd, $rs, $rt不一样。在设计译码器的时候必须根据指令类型选择写回寄存器编号。很多教材喜欢画“写寄存器地址选择”这个多路器就是为这条规则准备的。我们这个项目里第一版只支持ori写回地址直接锁死在rt但代码结构上我会把写回地址的mux留出来方便后面扩展。2. 五级流水线到底在“流水”什么2.1 五级流水线的分工经典MIPS五级流水线就是取指IF、译码ID、执行EX、访存MEM、写回WB。每一级的核心任务如下IF根据PC从指令存储器取出32位指令同时计算PC4。ID解析指令生成控制信号读取寄存器堆。EX执行ALU运算或计算访存地址。MEM访问数据存储器读写。ori这条指令在这一级什么都不做但不代表可以跳过。WB把结果写回寄存器堆。为什么要把CPU拆成五级最直白的理由是为了提高时钟频率。假设单周期CPU完成一条指令需要10ns五级流水线每一级只需要2ns理论上时钟周期可以压到2ns吞吐量提升接近5倍。代价是引入了流水线寄存器每级边界都要锁存中间数据而且还得处理冒险。顺着这个思路我给这个五级流水线设计的数据通路里一共插了四组流水线寄存器IF/ID、ID/EX、EX/MEM、MEM/WB。每一组寄存器都存当前级计算出来的所有结果以及后面几级需要的控制信号。这个设计是CPU的骨架你把这个骨架搭对了指令就是一个数据包沿着流水线一格格往前挪。2.2 流水线寄存器里到底该存什么很多刚开始写CPU的朋友会有个疑问流水线寄存器不就是几个D触发器吗随便存一存不就行了事实远没那么简单。流水线寄存器的宽度和内容直接决定了后续每一级有没有足够的信息完成工作。拿ID/EX寄存器来说它至少要存这些内容从寄存器堆读出的rs_val、rt_val各32位立即数扩展后的imm_ext32位指令中的rt字段5位这是写回地址控制信号ALUOp、ALUSrc、RegWrite、MemWrite、MemRead、MemToReg等为什么要存rt字段因为到WB级需要一个寄存器地址来写回。为什么不直接在ID级写入寄存器堆因为流水线里指令是并行的当前指令在EX级运算时后面的指令可能已经在ID级读取寄存器了写回必须安排在WB级才能保证顺序不会乱。这里我给出一个常见的简化控制信号表ori这条指令的控制信号取值如下控制信号取值含义RegWrite1需要写寄存器堆ALUSrc1ALU第二个操作数来自立即数ALUOp2b01执行按位或运算MemWrite0不写数据存储器MemRead0不读数据存储器MemToReg0写回数据来自ALU结果Branch0不是分支指令译码器输出这一堆信号的组合就是CPU的“决策大脑”。等后续加入lw、sw、beq时这个表格会越拉越长但核心思路不变控制信号跟随数据包一起向后传递每一级只用自己需要的信号。3. 具体实现用Verilog把流水线搭出来3.1 顶层模块的端口设计我习惯先定清顶层模块的对外接口再一层层往里填。一个最简CPU的端口如下module cpu( input wire clk, input wire rst_n, output wire [31:0] debug_pc, output wire [31:0] debug_wb_data );debug_pc和debug_wb_data是给testbench看的调试端口方便在仿真时直接观测当前跑到哪条指令、写回的数据是什么。等以后接串口或者JTAG调试器这组信号也可以复用。内部我会拆成五个子模块pc_reg、instruction_mem、regfile、alu、data_mem再配合流水线寄存器模块if_id_reg、id_ex_reg、ex_mem_reg、mem_wb_reg。这种模块划分方式有一个好处每个模块的职责极其单一出bug时定位非常快。3.2 取指阶段PC和指令存储器的坑PC寄存器在时钟上升沿更新复位后清零。因为我们还没有支持分支跳转PC就是每个周期加4。always (posedge clk or negedge rst_n) begin if (!rst_n) pc 32h00000000; else pc pc 32d4; end简单得让人怀疑人生对吧但后面加跳转指令时你会在pc_next上接一个多路器这里先留个位置。指令存储器用只读存储来实现。因为ori是一条纯计算指令我不需要数据存储器参与但为了后面扩展还是把data_mem也搭了出来。指令存储器的Verilog写法如下reg [31:0] instr_mem [0:1023]; assign instr instr_mem[pc[11:2]];这里的pc[11:2]是字地址转换。PC是字节地址而数组每个元素是4字节所以要除以4。很多人第一次写这里会直接用pc当索引结果仿真时读出乱码原因就是没有做地址对齐转换。pc[11:2]取的是PC的第2到第11位等价于pc 2的低10位。测试程序的存放我用了$readmemh从hex文件加载。这样改指令只需要改文件不用重新编译整个工程非常省事。比如我想让CPU连续执行三条ori指令ori $1, $0, 0x00ff ori $2, $1, 0x0f00 ori $3, $2, 0x00f0对应的机器码需要自己算。这里就体现出“理解指令格式”的重要性了。我写了个小脚本帮我把汇编转成hex但如果你的环境不支持也可以手算后面我会说具体怎么算。3.3 译码阶段把ori的控制信号拆出来ID级的核心是寄存器堆和多路控制信号。寄存器堆用异步读、同步写的风格实现reg [31:0] regs [0:31]; assign rs_val regs[rs]; assign rt_val regs[rt]; always (posedge clk) begin if (RegWrite wb_addr ! 5b0) regs[wb_addr] wb_data; end这里有个约定俗成的细节寄存器0恒为0写操作要屏蔽。不然某些程序把0号寄存器改了整个CPU都会行为异常。译码器本质上是一个真值表。对于ori指令我会生成如下信号wire [5:0] opcode instr[31:26]; wire [4:0] rs instr[25:21]; wire [4:0] rt instr[20:16]; wire [15:0] imm instr[15:0]; assign zero_ext_imm {16b0, imm};zero_ext_imm就是立即数零扩展后的32位值。注意一定要用零扩展这是ori和addi符号扩展的本质区别。我还会在ID级插入一个简单的冒险检测单元。虽然ori指令本身不会产生load-use冒险但这个模块的存在是为了后续lw指令接入时不用大改。冒险检测的核心逻辑很直白如果ID级当前指令的rs或rt等于MEM/WB级写回寄存器地址且MEM/WB级RegWrite有效那么当前指令的读数据就要从旁路获取不能直接读寄存器堆。具体怎么实现我在第4小节会展开。3.4 执行阶段ALU如何计算oriEX级做的事情是根据ALUSrc选择ALU第二个操作数然后交给ALU计算。对ori来说ALUSrc恒为1所以wire [31:0] alu_src2 ALUSrc ? zero_ext_imm : rt_val; wire [31:0] alu_result; alu u_alu( .a(rs_val), .b(alu_src2), .op(ALUOp), .result(alu_result) );这里我设计了ALUOp为2位目前只需要两种操作加减和按位或。为了方便后续扩展我把ALUOp定义成2位编码00表示加法01表示按位或10表示减法11保留。实际项目里很多教材用4位ALUOp配合funct字段那是为了支持R型指令。我们循序渐进先用2位足够。ALU的Verilog实现module alu( input [31:0] a, input [31:0] b, input [1:0] op, output reg [31:0] result ); always (*) begin case(op) 2b00: result a b; 2b01: result a | b; 2b10: result a - b; default: result 32b0; endcase end endmodule你没看错核心就这一个case。CPU的运算单元本质上就是个多路选择器加加法器。按位或甚至不需要加法器但我在这个ALU里保留了加减法因为后续addi、beq都需要。3.5 访存与写回为什么ori可以“跳过”MEM级ori不需要访问数据存储器那为什么流水线里还有MEM级答案是为了数据通路的一致性。你不可能为每一条指令单独搭一条硬件路径那样电路规模会爆炸。统一用五级流水线ori这条指令在MEM级什么都不做只是把ALU结果原封不动传下去。在实际代码里EX/MEM寄存器从ALU结果和rs_val里取数但因为MemRead和MemWrite都是0数据存储器不会发生读写。写回级根据MemToReg选择写回数据源——ori的MemToReg0所以选择ALU结果。清楚一点说访存级的通用逻辑是wire [31:0] mem_read_data data_mem[alu_result[9:2]]; assign mem_wb_data MemToReg ? mem_read_data : alu_result;这样后续实现lw指令时只需要把MemRead置1、MemToReg置1数据通路完全不用改。这就是硬件模块化的好处。4. ori指令的完整旅程从取指到写回4.1 用三步追踪法把指令跑通理论讲再多不如亲手track一条指令。我拿ori $1, $0, 0x00ff举例。这条指令的机器码是多少根据格式opcode001101rs00000$0rt00001$1immediate00000000111111110x00ff。拼起来就是001101 00000 00001 0000000011111111换算成十六进制是0x340100ff。把它写到指令存储器的0号地址。流水线启动后第一个周期IF级取出这条指令PC变成0x00000004。第二个周期ID级解析出控制信号RegWrite1ALUSrc1ALUOp2b01。此时寄存器堆读出$0的值为0。第三个周期EX级ALU执行0 | 0xff结果为0xff。第四个周期MEM级无动作。第五个周期WB级把0xff写入寄存器$1。这条指令总共花5个时钟周期但之后的指令每个周期都能完成一条这就是流水线的威力。如果想验证你可以让testbench连续执行三条ori观察每个周期的数据。4.2 仿真波形怎么看很多人写完代码仿真的第一反应是“波形好乱不知道对不对”。我的习惯是只关注三个关键信号pc、每条指令的写回地址和写回数据以及流水线寄存器的输出。在testbench里我会这样写always (posedge clk) begin $display(pc%h, ID/EX_rs%h, ID/EX_rt%h, ALUout%h, WB_addr%d, WB_data%h, pc, id_ex_rs, id_ex_rt, ex_mem_aluout, wb_addr, wb_data); end打印出来的结果每周期一行一目了然。比如你希望看到PC0x00000000时ID级已经是第一条ori然后PC0x00000004时ID级是第二条EX级是第一条。如果流水线寄存器设计正确这个数据就是依次错位的。用GTKWave看波形时重点看流水线寄存器使能信号和复位信号。我常犯的一个错是忘了把所有流水线寄存器接到全局复位上导致仿真一开始出现大量X态。处理器设计里X态是会传播的必须从源头消灭。5. 新手最容易踩的坑5.1 组合逻辑和时序逻辑分不清这是写CPU时最典型的问题。一个简单的判断标准always块里时钟沿触发的是时序逻辑纯always (*)或assign的是组合逻辑。比如寄存器堆的读操作要用组合逻辑写操作要用时序逻辑PC更新要用时序逻辑指令存储器的读要用组合逻辑。我曾经踩过一个坑把PC更新写成了时序组合混合的always块导致PC在每个周期跳了两个值。后来我把PC更新单独拆成一个always块问题立刻消失。所以我的建议是每个always块只干一件事时序归时序组合归组合别图省事混在一起。5.2 立即数扩展ori必须零扩展这个坑我在前面提过但值得单独拿出来说。MIPS的addi、lw等指令是符号扩展立即数而ori是零扩展。如果把ori的立即数用成符号扩展那么任何立即数最高位为1的ori计算都会出错。举个例子ori $1, $0, 0x8000正确结果应该是0x00008000但如果你用了符号扩展ALU会拿0xffff8000去或结果变成0xffff8000。排查这类问题的方法很简单在testbench里打印zero_ext_imm的值检查高16位是不是全0。5.3 写回地址搞错ori写入的是rt不是rd我在设计ID级时写回了地址选择器。如果是R型指令写回rd如果是ori写回rt。很多初学版CPU只写了rd一旦加入ori就发现寄存器值乱跳。这里的关键是写回地址来自ID/EX寄存器里的rt字段而不是来自INSTR。因为到WB级时译码级早已过去不可能再去取原始指令的rt位必须在ID/EX边界把rt锁存好。5.4 没有对流水线寄存器使能控制第五个容易踩的坑是流水线寄存器每个周期都无条件写入。这在指令按序执行时没问题但遇到冒险需要暂停流水线时缺少使能字段的寄存器会把无效数据传下去造成结果全错。所以我在设计初期就给每个流水线寄存器加了valid位当检测到险情时拉低valid后续级就忽略无效数据。ori指令用不到这个机制但后面实现lw时你肯定会感谢这个设计。6. 调试技巧让CPU自己“说话”6.1 用$display打印关键信号没有硬件调试器的时候testbench就是你的眼睛。我习惯在关键节点打印信息比如指令执行到哪一级、寄存器堆写了什么。有一个小技巧打印寄存器写回操作时顺带打印时间戳这样可以精确定位是第几个周期出现问题。always (posedge clk) begin if (RegWrite wb_addr ! 0) $display([%0t] WB: $%0d %h, $time, wb_addr, wb_data); end$time会输出当前仿真时间配合信号值很容易看出是哪一拍出了偏差。6.2 用波形和打印信息双轨验证只看打印信息有一个盲区它只显示你想显示的信号而波形能看到所有信号。我通常的做法是先用打印信息确认PC、写回地址、写回数据有没有大方向错误再用波形检查具体某一条数据通路的细节比如ID/EX寄存器的rs_val是否正确通过前递逻辑。6.3 从单条指令扩展到多条指令第一版只支持ori跑通了之后我建议立刻扩展三条指令addi、lw、sw。加addi只需要修改译码器和ALUOp加lw需要打通MEM级和写回选择加sw则需要用到寄存器rt的值写入内存。每加一条指令就把之前设计的控制信号表更新一遍这样你的CPU会进入一个良性迭代阶段。我实际测试下来从“支持ori”到“支持ori/addi/lw/sw”也就多花了半天时间因为数据通路骨架已经稳了剩下的只是填表问题。等你再往后加beq、jal的时候你会发现冒险处理的复杂度陡增但那是后面几章的故事了。7. 写在最后的一点体会自己动手写CPU这件事听起来门槛很高但当你从一条ori开始看着它五级流水线一步一步走完原理课上的那些抽象概念就都变成了看得见摸得着的信号。我个人最大的感受是比起整天刷题背概念真正写出一条指令并仿真通过对计算机体系结构的理解提升要快得多。如果你也是刚接触这块别急着追求支持多少条指令先把ori这条指令在流水线上的每一步都吃透后面的路会顺很多。最后分享一个实操小技巧保存一份“最小可用版”的Verilog文件每次改了实验性质的内容搞坏了工程就回到那份最小版本重新开始。这个习惯帮我省了不知道多少时间也推荐你试试。
返回列表