ARTICLE DETAIL

资讯详情

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

RISC-V五级流水线CPU设计:从Verilog RTL到数据冒险处理的完整实践

RISC-V五级流水线CPU设计:从Verilog RTL到数据冒险处理的完整实践 五级流水线CPU大概是每个做数字逻辑的人绕不开的一课。我最早是在教材里看MIPS的五级流水线总觉得不过如此直到自己从零开始用Verilog写了RV32I的完整实现才发现那些隐藏的坑远比章节标题多。RISC-V这几年在数字IC、FPGA、嵌入式的圈子越来越常见指令集开放、结构干净、教材例子也多比起MIPS更适合拿来动手做CPU。这篇东西就把我在设计过程中踩过的坑、重点要拆解的冒险处理细节、RTL实现和验证流程一次性整理出来适合正在做课程设计、毕业设计或者单纯想搞懂流水线CPU到底怎么回事的同学参考。1. 整体设计思路为什么是RV32I为什么是五级1.1 RISC-V指令集选择我最初设计时也纠结过到底是做单周期、多周期还是直接上流水线。单周期实现最快但控制信号全都组合成一大坨时序也难看多周期每个周期做一小步控制逻辑复杂而且很多功能单元一个周期只用一次利用率很低。五级流水线介于中间思路清晰、频率能跑上去又是绝大多数教材的标准模型所以最终选了五级流水线。指令集方面RISC-V里我选了最基础的RV32I也就是基本整数指令集包含算术逻辑、load/store、分支跳转、lui/auipc这些。为什么不用MIPSMIPS的专利和教材体系确实成熟但RISC-V的指令格式更规整RV32I总共就R/I/S/B/U/J六种格式译码逻辑比MIPS那种字段分散的格式更容易写对。而且RISC-V的x0寄存器硬连线为0任何写x0的操作都不生效天然帮我们省掉不少“写0寄存器”的特殊处理。RV32I不带乘除法指令乘法是RV32M扩展的内容。我第一版设计故意没做乘法因为流水线更重要的是把基础数据通路跑通乘除法扩展属于后话。如果需要跑乘法测试可以先在汇编层面用移位和加法模拟或者后面再加上一个多周期乘法单元这个扩展点很清晰。1.2 五级流水线的划分与取舍经典的RISC-V五级流水线分成取指IF、译码ID、执行EX、访存MEM、写回WB每一级之间用流水线寄存器隔开保证每一拍每个阶段都在处理不同的指令。这种划分的依据是一条指令的生命周期里最自然的动作顺序就是“拿到指令、看懂指令、计算、访问内存、写回结果”每个动作用一个周期互不重叠。五级划分最大的收益是吞吐量。单周期CPU里一条指令要等时钟周期足够长到完成“取指译码计算访存写回”全链路才能跑下一条流水线把这条长链路切成五段每个周期都可以同时有5条指令在不同阶段推进代价是单条指令的延迟还是5拍但吞吐率理论上提高了近5倍。实际达不到5倍因为冒险处理会插入气泡、冲刷流水线但这仍然比单周期划算得多。要特别注意五级流水线不是把“单周期内做的五件事”机械拆开那么简单。每条指令在每个阶段只做一部分那么它需要的控制信号、数据、寄存器信息就必须跟着它逐级传递。很多初学朋友写代码时控制信号只在ID级产生却到了WB级才用如果没用流水线寄存器打拍写回时机就会早一拍或晚一拍整个程序就乱了。后面我会专门把每一级流水线寄存器放什么东西列清楚。2. 数据通路与模块划分2.1 全流程数据通路不借助画图工具我用文字把这个数据通路描述一遍你可以边看边在纸上画。IF级PC寄存器输出地址给指令存储器指令存储器返回32位指令同时PC4作为下一条指令地址。IF/ID流水线寄存器锁存“当前指令”和“当前PC地址”。ID级从IF/ID拿到指令后立即数扩展产生Imm寄存器堆读端口根据rs1、rs2读出两个操作数。注意这一步用的是组合逻辑读寄存器堆也就是说rs1/rs2地址一来寄存器堆的值就出来了。控制单元根据opcode和funct3/funct7生成一堆控制信号。ID/EX流水线寄存器把“读到的操作数、立即数、PC、控制信号、rd地址”全部锁存。EX级ALU根据ALU控制信号对两个操作数做运算分支跳转指令在这里判断是否跳转并计算目标地址。如果跳转就产生一个flush信号清掉前面已经取进流水线的指令。EX/MEM寄存器锁存“ALU结果、写数据、PC4、控制信号、rd地址”等。MEM级如果要写内存数据存储器根据ALU结果作为地址把EX/MEM传来的写数据写入如果要读内存读出来的数据在MEM/WB寄存器里锁存。MEM/WB寄存器锁存“要写回寄存器的数据、rd地址、RegWrite、MemtoReg等”。WB级根据MemtoReg选择写回寄存器堆的数据源ALU结果、内存读数据、PC4在上时钟沿把数据写入rd寄存器。如果你把这五段的寄存器画出来会发现“流水线寄存器就是上一级结束、下一级开始的切面”每个切面上都有一份“这条指令到目前进度为止的所有现场”。设计的时候只要保证每个现场信息都是它到达该级时真正用得到的就不会出错。2.2 流水线寄存器与逐级传递流水线寄存器容易犯的错不是“少放了一个信号”而是“不知道该在哪一级用这个信号”。我的习惯是每设计一条指令都先把它的数据路径走一遍标出哪个信号在哪个阶段产生、哪个阶段消费。举几个典型信号RegWrite在WB级用所以从ID产生后要一路锁存到MEM/WB寄存器直到WB阶段才决定是否写寄存器堆。MemWrite只在MEM级用所以只需要从ID传到EX/MEM就行MEM/WB段不需要保留它。Branch控制信号在EX级用因为分支比较在ALU或分支比较器里做但它对应的RegWrite如果这条分支指令是jal/jalr就必须跟着传递到WB级。MemtoReg在WB级用用来选“ALU结果”还是“内存读数据”还是“PC4”这个信号也要从ID一路打到MEM/WB。我见过很多实现里把EX级用到的ALUSrc一直传到MEM才用也能跑因为多传一个控制位不影响正确性但不干净。尽量按“每级只保留本级和之后各级需要的控制信号”去做后面调试波形时会省心很多。说到PC4jal指令要把“当前指令地址4”作为返回地址写回寄存器堆但是到了EX级时原始的PC已经不在PC寄存器里了。所以必须把IF/ID寄存器锁存的PC一路传给EX再在EX里算pc_cur 4把这个结果传给WB级。这个pc_cur信号非常容易被忽略我第一次写的时候忘了传导致函数调用返回地址全错。2.3 CPU与存储器的连接细节RISC-V是字节寻址lw取32位数据要求地址4字节对齐sw同理。取指时PC每次加4所以指令存储器只要按字索引就好也就是inst_mem[addr[31:2]]直接取32位数据低两位地址不用。我在课程设计里把指令存储器和数据存储器分开了这本质上是哈佛结构IF级访问指令存储器MEM级访问数据存储器两个存储体互不干扰。如果共用一个存储器那么取指和访存就会结构冒险需要插入气泡等于掉性能不如一开始就分开。实际SoC里CPU也会在取指端和数据端各挂缓存也是同样的思路。从FPGA实现角度指令ROM可以简单写成组合读模型地址输入后立刻输出指令。数据RAM我建议采用“同步写、组合读”。组合读的好处是时序容易理解MEM级时钟上升沿写入下一个阶段WB级组合侧就可以拿到内存数据并写回。若数据RAM也做成同步读读数据会晚一拍出来虽然也可以靠流水线对齐但新手容易在这个地方把数据吃到错误的位置。上板追求容量时可以把数据RAM换成真正的BRAM但先把模型跑对再换不迟。3. RTL核心实现3.1 取指级PC更新与指令存储器PC逻辑看着简单其实是跳转和暂停处理的核心交汇点。我的PC寄存器代码如下always (posedge clk) begin if (!rst_n) begin pc 32h0; end else if (stall) begin pc pc; // load-use暂停PC保持不变 end else if (jump_taken_ex) begin pc jump_target_ex; // 跳转优先 end else begin pc pc 32h4; end end跳转信号来自EX级这也就意味着ID级已经取进来的指令、IF级正在取的下一条指令在跳转发生时都作废了所以还要同时清掉IF/ID和ID/EX两级。指令存储器的组合读模型module instr_mem #(parameter AW 12) ( input [AW-1:0] addr, output [31:0] inst ); reg [31:0] mem [0:(1AW)-1]; assign inst mem[addr[AW-1:2]]; endmodule这里的addr是PCaddr[AW-1:2]是字索引。机器码加载用$readmemh(test.hex, mem)仿真环境里很方便。有件事要反复提醒如果使用同步读的BRAM实现指令存储器PC上升沿更新后BRAM也要等下一个时钟沿才输出对应指令但IF/ID寄存器也在该上升沿采样就会采到旧PC对应的指令出现整条流水线错位。解决办法要么用组合读模型要么让IF/ID寄存器的时钟比PC晚半拍后者属于复杂做法课程设计完全没必要。我最终选择组合读指令ROM范围小、零延迟、逻辑清楚。3.2 译码级立即数扩展与控制信号RV32I的立即数扩展是这个项目的第一个“硬骨头”因为每条指令的立即数字段位置不一样。我把RISC-V的六种立即数封装成六个分支千万不要图省事都直接取inst[31:20]。I型{ {20{inst[31]}}, inst[31:20] }S型{ {20{inst[31]}}, inst[31:25], inst[11:7] }B型{ {19{inst[31]}}, inst[31], inst[7], inst[30:25], inst[11:8], 1b0 }U型{ inst[31:12], 12b0 }J型{ {11{inst[31]}}, inst[31], inst[19:12], inst[20], inst[30:21], 1b0 }这里有个特别容易犯的错B型和J型立即数在指令编码里最低位并没有存因为分支和跳转目标天然要求2字节对齐实际RISC-V目标地址按2字节边界即可但RV32I常用PC偏移也是偶数。所以扩展后最低位补一个0。很多朋友会在后续分支目标计算里再手动左移一位结果地址直接翻倍这是我在调试时亲眼见过的经典Bug。控制信号这块我给一个精简真值表覆盖最常用指令指令RegWriteALUSrcMemWriteMemReadMemtoRegBranchJumpALUOpadd/sub1000000010addi1100000010lw1101010000sw0110xx0000beq/bne/blt0000xx1001jal1x001001xxjalr1100100100MemtoReg用两位表示00选ALU结果01选内存读数据10选PC4。ALUOp用两位00表示做加法用于load/store地址计算01表示分支比较10表示由funct3和funct7进一步生成ALU控制信号。所有控制信号在ID级生成后按照前面说的规则逐级打拍传递。3.3 执行级ALU与分支跳转ALU的模块不复杂关键是生成正确的控制信号。RV32I里srai和srli的区别在funct7[5]sub和add的区别也在funct7[5]所以译码到ALU控制时要把funct7一起考虑always (*) begin case (aluc) 4b0000: alu_out a b; 4b0001: alu_out a - b; 4b0010: alu_out a b; 4b0011: alu_out a | b; 4b0100: alu_out a ^ b; 4b0101: alu_out ($signed(a) $signed(b)) ? 1 : 0; 4b0110: alu_out (a b) ? 1 : 0; 4b0111: alu_out a b[4:0]; 4b1000: alu_out a b[4:0]; 4b1001: alu_out $signed(a) b[4:0]; default: alu_out 32h0; endcase zero (alu_out 32h0); end分支判断我放在EX级统一处理好处是不用为分支再搭一套前递网络因为ALU输入的a/b本来就已经接了前递后的数据。RISC-V分支条件有六种beq看zerobne看!zeroblt看有符号lessbge看!lessbltu看无符号less_ubgeu看!less_u。我在ALU里多输出了zero和两个less标志分支条件生成逻辑就能覆盖全部B型指令。跳转目标地址的计算公式为pc_ex imm_ex。注意imm已经包含最低位0所以别在EX里再移位这是整个项目我叮嘱自己最多的一句话。jal同理会把pc_cur 4写回rdjalr的目标是(rs1 imm) ~1实现时要用前递后的rs1值参与计算。3.4 访存与写回数据存储器我用了同步写、组合读的模型这样可以确保在MEM级时钟上升沿把数据写入到WB级组合侧直接读出来时序干净module data_mem #(parameter AW 12) ( input clk, input mem_write, input mem_read, input [31:0] addr, input [31:0] wdata, output reg [31:0] rdata ); reg [31:0] mem [0:(1AW)-1]; always (posedge clk) begin if (mem_write) mem[addr[AW-1:2]] wdata; end always (*) begin if (mem_read) rdata mem[addr[AW-1:2]]; else rdata 32h0; end endmodule写回级其实就是一组多路选择器写回寄存器的路径在WB级合并。MemtoReg 2b10时选择PC401时选择内存读数据00时选择ALU结果。寄存器堆写端口在时钟上升沿采样配合组合读写回的数据在下一条指令ID级组合读时立刻可见加上前递网络性能损失可以压得很低。寄存器堆里x0必须写成不可写并且组合读端口遇到addr 0直接返回0避免仿真里x0被误写。4. 流水线冒险处理4.1 数据冒险前递与暂停五级流水线真正拉开差距的就是数据冒险处理。最常见的是RAW冒险也就是下一条指令用到了上一条指令刚写寄存器的值。如果老老实实等WB级写回再让ID级读要等3拍性能很差。解决办法是前递forwarding把还在EX/MEM、MEM/WB寄存器里的计算结果直接绕回EX级的ALU输入。我实现时在EX级加了两个二选一前递选择器always (*) begin if (ex_mem_regwrite (ex_mem_rd ! 0) (ex_mem_rd idex_rs1)) forward_a 2b10; // 从EX/MEM前递 else if (mem_wb_regwrite (mem_wb_rd ! 0) (mem_wb_rd idex_rs1)) forward_a 2b01; // 从MEM/WB前递 else forward_a 2b00; end这里要注意优先级如果EX/MEM和MEM/WB同时匹配必须选择更靠近当前EX级的EX/MEM数据因为它是更新的结果。两个前递源同时命中的典型场景是连续三条指令写同一个寄存器比如addi x1, x0, 1 addi x1, x1, 1 addi x3, x1, 1不加优先级的话第三条指令可能把已经过时的值前递过来。但前递解决不了load-use冒险。lw的数据要等MEM级结束才能拿到而紧随其后的指令必须在EX级马上用数据此刻还在存储器里。唯一办法是暂停一拍先让load走完访存再让后面的指令进EX。检测条件很简单wire load_use_hazard idex_memread ((idex_rd ifid_rs1) || (idex_rd ifid_rs2));一旦命中需要PC保持不变IF/ID寄存器不变ID/EX寄存器清成气泡。这样load在MEM阶段结束后数据进入MEM/WB下一拍后续指令在EX级通过前递网络从MEM/WB取到load数据。整个流程就是“暂停一拍 前递补位”两件事缺一不可。我当时只加stall没加前递仿真里load后面的加法永远算错排查了好久。4.2 控制冒险flush与分支优化分支跳转指令在EX级才判断是否跳转所以分支跳转真正发生时IF级和ID级都已经取进了两条后续指令必须把它们清掉。我用的是一个高电平有效的flush信号同时清IF/ID和ID/EX寄存器PC跳转到目标地址。这个方案最简单代价是每次跳转损失2拍。如果想优化成损失1拍可以把分支判断提前到ID级但前提是ID级的分支比较器也要接前递网络。因为分支指令比较的rs1/rs2可能还没写回寄存器堆如果不前递分支判断会拿着旧值去比较出现严重的逻辑错误。这个改动会让整个分支比较逻辑变得相对复杂我第一次做的时候没敢动先跑通再优化。还有一个很容易被忽略的点跳到目标地址后IF/ID里的旧指令虽然被flush了但PC已经变成目标地址下个周期取的必须是目标地址的指令。所以在PC更新逻辑里跳转信号优先级高于普通PC4。实际工程上还可以做分支预测缓冲BTB、静态预测“不跳转”、动态预测“跳转”等等。五级流水线里最简单的预测就是“默认不跳转”也就是说只有分支真的跳转时才有损失这使得那2拍惩罚只发生在实际跳转的指令上。很多乱序处理器里的静态预测也是这个思路理解原理后再看高级预测器会轻松很多。4.3 结构冒险存储结构的选择与总线扩展把指令存储器和数据存储器分成两个物理存储体IF级和MEM级就能同时访问而不冲突这是五级流水线的基本配置。如果非要共用一个存储器load/store指令在MEM级访问时IF级取指就得停一拍流水线性能直接下降所以我不推荐在课程设计阶段这么做。在真正的SoC里RISC-V核通过指令端总线接口和数据端总线接口连接缓存缓存再连主存。也就是说CPU对外是双总线主设备形态指令缓存和数据缓存分离这样即使主存只有一个物理存储体也能通过cache缓解结构冒险。这部分属于进阶内容你可以先知道“为什么教材上通常默认哈佛结构”后面需要连AXI总线时再深入。还有一个容易踩的“伪结构冒险”是寄存器堆读写同周期。如果寄存器堆在同一时钟上升沿既写入又读出而读地址恰好等于写地址读出来的可能是旧值或者新值取决于实现。我的做法是寄存器堆组合读、同步写这样WB级写的值不会立刻影响当拍ID级的组合读必须等下一拍才能读到再配合前递网络保证正确性避免同拍读写不定态问题。5. 仿真验证与上板调试5.1 测试程序设计与波形检查流水线CPU的验证不能只靠一条指令至少要覆盖R型运算、立即数运算、load/store、分支跳转、load-use冒险、连续寄存器写等场景。我写了一个经典的累加程序.section .text .globl _start _start: addi x1, x0, 100 addi x2, x0, 0 loop: add x2, x2, x1 addi x1, x1, -1 bne x1, x0, loop halt: beq x0, x0, halt这个程序预期跑完后x2 5050。别小看这几行它内部包含连续两条addi立即数写寄存器、add的源寄存器正好是上一条写的结果RAW冒险、bne跳转控制冒险、自跳循环制造稳定分支。跑完仿真后检查x2的值大概率能一把抓出冒险处理的问题。汇编和机器码生成我推荐用现成工具链riscv64-unknown-elf-gcc -marchrv32i -mabiilp32 -nostdlib -Ttext0x00000000 -o test.elf test.S riscv64-unknown-elf-objcopy -O verilog test.elf test.hex仿真里用$readmemh(test.hex, mem)加载指令存储器。如果你只用Verilog没有装完整工具链也可以手算几条机器码塞进ROM但扩展性差后面测试复杂程序会很难受。波形检查时我重点看几个位置PC是否在每个非stall周期都4分支命中后PC是否跳到目标地址IF/ID和ID/EX里出现bubble时数据是否为0load-use命中时PC是否保持一拍前递选择信号是否在RAW冒险时切到正确的旁路。只要这五个点都对了流水线基本不会有大问题。5.2 排查实录与踩坑清单我自己调试过程中整理过一张速查表现在贴出来能帮你少走很多弯路现象大概率原因指令执行结果全部为X态复位没把流水线寄存器清零或寄存器堆没初始化连续add指令第二条结果错前递网络缺失或优先级反了lw后紧跟add结果错load-use检测条件写错stall没生效分支目标翻倍跳B型/J型立即数已经在扩展时补0EX里又左移了一次blt/bge结果和预期相反比较时把有符号和无符号搞混slt和sltu控制信号没区分jal返回地址错PC4没有从IF/ID一路传到WB级srai结果和srli一样ALU控制里funct7高位没参与译码仿真里mem加载不到机器码hex路径错误或者readmemh大小写、文件格式不匹配其中“分支目标翻倍”是我见过最多人踩的。B型扩展后最低位已经是0比如偏移4字节的目标立即数扩展出来就是...0100你再左移一次变成...1000整个程序跳得乱七八糟。我建议在扩展代码旁边写一行注释// B/J type imm already shifted by 1, do NOT shift again防止第二天自己看代码时手滑。5.3 上板部署与调试技巧仿真通过后上FPGA板还可能有新的问题。首先是时钟开发板通常给100MHz或50MHz系统时钟五级流水线CPU在Artix-7这种板子上跑50MHz完全没压力。建议用create_clock -period 20 [get_ports clk]做一下时序约束如果组合逻辑链路太长导致时序违例优先检查前递网络到ALU的路径。上板调试我推荐用Vivado的ILAIntegrated Logic Analyzer把PC、IF/ID寄存器、EX/MEM寄存器、前递选择信号、stall信号拉出来观测。和仿真一条指令一条指令看不同上板调试是连续不断的所以最好在测试程序结尾用一个自跳转死循环再用ILA的触发条件抓PC地址来定位问题。如果不想用ILA最简单粗暴的办法是把测试结果通过GPIO接到LED上。比如把最终要验证的x2寄存器值拆成低8位送到LED灯跑完程序后看灯亮灭是否符合预期。这种调试方法虽然原始但能快速确认大方向对不对我在第一次上板时就用它确认了累加结果。让我提醒一句上板前先把仿真跑到足够充分不要拿上板当仿真环境。流水线CPU在仿真里已经能把冒险问题全部暴露出来上了板再通过ILA抓波形定位效率会低很多。仿真过了上板大概率只是时序或者初始化的小问题。这版CPU做完后我最大的体会是流水线设计和普通组合逻辑电路完全是两种思维。你要时刻去想“这条指令现在在哪个阶段”“哪些信号跟着它走到了哪一级”。前递、stall、flush这些词只有亲手写完代码、亲手把波形调到正确才会真正变成自己的东西。如果你也在做RISC-V流水线设计暂时卡住是很正常的把自己每一条指令的路径在纸上推一遍把冒险条件一条条列出来检查比盲目改代码管用得多。
返回列表