资讯详情

资讯详情

手把手实现RISC-V单周期CPU:Verilog设计全流程与调试技巧

做CPU这件事我说句实话它不神秘但也绝不是看两篇博客就能云出来的东西。我大概是大三那年第一次用Verilog写RISC-V单周期CPU踩过的坑比写的代码还多。后来工作了带新人、做验证、写文档回头再看当年那个工程发现很多当初卡住我的地方其实是有共性和套路的。这篇文章就是把这条路线完整走一遍——从零开始用手把手的方式实现一个能跑通基础指令的RISC-V单周期CPU附上可以直接用的Verilog模块代码、测试程序、仿真脚本以及我在调试过程中沉淀下来的波形排查方法。这个项目适合谁第一类是学计算机体系结构的学生课程设计或者复试需要交一个能演示、能讲清原理的东西第二类是刚接触Verilog的IC、FPGA方向新手缺一个有规模、但又不至于失控的工程用来练手第三类是自学者手里有一块FPGA开发板想搞清楚“指令在硬件里到底是怎么走完一拍”的。如果你只是想要一个能仿真通过的工程照这篇文章一步步来基本能一次跑通如果你想真正理解每个模块为什么这么设计、每个信号为什么这么接我把取舍逻辑也一并讲清楚。1. 先想清楚为什么选RISC-V为什么做成单周期1.1 指令集选型不是随便定的教材里现在讲得多的还是MIPS但MIPS的商业授权已经基本不对外了学习成本高后续想跑真实工具链也不方便。RISC-V是开放免费指令集生态这几年成熟得很快gcc工具链、模拟器、测试用例都是现成的而且工业界确实在大量使用学它不吃亏。更关键的是RISC-V的指令编码非常规整非常适合拿来做硬件实现的练习对象。RV32I基础指令集的编码规则统一立即数扩展、寄存器索引、操作码字段的位置都有固定规律这让控制器的设计难度降了一个档次。单周期CPU是理解处理器数据通路最好的起点。所谓单周期就是每条指令都在一个时钟周期内完成取指、译码、执行、访存、写回全部串在一条组合逻辑链路上周期末尾用同一个时钟沿统一更新PC和寄存器。它的CPI恒等于1不存在流水线冒险问题不需要转发、不需要停顿检测控制器逻辑非常直观。代价也很明显时钟周期必须能容纳最长的组合逻辑路径所以主频做不高。但作为学习项目这是优点——把每条指令的生命周期打开你能看清数据是怎么一步步流过去的。等这个跑通了再去做五级流水线才能真正理解冒险和转发的价值。1.2 单周期的数据通路到底是什么先画一条主线后面所有代码都是围绕这条线展开的PC给出地址 → 指令存储器读出指令 → 控制器译码寄存器堆读源操作数 → 立即数扩展 → ALU计算地址或运算结果 → 需要访存就访问数据存储器 → 写回寄存器堆 → 同时计算下一条PC。这条链路里最长的路径大概是从PC经过指令存储器、寄存器堆、ALU、数据存储器再到寄存器堆写入端口。单周期CPU的时钟频率就是被这条最差路径卡住的。这也是为什么后来流水线设计里要把这条长路径切成几段每段并行处理不同指令。几个关键选择要提前想明白。指令存储器和数据存储器分开这是哈佛结构的思路。因为单周期里要取指又要访存如果共用一块存储器读写端口会打架时序也说不清楚拆开是最省事的方案。指令存储器用组合读取指立刻出结果数据存储器的读也是组合的写则在时钟沿完成这样才能保证lw指令在一个周期内把数据送到寄存器堆写入端。1.3 先支持哪些指令测试程序怎么设计RV32I完整指令集上百条新手一上来全做了必然是给自己挖坑。我建议第一版只做这些R型的add、sub、and、or、xor、sltI型的addi、lw、jalrS型的swB型的beq、bne、blt、bgeU型的lui、auipcJ型的jal。这套子集覆盖了数据运算、访存、条件分支、无条件跳转、PC相对寻址、立即数加载足够跑一个带分支和循环的小程序也能完整展示各类指令的数据通路差异。测试程序的设计有个原则每加一类指令就设计一段能人工核对结果的代码。先只放addi确认寄存器值正确再放R型运算确认ALU译码没问题然后上lw/sw确认访存通路和写回选择最后上分支和跳转确认PC更新逻辑。不要一口气把所有指令拼在一起否则出了问题根本定位不了。我后面给的测试程序就是按这个思路组织的你仿真之后对寄存器值一条条都能对上。2. 模块拆分每个模块干什么边界怎么画2.1 顶层数据通路与信号流向模块化设计的第一步是明确每个模块的职责。我习惯把单周期CPU拆成这几个部分pc、imem指令存储器、regfile寄存器堆、imm_gen立即数扩展、controller主控制器、alu_decoderALU译码器、alu、dmem数据存储器最后用一个riscv_top把所有人串起来。这里面有一个容易纠结的地方控制器该输出多少信号、控制粒度多细。我采用经典的“主控制器ALU译码器”两层结构。主控制器只根据opcode输出粗粒度的控制信号比如alu_op、reg_write、mem_to_reg这种ALU译码器再根据funct3、funct7把alu_op细化成真正的ALU控制字。这样做的理由是R型和I型算术指令一共几十种如果每个控制信号都用case列出来主控制器会膨胀得没法维护。拆成两层之后新增一条运算指令只需要改alu_decoder的case主控制器根本不用动。2.2 PC与指令存储器PC模块非常简单本质就是一个带同步复位的寄存器。我选用st_n低有效复位复位后PC从0开始。指令存储器用寄存器数组实现通过$readmemh把机器码hex文件加载进去。这里有个新手最容易忽略的细节地址是字节地址而存储器的单位是32位字所以取数组下标时要对地址做右移两位即mem[addr[31:2]]。我见过太多人在这里写错导致读出来的指令全错。module pc ( input wire clk, input wire rst_n, input wire [31:0] d, output reg [31:0] q ); always (posedge clk or negedge rst_n) begin if (!rst_n) q 32h0; else q d; end endmodule module imem ( input wire [31:0] addr, output wire [31:0] instr ); reg [31:0] mem [0:63]; initial $readmemh(inst.hex, mem); assign instr mem[addr[31:2]]; endmodule2.3 寄存器堆与立即数扩展寄存器堆是RISC-V的基础32个32位寄存器x0固定为0。写使能有效时在时钟沿把数据写进rd指定的寄存器读是组合逻辑随时输出rs1、rs2对应的值。这里必须做的保护是rd等于0时禁止写入否则x0可能被写成非零值整条指令集规则就破坏了。module regfile ( input wire clk, input wire we, input wire [4:0] rs1, input wire [4:0] rs2, input wire [4:0] rd, input wire [31:0] wdata, output wire [31:0] rdata1, output wire [31:0] rdata2 ); reg [31:0] regs [0:31]; always (posedge clk) begin if (we (rd ! 5b0)) regs[rd] wdata; end assign rdata1 regs[rs1]; assign rdata2 regs[rs2]; endmodule立即数扩展是RISC-V的一个设计亮点也是很多人写错的地方。它的妙处在于所有类型的立即数在指令里的位置都是经过精心安排的硬件只要按类型把对应字段重新拼接再符号扩展就行。I型、S型、B型、U型、J型的拼接规则各不相同尤其是B型和J型因为立即数最低位在编码时被藏进了指令的不同位置。我的习惯是先把指令编码图打印出来贴在屏幕边上再写拼接代码写完逐位核对绝不靠肉眼硬瞪。module imm_gen ( input wire [31:0] instr, input wire [2:0] imm_sel, output reg [31:0] imm ); always (*) begin case (imm_sel) 3b000: imm {{20{instr[31]}}, instr[31:20]}; // I型 3b001: imm {{20{instr[31]}}, instr[31:25], instr[11:7]}; // S型 3b010: imm {{20{instr[31]}}, instr[7], instr[30:25], instr[11:8], 1b0}; // B型 3b011: imm {instr[31:12], 12b0}; // U型 3b100: imm {{12{instr[31]}}, instr[19:12], instr[20], instr[30:21], 1b0}; // J型 default: imm 32h0; endcase end endmodule2.4 ALU与控制器的分工ALU就是一组算术逻辑单元输入a、b输出result附带zero和lt两个标志位。这个设计里我特意把lt单独拉出来而不是让控制器根据slt指令去额外判断因为分支指令beq、bne、blt、bge都要用到比较结果。zero用于相等类分支lt用于大小比较类分支。注意slt和blt用的都是有符号比较Verilog里要用$signed()包起来否则负数比较结果会完全反掉这是高频bug之一。module alu ( input wire [31:0] a, input wire [31:0] b, input wire [3:0] alu_ctrl, output reg [31:0] result, output wire zero, output wire lt ); always (*) begin case (alu_ctrl) 4b0000: result a b; 4b0001: result a - b; 4b0010: result a b; 4b0011: result a | b; 4b0100: result a ^ b; 4b1010: result b; // lui专用直接传立即数 4b1000: result ($signed(a) $signed(b)) ? 32h1 : 32h0; default: result 32h0; endcase end assign zero (result 32b0); assign lt ($signed(a) $signed(b)); endmodule控制器是整个CPU的“魂”它接收opcode输出所有数据通路的控制信号包括寄存器写使能、ALU源选择、存储器写使能、写回选择、分支和跳转标志、立即数类型选择、ALU粗粒度操作码。每个信号背后都对应一个“这条指令该干什么”的问题。比如alu_src决定ALU的b端口是寄存器堆的rs2还是立即数mem_to_reg决定寄存器写回数据来自ALU结果还是数据存储器读取结果a_sel决定ALU的a端口是rs1还是PC这主要是给jal、auipc用的因为它们的目的是计算PC相对地址。3. 完整代码从底层模块拼到顶层3.1 控制器与ALU译码实现主控制器用case语句按opcode分类。我习惯先写好默认值再在case里覆盖需要变化的信号这样每个case分支都只需要写出和默认值不同的项代码更短、不容易漏信号。比如默认就把reg_write置0只有写寄存器的指令类型才把它拉高。module controller ( input wire [6:0] opcode, output reg reg_write, output reg alu_src, output reg mem_write, output reg mem_to_reg, output reg branch, output reg jump, output reg jalr, output reg a_sel, output reg link_en, output reg [2:0] imm_sel, output reg [2:0] alu_op ); always (*) begin reg_write 0; alu_src 0; mem_write 0; mem_to_reg 0; branch 0; jump 0; jalr 0; a_sel 0; link_en 0; imm_sel 3b000; alu_op 3b010; case (opcode) 7b0110011: begin // R型运算 reg_write 1; alu_op 3b000; end 7b0010011: begin // I型算术 reg_write 1; alu_src 1; alu_op 3b001; end 7b0000011: begin // lw reg_write 1; alu_src 1; mem_to_reg 1; alu_op 3b010; // add end 7b0100011: begin // sw alu_src 1; mem_write 1; imm_sel 3b001; alu_op 3b010; end 7b1100011: begin // B型分支 branch 1; imm_sel 3b010; alu_op 3b011; // sub end 7b1101111: begin // jal reg_write 1; alu_src 1; jump 1; a_sel 1; link_en 1; imm_sel 3b100; alu_op 3b010; end 7b1100111: begin // jalr reg_write 1; alu_src 1; jump 1; jalr 1; link_en 1; imm_sel 3b000; alu_op 3b010; end 7b0110111: begin // lui reg_write 1; alu_src 1; imm_sel 3b011; alu_op 3b100; end 7b0010111: begin // auipc reg_write 1; alu_src 1; a_sel 1; imm_sel 3b011; alu_op 3b010; end endcase end endmoduleALU译码器的输入是alu_op、funct3、funct7。alu_op为3b000时表示R型指令需要继续看funct3和funct73b001是I型算术指令也是看funct33b010直接输出add对应lw/sw/jal/jalr/auipc这些本质上都在做加法运算的指令3b011输出sub对应分支指令的比较3b100表示luiALU直接透传立即数。这样一个层级下来每条指令都能得到准确的ALU控制字。module alu_decoder ( input wire [2:0] alu_op, input wire [2:0] funct3, input wire [6:0] funct7, output reg [3:0] alu_ctrl ); always (*) begin case (alu_op) 3b010: alu_ctrl 4b0000; 3b011: alu_ctrl 4b0001; 3b100: alu_ctrl 4b1010; 3b000: begin case (funct3) 3b000: alu_ctrl funct7[5] ? 4b0001 : 4b0000; 3b111: alu_ctrl 4b0010; 3b110: alu_ctrl 4b0011; 3b100: alu_ctrl 4b0100; 3b010: alu_ctrl 4b1000; default: alu_ctrl 4b0000; endcase end 3b001: begin case (funct3) 3b000: alu_ctrl 4b0000; 3b111: alu_ctrl 4b0010; 3b110: alu_ctrl 4b0011; 3b100: alu_ctrl 4b0100; 3b010: alu_ctrl 4b1000; default: alu_ctrl 4b0000; endcase end default: alu_ctrl 4b0000; endcase end endmodule3.2 数据存储器与顶层数据通路连线数据存储器的写使能来自控制器的mem_write写入数据固定是rs2_data写入地址是ALU计算结果。因为只有sw指令需要往内存写写数据的来源就是唯一的不需要额外mux。读是组合的直接把对应地址的数据放到rdata上lw指令在同一个周期内就能把数据送到写回mux。module dmem ( input wire clk, input wire we, input wire [31:0] addr, input wire [31:0] wdata, output wire [31:0] rdata ); reg [31:0] mem [0:63]; assign rdata mem[addr[31:2]]; always (posedge clk) begin if (we) mem[addr[31:2]] wdata; end endmodule顶层模块是所有模块的连接点也是最容易出接线错误的地方。关键的三条逻辑是ALU的a端口选择rs1还是PC、PC的next选择顺序、分支、跳转、寄存器堆写回数据选择ALU结果、内存读数据、PC4。分支跳转的标志位逻辑我放在了顶层用连续赋值直接算因为涉及funct3和ALU标志位单独拉一个模块反而更绕。module riscv_top ( input wire clk, input wire rst_n, output wire [31:0] pc, output wire [31:0] instr ); wire reg_write, alu_src, mem_write, mem_to_reg; wire branch, jump, jalr, a_sel, link_en; wire [2:0] imm_sel, alu_op; wire [3:0] alu_ctrl; wire [31:0] pc_next, imm, rs1_data, rs2_data; wire [31:0] alu_a, alu_b, alu_result, mem_rdata, wb_data; wire zero, lt, branch_taken; wire [2:0] funct3 instr[14:12]; pc u_pc (.clk(clk), .rst_n(rst_n), .d(pc_next), .q(pc)); imem u_imem (.addr(pc), .instr(instr)); regfile u_rf (.clk(clk), .we(reg_write), .rs1(instr[19:15]), .rs2(instr[24:20]), .rd(instr[11:7]), .wdata(wb_data), .rdata1(rs1_data), .rdata2(rs2_data)); imm_gen u_imm (.instr(instr), .imm_sel(imm_sel), .imm(imm)); controller u_ctrl (.opcode(instr[6:0]), .reg_write(reg_write), .alu_src(alu_src), .mem_write(mem_write), .mem_to_reg(mem_to_reg), .branch(branch), .jump(jump), .jalr(jalr), .a_sel(a_sel), .link_en(link_en), .imm_sel(imm_sel), .alu_op(alu_op)); alu_decoder u_dec (.alu_op(alu_op), .funct3(funct3), .funct7(instr[31:25]), .alu_ctrl(alu_ctrl)); alu u_alu (.a(alu_a), .b(alu_b), .alu_ctrl(alu_ctrl), .result(alu_result), .zero(zero), .lt(lt)); dmem u_dmem (.clk(clk), .we(mem_write), .addr(alu_result), .wdata(rs2_data), .rdata(mem_rdata)); assign alu_a a_sel ? pc : rs1_data; assign alu_b alu_src ? imm : rs2_data; assign branch_taken branch ( (funct3 3b000) ? zero : (funct3 3b001) ? ~zero : (funct3 3b100) ? lt : (funct3 3b101) ? ~lt : 1b0 ); assign pc_next jump ? (jalr ? (alu_result 32hFFFFFFFE) : alu_result) : (branch_taken ? (pc imm) : (pc 4)); assign wb_data link_en ? (pc 4) : (mem_to_reg ? mem_rdata : alu_result); endmodule这个顶层里值得反复看的是pc_next和wb_data的优先级。跳转和分支同时为1时跳转优先所以先判断jumpjalr要的是寄存器加立即数的结果且最低位强制清零因为RISC-V要求jalr目标地址按2字节对齐。写回数据里link_en优先因为jal和jalr要把返回地址PC4写回rd这和普通的数据写回是两条独立的路径。3.3 测试程序与仿真环境搭建isp这里用一个有代表性的测试程序验证R型运算、访存、分支三条核心通路。程序逻辑是x56x67x7x5x613把x7写入内存地址8再从地址8读回x8然后beq比较x7和x8相等则跳过一条错误指令最终x9应该等于0。如果仿真结束时x9是1说明程序跑到了被跳过的指令分支逻辑有问题。对应十六进制机器码如下保存为inst.hex放在仿真工作目录下00600293 00700313 006283B3 00072423 00802403 00838463 00100493 00000493如果不放心手算的机器码可以用工具链自己汇编验证riscv64-unknown-elf-as加objdump或者直接在线的RISC-V汇编工具生成再对着反汇编结果改指令。我建议新手至少手动核一条走一遍编码过程会印象深刻得多。测试平台的核心思路给时钟、给复位跑固定周期后打印目标寄存器的值。用层次化引用dut.u_rf.regs[7]可以直接看寄存器内部仿真时不涉及综合不用避讳这种写法。module tb; reg clk 1b0; reg rst_n 1b0; wire [31:0] pc, instr; riscv_top dut ( .clk(clk), .rst_n(rst_n), .pc(pc), .instr(instr) ); always #5 clk ~clk; initial begin repeat (2) (posedge clk); rst_n 1b1; repeat (10) (posedge clk); $display(pc%0d instr%h, pc, instr); $display(x5%0d x6%0d x7%0d x8%0d x9%0d, dut.u_rf.regs[5], dut.u_rf.regs[6], dut.u_rf.regs[7], dut.u_rf.regs[8], dut.u_rf.regs[9]); $finish; end endmodule仿真我用的是Icarus Verilog加GTKWave轻量、免费学习阶段完全够用。命令行工具链很直接。先编译再跑仿真最后开波形iverilog -o tb.vvp pc.v imem.v regfile.v imm_gen.v controller.v alu_decoder.v alu.v dmem.v riscv_top.v tb.v vvp tb.vvp gtkwave tb.vcd一个常见的坑iverilog运行时会从当前工作目录找inst.hex如果你在别的目录敲命令会报$readmemh打开失败。要么把hex文件和编译命令放在同一个目录要么在$readmemh里写绝对路径这个细节能省你十分钟的排查时间。4. 调试技巧与高频踩坑实录4.1 波形排查的正确打开方式CPU调试最忌讳的就是盯着代码干瞪眼。我调试这类工程的经验是严格按照信号依赖顺序看波形先确认时钟和复位再看PC是否按0、4、8、C这样递增然后看取出的指令是否正确最后追数据通路上的关键信号。具体来说打开波形先干这几件事。第一确认复位信号确实松开过很多PC一直是x的问题都是复位没生效要么是极性搞反要么是复位时间太短。第二观察每个时钟沿PC的变化如果某条指令执行后PC跳跃到了奇怪的地方先分清是分支指令导致还是pc_next逻辑写错。第三对比imem输出和inst.hex里的数据指令取错通常是地址位没有右移两位或者imem索引表达式写错。第四追一条具体指令的完整生命周期比如lw指令从控制器的reg_write、mem_to_reg到ALU算出的地址再到dmem的rdata和最终的wb_data逐个信号核对总有一个能暴露出问题。4.2 单周期CPU高频Bug速查表我把这些年见过、踩过的高频问题整理成了一张表仿真跑不通的时候按表对号入座效率会高很多。现象可能原因排查建议PC一直是x或者不变化复位信号没生效、极性接反先看rst_n波形和PC的q端PC不按4递增pc_next选择逻辑里有x态检查分支、跳转信号是否误拉高指令读出全是ximem地址索引错误或hex文件没加载确认addr[31:2]和文件路径寄存器堆一个数都不写reg_write没拉高或rd被x0屏蔽查controller输出和rd接线算出来的结果总是差一拍dmem的rdata用了时序逻辑输出rdata必须是组合assignlw写回的数据是x写使能或写入数据接错检查mem_write和wdata来源beq、bne跳得不正确B型立即数拼接错误对照编码图逐位核对imm_gen负数比较结果全反了slt、blt没做有符号比较ALU里用$signed()仿真跑不死循环分支目标算到自身或0x0去掉分支指令单独验证运算类case语句缺default产生锁存器综合时warning里有latchcontroller、alu_decoder补default这里面最隐蔽的是x态传播。Verilog里x是会传染的一个信号是x它参与的所有运算结果几乎都是x最后PC跳不可控、寄存器写不可控整个仿真全废。我遇到这类问题套路是把case的default都补上然后找到第一个出现x的信号往前追。用$display打印而不是看波形的话经验和看波形是一样的先打印clk和rst_n附近的PC再往前打指令逐级定位一定会找到源头。4.3 有符号比较、立即数与待办清单有符号比较是新手重灾区。RISC-V里slt、slti、blt、bge都是按有符号数比较但Verilog的默认按无符号处理。如果不加$signed()x5和x6比较大小时0xFFFFFFFF会被当成4294967295而不是-1结果自然不对。我在ALU里单独拉出lt输出就是为了统一处理这个逻辑分支单元直接复用一劳永逸。立即数的符号扩展也是检查重点。B型和J型立即数的位序不像I型和S型那么直观出错率最高。我的做法是在imm_gen里每个case分支就写一次拼接然后针对每种类型写一条指令去测比如beq跳到一个明确地址看PC是否落到预期位置。测通之后再组合测不要在没验证单类型前就盲目集成。这个工程跑通之后扩展方向其实很清晰。想加深理解可以把R型里没做的sll、srl、sra、sltu补齐改动只涉及alu_decoder和alu想让CPU真实可上板可以在顶层加一个内存映射的LED寄存器把程序结果输出到开发板想挑战进阶内容就把它改造成五级流水线加冒险检测单元和转发通路体会单周期里被隐藏起来的时序问题如何浮出水面。每一步都是在已经跑通的骨架上做增量比重新写一个要省力得多。最后分享一点个人体会我见过太多人做这种项目时上来就想一步到位结果被一堆模块同时出错淹没。单周期CPU这个项目最大的价值就是让你体会“把一个复杂系统拆成小模块每个小模块单独验证再拼接集成”这个过程。慢就是快先把addi跑通再一步步加每个阶段都有明确的可观测结果整个项目就不会失控。芯片设计行业里这种自底向上、可验证的做法其实才是真正吃饭的本事。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →