多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

用Verilog实现RISC-V单周期CPU:RV32I数据通路与核心模块设计详解

用Verilog实现RISC-V单周期CPU:RV32I数据通路与核心模块设计详解 做CPU设计这几年我见过太多人一上来就直奔流水线结果被各种冒险问题折磨到怀疑人生。其实单周期CPU才是理解处理器内核的最佳切入点它把“取指→译码→执行→访存→写回”这条数据通路摊开了摆在桌面上每一个控制信号都看得见摸得着。这次我就用Verilog把RISC-V架构的单周期CPU完整搭了一遍本文会把整个设计过程、核心模块实现和踩过的坑都梳理清楚给同样在折腾CPU设计的同学一份可以直接抄作业的参考。我选择了RISC-V的RV32I基础整数指令集作为目标包含了算术逻辑、加载存储、分支跳转等常用指令类型。用单周期实现方式每个指令在一个时钟周期内完成取指、译码、执行、访存和写回全流程。文中涉及的所有Verilog代码都是可以直接拿来跑仿真的工具链选用Icarus Verilog加GTKWave全程免费开源无需任何商业软件授权。1. 整体设计与指令集选型1.1 为什么选择RISC-V而不是MIPS或ARM很多学校的实验课还在用MIPS32作为教学CPU的指令集我之前也折腾过MIPS单周期实验对比下来RISC-V有一个非常明显的优势指令格式的规整性更好。RISC-V的六种基本指令格式R/I/S/B/U/J在字段位置设计上极其考究比如rs1、rs2、rd这三个寄存器字段在所有包含它们的指令格式中都固定在同一个位置这意味着译码逻辑可以大幅简化不需要像MIPS那样根据指令类型反复切换字段解读方式。ARM的指令集虽然生态庞大但光是ARM和Thumb两套指令集的切换机制就足以劝退初学者。相比之下RV32I只有40多条基础指令没有条件执行、没有寄存器窗口、没有桶式移位器这些历史包袱非常适合用来理解CPU的核心工作原理。另外RISC-V的开源生态这些年迅速成熟指令集手册、工具链、仿真环境全部免费开放想做扩展随时能查到权威资料不再需要对着厂商NDA文档猜谜。1.2 单周期CPU的取舍与适用场景单周期CPU的核心思想是让每条指令都在一个时钟周期内完成控制逻辑通过一个大的组合逻辑真值表生成所有控制信号。这种方式的优点非常直观控制单元简单时序关系清晰不需要考虑流水线冒险问题每个周期结束后寄存器状态就是最终结果。代价是时钟频率受限因为周期长度必须满足最慢指令的延迟比如加载指令需要依次经过取指、寄存器读取、ALU计算、数据存储器访问、写回寄存器这么长的组合逻辑路径。RV32I里lw这条指令的延迟基本上就是单周期CPU性能的天花板。好在我们的目标不是追求高性能而是彻底搞懂处理器的工作流程。单周期实现拿来做教学、做原型验证、做FPGA实验都是非常合适的选择。等把单周期的每根控制信号都弄明白后面上手流水线设计会顺很多。1.3 开发环境的搭建与工具链这次我用的工具组合如下工具用途说明Icarus Verilog编译仿真免费开源支持绝大多数Verilog-2001语法GTKWave波形查看配合iverilog生成VCD文件调试利器VS Code代码编辑配合Verilog-HDL/SystemVerilog插件RISC-V GNU Toolchain交叉编译生成测试程序的机器码如果你用的是Quartus或者Vivado流程类似只是把仿真器换成ModelSim/Vivado Simulator就行。网上经常有人遇到类似“17.1 error: failure to obtain a verilog simulation license”这种问题绝大多数是仿真器授权配置有误换成开源的Icarus Verilog之后就没有这种烦恼了直接用命令行干活反而更顺畅。安装完iverilog之后建议顺手装一下gtkwave这两个组合能覆盖90%以上的仿真调试场景。测试用的机器码我推荐先手写十六进制等CPU跑通后再上GCC生成的二进制这样能避免编译器优化引入的不可控变量。2. RV32I指令集核心模块实现2.1 指令格式与译码逻辑RV32I的每条指令长度固定为32位这是RISC-V相比x86这种变长指令集最大的优势之一取指阶段不需要判断指令边界。我按照六种基本格式来拆分指令字段R型funct7 | rs2 | rs1 | funct3 | rd | opcode用于寄存器之间的运算I型imm[11:0] | rs1 | funct3 | rd | opcode用于立即数运算和加载S型imm[11:5] | rs2 | rs1 | funct3 | imm[4:0] | opcode用于存储指令B型imm[12] | imm[10:5] | rs2 | rs1 | funct3 | imm[4:1] | imm[11] | opcode用于条件分支U型imm[31:12] | rd | opcode用于LUI和AUIPCJ型imm[20] | imm[10:1] | imm[11] | imm[19:12] | rd | opcode用于JAL跳转动手写译码逻辑之前务必先把opcode、funct3、funct7这几个字段的查表弄清楚。RV32I的主opcode低7位标识了指令的大类比如0110011是寄存器运算0010011是立即数运算0000011是加载0100011是存储1100011是条件分支。真正容易搞错的是立即数扩展不同指令格式的立即数位宽和符号扩展方式都不一样。I型指令的立即数直接取inst[31:20]再符号扩展S型要把高位和低位拼起来B型和J型更是直接把立即数打散到了多个字段中拼接顺序错了整个程序就跑飞。我贴一下B型立即数扩展的实现assign imm_b { {20{inst[31]}}, // 符号扩展 inst[7], // imm[4:1]的最高位 inst[30:25], // imm[10:5] inst[11:8], // imm[4:1]的低四位 1b0 }; // imm[0]固定为0因为分支目标必须2字节对齐这里的技巧是先画出每个字段在32位立即数中的目标位置然后从高到低逐段拼接。我第一版就把inst[11:8]和inst[30:25]的顺序搞反了仿真时条件分支永远跳错地方花了整整一个晚上才排查出来。2.2 寄存器堆与ALU设计寄存器堆是CPU里最常访问的结构之一RV32I规定有32个32位通用寄存器其中x0被硬连到常数0任何写入x0的操作都会被丢弃。这个硬连线特性在硬件上实现起来很优雅module regfile #( parameter ADDR_WIDTH 5, parameter DATA_WIDTH 32 )( input wire clk, input wire we, input wire [ADDR_WIDTH-1:0] raddr1, raddr2, waddr, input wire [DATA_WIDTH-1:0] wdata, output reg [DATA_WIDTH-1:0] rdata1, rdata2 ); reg [DATA_WIDTH-1:0] regs [0:31]; // x0 硬连线为 0当我们读取 x0 时永远返回常数 0 always (*) begin rdata1 (raddr1 5b0) ? 32b0 : regs[raddr1]; rdata2 (raddr2 5b0) ? 32b0 : regs[raddr2]; end always (posedge clk) begin if (we (waddr ! 5b0)) regs[waddr] wdata; end endmodule这个寄存器堆设计成同步写、异步读单周期CPU里这是标准做法。题外话如果将来做流水线读取端口通常也要改成寄存输出因为时序收敛对异步读比较敏感。ALU部分我参考了RISC-V规范定义了四种基本操作加法减法、按位逻辑、移位、比较。实现上用funct3和funct7联合生成ALU控制信号funct7的第5位用来区分ADD/SUBwire [3:0] alu_ctrl; assign alu_ctrl {funct7_5, funct3}; always (*) begin case (alu_ctrl) 4b0_000: alu_result src_a src_b; // ADD 4b1_000: alu_result src_a - src_b; // SUB 4b0_111: alu_result src_a src_b; // AND 4b0_110: alu_result src_a | src_b; // OR 4b0_100: alu_result src_a ^ src_b; // XOR 4b0_001: alu_result src_a src_b[4:0]; // SLL 4b0_101: alu_result src_a src_b[4:0]; // SRL 4b1_101: alu_result $signed(src_a) src_b[4:0]; // SRA 4b0_010: alu_result ($signed(src_a) $signed(src_b)) ? 1 : 0; // SLT 4b0_011: alu_result (src_a src_b) ? 1 : 0; // SLTU default: alu_result 32b0; endcase end这里有一个最容易被忽略的细节SLT和SLTU是有符号数与无符号数比较的区别。Verilog里做有符号比较需要$signed()显式转换否则两个大于0x80000000的数会被当成负数参与比较结果完全错误。我第一次写的时候没注意这点单测ALU时没暴露问题跑到排序程序时结果全乱了。2.3 立即数扩展单元前面贴了B型立即数的拼法这里把六种格式的扩展统一放到独立的imm_extend模块里方便顶层模块保持整洁。我习惯用一个2位的ImmSrc信号来选择立即数格式ImmSrc 2b00I型lw/addiImmSrc 2b01S型swImmSrc 2b10B型beq/bneImmSrc 2b11J型jalU型的LUI和AUIPC在RV32I里可以直接把inst[31:12]左移12位我单独处理当然也有更激进的写法直接拿inst[31]作为所有立即数的符号位再按格式拼接低位的不同字段。核心是确保写回寄存器的立即数符号扩展正确否则有符号运算和负数偏移全都会出错。3. 单周期数据通路搭建3.1 顶层数据通路设计单周期CPU的顶层模块可以看作一条从左到右的数据流PC取指指令送到寄存器堆和立即数扩展ALU计算数据存储器访存最后把结果写回寄存器堆。控制信号则由指令中的opcode、funct3、funct7统一解码。我的顶层模块例化结构大致如下// 取指 pc_reg u_pc ( .clk(clk), .rst_n(rst_n), .pc_next(pc_next), .pc(pc) ); // 指令存储器实际上是一个只读的ROM imem u_imem ( .addr(pc[9:2]), // 字节地址转字地址 .inst(inst) ); // 寄存器堆 regfile u_regfile ( .clk(clk), .we(reg_write), .raddr1(inst[19:15]), // rs1 .raddr2(inst[24:20]), // rs2 .waddr(inst[11:7]), // rd .wdata(result), .rdata1(rs1_data), .rdata2(rs2_data) ); // ALU alu u_alu ( .src_a(rs1_data), .src_b(alu_src_b), .alu_ctrl(alu_ctrl), .alu_result(alu_result), .zero(zero) ); // 控制单元 control u_ctrl ( .opcode(inst[6:0]), .funct3(inst[14:12]), .funct7(inst[31:25]), .reg_write(reg_write), .alu_src(alu_src), .mem_write(mem_write), .mem_read(mem_read), .mem_to_reg(mem_to_reg), .branch(branch), .jump(jump), .imm_src(imm_src), .alu_op(alu_op) );这里有个实际工程中经常纠结的点指令存储器用ROM实现还是用RAM实现。单周期CPU没有自修改代码的需求所以用只读的ROM最省事。在仿真环境里无非是一个提前初始化的数组在FPGA上可以映射到Block RAM。地址方面我选择了pc[9:2]也就是一份最多256条指令的存储空间做基础验证完全够用。3.2 控制单元的真值表与实现控制单元是整个CPU的心脏它把指令翻译成一条条控制信号。以我实现的RV32I子集为例核心控制信号包括信号功能适用指令RegWrite是否写寄存器堆运算/lw/jal等写回指令为1ALUSrcALU第二个操作数来源立即数运算为1寄存器运算为0MemWrite是否写数据存储器仅sw为1MemRead是否读数据存储器仅lw为1MemtoReg写回数据来源lw为1其他写回指令为0Branch是否为条件分支指令beq/bne/blt/bge等为1Jump是否为无条件跳转jal/jalr为1ALUOpALU操作码由funct3/funct7进一步解码写出这个真值表之后控制逻辑就是一组简洁的assign语句。我建议先用表格把每条指令对应的控制信号列全再写代码否则很容易漏掉某个信号的默认值。一个非常经典的bug是MemtoReg和RegWrite信号在分支指令下同时为1导致分支指令把ALU结果误写回寄存器堆。RISC-V的分支判断在ALU里完成用减法结果是否为零判断BEQ/BNE用SLT结果判断BLT/BGE。这里需要注意BGE和BEQ的区别BEQ是比较两个数相等BGE是比较是否有符号大于等于两者在翻译成ALU控制信号时完全不同。3.3 分支跳转与PC更新逻辑单周期CPU的PC更新逻辑是组合逻辑下一周期的PC由当前PC、分支信号和跳转信号共同决定assign pc_next jump ? pc_jump_target : branch branch_taken ? pc_branch_target : pc 4; assign branch_taken (branch (funct3 3b000 alu_zero) || // beq (funct3 3b001 !alu_zero) || // bne (funct3 3b100 alu_result[0]) || // blt (funct3 3b101 !alu_result[0])); // bge分支目标地址的计算方式是pc 立即数立即数来自B型扩展。跳转目标地址则分两种情况JAL的目标是pc 立即数JALR的目标是(rs1 立即数) ~1这个掩码操作是为了保证跳转目标地址是2字节对齐的。一个容易掉的坑分支指令计算目标时用的隐含值是当前的PC而不是pc4。在单周期CPU里分支指令执行到这一周期时PC寄存器里的值还是当前指令的地址所以直接用pc imm_b是正确的。如果你偷懒直接把地址右移两位再做加法仿真波形可能恰好对了但一旦碰到边界情况就会出问题。3.4 数据存储器与加载/存储指令数据存储器我用了同步写的RAM模块读操作是组合逻辑。RISC-V的加载指令还区分字节、半字和字这里有符号扩展和零扩展的区别LB加载1字节并符号扩展LBU加载1字节并零扩展LH加载2字节并符号扩展LHU加载2字节并零扩展LW加载4字节// 以LB/LBU为例 wire [31:0] mem_rdata_word; wire [7:0] byte_sel mem_addr[1:0]; wire [7:0] load_byte byte_sel[1] ? (byte_sel[0] ? mem_rdata_word[31:24] : mem_rdata_word[23:16]) : (byte_sel[0] ? mem_rdata_word[15:8] : mem_rdata_word[7:0]); assign mem_rdata lb ? {{24{load_byte[7]}}, load_byte} : // LB 符号扩展 lbu ? {24b0, load_byte} : // LBU 零扩展 mem_rdata_word; // LW 直接取整字这里的地址对齐问题值得注意RV32I允许非对齐访问吗规范里说支持但很多实现并不完整支持。我这次实现的存储器是32位对齐的如果lw的地址低两位不为0读出来的数据就会错位。最省事的做法是在测试程序里保证所有加载存储指令都对齐到4字节边界。SW的写入也是类似的逻辑需要根据addr[1:0]选择写哪些字节通道。我最初图省事直接整字写入结果一个sb指令把相邻字节全部覆盖成0了调试了好久才意识到问题。4. 仿真验证与常见问题排查4.1 测试程序与仿真环境的搭建CPU写完之后验证阶段的工作量其实一点不比写RTL小。我的做法分三步先写几个简单的单指令测试再写一段小循环程序验证分支跳转最后跑一个数组求和或冒泡排序来综合验证。第一阶段的测试程序用手写十六进制机器码这样能精确控制每条指令的行为。比如验证ADDIaddi x1, x0, 5 # x1 5 addi x2, x1, 7 # x2 12 sub x3, x2, x1 # x3 7 sw x3, 0(x0) # 把结果存到内存地址0 lw x4, 0(x0) # 读回来对应的机器码需要手动编码这个过程比较折磨人但也是熟悉指令编码格式的好机会。我一般先用Python脚本生成机器码再Verilog里$readmemh加载到指令ROMiverilog -o cpu_tb tb_cpu.v cpu.v regfile.v alu.v control.v imm_extend.v vvp cpu_tb gtkwave cpu_tb.vcd 测试平台的核心是检查每个周期之后关键寄存器的值是否符合预期。我的测试平台里写了一个简单的自检逻辑每条指令执行完后比对x1~x31的值和内存数据任何不一致立刻报告错误并停止仿真。这种自检方式比人眼看波形高效得多。4.2 常见问题速查表与调试技巧把这次设计过程中遇到的和网上高频出现的问题整理成一份速查表遇到类似问题的同学可以直接对照排查现象可能原因排查方法所有指令都不动rst_n没有正常释放或时钟没跑起来先检查时钟复位波形程序计数器一直停在0PC使能或更新条件不成立检查pc_next是否一直等于pc寄存器写不进去RegWrite信号始终为0或者waddr选错核对控制信号真值表和rd字段位置分支跳转位置总差一条指令分支目标地址算错可能在pc和pc4之间仔细检查B型立即数扩展lw结果不对但add正常MemtoReg选择错了或者data memory读端口时序不对在波形里看mem_rdata是否在正确周期有效数组求和结果全是0数据存储器和指令存储器地址空间冲突检查数据存储器地址是否映射到了代码区域跑GCC编译的汇编出错编译器生成了一些你还没实现的扩展指令先用-marchrv32i -mabiilp32强制RV32I异步复位时寄存器被写脏复位期间RegWrite仍有效用if(!rst_n)优先拦截复位分支调试技巧方面我最想强调两点。第一点是充分利用$display在关键信号变化时打印信息比如每周期结束打印PCxxx instxxx rdx1 rs1_valxxx alu_resultxxx这样可以直接在终端追踪程序执行流程比反复看波形快得多。第二点是善用GTKWave的标记功能把PC、当前指令、关键控制信号拖到同一个时间轴上对比尤其是调试分支决策逻辑时一目了然。4.3 一套跑通的初步冒烟测试方案最让我印象深刻的验证脚本是一个12行的汇编冒烟测试它依次验证了算术逻辑指令、立即数操作、分支跳转、加载存储这几类核心功能。12条指令虽然简单但覆盖了单周期CPU里绝大部分控制信号作为一个“冒烟测试”再合适不过了。举个例子这个简单程序里我特意安排了一条BEQ和一条JALaddi x1, x0, 10 addi x2, x0, 10 beq x1, x2, equal # 相等跳到equal addi x3, x0, 1 # 这条不应该执行 equal: jal x4, end # 跳转,end应该保存返回地址 addi x5, x0, 0 end:如果BEQ判断正确x3应该保持0而不是变成1如果JAL的返回地址写回正确x4应该是end标签对应的PC4。这类程序在仿真空洞期特别有用它能快速暴露是不是核心控制信号错了而不是外设或其他模块的问题。5. 扩展方向与后续演进5.1 流水线扩展的思路与风险单周期CPU跑通之后最自然的下一步是把它改造成流水线。我曾经在一版作业里直接做五级流水取指IF、译码ID、执行EX、访存MEM、写回WB结果掉进了数据冒险的坑里出不来回。建议先做两级流水试探一下理解指令重叠执行的基本原理再扩展到五级。流水线化真正的难点是冒险处理数据冒险需要前递控制冒险需要分支预测或插入气泡结构冒险需要确保不会出现同一周期访问同一存储器的冲突。以数据冒险为例最简单的解法是暂停流水线stall虽然损失性能但先保证正确性等调试通了再上forwarding。一个很实用的过渡方案是保留单周期CPU的数据通路只在每两个模块之间插入流水线寄存器。形式上像流水线但把所有阶段的时间参数统一成单周期版本这样就变成了一个“伪流水线”主要用来学习流水线寄存器的位置和控制信号同步。5.2 功能扩展中断、乘除法与协处理器RV32I只是个基本形态实际产品级的RISC-V处理器一般会扩展M扩展乘除法、F/D扩展浮点、C扩展压缩指令等。M扩展的乘法和除法指令是很多算法的基础实现起来不算太难主要难点是选好乘法器的架构组合逻辑直接乘还是用移位加法串行计算。中断和异常处理对单周期CPU来说是个比较大的改造因为需要增加CSR控制和状态寄存器以及相应的异常处理入口。我在一版作业里加了一个简单的定时器中断核心是增加一条ebreak指令触发异常CPU跳转到固定地址执行中断服务程序中断服务程序里执行mret返回。这个过程能在单周期CPU上独立跑通后面学特权架构就轻松多了。如果想在上面跑更复杂的程序可以试一试把UART串口模块和异步FIFO接进来。异步FIFO在跨时钟域的通信里几乎必不可少这也是Verilog工程实战里极其常见的模块配合I2C读写EEPROM这类外设基本就是一台迷你SOC的雏形了。5.3 综合到FPGA板的注意事项如果你手头有一块FPGA开发板用Altera Cyclone IV或者Xilinx Artix-7把CPU烧进去体验会完全不同。综合之前有几个必须修改的点把仿真用的ROM/RAM换成板载存储资源如Block RAM把时钟替换成板载晶振驱动的PLL输出必要时做跨时钟域处理增加复位按键和LED/数码管显示模块方便观察CPU运行状态时序约束文件SDC/XDC要提前写好尤其是时钟约束我见过一个常见的翻车现场RTL仿真完美综合到FPGA之后CPU不工作最后定位是复位信号毛刺导致寄存器初始化异常。建议统一使用异步复位、同步释放的复位策略FPGA的综合工具对这种模式识别最友好。还有一个忠告板级调试要先从点灯开始确保时钟和复位没问题再逐步加载真正的程序。一次直接上大程序出了问题根本不知道是RTL逻辑错误还是时序约束问题排查效率极低。6. 这次设计过程的几点心得写到这里说几个我真实的体会。首先是不要急着上来就敲代码先把指令集的数据通路图画明白。我在纸上画了三遍RV32I数据通路才动手写RTL每次画完都会发现新的理解盲区和设计优化点。其次仿真测试的完备性比代码本身更重要。有时候一条指令看似仿真通过了但换个边界值就翻车。我有一个习惯是给每个指令都写边界测试比如用最大的立即数做ADDI用x0做源寄存器用相同的源寄存器做SUB这类边界情况最容易暴露组合逻辑的漏洞。最后想说如果你也卡在某个阶段很久不妨把你的设计代码和测试样例整理一下对照着别人的实现逐行读。Verilog学习的本质是“读代码→写代码→调代码”的循环多读多写这层窗户纸很快就捅破了。这篇单周期CPU设计的分享就先到这里后续如果大家在实现过程中遇到问题欢迎交流讨论。
返回列表