资讯动态

RISC-V单周期CPU设计与实现:基于SystemVerilog的RV32I数据通路实战

发布时间:2026/10/21 16:22:44 来源:尧图企业网站定制
我最近刚把一个 RISC-V 32 位单周期处理器完整跑通工程是在 Vivado 里搭的代码用 SystemVerilog 编写整体结构刻意做得很简单目标是先把 RV32I 的核心数据通路完整捋清楚。这个项目不是一个生产级的 CPU 核而是一个能执行基础整数指令的小处理器麻雀虽小五脏俱全取指、译码、执行、访存、写回全部在一个时钟周期内完成配合一个手写的汇编程序能在上板后看到计算结果通过 LED 或数码管输出。它的价值在于把《计算机体系结构》里那堆概念——数据通路、控制信号、立即数扩展、寄存器堆读写、ALU 控制——一次性落到真实的 RTL 代码上。如果你正在学体系结构或者已经用 Verilog 写过一些组合逻辑和状态机正想挑战“自己写一个 CPU”这个项目是非常合适的练手目标。就算你是刚接触 SystemVerilog 的初学者只要耐心跟着仿真一步步看信号也能把它吃透。下面是我完整做完一次后的复盘包括设计思路、代码要点、工程步骤、踩过的坑以及后续可以怎么扩展。1. 单周期 CPU 的项目定位与整体设计1.1 为什么选择 RISC-V而且是最简的 RV32IRISC-V 这几年能成为体系结构教材里的“新宠”不是没有道理的。它最吸引人的一点是开放免费指令集规范任何人都能拿到不存在授权问题另一个关键点是它的基础指令集非常干净尤其 RV32I只有 40 多条整数指令没有历史包袱。对比以前教材里常用的 MIPSRISC-V 的指令格式更多样但反而更贴近现代编译器的需求而且在工业界已经有大量落地场景。对学习者来说用 RISC-V 做处理器等于站在一套活跃生态的入口上做完之后还能用交叉编译器跑真正的 C 程序这种“做完还能接着玩下去”的延展性是很多私有指令集给不了的。具体到本项目我选择的子集是 RV32I 里最核心的一批指令加法类 R 型指令add、sub、and、or、xor、sll、srl、sra、slt、sltu立即数类 I 型指令addi、xori、ori、andi、slli、srli、srai、slti、sltiu访存指令lw、sw分支指令beq、bne、blt、bge、bltu、bgeu跳转指令jal、jalr以及两条与位置无关的指令lui、auipc。这套指令集虽然只占整个 RISC-V 的一小部分但是足够让数据通路完整跑起来也能支持循环、函数调用、数组访问这类基础程序行为。为什么不直接上 RV32IM 甚至带乘除因为单周期 CPU 的特性决定了每个指令周期要在一个时钟里走完全部流程如果加入乘法器或者除法器关键路径会指数级变长到时候时钟频率会低得很难看。更合理的做法是先把整数核心跑通后面再考虑用多周期或者流水线去吸收复杂指令的延迟。这也是我这次刻意把结构做简单的原因先让“能跑”发生再让“跑得快”发生。1.2 单周期结构的优势与代价单周期处理器的核心定义很简单每条指令在一个时钟周期内完成整个生命周期。一个周期的长度由这条路径上所有组合逻辑的延迟之和决定典型的最长路径是 load 指令PC 出发 → 读指令存储器 → 读寄存器堆 → 经过 ALU 计算访存地址 → 读数据存储器 → 结果写回寄存器堆。这条链路如果放在一个高主频环境里往往就成了性能瓶颈。但从教学和项目落地角度来看单周期有一个不可替代的好处——数据通路极其清晰。它不需要考虑流水线冒险不需要转发逻辑不需要停顿控制每条指令的执行路径都是同一条只是不同指令通过不同的控制信号选择不同的数据流。调试的时候你在波形里看着 PC 一点一点增加每个周期都能明确地看到“这条指令从哪来”“访问哪些寄存器”“ALU 做了什么”“结果写到哪里”这种直观是流水线 CPU 给不了的。所以这个项目非常适合作“第一个 CPU”。它解决的问题是让学习者建立对指令生命周期和数据通路映射的直觉。代价是性能并不理想但我们本来就不是冲着性能去的。1.3 SystemVerilog 相比 Verilog 好在哪既然说要用 SystemVerilog 写那就顺便讲讲我为什么不用传统 Verilog。很多人写 CPU 还是几十年前的那套 Verilog-2001 风格always(posedge clk) 加上 reg 和 wire 满天飞。SystemVerilog 带来的核心改进恰好都是 CPU 设计刚需使用 logic 类型替代 reg/wire 的区分写组合逻辑和时序逻辑都统一用 logic省掉大量可读性负担。在编写组合逻辑时务必使用 always_comb 而不是 always(*)——always_comb 会检查敏感列表是否完整并且对阻塞赋值的使用有约束很多因为漏写敏感列表导致的仿真错误可以被预防。interface 类型特别适合把 CPU 内部的取指总线、访存总线划分成独立的连接束不用再手拉十几条线如果之后想改成 AXI 或 Wishbone 总线只需在 interface 上封装。像我这次虽然没直接用 interface 连接每个模块但在 top 层用 struct 打包了控制信号组代码比 Verilog 干净很多。enum 类型可以给指令类型、状态机状态起名字比如 enum bit [1:0] {ALU_R, ALU_I, MEM, BRANCH} ctrl_state; 调试波形时看到的是“ALU_R”而不是裸的 2 进制数对排查问题的帮助是实打实的。对于 UVM 验证环境或后续使用断言SVA也有天然支持以后想往验证方向深入也能平滑过渡。当然如果你手头只有 Verilog 基础也不影响阅读项目代码。两者在可综合的 RTL 层面差别并没有那么大关键还是把数据通路理解清楚。1.4 系统架构与会话模块一览整个 CPU 的数据流可以用一句话描述一台“取指 → 译码 → 执行 → 访存 → 写回”的单周期流水线实际上只有一条长组合路径。具体模块划分如下表所示模块名称主要职责关键输入/输出pc_reg程序计数器保存当前指令地址输入时钟、复位、跳转使能、跳转目标输出当前 PCinst_rom指令存储器只读存储程序输入地址输出 32 位指令regfile通用寄存器堆32 个 32 位寄存器输入 rs1/rs2/rd、写使能、写数据输出读数据 1/读数据 2imm_gen立即数扩展支持 I/S/B/U/J 五种格式输入指令字段输出 32 位立即数alu算术逻辑单元输入两个 32 位操作数、ALUOp输出运算结果与分支条件control_unit主控制单元生成全模块信号输入 opcode/funct3/funct7输出各控制信号data_ram数据存储器读写 32 位字输入地址、写数据、MemWrite/MemRead、时钟输出读数据top顶层例化全部模块引出外设接口输入时钟、复位输出 LED/数码管信号pc_reg 到 inst_rom 之间的地址是 PC 本身inst_rom 的输出直接连接到最长的组合逻辑链所有控制信号由 control_unit 根据指令的 opcode、funct3、funct7 产生它们控制了 ALUSrc选择寄存器数据还是立即数、MemWrite、MemRead、MemToReg选择 ALU 结果还是访存结果写回、Branch、Jump 等。这个架构图如果画出来是一个很漂亮的环PC → 指令 → 控制信号 → 寄存器堆 → ALU → 内存 → 写回寄存器堆。理解了这个环后面每一段代码都只是在给这个环里不同的点补上细节。2. 核心模块设计与实现要点2.1 取指单元与 PC 更新逻辑PC 模块是整个 CPU 的“节拍器”每个时钟上升沿决定下一条指令的地址。单周期里它很简单默认 PC PC 4遇到分支且条件成立时PC PC 分支目标偏移遇到跳转时PC 跳转目标。在 RV32I 里PC 更新永远是 32 位地址不需要考虑对齐因为 RV32I 的基础指令都是 32 位定长地址最低两位永远为零PC 4 就是正确的下一条地址。我实现的 PC 逻辑如下面的代码所示always_ff (posedge clk or negedge rst_n) begin if (!rst_n) pc 32h00000000; else if (branch_taken) // beq/bne/blt 等条件分支 pc pc imm_se; // imm_se 是符号扩展后的 B 型偏移 else if (jump) pc jump_target; // jal/jalr 的目标地址 else pc pc 32h4; end这里有几个初学者容易出问题的点。第一分支目标不是简单地加上立即数就算完B 型立即数扩展时最低位是 0第 0 位补 0本质是给了一个按 2 字节对齐的偏移但由于指令是 4 字节对齐所以实际使用时会左移 1 位或者直接按字对齐。稳妥做法是在 imm_gen 模块里就把 B 型和 J 型的立即数位移到正确的位再符号扩展成 32 位这样 PC 端加一次就行。第二跳转和分支同时为 1 时按优先级应该让 jump 覆盖 branch虽然实际编码中这两类指令的 opcode 不冲突但防御性写法还是应该 if-else if 分开。第三复位后 PC 一定要是 0x0这要求复位信号必须稳定地覆盖到所有寄存器后面我会讲到仿真和上板复位极性不一致的坑。2.2 指令存储器与初始化文件的坑指令存储器用只读的 ROM 实现最简单的 RTL 写法就是组合逻辑读数组logic [31:0] mem [0:4095]; // 4K 条指令 initial $readmemh(program.hex, mem); always_comb inst mem[pc[31:2]];这样写可以让行为仿真非常直观给一个 PC立刻得到一个指令。到了综合阶段工具会把它综合成 LUTRAM 或者块 RAM 的 ROM 形式。这里有两个我实际踩过的坑必须单独拿出来说。第一个坑是初始化的路径问题。$readmemh里的文件名在仿真和综合时的搜索路径逻辑不一样如果你的工程目录结构变化或者换了电脑容易发生“仿真正常但综合后 BIT 文件里没有程序”这种奇怪现象。稳妥的办法是把 hex 文件放到工程根目录下文件名用相对路径如果确实担心可以在顶层模块用绝对路径写一次等综合前再改成相对路径或者直接在 initial 块里用$readmemh(“program.hex”, mem)并用 Vivado 的add_files把它加入工程作为“Source File”让工具跟踪文件依赖。第二个坑是综合时 ROM 被优化掉的情况。某些版本的 Vivado 对纯组合读的数组初值支持并不彻底特别是当你只读一个固定地址区间时工具可能会认为它只是一个常量表直接摊平成 LUT 逻辑导致资源的浪费和时序恶化。为了解决这个问题如果是在实际板卡上跑比较长的程序我建议直接用 Block Memory Generator IP 核生成一个单端口 ROM并把 hex 文件加载进去如果只是想验证 CPU 逻辑用数组加$readmemh就够了。而如果你不想引入 IP也可以给数组声明加上综合属性(* ram_style block *) logic [31:0] mem [0:4095];告诉工具“请用块 RAM 实现”这通常就能避免初值丢失问题。2.3 寄存器堆的读写时序容易翻车的地方寄存器堆是 CPU 里写代码最容易“差一行”的模块。RISC-V 有 32 个通用寄存器x0 硬连线为 0任何写入 x0 的操作都被丢弃。寄存器堆提供两个异步读端口和一个同步写端口。读是组合逻辑因为本周期指令在执行阶段需要立即拿到 rs1/rs2 的值写是时序逻辑在时钟上升沿写入。我写的寄存器堆核心代码如下// 读端口 1组合逻辑 always_comb begin if (rs1_addr 5b0) rd1 32b0; else rd1 regs[rs1_addr]; end // 写端口 always_ff (posedge clk) begin if (we rd_addr ! 5b0) regs[rd_addr] wd; end读地址为 0 时返回 0这一行千万别漏否则遇到 x0 当基址的指令会读出随机值。真正容易迷惑的是“先读后写”的语义。在单周期 CPU 里每条指令只占一个周期不存在两条指令在同一周期读写同一个寄存器的并发问题。但要注意的是如果在同一个周期内既有一个读操作比如本指令的 rs1 是 x5又有写操作假设本指令的 rd 也是 x5那么读到的应该是旧值还是新值答案是读端口是组合逻辑时钟上升沿来临时写才发生所以在整个组合逻辑计算期间读到的都是上一条指令留下、本周期尚未更新前的旧值。这个行为正好符合 RISC-V 规范中“先读后写”的定义。这一点很重要如果你之后做流水线版本的 CPU就必须显式去做寄存器堆的“旁路/转发”否则就会发生写后读冒险。这也是为什么我推荐做这个项目时先把单周期吃透——流水线里的很多问题都是从这里衍生出去的。顺便说一下把写地址和写数据信号引到模块外部后在 testbench 里观察写回路径是调试 CPU 最直观的窗口。我习惯在顶层留一组dbg_*输出把 rd 地址、寄存器堆写数据、PC 都引到仿真波形里。2.4 立即数扩展RV32I 五种格式直接给你立即数扩展是 CPU 里另一个容易出现低级错误但在图纸上看起来极简单的模块。RV32I 的指令格式一共有六种R 型、I 型、S 型、B 型、U 型、J 型。其中 R 型没有立即数其余每种都有一套自己的立即数排列方式。RISC-V 设计上已经保持了高度规律性——每种格式下立即数的高位都是原始指令的高位所以扩展本质就是把指令的某些字段拼起来再做符号扩展或零扩展。以 I 型为例addi x1, x0, 5 的机器码是 0x00500093二进制是 000000000101_00000_000_00001_0010011其中立即数位段是 inst[31:20] 12b000000000101需要扩展到 32 位由于第 11 位符号位是 0所以扩展为 0x00000005。如果是负数比如 addi x1, x0, -1立即数字段是 12hfff符号位是 1扩展后是 32hffffffff。这就是为什么不能用简单的 {20b0, inst[31:20]} 来拼后面的高 20 位必须是全 0 还是全 1取决于 inst[31]。我实现的 imm_gen 模块可以简化成下面这样同时处理需要的格式always_comb begin unique case (imm_sel) // 由控制单元给出 IMM_I: imm {{20{inst[31]}}, inst[31:20]}; IMM_S: imm {{20{inst[31]}}, inst[31:25], inst[11:7]}; IMM_B: imm {{20{inst[31]}}, inst[7], inst[30:25], inst[11:8], 1b0}; IMM_U: imm {inst[31:12], 12b0}; IMM_J: imm {{12{inst[31]}}, inst[19:12], inst[20], inst[30:21], 1b0}; default: imm 32b0; endcase end这里 B 型和 J 型已经做了左移一位也就是把最低的 0 补上对应 2 字节偏移量。实际使用中分支偏移会按 4 字节对齐但 B 型生成的 immediate 其实已经左移了 1 位所以 PC imm 就是分支目标。J 型同理JAL 的目标地址是 PC 符号扩展后的 20 位偏移低 1 位是 0。一个经常出现的错误是有人把 U 型立即数想成是 20 位符号扩展再加 12 位 0这是不对的。LUI 和 AUIPC 的立即数是 20 位直接放在高 20 位低位补零不进行符号扩展。这是 RISC-V 特意设计成可以在一条指令里装载任意 32 位常数的高 20 位不用管符号。2.5 ALU 设计与运算编码ALU 是数据通路的中心几乎每一条指令都会经过它。RV32I 基础指令里ALU 要支持加法、减法、按位与、或、异或、逻辑左移、逻辑右移、算术右移、有符号小于比较、无符号小于比较。虽然指令种类多但 ALU 只需要一组合适的编码就能区分。我给 ALUOp 分配了 4 位编码控制单元根据指令功能生成 ALUOpALU 内部再对操作码做译码。设计如下ALUOp 编码功能说明0000ADD加0001SUB减0010AND按位与0011OR按位或0100XOR按位异或0101SLL逻辑左移0110SRL逻辑右移0111SRA算术右移1000SLT有符号小于1001SLTU无符号小于有符号小于比较可以这样实现先计算 a - b然后根据最高位判断。但存在溢出问题更稳妥的做法是用比较运算$signed(a) $signed(b)让工具综合成合适的电路虽然综合出的面积稍微大一点但对 FPGA 来说毫无压力。对于 SLTU 则直接做无符号比较即可。这里还涉及到一个分支处理的细节beq 的组合逻辑比较其实可以在 ALU 里用减法完成结果为 0 表示相等blt/bltu 则直接用 SLT/SLTU 结果。控制单元在分支指令时会让 ALUOp 指向 SUB 或 SLT然后根据 funct3 决定分支条件是否为真。我是这样组合分支信号的控制单元输出一个branch_type[2:0]ALU 内部根据它和比较结果生成 branch_takencase (branch_type) 3b001: branch_taken equal; // beq 3b010: branch_taken !equal; // bne 3b011: branch_taken lt_signed; // blt 3b100: branch_taken !lt_signed; // bge 3b101: branch_taken lt_unsigned;// bltu 3b110: branch_taken !lt_unsigned;// bgeu default: branch_taken 1b0; endcase实际上在单周期里ALU 中有专门的比较输出同时送给 controller 生成 branch_taken这样整体比较清晰。2.6 数据存储器的读写控制数据存储器 RAM 的读写比 ROM 复杂一点因为有写使能。我的简化实现里只支持 32 位整字访问也就是说 lw、sw 都按字地址操作不处理字节/半字的部分写使能。对于本项目这样做完全够用而且少了很多 byte enable 的麻烦。如果需要扩展 lb、lbu、lh、lhu、sb、sh就要额外增加字节掩码逻辑。数据存储器的核心逻辑如下logic [31:0] dmem [0:1023]; always_ff (posedge clk) begin if (we) // MemWrite dmem[addr[31:2]] wdata; // 写使能按字地址 end always_comb rdata dmem[addr[31:2]]; // 组合读这里有个容易被工具“优化”的点由于数据存储器只有写使能没有读使能而且读数据始终等于当前地址处的值所以工具可能推断成 block RAM 或 LUTRAM这个都没关系。但要注意复位后 RAM 内容是未知的所以在测试程序里一定先写后读或者明确知道某些位置有值再去读。如果以后想跑更复杂的程序比如从串口加载程序可以把 data_ram 换成双端口 RAM一个端口给 CPU一个端口给调试/加载逻辑。这个扩展对架构冲击很小因为访存接口本来就是标准的读写端口。2.7 控制单元真值表整个 CPU 的“大脑”控制单元是整个 CPU 里最“人文”的部分——它把指令语义翻译成数据通路上各模块的控制信号。我列的信号包括 RegWrite、ALUSrc、MemRead、MemWrite、MemToReg、Branch、Jump、ALUOp、ImmSel 这些。下表是带基础指令类型的控制信号真值表指令类别RegWriteALUSrcMemReadMemWriteMemToRegBranchJumpALUOpImmSelR 型1000000functIMM_NONEI 型 (addi等)1100000add/sub/...IMM_ILoad (lw)1110100addIMM_IStore (sw)0101x00addIMM_SBranch (beq等)0000x10sub/sltIMM_BJAL1x00001noneIMM_JJALR1100001addIMM_ILUI1100000luiIMM_UAUIPC1100000luiIMM_U这张表是写控制单元时的“作战地图”。建议在写代码之前打开 RISC-V 手册把每一条你打算支持的指令都填到这张表里然后照着它写 case。写的时候务必对 opcode 做 default 处理给所有输出赋成安全值通常全零否则综合时会推断出锁存器。比如 Store 指令不写寄存器RegWrite 必须为 0MemToReg 在非 load 指令时可以是任意值但最好固定为 0方便一致性。写 case 时用 enum 定义 opcode 值比如OPCODE_STORE 7b0100011会极大提升代码可读性。Vivado 的波形显示也会列出枚举名排查时一目了然。3. Vivado 工程搭建与完整实操流程3.1 从新建工程到第一个行为仿真在 Vivado 中创建工程时选 RTL Project 即可器件型号按你手头开发板选择比如常见的中端 FPGA 系列型号。工程建立后把所有 .sv 文件添加进 Design Sources把 hex 文件添加为 Source 或直接放在工程目录。顶层模块起名top里面例化所有子模块并引出 clk、rst_n、output 端口。在写 testbench 时我建议做两件事一是初始化时钟和复位二是给足够长的仿真时间。一个非常典型的 testbench 模板是这样bit clk 0; always #5 clk ~clk; // 10ns 周期100MHz initial begin rst_n 0; repeat (5) (posedge clk); rst_n 1; // 等待足够多的周期让程序跑完 repeat (100) (posedge clk); $finish; end重点在于复位释放之后至少要跑 100 个周期以上因为单周期加程序跳转指令执行的周期数和指令条数一致100 个周期能覆盖一个小型测试程序比如 30 条指令加循环。仿真结束后你可以在波形里把pc、inst、rd、rd_we、alu_result等关键信号加到 Viewer 里逐周期检查。我自己的习惯是干脆把写回寄存器堆的值打印到终端用$monitor监控写的动作这样不用盯着波形也能快速判断 CPU 是否在“干活”。比如always_ff (posedge clk) begin if (regs_we rd ! 0) $display(t%0t PC%08x write x%0d %08x, $time, pc, rd, wd); end如果仿真结束后没有打印任何一行“write x...”那说明控制单元的 RegWrite 信号有问题或者写地址恒等于 0。3.2 上板验证约束文件、引脚分配和调试技巧行为仿真通过只代表 RTL 逻辑正确上板验证才能真正检验“硬件能不能跑”。设计约束文件时我通常会在 XDC 里至少完成三件事时钟引脚约束、复位引脚约束、LED 输出引脚约束。由于不同开发板的引脚分配差异很大这里不写具体引脚编号只说明必须用 set_property 指定引脚和 IOSTANDARDset_property PACKAGE_PIN U18 [get_ports clk] set_property IOSTANDARD LVCMOS33 [get_ports clk] set_property PACKAGE_PIN E16 [get_ports rst_n] set_property IOSTANDARD LVCMOS33 [get_ports rst_n] set_property PACKAGE_PIN N14 [get_ports led[0]] set_property IOSTANDARD LVCMOS33 [get_ports led[0]]约束写完后先跑综合前的基本检查确认没有悬空端口没有未约束的输入时钟。然后用综合后的资源报告确认 LUT/FF 使用量是否符合预期。我这个 CPU 占用比不上大型核通常几十到几百个 LUT 而已如果资源报告里出现几千上万个 LUT说明工具把 ROM/RAM 全部摊平了可以回到上文的 ram_style 处理。上板验证时一个非常实用的小技巧是把 PC 的低几位接到 LED 上用来指示程序运行状态。如果 LED 一直在快速变化说明 CPU 在持续取指执行如果 LED 固定在某个值可能是程序跑完进入死循环也可能是 CPU 卡在复位状态。更进一步的验证方式是把某个寄存器的值接到七段数码管上比如程序跑完后把结果写到某个 GPIO 寄存器再输出到数码管。我通常会做一个很简单的“观测模块”用拨码开关选择要观察的寄存器号然后把它实时送到数码管显示。这个模块本身也只有几十行代码但对调试的幸福感提升非常明显。上板后如果完全没有反应第一步不是改代码而是确认时钟和复位用示波器/逻辑分析仪看开发板上的时钟源是否到了 FPGA 的时钟引脚用按键切换复位信号观察复位电平极性是否与代码一致。很多板子的按键按下是低电平而你的设计是低有效复位那没问题但如果按键按下是高电平又不做按键检测可能永远按不出复位效果。所以上板前建议把复位信号也引到一个 LED 上实时反映复位状态。3.3 一个简单但完整的测试程序构建过程要验证 CPU 正确性我建议不要一上来就跑复杂程序而是走一条“指令覆盖递增”的路线。我常用下面这个手写汇编测试序列来做第一轮验证addi x1, x0, 5 # x1 5 addi x2, x0, 3 # x2 3 add x3, x1, x2 # x3 8 sub x4, x1, x2 # x4 2 sw x3, 0(x0) # 将 8 写入内存地址 0 lw x5, 0(x0) # x5 8 beq x3, x5, loop # 应跳转 addi x6, x0, 1 # if branch works, this is skipped loop: jal x7, end end: lui x6, 0x12345然后把这段汇编翻译成机器码我一般用工具或者查表写进 hex 文件。如果行为仿真里每一步的寄存器值都符合注释说明这条链路的基础是通的。接下来再逐批加入 SLT、SLL、SRL、AUIPC、JALR、分支组合等指令。这里给一个我自己的经验与其手工翻译汇编不如装一个 RISC-V 的交叉编译器或者使用在线的汇编环境把汇编编译成二进制再反汇编检查编码。因为手工翻译非常容易错位比如把sw x3, 0(x0)的 S 型立即数字段给搞反了。用工具生成后再对比你的 RTL 行为会省下大量查错时间。4. 常见问题与排查技巧实录4.1 仿真波形全是 X 态问题出在哪这种情况几乎每个写 CPU 的人都会遇到尤其是第一次搭寄存器堆和 ROM。全 X 态的根源通常只有一个有模块在复位释放之前没有被正确初始化或者复位信号根本没有起作用。排查步骤是看复位释放时刻是否真的对各个模块生效。在波形里看顶层rst_n是否从 0 变 1再看pc在复位时是否归零。看时钟是否打到了每个时序模块。比如regfile的clk端口是否连上如果模块例化时端口漏连信号天然就是 Z。看 ROM 的$readmemh是否真的加载成功。仿真日志里应该不会报“file not found”之类的错误但如果有那就是路径问题。看是否存在位宽不匹配。比如寄存器堆的地址端口只有 4 位却接了 32 位 PC高位缺失导致读取到了随机地址。解决思路是“由点及面”先把 PC 和 ROM 数据通路独立仿一遍确保每周期都能拿到指令再逐步接入控制单元和寄存器堆。不要一次性把全部模块堆上去然后看着一片 X 发呆。模块化仿真虽然费一点功夫但调试成本反而是最低的。4.2 综合后出现 latch 警告Vivado 的综合日志里如果出现 “latch inferred” 或 “WARNING: A latch is inferred for the signal ...”这就是个大坑。锁存器会让设计出现电平敏感的存储行为对同步时序设计来说非常危险表现为同一信号在不同输入组合下可能记住旧值极难定位。产生 latch 的原因很典型在 always_comb 里对某个信号没有在所有分支中都赋值。比如always_comb begin if (sel) begin data_out a; end // 缺少 else end当 sel 0 时 data_out 没有被赋值工具只能推断出锁存器。解决方法是无论多少分支都确保所有信号在所有路径上都被赋值或者干脆在每个 always_comb 开头赋默认值然后在 case 里覆盖。我写控制单元时有一个习惯先写regwrite 0; alusrc 0; ...一整行默认赋值再接着写case(opcode)这样到最后也不会遗漏任何分支。这个做法对 CPU 这种控制信号极多的模块非常有效。4.3 上板后 RAM/ROM 初值不对前面提过行为仿真正常但上板跑飞常见原因是 ROM 初值没有正确放进 bitstream。这里给一个通用的检查方法在仿真中把 ROM 数组的初值打印出来确认加载正确在综合后的报告中查 Block RAM 或分布式 LUTRAM 的资源列表看有没有包含该 hex 文件。如果综合结果显示 ROM 只是组合逻辑就有理由怀疑它被摊平看它的输入端是否被优化掉了。解决办法用 IP 核生成 ROM或者用 ram_style 属性强制成 block。另外一个值得注意的现象是数据存储器的初值全为 X但 CPU 上电后程序会往内存写入数据只要程序逻辑正确这个问题本身不会导致跑飞。但如果你用了一个未初始化的内存单元去加载读出来就是 X后续比较就会出问题。所以测试程序务必做到先用再取。4.4 时钟频率提不上去关键路径太长怎么读报告单周期 CPU 的时序报告通常不难看可以在 Vision 的时间摘要Timing Summary里看到最差负裕量WNS对应的路径。这条路径大概率就是 PC → 指令 ROM → 寄存器堆 → ALU → 数据 RAM → 寄存器堆写回。如果 WNS 为负数意味着时钟频率要求太高时序收敛不了。处理方案分三步先看是否只是约束给的时钟频率过于激进比如给开发板定的时钟频率写成了 200MHz而它实际只能跑到 70MHz。对于教学向的 CPU把频率限制在 10~20MHz 是非常合理的一般时序都能满足。如果真的不满足就试着切到更低的时钟输入或者在设计中加 PLL/MMCM 分频。如果连 10MHz 都跑不动说明某个组合模块产生了一个很长的级联比如我记得自己曾因 ALU 里用一个循环移位而不是桶形移位器导致时钟频率惨不忍睹。这时可以把 ALU 里乘除、移位这些重运算改成多周期实现但那就脱离单周期的初衷了。正确的选择是认清单周期 CPU 的性能天花板不要强求高频。4.5 分支不跳转或者跳错地址这是很多学员在调分支指令时绕不开的问题。现象是beq 明明相等却往下走或者跳到了错误地址。原因排查通常集中在三处立即数扩展错误。B 型偏移的位拼写最复杂很多人会漏掉inst[7]这一位或者把符号扩展做错。对照前面给的 IMM_B 拼接仔细检查 inst[31]、inst[30:25]、inst[11:8]、inst[7] 的顺序是否正确。PC 计算错误。分支目标 当前 PC 立即数如果你的pc已经更新到了下一个周期的 PC但你还是拿pc加上立即数就会多偏 4 个字节。所以一定要用当前指令的 PC 值来计算分支目标通常做法是把当前周期的pc_in引入到分支计算里而不是用已经加过 4 的pc_out。控制单元给出错误的 Branch 信号。比如bne被错误地编码成了beq或者 branch_type 的编码覆盖错了。把波形里funct3与控制单元的branch_type对照检查即可。我个人的调试顺序是先看 funct3 是否正确再看立即数扩展结果再看 ALU 输出和 branch_taken最后看 PC 更新。每一步都在波形里对照通常三轮就能定位。4.6 汇编翻译错了用反汇编自查普通的测试程序不长但如果存在手写机器码错误排查会非常痛苦。我在第一次跑bge这类带 funct3 的指令时就曾经把 funct3 的二进制位写错导致控制单元输出预期的分支类型CPU 却按照错误类型比较。避免这个问题最有效的办法是用编译器生成机器码之后再用objdump反汇编一次确认机器码和汇编指令的对应关系。现在的 RISC-V 工具链很成熟甚至浏览器在线汇编器都能完成这件事。在调试时我经常把从 ROM 里读到的 32 位指令复制到反汇编工具里确认它就是我想要的那条指令这一步能把指令编码错误排除在外让你集中精力查数据通路和控制逻辑。5. 这个项目还能怎么继续往里走5.1 从单周期到多周期与流水线单周期 CPU 跑通后最自然的下一步就是做流水线。经典的五级流水线IF、ID、EX、MEM、WB会把单周期的长路径拆成五段每一段都短很多时钟频率能大幅提升。代价是必须处理流水线冒险写后读冒险需要加转发forwarding逻辑控制冒险需要分支预测或 stall结构冒险在 RV32 里基本不存在因为指令存储和数据存储通常是分开的。如果觉得直接上流水线跨度太大可以先做多周期将一条指令拆成取指、译码、执行、访存、写回等若干个时钟周期完成状态机来调度这样能直观地看到“资源复用”和“周期划分”的思想。多周期里 ALU 可以被重复使用ROM/RAM 可以共用一套总线这些对理解“吞吐量”和“延迟”的区别特别有帮助。5.2 给 CPU 挂上总线跑点汇编之外的事单周期 CPU 只是计算核心如果把它做成一个小系统必须给它接外设。最简单的方式是加一个极简总线把 RAM、UART、GPIO、Timer 都映射到地址空间里。这样你的 CPU 就能跑一个循环打印程序把字符串通过 UART 发到电脑上或者在终端里看到内存数据的变化。对这个项目来说一个可选的做法是在数据存储器里预留一段地址做内存映射 I/O比如地址0x10000000对应输出寄存器写入时点亮 LED地址0x10000004对应开关输入CPU 读它来感知外部状态。这种极简映射本质上就是 MMIO 的原型。后续如果想更正规化可以把数据通路封装成 AXI4-Lite Master 接口那对理解 FPGA 片上系统SoC设计也是很好的铺垫。5.3 引入 CSR 与异常/中断机制现代处理器不可能只有加法算术还需要异常处理非法指令、访存越界、外部中断等等。RISC-V 定义了一套控制和状态寄存器CSR比如 mtvec异常入口地址、mcause异常原因、mstatus全局中断开关。要支持这些就要在数据通路上增加“异常地址产生”和“异常原因记录”的路径控制状态机也要增加“进入异常”和“返回异常”的操作序列。这个扩展工作量不大但对体系结构的认识会再上一个台阶。如果你已经做到了 CSR就可以跑一个带系统调用的小程序甚至轮询读按键、定时器中断驱动都变得物尽其用了。5.4 工具链与验证环境的进阶写 RTL 只是处理器设计的一半另一半是验证。当程序复杂起来手工$display的方式就不够用了。建议接下来做两件事一是学 SystemVerilog AssertionsSVA给关键协议加断言比如“写寄存器的地址永远不会是 x0”“当 MemWrite 为 1 时 ALUSrc 为 1”这类不变量仿真时一旦打破立刻报错二是搭一个简单的RISC-V指令级测试平台把 RISC-V 官方提供的 riscv-tests 里的 “rv32ui-p-*” 测试用例跑进你的模拟器。这些测试覆盖每条指令的边界情况可比自己写那点测试程序全面多了。把官方的测试集跑通了才算得上“这个 CPU 基本正确”。最后分享一点我个人的体会这个项目虽然结构简化但做完之后的收获比我预想大得多。最直观的一点是以前上课时总觉得数据通路图是抽象的线条直到自己在一个波形里看到 PC 一步步往前走、看到某条 store 指令把寄存器堆的数据一路送到内存那个地址才真正理解什么叫“程序的执行就是数据在硬件里流动”。调出第一个能算加法的 CPU 时那种兴奋不是看到成功波形本身而是意识到“我的代码确确实实变成了硅片里的一条物理路径”。如果让我给正准备动手的人一个建议那就是千万沉住气。不要急着把 RISC-V 全部指令集都实现先从 15~20 条最基础的指令做起把取指、译码、执行、访存、写回这条链路走通。每加一组新指令就对应更新控制信号真值表并且立刻写一个针对该指令的测试程序跑仿真。你的第一个 CPU 不一定要性能好、面积小只要能正确执行程序、能在波形里看清每一步它就是成功的。之后无论是往流水线、总线扩展还是往安全、验证方向深入地基都会稳稳的。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑