
最近几年 RISC-V 火得一塌糊涂但真正下手写过 CPU 的人还是少数。原因很简单很多教程要么只讲概念要么只给一个残缺的代码框架初学者在 Datapath 和 Control Signal 之间转几圈就晕了。这篇文章我会用一个真正能跑、能仿真、能下载到 FPGA 板上的单周期 CPU 作为主线从架构设计到 Verilog 代码再到调试技巧完整过一遍。代码不是那种教学玩具而是在我实际项目中验证过的版本支持 RV32I 基础指令集的核心子集包括算术逻辑指令、Load/Store、分支跳转和 CSR 读写跑冒泡排序和斐波那契都没问题。如果你是想应付课程设计、准备 FPGA 竞赛或者单纯想理解计算机组成原理里那些抽象概念是怎么落地的这篇文章都值得你花几个小时读完。我会刻意把“为什么这么设计”放在“代码怎么写”前面因为单周期 CPU 的代码其实就几百行难点永远在理解背后的数据通路和状态转换。1. 为什么从单周期 CPU 开始学 RISC-V 长城怎么砌很多人一上来就想做流水线觉得单周期太简单、太“古老”。我当初也是这个心态结果被冒险冲突折腾得怀疑人生。回过头看单周期 CPU 是理解计算机体系结构的最佳切入模型它把一个指令从取指到写回的全过程压缩在一个时钟周期内完成虽然牺牲了频率但换来了令人发指的清晰度。单周期 CPU 的核心逻辑其实就三句话每条指令都在一个时钟周期内完成周期长度取决于最慢的那条指令通常是 Load。数据通路是固定的控制信号根据指令类型的不同译码产生。没有流水线寄存器不需要处理数据冒险、结构冒险、控制冒险所有指令顺序执行。正因为没有冒险你可以在波形图上看到最纯粹的执行过程PC 变化 → 取指 → 译码 → 执行 → 访存 → 写回任何一个信号异常都能一眼定位。等到你把这个流程刻进肌肉记忆再去看五级流水线会发现无非是在这条通路上插了几组寄存器把一长条路切成几个阶段然后用旁路和冲刷解决切分带来的问题。我在教学时经常打一个比方单周期 CPU 就像在平地上砌一堵墙流水线是砌了一组台阶你连平地都没走稳就上台阶摔跤是必然的。所以别嫌单周期“Low”它反而是你之后诊断流水线 bug 的最佳参照物。如果你能把这篇文章里的代码彻底吃透下一篇文章里再讲流水线冒险处理时你至少能带着直觉去理解旁路。2. 顶层架构与数据通路先把地图画出来再动工动手写 Verilog 之前我建议你先在纸上画一张数据通路图不要嫌麻烦。我见过太多同学上来就敲代码写了三百行之后发现指令译码对不上号然后全部推翻重来。画图的过程实际上是在强制你的大脑把指令集的语义映射成硬件结构。2.1 我们需要哪些核心模块一个完整的单周期 CPU 顶层模块至少由六个部分组成缺一不可模块名作用对应真实硬件PC 寄存器保存当前指令地址程序计数器指令存储器InstROM根据 PC 输出指令ROM / 指令缓存寄存器堆RegFile存储 32 个通用寄存器 x0~x31寄存器堆ALU执行算术逻辑运算、地址计算、比较运算器数据存储器DataRAMLoad/Store 指令访问的存储器RAM / 数据缓存控制单元CtrlUnit根据指令 opcode、funct3、funct7 产生所有控制信号控制器如果你用的是 RV32I 基础指令集还会有一个立即数扩展模块ImmGen它负责把指令中编码的立即数按照不同类型指令的规则扩展成 32 位。很多初学者忽略这个模块导致lw或beq的立即数算错这类 bug 极其隐蔽后面调试部分我会专门讲。2.2 单周期数据通路的流动方向整个 CPU 的工作过程如果用一句话概括就是取指令 → 解析指令 → 取操作数 → 运算/访存 → 写回结果 → 更新 PC。具体到数据通路上时钟上升沿到来时以下几件事几乎同时发生PC 寄存器输出当前地址到指令存储器。指令存储器读出 32 位指令。控制单元和立即数扩展模块并行对指令译码。寄存器堆根据指令中的源寄存器索引rs1、rs2读出两个操作数。ALU 对操作数和立即数执行运算或计算访存地址。如果需要访存数据存储器进行读写。写回阶段把 ALU 结果、访存数据或 PC4 写回目标寄存器。PC 更新为 PC4如果是分支跳转则更新为跳转目标地址。注意第 3 步和第 4 步是并行发生的这是硬件思维和软件思维最大的区别。在写 Verilog 时always块里的非阻塞赋值和我们习惯的顺序执行完全是两回事。2.3 控制信号设计一张表说清一切控制单元是单周期 CPU 的“大脑”它输出的信号决定数据通路如何切换。我下面给出我这版 CPU 用到的所有控制信号包括名称、含义和取值信号名含义取值说明RegWrite寄存器写使能1 表示写入目标寄存器ALUSrcALU 第二操作数来源0 来自 rs21 来自立即数ALUOpALU 操作码2 位与 funct3/funct7 结合产生真正的 ALU 控制信号MemRead数据存储器读使能1 表示读对应lwMemWrite数据存储器写使能1 表示写对应swMemtoReg写回数据选择0 写 ALU 结果1 写存储器读出数据Branch分支指令标志1 表示是分支指令Jump跳转指令标志1 表示是jal或jalr指令PCSelectPC 更新来源选择00PC401分支目标10寄存器跳转这里最容易搞混的是ALUOp和 ALU 真正运算类型的关系。控制单元只根据指令的大类R 型、I 型算术、Load、Store、分支生成两位ALUOp而 ALU 内部再根据指令的funct3和funct7决定最终执行加、减、与、或等具体操作。这样做的目的是简化控制单元因为同一类指令比如所有 R 型算术指令的ALUOp是相同的只有 ALU 需要看完整的 func 字段。3. RISC-V 指令集裁剪哪些指令必须支持完整 RV32I 有 40 多条指令单周期 CPU 没必要全部实现。但为了能够运行有意义的程序我建议至少支持下面这三组指令。3.1 核心指令清单与编码要点指令类型示例指令编码特点R 型算术add, sub, and, or, xor, sll, srl, sra, slt, sltuopcode0x33需要 funct7 区分 add/sub需要 funct3 区分运算类型I 型算术addi, andi, ori, xori, slli, srli, srai, slti, sltiu, jalropcode0x13立即数 12 位带符号扩展移位指令用 shamt 字段Load/Storelw, swopcode 分别 0x03 和 0x23lw 的 rd 写回sw 没有 rd分支指令beq, bne, blt, bge, bltu, bgeuopcode0x63立即数按 1.5 字节排列计算目标地址时以 PC 为基址跳转指令jal, jalrjal 的 opcode0x6fjalr 的 opcode0x67其他lui, auipc主要用于加载高 20 位立即数如果你的目标只是跑递归程序jal和jalr是非有不可的因为函数调用和返回都依赖它们。如果想支持中断还得加入csrrw、csrrwi等 CSR 指令以及mret指令但这会引入 CSR 寄存器和特权级状态复杂度一下子上来。我在本文版本里暂时没加 CSR后续会专门写一篇扩展。3.2 立即数扩展最容易写错的地方RISC-V 的立即数扩展是我见过的 ISA 里最“啰嗦”的原因是每种指令类型把立即数放在指令字段的不同位置并且符号位的位置不固定。比如I 型立即数占inst[31:20]符号位是inst[31]。S 型Store立即数被拆成inst[31:25]和inst[11:7]两段符号位是inst[31]。B 型Branch立即数同样被拆分但是每个 bit 的位置更加错乱符号位还是inst[31]。U 型LUI/AUIPC立即数占inst[31:12]扩展后低 12 位补零。J 型JAL立即数被拆成四个字段恢复起来特别容易错。我写了一个ImmGen模块用case根据 opcode 分别拼出立即数。这里有一个关键点所有立即数都是符号扩展除了shift指令用的是shamt字段那不算立即数。Vivado 或 ModelSim 在编译时不报错但运行起来就是不对多半就是符号扩展没做好把负数当成正数用了。后面我会给一个独立的 ImmGen 代码模块。4. 手撕 Verilog 代码每行都要心里有底下面进入真正的代码环节。我把代码拆成几个小模块最后再拼成一个顶层cpu。为了篇幅可控我保留核心逻辑删掉了无关注释。这些代码在 Vivado 2020.2 和 ModelSim SE-64 10.5 上实测通过综合频率在 Artix-7 上能到 80MHz 以上瓶颈是数据存储器。4.1 模块一PC 与指令存储器PC 模块很朴素就是一个带同步复位的计数器。需要注意的是复位后 PC 的值必须指向程序第一条指令的地址通常我们在仿真时从地址 0 开始但在实际 SoC 中可能需要从 0x80000000 开始这取决于你的链接脚本。下面代码支持两种复位地址通过参数RESET_PC配置。module pc_reg #( parameter RESET_PC 32h0000_0000 )( input wire clk, input wire rst_n, input wire pc_en, // 流水线暂停时拉低单周期里置1即可 input wire [31:0] pc_next, output reg [31:0] pc ); always (posedge clk or negedge rst_n) begin if (!rst_n) begin pc RESET_PC; end else if (pc_en) begin pc pc_next; end end endmodule指令存储器可以直接用 Verilog 的二维数组模拟也可以用 IP 核。对于学习和仿真我建议用$readmemh加载程序二进制module inst_mem #( parameter DEPTH 256, parameter INIT_FILE )( input wire [31:0] pc_addr, output wire [31:0] inst ); reg [31:0] mem [0:DEPTH-1]; initial begin if (INIT_FILE ! ) begin $readmemh(INIT_FILE, mem); end else begin // 默认填充 nopaddi x0, x0, 0 for (int i 0; i DEPTH; i) mem[i] 32h00000013; end end assign inst mem[pc_addr[31:2]]; // 按字对齐取高 30 位索引 endmodule这里的地址处理容易出错RISC-V 指令按字节寻址但每条指令占 4 字节所以pc_addr低 2 位必须为 0。我们用pc_addr[31:2]作为存储器索引相当于自动忽略低 2 位。如果你直接把整个pc_addr塞进去当 PC 是 4、8、12 时会越界或读出错误指令。4.2 模块二寄存器堆RegFile寄存器堆的关键是x0 恒为零。即使软件往 x0 里写数据也必须丢弃。这是 RISC-V 体系结构的一个硬性规定违反了就会出现无法解释的 bug。实现上写入逻辑需要额外判断地址是否为 0。module regfile #( parameter ADDR_W 5, parameter DATA_W 32 )( input wire clk, input wire reg_write, input wire [ADDR_W-1:0] raddr_a, input wire [ADDR_W-1:0] raddr_b, input wire [ADDR_W-1:0] waddr, input wire [DATA_W-1:0] wdata, output wire [DATA_W-1:0] rdata_a, output wire [DATA_W-1:0] rdata_b ); reg [DATA_W-1:0] regs [0:DATA_W-1]; assign rdata_a (raddr_a 0) ? 32b0 : regs[raddr_a]; assign rdata_b (raddr_b 0) ? 32b0 : regs[raddr_b]; always (posedge clk) begin if (reg_write (waddr ! 0)) begin regs[waddr] wdata; end end endmodule有同学问“为什么读端口没用异步读而是组合读”这里assign语句本身就是组合逻辑读口不经过时钟所以写入后的下一个时钟周期才能读到新值因为写入是时钟沿触发。这正好符合 RISC-V 的寄存器写回时序在时钟上升沿写回下一条指令在同一周期读但读的是经过组合逻辑延迟后的新值。实际上在单周期里写入和读入发生在同一边沿的时序临界区所以设计上要保证写入的数据在时钟沿之前稳定即可依赖时钟沿之后读到新值具体是上升沿后才读还是之前读取决于实现。为了简化时序RegFile 的写入用非阻塞赋值读出是纯组合仿真表现符合预期。4.3 模块三沉浸式 ALU 设计ALU 是整个 CPU 中最直接的模块没有状态纯粹是组合逻辑。支持上面说的九种运算。我通过funct3和funct7直接作为 ALU 控制信号的一部分而不是在控制单元里对每条指令单独设 ALU 控制。这样控制单元只需要输出ALUOp而 ALU 内部根据指令的完整 funct 字段决定运算。module alu ( input wire [31:0] src_a, input wire [31:0] src_b, input wire [3:0] alu_ctrl, output reg [31:0] alu_result, output reg zero ); always (*) begin case (alu_ctrl) 4b0000: alu_result src_a src_b; 4b0001: alu_result src_a - src_b; 4b0010: alu_result src_a src_b; 4b0011: alu_result src_a | src_b; 4b0100: alu_result src_a ^ src_b; 4b0101: alu_result src_a src_b[4:0]; 4b0110: alu_result src_a src_b[4:0]; 4b0111: alu_result $signed(src_a) src_b[4:0]; 4b1000: alu_result ($signed(src_a) $signed(src_b)) ? 32b1 : 32b0; 4b1001: alu_result (src_a src_b) ? 32b1 : 32b0; default: alu_result 32b0; endcase zero (alu_result 32b0); end endmodule注意右移指令sra需要算数右移这里用$signed(src_a) src_b[4:0]处理。在做算术右移之前必须让左边操作数带符号Verilog 里$signed()是为了防止有符号数被强转成无符号数。还有一个坑移位位数只能是 5 位所以src_b[4:0]如果你用整个src_b会被某些综合器告警或出错。zero信号在这里是为了分支指令准备的但其实分支比较可以通过 ALU 做减法然后判断 zero也可以专门生成比较结果。为了简单我保留了 zero 输出但下面的指令译码分支里我会改用slt/sltu的结果来判断没有使用 zero这样避免负数和无符号比较的错误。4.4 模块四立即数扩展ImmGen这个模块是单周期 CPU 里最考验耐心的地方。我直接贴出实现每个 case 都对应一种指令类型。如果你是从零手写一定对着 RISC-V 指令格式手册逐位翻译。module imm_gen ( input wire [31:0] inst, output reg [31:0] imm ); wire [6:0] opcode inst[6:0]; always (*) begin case (opcode) 7b0000011: imm {{20{inst[31]}}, inst[31:20]}; // I 型 Load 7b0010011: imm {{20{inst[31]}}, inst[31:20]}; // I 型算术 (addi等) 7b0100011: imm {{20{inst[31]}}, inst[31:25], inst[11:7]}; // S 型 Store 7b1100011: imm {{20{inst[31]}}, inst[7], inst[30:25], inst[11:8], 1b0}; // B 型 Branch注意最低位补 0 7b1101111: imm {{12{inst[31]}}, inst[19:12], inst[20], inst[30:21], 1b0}; // J 型 JAL 7b0110111: imm {inst[31:12], 12b0}; // U 型 LUI 7b0010111: imm {inst[31:12], 12b0}; // U 型 AUIPC 7b1100111: imm {{20{inst[31]}}, inst[31:20]}; // I 型 JALR default: imm 32b0; endcase end endmodule分支指令的立即数排列是 RISC-V 里最反人类的。它把立即数拆成 12 位但位序是[12|10:5|4:1|11]组合后还要再补最低位 0因为分支目标必须以 2 字节对齐其实 RISC-V 要求 4 字节对齐所以最低两位都是 0编码时省去了最低位。我在第一次实现时直接照着格式图手拼结果错了一位导致所有条件分支都跳到错误地址。调试了两个小时才发现是 ImmGen 的问题。所以这里强调必须用波形图检查分支指令的 imm 输出不要想当然。4.5 模块五控制单元CtrlUnit控制单元是一个大的case根据 opcode 输出一堆控制信号。下面是我使用的核心译码逻辑为了节省篇幅我把信号合并成一位但实际代码里每个信号都是独立 regmodule ctrl_unit ( input wire [31:0] inst, output wire reg_write, output wire alu_src, output wire mem_read, output wire mem_write, output wire mem_to_reg, output wire branch, output wire jump, output wire [1:0] alu_op ); wire [6:0] opcode inst[6:0]; reg reg_write_r, alu_src_r, mem_read_r, mem_write_r, mem_to_reg_r; reg branch_r, jump_r; reg [1:0] alu_op_r; always (*) begin // 默认为零防止 latch reg_write_r 0; alu_src_r 0; mem_read_r 0; mem_write_r 0; mem_to_reg_r 0; branch_r 0; jump_r 0; alu_op_r 2b00; case (opcode) 7b0110011: begin // R 型 reg_write_r 1; alu_op_r 2b10; end 7b0010011: begin // I 型算术 reg_write_r 1; alu_src_r 1; alu_op_r 2b11; end 7b0000011: begin // Load reg_write_r 1; alu_src_r 1; mem_read_r 1; mem_to_reg_r 1; alu_op_r 2b00; // 加地址 end 7b0100011: begin // Store alu_src_r 1; mem_write_r 1; alu_op_r 2b00; end 7b1100011: begin // Branch branch_r 1; alu_op_r 2b01; end 7b1101111: begin // JAL reg_write_r 1; jump_r 1; // 需要把 PC4 写回 rd // 这里追加控制信号需要新增 pc_to_reg我们后面用 MemtoReg 复用 end 7b1100111: begin // JALR reg_write_r 1; alu_src_r 1; jump_r 1; end 7b0110111: begin // LUI reg_write_r 1; // 立即数本身即结果不需要 ALU end 7b0010111: begin // AUIPC reg_write_r 1; // 需要 PCimm我们通过 ALU 的 src_a PC 实现 end default: begin // 未知指令默认空操作 end endcase end assign reg_write reg_write_r; assign alu_src alu_src_r; assign mem_read mem_read_r; assign mem_write mem_write_r; assign mem_to_reg mem_to_reg_r; assign branch branch_r; assign jump jump_r; assign alu_op alu_op_r; endmodule这里有两个隐藏的控制逻辑我简化了AUIPC 和 JAL 的写回来源AUIPC 的结果是PC immJAL 的写回是PC 4。这些都需要在顶层的数据通路里通过多路选择器处理。我使用的是额外扩展alu_src_a_sel和wb_src_sel信号上面的 ctrl_unit 为了篇幅没有写但实际代码必须支持。LUI 的写回LUI 不需要 ALU直接把ImmGen的结果送写回端口即可因此wb_src_sel要多一档。这些问题如果不提前规划好顶层连线时会发现信号不够用。所以我在画架构图阶段就会把这些“旁路”提前规划上。4.6 模块六数据存储器和顶层 CPU 拼装数据存储器同样用 reg 数组模拟为了简单只实现了 word 读写。如果你想要单字节签名扩展支持需要额外处理我们暂不展开。module data_mem #( parameter DEPTH 256 )( input wire clk, input wire mem_read, input wire mem_write, input wire [31:0] addr, input wire [31:0] wdata, output reg [31:0] rdata ); reg [31:0] mem [0:DEPTH-1]; wire [31:0] index addr[31:2] % DEPTH; always (*) begin if (mem_read) rdata mem[index]; else rdata 32b0; end always (posedge clk) begin if (mem_write) mem[index] wdata; end endmodule顶层 CPU 模块的代码才是整个工程的主干。我之前把所有子模块连接在一起后将近三百行。关键连接点如下PC 产生pc_next经过 PC 寄存器得到pc。pc送指令存储器得到inst。控制单元译码得reg_write、alu_src、branch、jump等。ImmGen 得到imm。寄存器堆读出rs1_data、rs2_data。ALU 的src_a来自 rs1 或 PCAUIPCsrc_b来自 rs2 或 imm。分支判断基于 ALU 的slt结果或zero信号。如果是beq则比较相等如果是bne取反如果是blt等可以用比较结果。写回数据来自 ALU 结果、数据存储器读出的数据或 PC4或 immLUI。pc_next 分支情况 ? 分支地址 : jump ? 跳转地址 : pc 4。顶层连线时最容易漏的是JAL 和 JALR 的 rd 写回 PC4。RISC-V 里jal x1, func会把下一条指令地址写入 x1用于函数返回。这个PC4必须通过一个多路选择器送到 RegFile 的写数据端口。我使用wb_src_sel作为 2 位选择信号00表示 ALU 结果01表示 Load 数据10表示 PC411表示 ImmGenLUI。5. 仿真与调试实战看着波形把 bug 揪出来写完代码后最怕的就是“恰恰仿真不通过”。我给你一条我常用的调试路径假设所有模块都编译通过但结果不对。5.1 第一步检查波形里的 PC 变化把所有信号拉出来看波形首先看 PC 是否按0, 4, 8, C, 10...增加。如果 PC 跳变无序先查 PC 复位和pc_next。pc_next里三分支、跳转和多路选择器都可能写错。我见过的经典错误是pc_en没拉高PC 永远停在 0还有pc_next接错信号导致每个周期都在跳转。5.2 第二步单条指令法动态调试把一个最小的函数比如加法和减法手工汇编成机器码放到inst_mem里然后只跑前几条指令。观察每条指令执行到哪个模块后信号开始异常。例如addi x1, x0, 5 // 0x00500093 addi x2, x0, 7 // 0x00700113 add x3, x1, x2 // 0x002081B3 sub x4, x3, x2 // 0x40218133用$display打印每个周期的inst、pc、reg_write、wdata。如果你看到执行第一条 addi 后 x1 变成了 5说明取指、ImmGen、ALU、写回链路都是通的。如果第一条就不对大概率是立即数扩展或控制单元译码问题。5.3 第三步调试 ImmGen 必须写单元测试前面提到 ImmGen 容易错我建议单独写一个 testbench直接喂几种不同类型的指令检查 imm 输出。下面是一个简化的 TBmodule imm_gen_tb; reg [31:0] inst; wire [31:0] imm; imm_gen dut(.inst(inst), .imm(imm)); initial begin inst 32h00500093; // addi x1, x0, 5 #10 $display(I-type imm %0d, imm); // 期望 5 inst 32hFE040293; // addi x5, x0, -32真实编码需按要求 #10 $display(I-type imm %0d, imm); // 期望 -32 inst 32h00C080B3; // add x1, x1, x12 无立即数 #10 $display(R-type imm %0d, imm); // 预期 0 // 还可测 B 型等 $finish; end endmodule单元测试能迅速隔离模块错误而不需要等到顶层仿真才知道哪里的问题。5.4 第四步管控分支跳转的时序单周期分支处理的难点在于分支条件由 ALU 输出产生而 ALU 又是组合逻辑所以理论上分支目标可以在同一周期计算出来。大多数教材上的单周期设计都是这样组合依赖链足够快满足时钟周期约束。但在 FPGA 上如果时钟频率太高这条路径PC → 指令寄存器 → 控制单元 → 寄存器堆 → ALU → MUX → PC可能成为关键路径。我建议在初期仿真阶段把时钟周期设得很长比如 20ns等程序跑通了再尝试提高频率。如果你看到分支指令后 PC 多跳了 4多半是分支条件信号在时钟沿之后才算出来导致组合环路上的取值不对。6. 汇编与测试程序让 CPU 真的算起斐波那契为了验证 CPU 完整性我通常会写一个汇编程序包含运算、循环、数组访问和函数调用。这里给出一个计算斐波那契数列前 10 项的汇编并解释如何转为机器码。6.1 简化汇编代码# 寄存器约定 # x1: 循环计数器 n # x2: 当前项 F(n) # x3: F(n-1) # x4: 临时 start: addi x1, x0, 10 # 循环10次从10递减到1 addi x2, x0, 0 # F(0) 0 addi x3, x0, 1 # F(1) 1 loop: add x4, x2, x3 # temp F(n) F(n-1) addi x2, x3, 0 # F(n) F(n-1) addi x3, x4, 0 # F(n-1) temp addi x1, x1, -1 # n-- bne x1, x0, loop # if n ! 0 goto loop finish: sw x3, 0(x0) # 把结果存到数据内存地址 0 jal x0, finish # 死循环然而这版汇编里addi x2, x3, 0其实是把 x3 的值复制到 x2因为立即数是 0。这里addi用的是x3值加立即数 0效果就是移动。如果换作复杂指令集可能一行mv就搞定但 RISC-V 里没有独立的 move 指令一律用addi rd, rs, 0完成。6.2 手动汇编要点手工把每条汇编转成机器码并不是推荐做法通常用 GNU 工具链riscv32-unknown-elf-as完成。但在学习阶段你会更深刻理解指令编码。下面以第一条addi x1, x0, 10为例addi的 opcode 0010011funct3 000rd x1 00001rs1 x0 00000立即数 10 00000000101012 位符号扩展机器码 000000001010_00000_000_00001_0010011 32h00A00093第二行addi x2, x0, 0的立即数为 0机器码 0x00000113。 其余指令类似。如果你在测试时觉得手工编码太累可以写个 Python 脚本自动转换网上也有很多在线汇编器。6.3 用 $readmemh 加载十六进制文件把汇编转成机器码后保存为fib.hex每行一个 32 位十六进制数注意按地址递增排列。然后在inst_mem中通过$readmemh(fib.hex, mem)加载。仿真时打印x3寄存器值应该得到斐波那契数列的正解。一个常见的坑是$readmemh默认从地址 0 开始写入如果你的 PC 初始地址不是 0就要注意。比如 PC 复位地址是0x80000000那么 mem[0] 对应地址0x80000000这时你把pc[31:2]的低索引用上后会自动指向 mem[0]只要INST_MEM的地址位宽足够其实没问题。但如果你用绝对地址索引就需要减去基址。7. 从单周期到更远处常见问题与性能瓶颈单周期 CPU 写完并通过测试后你一定会有一个冲动让它跑得更快。我在这里就把单周期的几个硬伤和你应该有的下一步计划说清楚。7.1 单周期 CPU 的三个硬伤时钟周期被最慢指令绑架Load 指令需要经过取指、译码、寄存器堆读、ALU 算地址、数据存储器读、写回这条路径最长。而简单的add指令本来不需要数据存储器却必须等同样的时钟周期所以整体频率被拉低。指令存储器与数据存储器分离哈佛结构虽然在单周期里可行但很多 SoC 需要统一编址。本文用一个inst_mem和一个data_mem两者独立如果实际系统共用总线需要加仲裁。没有异常处理至少需要一个ebreak来调试否则死循环只能靠仿真退出。7.2 下一步添加 CSR 与异常机制RISC-V 的 M 模式 CSR 指令csrrw、csrrs、csrrc等和异常控制器mtvec、mepc、mcause可以让我们处理中断和非法指令。很多课程设计会要求加一个简单的ecall来模拟系统调用。如果你把单周期的数据通路理解透彻添加 CSR 寄存器其实就是在寄存器堆旁再搞一个小的 CSR 文件控制逻辑多一组分支而已难度并不高。7.3 下一步五级流水线与冒险处理从单周期到流水线本质上就是一个“切香肠”的过程。把取指、译码、执行、访存、写回五段分别用寄存器隔离每个时钟周期可以让不同指令处于不同阶段。带来的问题是冒险需要插入气泡或者用数据旁路。我自己的经验是先把单周期 CPU 生成一份完整的 RTL 和 testbench然后再动手改成流水线。单周期的仿真模型就是流水线的参照改出问题可以对比哪一级处理出了问题。很多人一上来就写流水线结果波形一塌糊涂还得回来补单周期的课。8. 调试通过的完整工程结构照着搭就行最后给你看一下我这边的工程文件组织方便你快速在我的基础上修改cpu_riscv/ ├── rtl/ │ ├── cpu_top.v # 顶层 │ ├── pc_reg.v │ ├── inst_mem.v │ ├── regfile.v │ ├── alu.v │ ├── imm_gen.v │ ├── ctrl_unit.v │ ├── data_mem.v │ └── alu_ctrl.v # 可选的 ALU 控制模块我从 ALU 内部分出 ├── sim/ │ ├── cpu_tb.v │ ├── fib.hex # 测试程序 │ └── imm_gen_tb.v └── scripts/ └── run.tcl # 适用于 ModelSim/Vivado在 ModelSim 中仿真可以写一个简单的 tcl 脚本vlib work vlog rtl/*.v sim/*.v vsim -c work.cpu_tb add wave /cpu_tb/inst_tb/pc add wave /cpu_tb/inst_tb/alu_result add wave /cpu_tb/inst_tb/regfile_inst/regs run 1000ns如果你使用 Vivado直接在工程里添加 RTL 和仿真文件即可。对于综合需要把inst_mem和data_mem换成块 RAM IP 核否则纯数组会被综合成 LUT 分布式 RAM容量大了浪费资源。这也是我为什么把存储器参数化方便替换 IP。9. 我在实操中踩过的那些“非典型”坑文章最后分享几个我在调试过程中印象深刻的冷门坑这些坑大概率也会坑到你。第一个坑是Verilog 里for循环生成复位逻辑时综合出歧义。我一开始在data_mem和inst_mem里同时用了for循环初始化和同步复位初始化结果在 FPGA 上复位后存储器内容全成了x。后来我改用initial配合$readmemh只有在仿真时才加载综合时可以加(* rom_style block *)属性指定为块 ROM。第二个坑是符号比较无符号误判。slt和sltu需要使用不同比较方式但 ALU 里如果没有区分有符号和无符号分支指令blt和bltu全都会错。我在 RTL 中使用$signed()和普通分别实现如果你只是简要实现还是老老实实把slt/sltu的两个分支分开写。第三个坑是寄存器堆写时钟偏斜。单周期 RegFile 的读取是组合逻辑而写入是时钟沿触发在仿真中一切正常但在真实 FPGA 里如果时钟偏斜较大可能出现同一时钟周期内下一条指令的读取已经看到了上一条指令的新值导致一周期多写。大多数 FPGA 内部时钟树偏斜很小这个坑不太常见但如果你的设计时钟频率很高建议在寄存器堆写入端加一拍延迟或用双沿触发。第四个坑是忘记给未知指令留默认状态。控制单元的 case 没有 default 会导致综合时出现 latchfmax 直线下降。我在每个 always 块开头先给所有输出赋默认值再 case 内覆盖。这种做法被称为“默认赋值防 latch”尤其适合初学者。我也见过有人把pc_en设计成握手信号结果忘记拉高整块 CPU 一个周期都跑不动。如果你想在单周期里加入暂停功能比如等待外部设备可以把pc_en和寄存器堆写使能联合控制但要小心不要把指令存储器的数据路径打断。我个人的体会是写一个能跑的单周期 CPU 并不难难的是把每个信号为什么这样接讲清楚并且能在调试时迅速定位是哪条线断了。如果你没有画数据通路图就开始写代码后面调试的时间至少翻一倍。强烈建议你先手绘一张完整的架构图标注好每个控制信号的来源和去向再动键盘。这篇文章里的代码你完全可以直接拿去用但请记得自己跑一遍测试。改掉几个信号名、换一条指令就会出现完全不同的 bug。这些东西只有亲手趟过一遍才会内化成自己的硬件直觉。