手写RISC-V单周期CPU:Verilog从零到跑通

📅 发布时间:2026/9/29 1:06:48
手写RISC-V单周期CPU:Verilog从零到跑通
我一直觉得CPU设计最难的坎不是那些数据通路图而是“打开编辑器之后先写哪一行代码”。RISC-V的指令手册不算厚单周期CPU的原理图网上也搜得到但真到了自己动手用Verilog实现的时候大部分人还是会愣住先写PC还是先写寄存器堆控制信号什么时候给立即数为什么一定要符号扩展这篇文章想讲的就是我用Verilog从零搭一个RISC-V架构单周期CPU的完整过程。不是抄一个现成的开源仓库而是自己画数据通路、自己定指令子集、自己写每一行代码、再自己把它调通。这个项目能解决的核心问题很简单让“看得懂原理图”变成“写得出硬核代码”同时把RISC-V指令集里的格式、字段、控制信号这些偏理论的东西全部落到真实的组合逻辑和触发器上。如果你正在学计算机组成原理或者准备数字IC相关面试又或者只是好奇“一条指令在CPU里到底经历了什么”这篇文章都值得读下去。我会把指令子集的选择、数据通路的搭建、控制信号的生成、仿真的排错一条线讲完顺便告诉你哪些地方容易翻车。1. 设计之前先把三件事钉死在纸上1.1 为什么选RV32I指令集越小越好入门市面上做CPU的指令集不少。MIPS讲过很多年ARM也常见但最近RISC-V热度明显更高。原因是它开放、精简而且基础整数指令集RV32I只有四十多条指令非常适合学习和二次开发。在单周期CPU里我最后只选了不到十条指令。不选多的原因很简单单周期CPU里每条指令的“路径”都要在一个时钟周期内跑完指令越多控制逻辑越复杂验证的难度也越大。我建议第一次接触的同学就学我这样做——先砍出一个最精简但能跑通完整数据通路的子集后面再慢慢加。这个子集长这样指令类型功能覆盖的数据通路addR型寄存器加法RegFile读、ALU运算、RegFile写subR型寄存器减法RegFile读、ALU运算、RegFile写andR型寄存器与运算RegFile读、ALU运算、RegFile写orR型寄存器或运算RegFile读、ALU运算、RegFile写sltR型小于则置1RegFile读、ALU比较、RegFile写addiI型算术立即数加法立即数扩展、ALU、RegFile写lwI型访存从内存读字立即数扩展、ALU算地址、DataMem读、RegFile写swS型访存写字到内存立即数扩展、ALU算地址、DataMem写beqB型分支相等则跳转ALU比较、Zero信号、PC跳转jalJ型跳转无条件跳转并保存返回地址PC跳转、PC4写回RegFile这十条指令不是随便挑的。算术逻辑类把R型和I型都覆盖了访存类把内存读写覆盖了分支和跳转把PC的两种跳转路径覆盖了。也就是说所有典型数据通路都能被测试到。以后想加指令无非是在这个框架上改alu_ctrl、改控制信号表、改立即数扩展而已。1.2 六种指令格式RISC-V比MIPS“好说话”的地方写CPU代码之前必须对指令编码格式有肌肉记忆。RISC-V的RV32I一共有六种指令格式R、I、S、B、U、J。R型指令的31到25位是funct724到20位是rs219到15位是rs114到12位是funct311到7位是rd6到0位是opcode。I型指令把rs2和funct7换成了12位立即数。S型指令把rd换成了立即数的一部分而且立即数被拆成了两段一段在高位一段在低位。B型指令和S型很像但立即数拆得更碎。U型指令是20位立即数加rd。J型指令的立即数也是打散的。很多人问为什么RISC-V的立即数要拆得七零八落不像ARM那样整整齐齐这是因为RISC-V想尽量保持rs1、rs2、rd这些寄存器字段在每条指令里的位置固定不变。这样硬件电路只需要在固定的位段去取寄存器编号不需要根据指令类型来回切换。举个例子RISC-V的rd永远固定在第11到第7位。而MIPS的写回寄存器可能是rt也可能是rd需要额外的RegDst控制信号。RISC-V省掉这个信号控制单元就少一个输出数据通路也少一层多路选择。这种设计取舍写代码的时候会真切体会到。1.3 单周期的时钟模型为什么lw是最慢的一条指令单周期CPU的意思是整个执行过程都在一个时钟周期内完成。取指、译码、执行、访存、写回这些动作在这个周期内是“接力跑”的。也就是说下一个时钟上升沿到来之前这条指令的所有结果必须已经稳定下来。在这个模型里最痛苦的是ld的路径。它要从PC出发先读指令存储器再从指令里解出rs1立即数经过寄存器堆读出基地址再由ALU完成基地址加立即数然后读数据存储器最后数据还要通过MUX选择器回到寄存器堆的写端口。这条路径几乎串起了全部模块延迟最长。时钟周期的下限就是最长路径的延迟。所以单周期CPU的时钟频率做不高因为所有指令都要为lw这条最慢指令让路。明白了这一点你就理解了为什么实际芯片不用单周期、而是用流水线或至少多周期。这不是工程上的炫技是物理延迟在逼你。2. 用Verilog搭数据通路每段信号都要有家2.1 顶层连线图先画出数据通路再动手写Verilog之前我强烈建议先在纸上把数据通路画出来或者至少用表格把模块间的信号列清楚。这一步骤能帮你省下后面大量调试时间。我的顶层模块大概长这样PC - 指令存储器 - 指令拆解 - 立即数扩展 - 寄存器堆读端口 - ALU - 数据存储器 - MUX - 寄存器堆写端口 - 控制单元生成各类控制信号PC的输出同时接到指令存储器的地址端口和分支地址加法器指令存储器的输出拆成opcode、rs1、rs2、rd、funct3、funct7等字段分别接到控制单元、寄存器堆和ALU。顶层代码结构可以写成module riscv_single( input wire clk, input wire rst, output wire [31:0] pc, output wire [31:0] instr ); wire reg_write; wire alu_src; wire mem_write; wire mem_read; wire [1:0] mem_to_reg; wire branch; wire jump; wire [1:0] alu_op; wire [31:0] pc_next; wire [31:0] pc_plus4; wire [31:0] imm_ext; wire [31:0] rd1, rd2; wire [31:0] alu_in2; wire [3:0] alu_ctrl; wire [31:0] alu_result; wire zero; wire [31:0] mem_read_data; wire [31:0] reg_write_data; // 后续模块例化... endmodule这样的写法每个信号从哪个模块来、到哪个模块去一眼就能看明白。很多初学同学喜欢直接硬写写到哪算哪最后信号满天飞仿真出错根本查不出来。我的经验是哪怕不画图也至少把模块接口列个清单。2.2 PC与指令存储器取指阶段的两个坑PC是程序计数器本质就是一个32位寄存器用来存放当前指令地址。单周期CPU里PC在每个时钟上升沿更新为下一条指令地址。reg [31:0] pc_reg; always (posedge clk or posedge rst) begin if (rst) pc_reg 32h0000_0000; else pc_reg pc_next; endPC的复位值要仔细。如果程序从0地址开始复位为0没错但如果你的片上系统把程序放在另一个地址这里就要改。我见过有人复位设为0x8000_0000结果指令存储器里根本没这个地址的程序仿真到处是X态花了一晚上查出来。指令存储器的实现有不少讲究。最简单的写法是reg [31:0] imem [0:4095]; assign instr imem[pc_reg[31:2]];这里用pc_reg[31:2]是因为每条指令4字节地址低两位永远是0。如果是按字节编址的存储器取指地址要对齐到字边界。很多初学者直接用imem[pc_reg]位宽和索引都对不上仿真直接报错或者读出乱七八糟的数据。还有一个坑指令存储器用reg数组组合读在单周期设计里是没问题的因为不需要真正的SRAM时序模型。但你如果后续要上FPGA最好用厂家提供的RAM IP或者把imem独立成模块方便以后替换。2.3 寄存器堆组合读、同步写的两个约定寄存器堆是CPU里最典型的“读写同时进行”的存储结构。RV32I有32个32位寄存器其中x0恒为0。寄存器堆需要提供两个读端口、一个写端口读端口用组合逻辑输出写端口用时钟同步写入。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] wd, output wire [31:0] rd1, output wire [31:0] rd2 ); reg [31:0] regs [0:31]; assign rd1 (rs1 5d0) ? 32b0 : regs[rs1]; assign rd2 (rs2 5d0) ? 32b0 : regs[rs2]; always (posedge clk) begin if (we (rd ! 5d0)) regs[rd] wd; end endmodule读端口用组合逻辑意思是只要rs1和rs2地址一变输出立刻跟着变不需要等时钟。写端口用同步写只有时钟上升沿到来且we有效时才会把wd写进rd对应的寄存器。这里有一个非常关键的细节是x0的固定零。不只是写的时候要判断rd是否等于0读的时候也要判断rs1或rs2是否等于0。有些同学只在写端口判断了rd没管读端口结果程序里只要用了x0就读出了垃圾值这种bug隐蔽得让人想摔键盘。另一个细节是读寄存器堆的操作和写寄存器堆的操作在单周期里是同时进行的吗并不是。这条指令的写回和这条指令的读操作针对的是同一条指令但发生在同一个周期里的不同阶段——先读后写。单周期的组合逻辑保证了读出来的数据是“当前这条指令执行前”寄存器堆里的旧值而写回的结果会稳定在周期末尾的时钟沿上被下一个周期的指令读到。理解了这个时序后续仿真看波形就不会犯迷糊。2.4 ALU与ALUCtrl四个态够用但要想清楚算术逻辑单元ALU是执行的核心。我的单周期CPU内部只需要四个运算加法、减法、按位与、按位或、小于比较。可以用一个4位alu_ctrl来控制。module alu( input wire [31:0] a, input wire [31:0] b, input wire [3:0] alu_ctrl, output reg [31:0] alu_result, output wire zero ); always (*) begin case (alu_ctrl) 4b0000: alu_result a b; 4b0001: alu_result a - b; 4b0010: alu_result a b; 4b0011: alu_result a | b; 4b0100: alu_result ($signed(a) $signed(b)) ? 32b1 : 32b0; default: alu_result a b; endcase end assign zero (alu_result 32b0); endmoduleALUSrc信号决定了ALU的第二个输入是寄存器堆读出的rd2还是立即数扩展的结果。这个引脚直接连到ALU的b输入之前的多路选择器上。ALUCtrl的生成是重点。一般做法是控制单元输出一个较粗粒度的ALUOp再由一个独立的ALU控制模块根据指令的funct3和funct7生成精确的4位alu_ctrl。我的设计里ALUOp只有两到三种取值当指令是lw、sw、addi时ALU只需要做加法当指令是beq时ALU做减法来判断是否相等当指令是R型指令时需要根据funct3和funct7进一步细分。所以R型指令的alu_ctrl生成逻辑大概是always (*) begin case (funct3) 3b000: alu_ctrl (funct7[5]) ? 4b0001 : 4b0000; // sub/add 3b111: alu_ctrl 4b0010; // and 3b110: alu_ctrl 4b0011; // or 3b010: alu_ctrl 4b0100; // slt default: alu_ctrl 4b0000; endcase end这里要注意funct7[5]。RISC-V的R型指令通过funct7区分add和subadd的funct7是0000000sub的funct7是0100000二者就差bit30这一位。写代码时不要用funct7 7b0100000去匹配直接用funct7[5]最省事也最不容易错。2.5 立即数扩展最容易写错也最容易“隐蔽”的模块如果说有一个模块能让你调试到怀疑人生那一定是立即数扩展。RISC-V的不同指令格式立即数在指令中的位置完全不同扩展方式也不一样。I型立即数扩展最简单assign imm_ext {{20{instr[31]}}, instr[31:20]};S型立即数有点绕因为立即数的高位在instr[31:25]低位在instr[11:7]assign imm_ext {{20{instr[31]}}, instr[31:25], instr[11:7]};B型立即数最阴间它把立即数打散到了五个位置而且最低位固定为0。因为分支跳转的目标地址必须按2字节对齐所以bit0直接被丢弃了assign imm_ext {{19{instr[31]}}, instr[31], instr[7], instr[30:25], instr[11:8], 1b0};J型立即数也是打散的assign imm_ext {{11{instr[31]}}, instr[31], instr[19:12], instr[20], instr[30:21], 1b0};U型立即数最简单直接把高20位放到[31:12]低12位补零assign imm_ext {instr[31:12], 12b0};我一开始只实现了I型和S型的立即数扩展程序里用lw和sw测起来没问题一加分支指令就开始乱跳。查了半天发现B型立即数的位序拼错了把instr[11:8]和instr[7]的位置弄反了。这种错误在波形上表现出来的现象非常迷惑CPU并不是完全不动而是跳到有的地方对、有的地方错看起来就像“灵异事件”。所以做这个项目时建议先把六种格式的立即数扩展全部写好并单独写一个仿真来验证扩展结果不要等CPU整体调通后再回头找问题。独立模块验证的成本远远低于全系统联调。3. 控制单元像给每个开关发指令3.1 控制信号真值表先把每条指令的开关状态写出来数据通路搭好后剩下的核心工作就是把每条指令“翻译”成一组开关信号。这个翻译器就是控制单元。你不需要一开始就写代码你真正需要的是一个真值表把每条指令需要的控制信号全部列出来。我的单周期CPU控制信号包括RegWrite、ALUSrc、MemRead、MemWrite、MemtoReg、Branch、Jump、ALUOp。指令RegWriteALUSrcMemReadMemWriteMemtoRegBranchJumpALUOplw1110000000sw0101xx0000R型1000010010beq0000xx1001addi1100010000jal1000100100写这段代码的时候有一个很痛的领悟不要把这个真值表埋在case语句里面一张表对应一段case后续加指令会非常爽。我的控制单元大概长这样always (*) begin case (opcode) 7b0000011: begin // lw reg_write 1b1; alu_src 1b1; mem_read 1b1; mem_write 1b0; mem_to_reg 2b00; branch 1b0; jump 1b0; alu_op 2b00; end 7b0100011: begin // sw reg_write 1b0; alu_src 1b1; mem_read 1b0; mem_write 1b1; mem_to_reg 2bxx; branch 1b0; jump 1b0; alu_op 2b00; end // 其他指令类似... endcase end这里要注意MemtoReg是一个两位信号。00表示从数据存储器读出来的数据写回寄存器01表示ALU的计算结果写回寄存器10表示PC4写回寄存器。PC4个这个值在jal指令里特别重要它用来保存返回地址。至于两位信号的第三位单周期设计里可能用不到但留出来以后扩展会方便很多。3.2 PC跳转Branch和Jump在同一周期如何生效PC的下一值来源有三个顺序地址PC4、分支目标地址、跳转目标地址。因此在拍PC更新逻辑时需要一个多路选择器选谁取决于Branch、Jump和Zero三个信号。分支目标地址的计算方法很简单PC 立即数扩展后的imm。这个加法在数据通路上由专用的加法器完成不需要复用主ALU。复用主ALU当然也可以但会让关键路径变得更长。跳转条件判定是这样的assign pc_src (branch zero) | jump;beq的核心就是ALU做完减法后Zero信号为1表示两个数相等此时branch为1两个条件同时满足就跳转。而jal属于无条件跳转jump为1就直接跳完全不理Zero。选择器逻辑assign pc_next pc_src ? (pc_reg imm_ext) : pc_plus4;这里有一个小坑分支目标地址用的是当前PC值加上立即数而不是PC4。RISC-V的分支指令的基准地址是当前指令地址PC而不是下一条指令地址PC4。如果你按MIPS的经验去算分支跳转会偏一条指令的位置。另外分支目标地址的立即数在B型扩展中已经强制把最低位设为0了所以pc imm_ext之后不可能产生不对齐的奇数地址。但还是建议写代码时保持这个约束以后如果改成立即数不再强制对齐就要在加法器前面做一次位对齐处理。3.3 jal为什么需要第三个写回数据源很多人以为PC跳转就是改改PC值但jal指令比beq多一个任务它要把返回地址保存到寄存器里。返回地址就是PC4也就是当前指令的下一条指令地址。所以寄存器堆的写数据来源必须多一条路从“ALU结果”和“内存数据”之外再增加一个“PC4”。这就是MemtoReg需要两位的原因。在顶层模块里这个MUX可以写成assign reg_write_data (mem_to_reg 2b00) ? mem_read_data : (mem_to_reg 2b01) ? alu_result : (mem_to_reg 2b10) ? pc_plus4 : 32b0;我最初只设计了ALU结果和内存数据两条写回路径后来想加jal指令发现根本没法存返回地址只能临时在顶层用连续赋值加一个通道。代码虽然改出来了但控制信号表里没有预留这个位整个设计看起来就很别扭。做新CPU的时候一开始就写好三个写回数据源后面加分支跳转类的指令会从容得多。这里再补充一点RISC-V的用法jal指令通常写作jal ra, labelra是寄存器x1。在RISC-V调用约定里x1专门用来存函数返回地址。但硬件层面看jal其实可以写入任何一个rd甚至写入x0也可以——那就不保存返回地址纯粹变成一条无条件跳转指令。3.4 x0寄存器一个“软约束”背后的硬电路x0恒为0这件事听起来简单但在Verilog里如果不小心会在很多地方制造诡异bug。寄存器堆里x0的处理我在前面已经写了读写两个方向都要判断。但还有一个容易漏的地方是控制单元的RegWrite信号如果拉高且rd恰好是x0写操作必须被屏蔽。否则如果x0真的被写成一个非零值后续的程序的正常执行就会整个崩溃。这个软约束在指令集手册里是一条规则但在硬件里必须做成强制行为。我的做法是把判断放在寄存器堆模块内部不给顶层逻辑增加任何区分。也就是说不管控制单元怎么给RegWrite信号寄存器堆只管判断rd是否等于0。这是一个很符合“模块自治原则”的设计推荐你也这么做。还有一条经验在testbench里看不出来x0是否被误写因为读x0的地方已经被assign语句强制归零了。只有在寄存器堆内部打印regs[0]才能发现。所以调试时不要太相信综合后的行为怀疑x0被写了就在寄存器堆模块里打一段display看看。4. 用仿真把CPU跑起来iverilog下的完整调试链路4.1 搭testbench把CPU装进一个可控时钟里我使用的仿真工具是iverilog配合GTKWave查看波形。它免费、轻量、跨平台非常适合学习阶段的RTL仿真。先写一个testbench把CPU例化进去产生时钟和复位信号module tb; reg clk, rst; wire [31:0] pc, instr; riscv_single u_cpu( .clk(clk), .rst(rst), .pc(pc), .instr(instr) ); initial begin clk 0; rst 1; #20 rst 0; repeat(100) begin #10 clk 0; #10 clk 1; end $finish; end initial begin $dumpfile(cpu.vcd); $dumpvars(0, tb); end endmodule这里有个细节复位信号不要和时钟上升沿同时释放。上面代码里#20的延迟保证了rst释放发生在时钟沿之后避免出现“不知道是先释放复位还是先采到复位值”的竞态。很多新手把rst和clk同时翻转仿真结果看起来一切正常但综合到FPGA上偶尔就是不对这就是仿真和真实硬件之间的灰色地带。4.2 测试程序每条指令都有对应的单点验证测试程序的质量决定了你调通的CPU是不是真的调通了。我的验收标准是每个指令类型至少有一条专门验证它的指令并且可以观察到预期的寄存器变化和内存变化。我写过一个小程序功能是对两个数做加法、存储、加载、分支比较和跳转你可以参考addi x5, x0, 10 # x5 10 addi x6, x0, 20 # x6 20 add x7, x5, x6 # x7 30 sw x7, 0(x0) # mem[0] 30 lw x8, 0(x0) # x8 30 beq x7, x8, label # 应该跳转 addi x9, x0, 1 # 这条应该被跳过 label: jal x1, end # 跳到end并且x1 返回地址 sub x10, x5, x6 # 这条应该被跳过 end: and x11, x5, x6 # x11 10 20这个程序的验证思路是如果CPU没有正确实现beqx9会被写成1如果jal没实现x10会被执行如果lw和sw没实现x8和x11的结果都对不上。这样一条测试程序就能把十个指令的核心功能全覆盖到。机器码怎么生成我的经验是规模小就手工编码规模大才搭工具链。十几条指令手里拿一张指令格式表每条指令按位拼一下总共也就半小时。比如addi x5, x0, 10opcode是0010011rd是x5即00101rs1是x0即00000立即数是10即000000001010funct3是000拼起来就是32h00a00293。不过我建议基础好一点的同学还是走正规流程用RARS这类RISC-V模拟器或者riscv64-unknown-elf工具链导出机器码后再写进hex文件既不费脑子也不容易错。4.3 排错现场五个典型Bug的排查思路这段是我最想写的内容因为我在这个CPU上踩的坑基本就是所有刚接触CPU设计的人都会踩的坑。第一个典型Bug是程序计数器在跑但寄存器全是零。遇到这种情况先看RegWrite信号。如果RegWrite一直为0说明控制单元的opcode没匹配上指令指令拆解可能出了问题。再看wr_data的来源如果MemtoReg接到了内存数据但当前是一条alu指令那结果自然是零。第二个典型Bug是beq永远跳不转。先确认Zero信号有没有拉高再看Branch信号有没有被控制单元拉高最后检查B型立即数扩展。我前面提到的B型立即数位序错误就是这种情况里最隐晦的。第三个典型Bug是lw读回来的数据不对。排查思路是从数据存储器读数据线往前查地址是不是ALU算出来的正确地址MemRead有没有拉高地址有没有超出存储器深度我曾经定义了一个只有16个字的dmem程序里却去读地址100这时候仿真里全是X态稍微不仔细就会以为是存储器本身坏了。第四个典型Bug是仿真卡死。这通常是PC跳到一个不存在的地址又回到自身形成了死循环。排查方法是在testbench里每隔一个时钟周期打印PC值看PC的轨迹是否符合预期。用$finish之外还可以用#200 $finish;设置一个仿真超时时间防止卡死时整个仿真无限制地跑下去。第五个典型Bug是波形里大量X态。X态意味着“未知”可能是寄存器或存储器没复位也可能是多驱动。比如一个wire信号在两个assign语句里被赋值仿真器会把它标记为X。我在实际项目里还遇到过位宽不匹配的问题两个信号一个32位一个31位连接处的高位全部浮空表现出来就是整个总线都是X。调试工具的优先级也很重要。我建议先看信号波形再看仿真日志最后才是修改代码。很多时候一个bug的根因不在你盯着的那几行代码上而在上游的信号根本没有正确驱动。4.4 这块CPU做完后下一步往哪走CPU能跑通测试程序核心学习目标就完成了一大半。但说实话这块单周期CPU离“能用的处理器”还很远。它的性能上限被lw这条指令拖死了所有其他指令都要跟着它用同一个时钟周期跑FPGA时频率很难上去。我做完单周期之后做了几件事感觉对理解CPU设计帮助很大第一个是把指令子集加宽。把addi、andi、ori、xori、slli、srli这些I型算术逻辑指令补上控制信号表多几行ALU里多几个运算单元代码量增加不多但对指令格式的理解会加深一层。第二个是加bne。beq的反向分支只需要把Zero信号取反再与Branch做逻辑与改动量非常小但会让你更清楚地理解“条件”和“跳转”是两件事。第三个是把CPU接到一个简单的GPIO外设上上FPGA板跑一个LED流水灯。这个看起来很简单的事情其实涉及总线地址映射、存储器扩展、以及如何把CPU的访存请求转成外设读写信号。做完这一关你才算真的理解“CPU是拿来跑的不是拿来仿真的”。更远的路线自然是多周期和流水线。多周期把一条指令拆成多个周期硬件复用率变高但需要设计状态机。流水线则是在周期基础上叠加阶段重叠逻辑复杂度和时序复杂度同时上了一个台阶。回头再看单周期CPU你会意识到它最大的价值是提供了一个“标准答案版”的数据通路和时序模型后续的一切优化都是在和这个“标准答案”做对比。最后再分享一句实操体会这个单周期CPU项目看起来代码量不大顶多几百行但给人的锻炼完全不亚于做一个复杂的通信接口。因为CPU是所有数字系统里“信号依赖链”最长的一种设计一个信号错了可能让你面对十几个看起来无关的诡异现象。我个人的体会是做这类项目时宁可把一半时间花在画数据通路和控制信号表上也不要急着敲代码。画出PCIe和SDR这个表后面无一例外会栽在某个立即数位序或者控制信号没拉高的细节上。最后分享一个小技巧在testbench里加一段打印PC寄存器和关键寄存器变化量的代码每次仿真结束后自动把每拍的变化列出来。这个习惯帮我省下了大量看波形的时间。希望你的第一颗RISC-V核也能一次跑通。