做过FPGA或数字IC的人应该都有同感学Verilog写了一堆计数器、状态机、UART收发、FIFO之后总觉得差点意思好像每个模块都会写但脑子里始终没有一张完整的“计算机是怎么跑起来的”地图。直到你动手用Verilog搓出来一个单周期CPU把所有模块像拼乐高一样连成数据通路那种“原来指令真的是一条一条被取出来、被解读、被执行的”的通透感是其他练习完全给不了的。这篇文章我打算把MIPS32单周期CPU设计从头到尾拆开讲一遍包括整体架构怎么搭、每个部件怎么用Verilog写、控制信号的每一位怎么定、顶层怎么连线、仿真怎么查错以及我在实际调试中踩过的那些坑。目标是让你看完之后能自己动手复现一个能运行lw、sw、add、sub、and、or、slt、beq、j这几类基础指令的单周期CPU。适合正在学计算机组成原理的学生、Verilog入门想进阶的开发者也适合准备数字IC/FPGA相关面试、想在简历上放一个完整项目的人。1. 单周期CPU设计思路拆解1.1 什么叫“单周期”为什么拿它入门单周期CPU顾名思义就是让每条指令都在一个时钟周期内完成。从取指令、译码、执行、访存到写回寄存器所有动作都在同一个时钟边沿到来之前完成然后在下一个时钟沿到来时统一更新状态PC、寄存器、存储器。你可以把单周期CPU想成一条手工生产线工人A负责从仓库拿原料取指、工人B负责看图纸决定怎么加工译码、工人C负责实际动手加工ALU运算、工人D负责把成品放进仓库或运走访存/写回。单周期的意思是这条线上所有工人必须在同一次“开工铃”到“收工铃”之间把活全部干完下一次铃响才开始处理下一条指令。流水线CPU则是把这条线分成多段每段同时处理不同指令——那是后续进阶的事。那为什么学习时都从单周期开始因为单周期CPU的控制逻辑最简单数据通路也是教科书式的一条直线你能把每条指令在每个时刻做什么看得一清二楚。等理解了单周期里“数据怎么流动、控制信号怎么指挥”再去看五级流水线、冒险消除、乱序执行这些概念就有了扎实的底子。直接上手流水线的结果基本都是七天憋出六个字最后连波形都看不懂。1.2 为什么选MIPS32指令集而不是RISC-V或x86我大学时第一次写CPU用的是MIPS现在自己做项目教学也还是推荐MIPS理由很实在指令格式规整。MIPS指令固定32位总共就三种格式——R型、I型、J型每种格式里寄存器字段的位置都是固定的。比如rs、rt字段在R型和I型指令里永远在同样的比特位这让译码逻辑可以用最粗暴的线束直接连不需要像x86那样做变长译码。寻址方式简单。访存只有lw/sw基址加立即数偏移一种模式分支只有beq/bne这种比较相等/不等的简单判断。寄存器数量刚好。32个32位通用寄存器写入寄存器0恒为0的约定也让设计简化不少。教学资料极多。无论是《计算机组成与设计》还是各类公开课MIPS单周期设计都有完整的数据通路图和控制信号真值表你卡住了很容易找到参考。RISC-V的格式其实也很规整但它的Funct3/Funct7字段拆分、以及可选的压缩指令扩展对初次接触CPU设计的同学来说干扰项多了一点。先把MIPS玩明白你再去迁移到RISC-V三天就能上手。1.3 单周期CPU的总体架构单周期CPU的核心由以下部件构成部件作用输入输出PC程序计数器保存当前指令地址每个时钟周期后更新clk, rst_n, 下一个地址当前指令地址指令存储器IMem只读存储指令32位地址32位指令寄存器堆RegFile32个32位通用寄存器读地址1、读地址2、写地址、写数据、写使能读数据1、读数据2ALU执行算术/逻辑运算操作数1、操作数2、ALU控制信号运算结果、零标志位数据存储器DMem读写数据地址、写数据、读使能、写使能读数据控制器ControlUnit产生各类控制信号opcodeRegDst、ALUSrc、MemtoReg、RegWrite等立即数扩展SignExtend将16位立即数符号扩展为32位instr[15:0]32位立即数指令的完整旅程是这样的PC把地址送给指令存储器拿到32位指令后立即数段直接送扩展器寄存器字段送寄存器堆读取操作数opcode和funct段送控制器和ALU控制单元。ALU算完后若是lw指令结果作为地址去读数据存储器若是运算类指令则直接作为结果最后把要写回寄存器的数据选通到寄存器堆写端口。同时计算下一个PC——顺序执行就是PC4分支命中则跳到目标地址j指令则直接跳转到26位地址拼出来的目标。后面每一节我会把这条链路上每个部件用Verilog逐个敲出来。2. 核心部件模块逐一搭建2.1 PC与指令存储器的实现PC模块本质上就是一个带同步复位的32位寄存器。复位后从地址0开始取指令每个时钟上升沿更新为下一个指令地址。module pc_reg ( input wire clk, input wire rst_n, input wire [31:0] next_pc, output reg [31:0] current_pc ); always (posedge clk or negedge rst_n) begin if (!rst_n) current_pc 32h0000_0000; else current_pc next_pc; end endmodule注意这里用的是异步复位按复位键的瞬间PC立即清零不等待时钟沿。实际项目里还会讲究同步复位和异步复位的选型但单周期CPU教学项目里两种都行我习惯用异步复位因为仿真时一拉低复位信号所有时序逻辑立刻回到确定状态排查问题更方便。指令存储器用只读的reg数组实现module inst_mem ( input wire [31:0] addr, output wire [31:0] inst ); reg [31:0] mem [0:63]; // 支持64条指令实际测试程序只会用到很小一部分 initial begin $readmemh(inst.hex, mem); end assign inst mem[addr[31:2]]; // 按字寻址地址右移两位 endmodule这里有个关键细节MIPS内存按字节寻址但一条指令占4字节所以指令地址永远是4的倍数。实现时不能直接把32位地址当数组下标用必须用addr[31:2]做字索引。如果直接用mem[addr]你会在仿真里看到永远取到第一条指令极其诡异。$readmemh是Verilog里的系统任务用于从文本文件加载十六进制数据到存储器数组。文件格式很简单每行一个32位十六进制数比如8d080000。你也可以用initial块里手工赋值的方式对于测试项目反而更直观initial begin mem[0] 32h8D080000; // lw $t0, 0($zero) mem[1] 32h21090005; // addi $t1, $t0, 5 mem[2] 32hAD090004; // sw $t1, 4($zero) end2.2 寄存器堆读口组合逻辑、写口时序逻辑寄存器堆是整个CPU里最容易写错的地方。32个32位寄存器两个读端口和一个写端口。先说结论读操作是组合逻辑写操作是时序逻辑。module regfile ( input wire clk, input wire rst_n, input wire [4:0] raddr1, input wire [4:0] raddr2, input wire [4:0] waddr, input wire [31:0] wdata, input wire we, output wire [31:0] rdata1, output wire [31:0] rdata2 ); reg [31:0] regs [0:31]; integer i; always (posedge clk or negedge rst_n) begin if (!rst_n) begin for (i 0; i 32; i i 1) regs[i] 32h0; end else if (we (waddr ! 5b0)) begin regs[waddr] wdata; end end assign rdata1 (raddr1 5b0) ? 32h0 : regs[raddr1]; assign rdata2 (raddr2 5b0) ? 32h0 : regs[raddr2]; endmodule三个容易踩的坑第一寄存器0必须恒为0。MIPS架构约定$zero永远是0软件依赖这个特性。如果你在写端口允许写入寄存器0硬件上当然能写成功但跑测试程序时会得到完全不同的结果。上面代码在写端口加了waddr ! 5b0判断读端口也做了地址0的特判用双保险确保$zero不被破坏。第二读口写口不要混。有的同学把读数据也做成时序逻辑在always (posedge clk)里赋值结果ALU在同一个时钟周期里拿到的是上一个周期的旧值整个数据通路时序全部错乱。记住执行阶段需要当前周期就读出寄存器内容给ALU用所以读口必须组合逻辑。第三异步复位时用integer i循环清零32个寄存器这是Verilog里对数组初始化的标准办法。如果你用的是SystemVerilog也可以直接写foreach但纯Verilog里integer循环最通用。2.3 ALU与ALU控制信号的约定ALU就是一个纯组合逻辑的运算器有点像计算器你给它两个32位输入它按控制信号选择做加、减、与、或、比较大小等操作。我用的ALU控制信号是4位编码如下ALUControl功能说明0000ANDrdata1 rdata20001ORrdata1 | rdata20010ADDrdata1 rdata20110SUBrdata1 - rdata20111SLTrdata1 rdata2 ? 1 : 0这套编码不是随便定的它是从经典教材《计算机组成与设计》里继承过来的好处在于ALU控制可以从指令funct字段直接映射后续写ALUControl模块时会非常方便。比如rdata1 - rdata2并判断符号位就是SLT而SUB就是减法两者共享减法器但最后的输出选择不同。module alu ( input wire [31:0] a, input wire [31:0] b, input wire [3:0] alu_ctrl, output reg [31:0] result, output wire zero ); always (*) begin case (alu_ctrl) 4b0000: result a b; 4b0001: result a | b; 4b0010: result a b; 4b0110: result a - b; 4b0111: result (a b) ? 32h1 : 32h0; default: result 32h0; endcase end assign zero (result 32h0); endmodule注意always (*)组合逻辑块里每个分支都要给result赋值否则综合时会生成锁存器。我见过有人写了case漏掉default仿真功能正常综合时报一堆warning然后上板跑飞。这种问题极难排查因为波形上看不出锁存器行为只有时序分析或者实际运行才会暴露。zero信号是为beq分支指令准备的——ALU做减法如果结果为0就说明两个操作数相等zero拉高控制器据此决定是否跳转。2.4 数据存储器与立即数扩展数据存储器DMem和指令存储器不同它既要读也要写。读写信号都是高电平有效module data_mem ( input wire clk, input wire [31:0] addr, input wire [31:0] wdata, input wire we, input wire re, output wire [31:0] rdata ); reg [31:0] mem [0:63]; always (posedge clk) begin if (we) mem[addr[31:2]] wdata; end assign rdata re ? mem[addr[31:2]] : 32h0; endmodule这里读操作也是组合逻辑跟寄存器堆同理lw指令在同一个周期内ALU算出的地址直接进访存然后把读出的数据写回寄存器堆。如果读操作也做成时序逻辑lw指令就得等两个周期才能完成这在单周期CPU里是致命的。立即数扩展模块则简单得多就是符号扩展module sign_extend ( input wire [15:0] imm16, output wire [31:0] imm32 ); assign imm32 {{16{imm16[15]}}, imm16}; endmodule最容易被忽略的是为什么必须做符号扩展。MIPS的lw/sw地址是“寄存器16位有符号立即数”立即数可能是负数。如果把负数当成无符号数直接补零扩展例如lw $t0, -8($sp)扩展出来的地址会变成0x0000FFF8而不是0xFFFFFFF8直接跑到错误的内存区域数据全乱。这个坑我一哥们调了一晚上最后发现是符号扩展写成0扩展气得差点把开发板砸了。3. 控制器设计与指令解码3.1 R型、I型、J型指令格式MIPS的三类指令格式是固化的每个字段的比特位置都不会变R型寄存器型[31:26] opcode0[25:21] rs[20:16] rt[15:11] rd[10:6] shamt[5:0] functI型立即数型[31:26] opcode[25:21] rs[20:16] rt[15:0] 立即数J型跳转型[31:26] opcode2或3[25:0] 26位地址R型指令的操作码一直是0真正决定具体操作的是低6位的funct字段。I型指令靠opcode就能区分。这种设计大大简化了译码硬件。在Verilog里提取字段就是简单的位切片wire [5:0] opcode inst[31:26]; wire [4:0] rs inst[25:21]; wire [4:0] rt inst[20:16]; wire [4:0] rd inst[15:11]; wire [5:0] funct inst[5:0]; wire [15:0] imm16 inst[15:0]; wire [25:0] jaddr26 inst[25:0];3.2 主控制器的控制信号真值表主控制器根据opcode产生整条数据通路的控制信号。支持9条指令的控制信号如下指令opcodeRegDstALUSrcMemtoRegRegWriteMemReadMemWriteBranchJumpR型00000010010000lw10001101111000sw101011X1X00100beq000100X0X00010j000010XXX00001addi00100001010000每个控制信号的含义RegDst写寄存器地址选哪个。1表示R型指令选rd字段0表示I型指令选rt字段。ALUSrcALU第二操作数来源。0选寄存器堆读出的rt数据1选符号扩展后的立即数。MemtoReg写回寄存器的数据来源。1表示来自数据存储器读口lw指令0表示来自ALU结果。RegWrite寄存器堆写使能决定这条指令是否要写寄存器。MemRead数据存储器读使能只有lw才拉高。MemWrite数据存储器写使能只有sw才拉高。Branch分支指令标志最终控制PC选择。Jump无条件跳转标志直接改写PC。我用case(opcode)实现主控制器多个指令对应相同默认值时可以通过default兜底不需要一条条列完。3.3 ALU控制信号的生成逻辑ALU控制信号不需要主控制器直接给而是通过ALUOp2位加上R型指令的funct字段共同决定。ALUOp由主控制器产生指令类型ALUOplw/sw/addi00做加法beq01做减法R型10由funct决定ALUOp这么编码的巧妙之处在于lw、sw、addi计算地址时本质都是加法beq判断相等本质是减法而R型指令种类最多必须看funct才能确定具体操作。ALUControl模块的Verilog实现module alu_control ( input wire [1:0] alu_op, input wire [5:0] funct, output reg [3:0] alu_ctrl ); always (*) begin case (alu_op) 2b00: alu_ctrl 4b0010; // 加法 2b01: alu_ctrl 4b0110; // 减法 2b10: begin case (funct) 6b100000: alu_ctrl 4b0010; // add 6b100010: alu_ctrl 4b0110; // sub 6b100100: alu_ctrl 4b0000; // and 6b100101: alu_ctrl 4b0001; // or 6b101010: alu_ctrl 4b0111; // slt default: alu_ctrl 4b0010; endcase end default: alu_ctrl 4b0010; endcase end endmodule你可能注意到addi指令的opcode是001000但它不需要看functALUOp直接给00让它做加法。这就是ALUOp编码分层设计的优势主控制器只需要知道“这条指令要做什么类型的ALU操作”具体做哪个R型操作由funct决定代码结构非常干净。4. 数据通路连接与整体功能验证4.1 顶层CPU模块的完整连线所有子模块准备好之后剩下的事情就是把它们连起来。顶层模块是整个CPU的“主板”上面全是一根根信号线。我习惯把顶层模块写成纯连线风格每个子模块的实例化都配上清晰注释方便在仿真波形里对照信号名排查。module single_cycle_cpu ( input wire clk, input wire rst_n ); // 取指阶段信号 wire [31:0] pc_current; wire [31:0] pc_next; wire [31:0] pc_next_plus4; wire [31:0] inst; // 译码阶段信号 wire [5:0] opcode; wire [4:0] rs, rt, rd; wire [5:0] funct; wire [31:0] sign_imm; wire [31:0] rdata1, rdata2; // 执行阶段信号 wire [3:0] alu_ctrl; wire [31:0] alu_src2; wire [31:0] alu_result; wire zero; // 访存与写回信号 wire [31:0] dmem_rdata; wire [31:0] wb_data; // 控制信号 wire reg_dst, alu_src, mem_to_reg; wire reg_write, mem_read, mem_write; wire branch, jump; wire [1:0] alu_op; assign pc_next_plus4 pc_current 32h4; assign opcode inst[31:26]; assign rs inst[25:21]; assign rt inst[20:16]; assign rd inst[15:11]; assign funct inst[5:0]; // 分支目标地址 (PC4) 加上符号扩展立即数左移2位 wire [31:0] branch_target pc_next_plus4 (sign_imm 2); // 跳转目标地址 当前PC高4位拼接26位地址再补00 wire [31:0] jump_target {pc_next_plus4[31:28], inst[25:0], 2b00}; // 选择下一指令地址 wire branch_taken branch zero; wire [31:0] pc_next_sel1 branch_taken ? branch_target : pc_next_plus4; assign pc_next jump ? jump_target : pc_next_sel1; // 实例化各个模块 pc_reg u_pc ( .clk (clk), .rst_n (rst_n), .next_pc (pc_next), .current_pc (pc_current) ); inst_mem u_imem ( .addr (pc_current), .inst (inst) ); regfile u_regfile ( .clk (clk), .rst_n (rst_n), .raddr1 (rs), .raddr2 (rt), .waddr (reg_dst ? rd : rt), .wdata (wb_data), .we (reg_write), .rdata1 (rdata1), .rdata2 (rdata2) ); sign_extend u_sign_extend ( .imm16 (inst[15:0]), .imm32 (sign_imm) ); alu_control u_alu_ctrl ( .alu_op (alu_op), .funct (funct), .alu_ctrl (alu_ctrl) ); alu u_alu ( .a (rdata1), .b (alu_src2), .alu_ctrl (alu_ctrl), .result (alu_result), .zero (zero) ); data_mem u_dmem ( .clk (clk), .addr (alu_result), .wdata (rdata2), .we (mem_write), .re (mem_read), .rdata (dmem_rdata) ); assign alu_src2 alu_src ? sign_imm : rdata2; assign wb_data mem_to_reg ? dmem_rdata : alu_result; control_unit u_ctrl ( .opcode (opcode), .reg_dst (reg_dst), .alu_src (alu_src), .mem_to_reg (mem_to_reg), .reg_write (reg_write), .mem_read (mem_read), .mem_write (mem_write), .branch (branch), .jump (jump), .alu_op (alu_op) ); endmodule顶层连线中几个容易出错的地方我重点说一下。第一写寄存器地址的选通。waddr不能简单连rd因为lw和addi这类I型指令要写回rt字段对应的寄存器。RegDst控制信号就是干这个的R型指令置1选rdI型置0选rt。我在实际项目里见过有人图省事把所有指令都写回rd结果addi的结果全写进了rt对应的编号——看起来好像没啥大问题但跟参考实现一比对寄存器值完全对不上。第二访存地址和数据。lw/sw指令的访存地址是“基址寄存器立即数”也就是ALU的输出写入存储器的数据应该是寄存器堆读出的第二个操作数rt字段而不是ALU的结果。如果粗心把wdata连成ALU结果内存里存的会是被计算过的数整个测试程序逻辑都乱套。第三beq的分支地址计算。MIPS的分支目标地址是(PC4) sign_extend(imm16) 2而不是PC sign_extend(imm16) 2。因为PC在流水线里已经指向下一条指令了在单周期里beq执行时PC已经更新为PC4所以偏移量要基于PC4计算。这个细节如果你用汇编器生成机器码偏移是汇编器算好的你的硬件计算基准错了跳转目标就会差4字节程序经常陷入死循环。第四j指令的目标地址。jump_target由{(PC4)[31:28], inst[25:0], 2b00}拼接而成取PC4的最高4位拼上26位指令地址和末尾的00。之所以用PC4而不用PC跟beq的原理一致。我见过有人直接用当前PC拼前4位在高地址时能差出256MB程序直接飞到未知区域。4.2 测试程序的设计与机器码生成写完CPU之后最关键的一步是设计一个能覆盖所有指令类型的测试程序。我的原则是先跑通最简单的指令再逐步增加复杂度。不要一上来就整一个冒泡排序出现问题你根本不知道是排序逻辑错了还是硬件错了。一个推荐的测试程序汇编形式lw $t0, 0($zero) # $t0 mem[0] addi $t1, $zero, 7 # $t1 7 add $t2, $t0, $t1 # $t2 $t0 7 sub $t3, $t0, $t1 # $t3 $t0 - 7 sw $t2, 4($zero) # mem[4] $t2 beq $t2, $t3, 2 # 如果相等跳到地址32 j 0 # 跳回开始对应的机器码十六进制我用下面的方式初始化到指令存储器initial begin mem[0] 32h8D080000; // lw $t0, 0($zero) --- 0x8D080000 mem[1] 32h20090007; // addi $t1, $zero, 7 --- 0x20090007 mem[2] 32h01095020; // add $t2, $t0, $t1 --- 0x01095020 mem[3] 32h01095822; // sub $t3, $t0, $t1 --- 0x01095822 mem[4] 32hAD090004; // sw $t2, 4($zero) --- 0xAD090004 mem[5] 32h114B0002; // beq $t2, $t3, 2 --- 0x114B0002 mem[6] 32h08000000; // j 0 --- 0x08000000 end这里给出几个机器码的推算方法方便你自己构造测试程序时对照lw $t0, 0($zero)opcode100011rs00000rt01000imm0000000000000000合成0x8D080000。addi $t1, $zero, 7opcode001000rs00000rt01001imm0000000000000111合成0x20090007。add $t2, $t0, $t1opcode000000rs01000rt01001rd01010shamt00000funct100000合成0x01095020。sw $t2, 4($zero)opcode101011rs00000rt01010imm0000000000000100合成0xAD0A0004。注意这里rt是01010而不是01001因为sw把rt当“要存储的数据来源”用。数据存储器也初始化一个已知值initial begin dmem[0] 32h0000_00AA; // 给lw提供一个已知数 dmem[4] 32h0; // 观察sw写回结果 end测试逻辑很简单$t0会装载0xAA$t17$t20xB1$t30xA3然后beq比较0xB1和0xA3不相等继续执行j 0跳回开头。我们可以通过观察寄存器值和内存值来验证每条指令是否都执行正确。如果要验证beq跳转可以临时改一下addi的立即数让$t2和$t3相等观察PC是否跳转到偏移为2的位置。4.3 仿真验证与波形调试方法编写testbench时最核心的一点是不要只给时钟和复位还要用$monitor或$display打印关键信号。我自己的习惯是每个时钟上升沿打印PC、当前指令、控制信号和关键寄存器值。module cpu_tb; reg clk 0; reg rst_n 0; always #5 clk ~clk; // 10ns时钟周期 single_cycle_cpu u_cpu ( .clk (clk), .rst_n (rst_n) ); initial begin rst_n 0; #20 rst_n 1; #2000 $finish; end always (posedge clk) begin $display(PC%h, inst%h, alu_result%h, reg_write%b, mem_write%b, u_cpu.pc_current, u_cpu.inst, u_cpu.alu_result, u_cpu.reg_write, u_cpu.mem_write); end endmodule仿真工具方面如果你不想折腾盗版license直接用开源工具链Icarus Verilog加GTKWave就够了。安装方法各平台不同Linux下apt install iverilog gtkwave一条命令搞定Windows下可以用MSYS2或直接下载安装包。编译运行iverilog -o cpu_tb.vvp cpu_tb.v single_cycle_cpu.v pc_reg.v inst_mem.v regfile.v alu.v alu_control.v control_unit.v sign_extend.v data_mem.v vvp cpu_tb.vvp gtkwave dump.vcd波形调试时重点看这几个信号PC是否按预期变化复位后为0每个周期加4遇到beq跳转是否变成分支目标遇到j是否变成跳转目标。每个周期控制信号是否匹配真值表比如lw指令时MemRead必须为1R型指令时RegDst为1等等。大部分逻辑错误用这个办法几分钟就能定位。lw指令的写回数据路径看MemtoReg信号为1时写回寄存器的数据确实来自存储器读口。我的经验是先验证每条指令独立运行正确再组合起来跑综合测试。只要PC变化正常、控制信号跟真值表一致剩下的问题大概率是数据路径的连线或者测试程序本身的问题。5. 常见问题排查与调试经验5.1 仿真中一片X态从何而来仿真看到满屏的红色X未知态时90%的情况是下面几个原因没有复位信号或复位时机不当。PC寄存器里是X取出的指令就是Xopcode译出来是XX控制信号全是X整条数据通路就废了。解决方法是一开始把rst_n拉低至少一个时钟周期再释放。这也是为什么我坚持给每个模块都加异步复位的原因。始终没有驱动某些输入。比如顶层模块里alu_src2如果忘记assign仿真器默认给Z高阻ALU运算结果全是X。可以给所有输入信号赋默认值或者把顶层连线拆成多个便于观察的中间信号。从存储器读取未初始化地址。指令存储器和数据存储器只初始化了部分地址PC一旦跳到没初始化的区域读出的指令就是X。可以在initial块里把整个数组清零再用$readmemh加载。组合逻辑自环。比如数据存储器的读数据又作为地址送回同一个存储器即使功能上可行仿真器也可能在某个初始条件下震荡出X。单周期CPU里尽量不要让数据在组合逻辑里绕圈宁可多一个周期打一拍。5.2 寄存器写不进数据或永远读到0这个坑非常经典。症状是仿真时寄存器堆的regs数组总是0写使能也为1写数据也有值但就是写不进去。我分析下来通常两种原因一种是复位逻辑覆盖了写操作。当复位信号rst_n还保持低电平时寄存器被持续清零如果你释放复位的时机晚于测试程序的写操作看起来就是写了没效果。可以在testbench里先复位30ns再看波形。另一种是寄存器0被写入后又被强制读成0。寄存器0这个特殊约定如果你在写端口没有地址判断直接允许regs[0]被覆盖那么只要某条指令的rd或rt恰巧是0硬件就会写坏$zero。更隐蔽的问题是仿真器里regs[0]已经被写成非0值但读端口的raddr0 ? 0 : regs[raddr]特判让读口还返回0这时你就会看到自己被自己骗了——写进去了但读出来是0。5.3 beq分支不跳转或跳错地址beq不跳转时先检查zero信号是否为1。ALU做减法结果确实是0zero才拉高。我遇过一个低级错误ALU的减法实现写成了a ~b但忘了加1导致减法结果永远是a-b-1beq自然永远不成立。检查时用$display把a、b、result、zero四个信号全部打出来一眼就能看出问题。跳错地址的话检查branch_target的计算基准。单周期里我直接用pc_next_plus4做基准如果误用pc_current所有分支目标都会偏4字节。还要注意立即数左移2位之前必须先做符号扩展左移后符号位会进入低两位如果你把顺序做反了得到的地址会差得离谱。5.4 sw指令写入错误的数据或错误的地址sw的访存数据来自rdata2寄存器堆第二个读口也就是rt字段对应的寄存器值。有的人在理解上把sw当成“把ALU结果写进内存”连错线后内存被写入一堆计算后的值。检查时可以直接打印u_dmem.mem数组看看写入的值跟预期是否一致。地址方面sw的访存地址是ALU计算出的基址加偏移这一点跟lw一样。如果地址总是不对检查符号扩展是否做对了以及ALU的alu_src2是否在sw时正确选择了立即数。sw指令ALUOp给的是00加法不能因为sw不写寄存器就放松ALU控制信号的设置。5.5 测试程序正确但CPU效果不对——先怀疑程序再怀疑硬件一个很反直觉但极常见的坑是硬件看起来都对实际上错在测试程序的机器码算错。比如beq的偏移量汇编器计算的时候是基于(PC4)偏移你手工编写机器码时也经常算错。我用过一个土办法先让程序跑最简单的加法寄存器结果正确后再加lw/sw最后才加beq/j。每加一条指令前先在纸上手动推演一遍每条指令执行后的PC、寄存器、内存状态仿真结果跟推演对不上就直接定位到出错指令。调试时多用$display打印每一条指令执行后的PC和寄存器值比对着波形数时钟周期快得多。我自己调试单周期CPU时波形主要看控制信号和跳转路径业务逻辑全部靠打印追踪。6. 从单周期到更远后续还能怎么玩单周期CPU跑通之后这个项目并不是终点而是起点。你可以按自己的兴趣和职业方向做以下扩展扩展指令集。加入ori、andi、sll、srl、jal、jr等指令同时补上对应的控制信号和ALU操作数据通路基本不用大改纯粹是控制逻辑的加法题。jal的难点在于需要把返回地址PC4写回$ra寄存器这时写回数据又要多一路31号寄存器的来源需要再加一个多路选择器。接上真实外设。在FPGA开发板上实现串口收发把CPU的调试信息通过UART打印到PC上甚至让CPU控制LED、数码管、按键。我见过有人给单周期CPU配了一个VGA控制器直接显示CPU内部寄存器的十六进制值看起来特别酷实际就是增加几个地址映射的外设相当于自己动手做了一个最简微型SoC。升级到流水线。这是CPU设计学习最重要的一步。五级流水线把一条指令拆成IF、ID、EX、MEM、WB五个阶段每个阶段同时处理不同指令吞吐量理论上提升到原来的5倍。紧接着你会碰到数据冒险、控制冒险、结构冒险然后实现转发电路和分支预测。这一套做完你对计算机体系结构的理解会远超只做过单周期的人。换到RISC-V。RISC-V指令集格式更现代生态也越来越好。做过MIPS单周期再迁移到RISC-V最多一周时间而且面试时候跟面试官聊RISC-V会更有话题性和前瞻性。很多人先做MIPS版再改RISC-V版简历上就成了“基于RISC-V指令集的单周期处理器”含金量立刻不一样。另外我自己写这个项目时还有一个体会写CPU是对Verilog基本功最好的检验。那些“手撕Verilog面试题”里的三段式状态机、异步FIFO、UART协议、CRC校验到了CPU项目里全是辅助模块。当你发现为了测试CPU你得写一个简单的汇编器、还得设计一个能通过串口加载程序的BootLoader时你已经从一个只会写模块的人变成一个能设计系统的人了。现在FPGA上跑CNN的文章很多但大多数都是拿现成IP搭积木而CPU设计是少数几个能让你把数字电路底层完全吃透的项目花两周时间把它做一遍绝对值回票价。