最近几年 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 | 执行算术逻辑运算、地址计算、比较 | 运算器 |
| 数据存储器(DataRAM) | Load/Store 指令访问的存储器 | RAM / 数据缓存 |
| 控制单元(CtrlUnit) | 根据指令 opcode、funct3、funct7 产生所有控制信号 | 控制器 |
如果你用的是 RV32I 基础指令集,还会有一个立即数扩展模块(ImmGen),它负责把指令中编码的立即数按照不同类型指令的规则扩展成 32 位。很多初学者忽略这个模块,导致lw或beq的立即数算错,这类 bug 极其隐蔽,后面调试部分我会专门讲。
2.2 单周期数据通路的流动方向
整个 CPU 的工作过程,如果用一句话概括就是:取指令 → 解析指令 → 取操作数 → 运算/访存 → 写回结果 → 更新 PC。
具体到数据通路上,时钟上升沿到来时,以下几件事几乎同时发生:
- PC 寄存器输出当前地址到指令存储器。
- 指令存储器读出 32 位指令。
- 控制单元和立即数扩展模块并行对指令译码。
- 寄存器堆根据指令中的源寄存器索引
rs1、rs2读出两个操作数。 - ALU 对操作数和立即数执行运算,或计算访存地址。
- 如果需要访存,数据存储器进行读写。
- 写回阶段把 ALU 结果、访存数据或 PC+4 写回目标寄存器。
- PC 更新为 PC+4,如果是分支跳转,则更新为跳转目标地址。
注意第 3 步和第 4 步是并行发生的,这是硬件思维和软件思维最大的区别。在写 Verilog 时,always块里的非阻塞赋值和我们习惯的顺序执行完全是两回事。
2.3 控制信号设计:一张表说清一切
控制单元是单周期 CPU 的“大脑”,它输出的信号决定数据通路如何切换。我下面给出我这版 CPU 用到的所有控制信号,包括名称、含义和取值:
| 信号名 | 含义 | 取值说明 |
|---|---|---|
RegWrite | 寄存器写使能 | 1 表示写入目标寄存器 |
ALUSrc | ALU 第二操作数来源 | 0 来自 rs2,1 来自立即数 |
ALUOp | ALU 操作码 | 2 位,与 funct3/funct7 结合产生真正的 ALU 控制信号 |
MemRead | 数据存储器读使能 | 1 表示读,对应lw |
MemWrite | 数据存储器写使能 | 1 表示写,对应sw |
MemtoReg | 写回数据选择 | 0 写 ALU 结果,1 写存储器读出数据 |
Branch | 分支指令标志 | 1 表示是分支指令 |
Jump | 跳转指令标志 | 1 表示是jal或jalr指令 |
PCSelect | PC 更新来源选择 | 00:PC+4,01:分支目标,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, sltu | opcode=0x33,需要 funct7 区分 add/sub,需要 funct3 区分运算类型 |
| I 型算术 | addi, andi, ori, xori, slli, srli, srai, slti, sltiu, jalr | opcode=0x13,立即数 12 位带符号扩展;移位指令用 shamt 字段 |
| Load/Store | lw, sw | opcode 分别 0x03 和 0x23,lw 的 rd 写回,sw 没有 rd |
| 分支指令 | beq, bne, blt, bge, bltu, bgeu | opcode=0x63,立即数按 1.5 字节排列,计算目标地址时以 PC 为基址 |
| 跳转指令 | jal, jalr | jal 的 opcode=0x6f,jalr 的 opcode=0x67 |
| 其他 | 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 = 32'h0000_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 // 默认填充 nop(addi x0, x0, 0) for (int i = 0; i < DEPTH; i++) mem[i] = 32'h00000013; 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) ? 32'b0 : regs[raddr_a]; assign rdata_b = (raddr_b == 0) ? 32'b0 : 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) 4'b0000: alu_result = src_a + src_b; 4'b0001: alu_result = src_a - src_b; 4'b0010: alu_result = src_a & src_b; 4'b0011: alu_result = src_a | src_b; 4'b0100: alu_result = src_a ^ src_b; 4'b0101: alu_result = src_a << src_b[4:0]; 4'b0110: alu_result = src_a >> src_b[4:0]; 4'b0111: alu_result = $signed(src_a) >>> src_b[4:0]; 4'b1000: alu_result = ($signed(src_a) < $signed(src_b)) ? 32'b1 : 32'b0; 4'b1001: alu_result = (src_a < src_b) ? 32'b1 : 32'b0; default: alu_result = 32'b0; endcase zero = (alu_result == 32'b0); 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) 7'b0000011: imm = {{20{inst[31]}}, inst[31:20]}; // I 型 Load 7'b0010011: imm = {{20{inst[31]}}, inst[31:20]}; // I 型算术 (addi等) 7'b0100011: imm = {{20{inst[31]}}, inst[31:25], inst[11:7]}; // S 型 Store 7'b1100011: imm = {{20{inst[31]}}, inst[7], inst[30:25], inst[11:8], 1'b0}; // B 型 Branch,注意最低位补 0 7'b1101111: imm = {{12{inst[31]}}, inst[19:12], inst[20], inst[30:21], 1'b0}; // J 型 JAL 7'b0110111: imm = {inst[31:12], 12'b0}; // U 型 LUI 7'b0010111: imm = {inst[31:12], 12'b0}; // U 型 AUIPC 7'b1100111: imm = {{20{inst[31]}}, inst[31:20]}; // I 型 JALR default: imm = 32'b0; 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 输出一堆控制信号。下面是我使用的核心译码逻辑,为了节省篇幅我把信号合并成一位,但实际代码里每个信号都是独立 reg:
module 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 = 2'b00; case (opcode) 7'b0110011: begin // R 型 reg_write_r = 1; alu_op_r = 2'b10; end 7'b0010011: begin // I 型算术 reg_write_r = 1; alu_src_r = 1; alu_op_r = 2'b11; end 7'b0000011: begin // Load reg_write_r = 1; alu_src_r = 1; mem_read_r = 1; mem_to_reg_r = 1; alu_op_r = 2'b00; // 加地址 end 7'b0100011: begin // Store alu_src_r = 1; mem_write_r = 1; alu_op_r = 2'b00; end 7'b1100011: begin // Branch branch_r = 1; alu_op_r = 2'b01; end 7'b1101111: begin // JAL reg_write_r = 1; jump_r = 1; // 需要把 PC+4 写回 rd // 这里追加控制信号:需要新增 pc_to_reg,我们后面用 MemtoReg 复用 end 7'b1100111: begin // JALR reg_write_r = 1; alu_src_r = 1; jump_r = 1; end 7'b0110111: begin // LUI reg_write_r = 1; // 立即数本身即结果,不需要 ALU end 7'b0010111: begin // AUIPC reg_write_r = 1; // 需要 PC+imm,我们通过 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 + imm,JAL 的写回是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 = 32'b0; 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 或 PC(AUIPC),src_b来自 rs2 或 imm。 - 分支判断基于 ALU 的
slt结果或zero信号。如果是beq,则比较相等;如果是bne,取反;如果是blt等,可以用比较结果。 - 写回数据来自 ALU 结果、数据存储器读出的数据或 PC+4(或 imm,LUI)。
pc_next= 分支情况 ? 分支地址 : jump ? 跳转地址 : pc + 4。
顶层连线时最容易漏的是JAL 和 JALR 的 rd 写回 PC+4。RISC-V 里jal x1, func会把下一条指令地址写入 x1,用于函数返回。这个PC+4必须通过一个多路选择器送到 RegFile 的写数据端口。我使用wb_src_sel作为 2 位选择信号:00表示 ALU 结果,01表示 Load 数据,10表示 PC+4,11表示 ImmGen(LUI)。
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 输出。下面是一个简化的 TB:
module imm_gen_tb; reg [31:0] inst; wire [31:0] imm; imm_gen dut(.inst(inst), .imm(imm)); initial begin inst = 32'h00500093; // addi x1, x0, 5 #10 $display("I-type imm = %0d", imm); // 期望 5 inst = 32'hFE040293; // addi x5, x0, -32(真实编码需按要求) #10 $display("I-type imm = %0d", imm); // 期望 -32 inst = 32'h00C080B3; // 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 = 0010011,funct3 = 000- rd = x1 = 00001
- rs1 = x0 = 00000
- 立即数 = 10 = 000000001010(12 位符号扩展)
- 机器码 =
000000001010_00000_000_00001_0010011= 32'h00A00093
第二行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 会导致综合时出现 latch,fmax 直线下降。我在每个 always 块开头先给所有输出赋默认值,再 case 内覆盖。这种做法被称为“默认赋值防 latch”,尤其适合初学者。
我也见过有人把pc_en设计成握手信号,结果忘记拉高,整块 CPU 一个周期都跑不动。如果你想在单周期里加入暂停功能,比如等待外部设备,可以把pc_en和寄存器堆写使能联合控制,但要小心不要把指令存储器的数据路径打断。
我个人的体会是:写一个能跑的单周期 CPU 并不难,难的是把每个信号为什么这样接讲清楚,并且能在调试时迅速定位是哪条线断了。如果你没有画数据通路图就开始写代码,后面调试的时间至少翻一倍。强烈建议你先手绘一张完整的架构图,标注好每个控制信号的来源和去向,再动键盘。
这篇文章里的代码你完全可以直接拿去用,但请记得自己跑一遍测试。改掉几个信号名、换一条指令,就会出现完全不同的 bug。这些东西只有亲手趟过一遍,才会内化成自己的硬件直觉。