简介面向计算机组成原理、FPGA入门与Verilog实践学习者的单周期CPU设计工程围绕取指、译码、执行、写回四个阶段构建简化处理器模型可在Vivado中完成代码组织、行为仿真、综合、实现与下载验证适合课程设计、实验课及自学处理器工作原理的人群。压缩包共107个文件约1.31MB以Verilog源码、Vivado工程文件为主辅以日志、仿真数据库、批处理脚本和综合报告等调试产物能够还原从工程建立到上板验证的完整设计痕迹。已有3635人学习说明该案例在入门群体中具有一定参考价值。读者可据此梳理程序计数器、指令寄存器、译码器、ALU、寄存器堆与控制单元的连接关系理解单周期时序约束和指令扩展的修改位置并借助日志与报告排查综合、仿真中的常见问题形成可复用的FPGA开发流程认知。1. 单周期 CPU 到底是什么一条 add 指令在一个时钟周期里走完全部链路前几年帮人看课程设计最常见的一幕是Vivado 工程建好了几个 .v 文件也加进去了仿真跑起来 PC 每拍都在动可寄存器堆里的值就是不更新学生盯着波形看了两小时最后发现是写回地址接错了。单周期 CPU 这类项目难的地方从来不是某一句话写不出来而是你要在动手前就把「一条指令在一个时钟周期内依次经过取指、译码、执行、访存、写回全靠组合逻辑串起来」这件事想清楚。这份单周期 CPU 设计用 Verilog 实现了一套 MIPS 子集覆盖 add、sub、and、or、lw、sw、beq、addi、j 九条指令配套 Vivado 工程能做行为仿真、综合和上板。适合刚学完数字逻辑、想拿一套能跑通的完整代码练手的人也适合要带课设、需要一份干净参考实现的人。2. 工程结构与数据通路先看清单周期 CPU 的模块划分拿到一个 CPU 工程我的习惯是先不看代码先看目录。因为单周期 CPU 的模块划分非常固定目录结构基本能反映出作者有没有把「控制通路」和「数据通路」这两条线分开。分不开的工程后面加一条指令就要动五个文件改到最后自己都不认识。2.1 目录与文件划分一个模块一个文件这套工程的组织方式是这样的rtl 下每个 .v 只放一个 module顶层 cpu_top.v 只做例化和连线不写任何 always 块single_cycle_cpu/ ├── rtl/ │ ├── cpu_top.v # 顶层只做例化和 mux 连线 │ ├── pc_reg.v # 程序计数器 │ ├── imem.v # 指令存储器ROM$readmemh 初始化 │ ├── regfile.v # 32x32 寄存器堆双读单写 │ ├── alu.v # 算术逻辑单元 │ ├── ctrl_unit.v # 主控制单元译码出全部控制信号 │ └── dmem.v # 数据存储器RAM ├── sim/ │ ├── tb_cpu.v # 仿真顶层 │ └── inst.hex # 机器码一行一条指令 ├── constr/ │ └── cpu.xdc # 引脚与时序约束 └── vivado/ └── cpu.xpr # 工程文件这么拆的好处很直接加指令只改 ctrl_unit 的 case 和 alu 的 case换存储器只改 imem/dmem想换成流水线顶层推倒重连下面五个模块一个都不用动。血泪经验是千万别把寄存器堆和 ALU 塞进顶层一个 always 里图省事等到要加转发或者要上板抓 ILA你会发现自己连个能单独例化的探针都挂不上去。要注意 inst.hex 这份文件仿真时 Vivado 在 sim 目录下找它综合时却在工程根目录下找路径不对综合不会报错只会悄悄生成一块全 0 的 ROM。这个坑放到第 5 章细说。2.2 数据通路的五段式连法把线画在纸上再写代码单周期 CPU 的数据通路是一条单向往前的链没有任何反馈除了 PC 自己。写代码之前我一般会在纸上把这几个节点的位宽标出来信号位宽来源去向pc32pc_regimem、pc4 加法器instr32imem译码、控制单元、立即数扩展rd1 / rd232regfileALU 的 A 口 / 数据存储器写口imm_ext32立即数扩展ALU 的 B 口经 ALUSrc 选择alu_y32aludmem 地址、写回 muxmem_rdata32dmem写回 muxwb_data32写回 muxregfile 写口这条链上有三个关键的多路选择器是整个设计的「关节」ALUSrc 决定 ALU 的 B 口吃寄存器还是吃立即数lw/sw/addi 吃立即数R 型和 beq 吃寄存器MemToReg 决定写回的是 ALU 结果还是存储器读出的数据lw 走后者RegDst 决定写的是 rd 还是 rtR 型写 rdlw/addi 写 rt。很多工程跑不对问题就出在这三个 mux 的选择信号接反了或者干脆没接。PC 的下一条指令也有三条路可选优先级是 jump (branch zero) pc4。这个优先级不是随便定的j 指令是无条件跳跳完就不该再看 beq 的判断beq 只有 ALU 减出 zero1 时才跳都不满足才顺序执行。写成一行三目运算最清楚assign pc_next jump ? jump_target : (branch zero) ? branch_target : pc_plus4;分支目标地址是pc_plus4 (imm_ext 2)注意是 pc4 再加偏移不是 pc 加偏移——因为 MIPS 的分支偏移是相对于下一条指令的。这个 4 漏掉跳转就会整体偏一条指令波形上看起来「跳了但跳歪了」特别难查。2.3 控制信号表先列表再写 Verilog控制单元本质上就是一张查找表。我建议先在文档里把表填满再照着表写 case比边写边想靠谱得多。这套实现的指令子集对应的表是这样指令opcodeRegDstRegWriteALUSrcMemWriteMemToRegBranchJumpALUCtrlR 型0000001100000由 funct 定lw10001101101000010sw101011x011x000010beq000100x000x100110addi00100001100000010j000010x0x0x01xxxxALUCtrl 的编码我自己习惯用四位0000 与、0001 或、0010 加、0110 减。选 0110 当减法不是为了好看是为了让「加」和「减」只差一位当年有些教材用 0010/0011 表示加减结果做 ALU 位切片实验时容易看混。这里不用纠结全工程统一就行关键是 ctrl_unit 和 alu 两边的 case 必须一字不差地对上。表里那些标 x 的项在 Verilog 里最好也给个确定值一般给 0不要真的写4bx。综合工具遇到 x 会当成 dont care 去优化仿真和上板行为可能不一致这是典型的「仿真对、板子跑飞」来源之一。3. Vivado 工程搭建与仿真从新建工程到波形对上代码写完了不等于跑通了。Vivado 这套工具链的坑一半在综合设置一半在仿真顶层没设对。这一章按我实际的操作顺序走一遍从新建工程到上板看到寄存器值变化。3.1 新建工程与文件加入顺序新建工程时选 RTL Project勾上「Do not specify sources at this time」先把工程建空。原因很简单如果建工程时一次性把文件加进去Vivado 会自动去猜哪个是顶层猜错了后面要手动改还容易在综合时留下残余的 top 设置。工程建好后分三步Add Sources → Add or create design sources把 rtl 下 7 个 .v 全部加进来右键 cpu_top.v → Set as Top确认 Sources 窗口里 cpu_top 是加粗的Add Sources → Add or create simulation sources加入 sim/tb_cpu.v然后右键它 → Set as Top这一步是在 Simulation Sources 那一栏里操作不是 Design Sources。第 3 步是最容易漏的。Design Sources 的 top 和 Simulation Sources 的 top 是两个独立设置只设了前者点 Run Simulation 时 Vivado 会把 cpu_top 当仿真顶层跑它没有激励波形上全是 X你会以为是复位逻辑写错了。另外顺手把仿真时间改一下。默认 1000ns 对单周期 CPU 来说太短一条指令一个周期跑十来条指令就结束了。在 Settings → Simulation 里把 xsim.simulate.runtime 改成 10us 或者直接 all省得反复重跑。3.2 testbench 与机器码让 CPU 有事可做仿真顶层不需要太花哨给时钟、给复位、跑一段时间后把关键寄存器打印出来就够了。下面这份 tb 我一直在用timescale 1ns / 1ps module tb_cpu; reg clk; reg rst_n; // 例化被测设计 cpu_top u_cpu ( .clk (clk), .rst_n (rst_n) ); // 10ns 周期对应 100MHz先跑通再说降频的事 initial clk 1b0; always #5 clk ~clk; initial begin rst_n 1b0; #100 rst_n 1b1; // 复位保持 10 个周期确保 PC 归零 #2000; // 跑 200 个周期足够执行完测试程序 $display(x1%0d x2%0d x3%0d x4%0d, u_cpu.u_rf.rf[1], u_cpu.u_rf.rf[2], u_cpu.u_rf.rf[3], u_cpu.u_rf.rf[4]); $display(mem[0]%0d, u_cpu.u_dmem.mem[0]); $finish; end endmodule这段 tb 有三个地方值得说。复位拉低 100ns 是为了避开 xsim 的初始化时序有些版本里复位只给一两个周期PC 还没来得及清零仿真就往下走了。u_cpu.u_rf.rf[1]这种层次化引用只在仿真里能用综合时会报错所以只写在 tb 里绝不写进 rtl。最后$display打印的是十进制比在波形里数位宽快得多。配套的 inst.hex 是一段能验证加减、访存和分支的小程序每行一个 32 位机器码2001000A 20020014 00221820 AC030000 8C040000 10830002 00000000 00000000逐行对一下2001000A是 addi $1,$0,1020020014是 addi $2,$0,2000221820是 add $3,$1,$2AC030000是 sw $3,0($0)8C040000是 lw $4,0($0)10830002是 beq $4,$3,2跳过后两条。跑完这段理想结果是 x110、x220、x330、x430、mem[0]30。如果 x4 是 0说明 lw 的写回路径断了如果 x3 是 0说明 R 型的 RegDst 没打开。机器码可以手算也可以用汇编器生成但手算一遍的价值在于你会真正理解 R 型指令那六个字段是怎么拼进 32 位的。第一次做这个项目建议至少手动拼三条。3.3 综合、约束与上板验证仿真波形对上之后才轮到综合。先点 Run Synthesis看综合报告里有没有 latch 推断的警告——有的话别急着上板回第 5 章看第三条。综合过了再点 Implementation最后 Generate Bitstream。引脚约束这块不同板子的引脚号完全不一样下面这份 XDC 只展示写法两个 PACKAGE_PIN 一定要换成你自己板子原理图上的编号# 时钟约束50MHz 输入先不追求频率跑通最重要 create_clock -period 20.000 -name sys_clk [get_ports clk] # 引脚分配按你手上板子的原理图替换 L16 / R2 set_property -dict {PACKAGE_PIN L16 IOSTANDARD LVCMOS33} [get_ports clk] set_property -dict {PACKAGE_PIN R2 IOSTANDARD LVCMOS18} [get_ports rst_n]上板之后怎么知道 CPU 真的在跑最省事的办法是把{u_cpu.pc, u_cpu.u_rf.rf[3]}接出来用 ILA 抓。做法是在 rtl 里给想看的信号加一句(* mark_debug true *)综合后在 Synthesized Design 里 Set Up Debug把 PC 和 x3 拉进 ILA。抓到的波形应该是 PC 每次加 4x3 在第 3 条指令之后跳到 30。用 ILA 而不是用 LED 看结果原因是 LED 只能告诉你「是 30」或者「不是 30」而 ILA 能告诉你「在第几拍变成 30 的」。单周期 CPU 里 PC 是一条绝对可靠的时间轴任何异常都可以拿 PC 去对指令一抓一个准。4. 核心模块代码拆解PC、译码、ALU 与寄存器堆逐段讲模块划分和数据通路都清楚了这一章直接进代码。我按数据流的顺序拆每个模块给一段能直接编译的实现然后说清楚关键语句为什么这么写、参数怎么改。4.1 程序计数器与指令存储器取指这一段PC 就是一个带异步复位和使能的 32 位寄存器写起来没什么花头但复位方式选错会直接影响上板表现module pc_reg ( input wire clk, input wire rst_n, // 低电平有效异步复位 input wire [31:0] pc_next, output reg [31:0] pc ); always (posedge clk or negedge rst_n) begin if (!rst_n) pc 32h0000_0000; // 复位后从 0 地址开始取指 else pc pc_next; end endmodule这里用异步复位是为了让板子上按一下复位键就能立刻归零不用等下一个时钟沿。代价是复位释放的瞬间要保证时钟稳定所以 testbench 里复位信号最好同步于时钟下降沿释放。如果后面要做多周期或者流水线我会把它改成同步复位那样时序分析更干净。指令存储器用 ROM 实现靠$readmemh从文件加载机器码module imem ( input wire [31:0] addr, output wire [31:0] instr ); reg [31:0] rom [0:255]; // 256 条指令够课设用了 initial begin $readmemh(inst.hex, rom); // 综合时会在工程根目录找这个文件 end assign instr rom[addr[9:2]]; // 字对齐忽略低两位 endmoduleaddr[9:2]这八位是这么来的256 个单元需要 8 位索引而 PC 是按字节寻址、指令是按字对齐的所以低两位直接丢掉用第 9 位到第 2 位正好。如果你的程序超过 256 条把数组开大、索引位宽跟着改比如 1024 条就用addr[11:2]。关于$readmemh有两点必须强调一是它写在 initial 块里综合工具能识别并把它转成 BRAM 的初始化数据这是可综合的二是文件路径是相对于 Vivado 工程的当前工作目录不是相对于 .v 文件所以综合和仿真找的目录不一样。第 5 章会专门讲这个坑。4.2 寄存器堆与立即数扩展译码段的两件事寄存器堆是双读单写的三端口存储器写法上最大的争议是「读要不要时序」。单周期 CPU 为了在一个周期内走完全程读口必须用组合逻辑写口才用时钟沿module regfile ( input wire clk, input wire we, // 写使能来自控制单元 input wire [4:0] ra1, ra2, // 两个读地址 input wire [4:0] wa, // 写地址 input wire [31:0] wd, // 写数据 output wire [31:0] rd1, rd2 ); reg [31:0] rf [0:31]; integer i; initial begin for (i 0; i 32; i i 1) rf[i] 32b0; // 仿真初值避免波形一片红 end // $0 恒为 0读的时候直接返回 0不依赖数组内容 assign rd1 (ra1 5d0) ? 32b0 : rf[ra1]; assign rd2 (ra2 5d0) ? 32b0 : rf[ra2]; always (posedge clk) begin if (we (wa ! 5d0)) rf[wa] wd; // 写使能有效且不是 $0 才写 end endmodule三个细节。ra1 5d0的三目判断不能省虽然 initial 里已经把 rf[0] 清成 0但只要有一条指令往 $0 写成功它就再也回不到 0 了。wa ! 5d0这道锁也是同一个原因双保险。initial 块负责给仿真一个确定的初值不然波形里 x1 到 x31 全是 X你根本分不清是没写进去还是写了但看不到。立即数扩展模块一般单独写成组合逻辑也可以直接塞在顶层// 16 位立即数符号扩展到 32 位addi/lw/sw/beq 共用 assign imm_ext {{16{instr[15]}}, instr[15:0]};{{16{instr[15]}}, instr[15:0]}这个写法是 Verilog 的位拼接加复制把最高位复制 16 份填到高半部分。这样正数保持正数负数也保持负数。新手常犯的错是写成{16b0, instr[15:0]}lw 取负偏移地址时会算出一个极大的正数访问到存储器外面去仿真直接越界。4.3 ALU 与控制单元译码结果是控制信号的集合ALU 是纯组合逻辑用 case 实现四种运算。这里有个小设计选择减法用a - b而不是a (~b) 1虽然两者综合出来的电路基本一样但写减法可读性好得多出问题的时候不需要在脑子里做补码变换module alu ( input wire [31:0] a, b, input wire [3:0] ctrl, output reg [31:0] y, output wire zero ); always (*) begin case (ctrl) 4b0000: y a b; // and 4b0001: y a | b; // or 4b0010: y a b; // add / lw / sw / addi 4b0110: y a - b; // sub / beq default: y 32b0; // 必须给默认值否则综合出锁存器 endcase end assign zero (y 32b0); // beq 靠这个信号判断是否相等 endmoduledefault分支不是可选项。写了 default综合出来是纯粹的组合逻辑不写工具会认为 ctrl 取到未列举值时输出应该「保持」于是推断出一个锁存器然后给你一条 LATCG 警告。锁存器在这个设计里没有任何用处还会让时序分析变得很难看。控制单元按第 2 章那张表来实现核心是一个双层 case外层看 opcodeR 型再进内层看 functalways (*) begin // ---- 先给全部输出赋默认值这一句省掉就是一堆 latch ---- reg_dst 1b0; reg_we 1b0; alu_src 1b0; mem_we 1b0; mem_to_reg 1b0; branch 1b0; jump 1b0; alu_ctrl 4b0010; case (opcode) 6b000000: begin // R 型指令 reg_dst 1b1; reg_we 1b1; case (funct) 6b100000: alu_ctrl 4b0010; // add 6b100010: alu_ctrl 4b0110; // sub 6b100100: alu_ctrl 4b0000; // and 6b100101: alu_ctrl 4b0001; // or default: alu_ctrl 4b0010; endcase end 6b100011: begin // lw reg_we 1b1; alu_src 1b1; mem_to_reg 1b1; alu_ctrl 4b0010; end 6b101011: begin // sw alu_src 1b1; mem_we 1b1; alu_ctrl 4b0010; end 6b000100: begin // beq branch 1b1; alu_ctrl 4b0110; end 6b001000: begin // addi reg_we 1b1; alu_src 1b1; alu_ctrl 4b0010; end 6b000010: jump 1b1; // j default: ; // 未定义指令全默认值 endcase end块首那一排默认赋值是整个控制单元最重要的几句话。Verilog 的 always (*) 会推断组合逻辑任何一个输出在所有分支里没有赋值综合工具就认为它需要保持原值于是生成锁存器。把默认值写在最前面后面每个分支只覆盖需要改的信号既不用每次都写全又不会漏。default: ;这个空分支是为了让未定义指令落到「什么也不做」的状态而不是让工具去猜。有些实现会在这里做非法指令异常那是后面扩展的事。4.4 顶层连线把 mux 和写回路径接起来顶层除了例化主要工作就是把三个 mux 和两个地址加法器接出来module cpu_top ( input wire clk, input wire rst_n ); wire [31:0] pc, pc_next, pc_plus4, instr; wire [31:0] rd1, rd2, imm_ext, alu_b, alu_y, wb_data, mem_rdata; wire [4:0] rs, rt, rd, wa; wire [31:0] branch_target, jump_target; wire zero; wire [3:0] alu_ctrl; wire reg_dst, reg_we, alu_src, mem_we, mem_to_reg, branch, jump; // 指令字段切分只切一次后面到处用 assign rs instr[25:21]; assign rt instr[20:16]; assign rd instr[15:11]; // 三个关键选择器 assign wa reg_dst ? rd : rt; // 写哪个寄存器 assign alu_b alu_src ? imm_ext: rd2; // ALU B 口吃什么 assign wb_data mem_to_reg ? mem_rdata : alu_y; // 写回什么数据 assign imm_ext {{16{instr[15]}}, instr[15:0]}; assign pc_plus4 pc 32d4; assign branch_target pc_plus4 (imm_ext 2); assign jump_target {pc_plus4[31:28], instr[25:0], 2b00}; // 下一条 PC 的优先级jump branch 顺序执行 assign pc_next jump ? jump_target : (branch zero) ? branch_target : pc_plus4; // 例化各模块 pc_reg u_pc (.clk(clk), .rst_n(rst_n), .pc_next(pc_next), .pc(pc)); imem u_im (.addr(pc), .instr(instr)); regfile u_rf (.clk(clk), .we(reg_we), .ra1(rs), .ra2(rt), .wa(wa), .wd(wb_data), .rd1(rd1), .rd2(rd2)); ctrl_unit u_ctl(.opcode(instr[31:26]), .funct(instr[5:0]), .reg_dst(reg_dst), .reg_we(reg_we), .alu_src(alu_src), .mem_we(mem_we), .mem_to_reg(mem_to_reg), .branch(branch), .jump(jump), .alu_ctrl(alu_ctrl)); alu u_alu (.a(rd1), .b(alu_b), .ctrl(alu_ctrl), .y(alu_y), .zero(zero)); dmem u_dm (.clk(clk), .we(mem_we), .addr(alu_y), .wdata(rd2), .rdata(mem_rdata)); endmoduleassign jump_target {pc_plus4[31:28], instr[25:0], 2b00};这一句是 MIPS 的 J 型指令格式决定的目标地址的高 4 位取 PC4 的高 4 位中间 26 位来自指令字段低 2 位补 0字对齐。如果你的程序全在低地址段高 4 位其实一直是 0直接写{4b0, instr[25:0], 2b00}也能跑但那是凑巧换个大点的程序就废了。数据存储器的写法跟 imem 类似只不过它有写口module dmem ( input wire clk, input wire we, input wire [31:0] addr, input wire [31:0] wdata, output wire [31:0] rdata ); reg [31:0] mem [0:255]; assign rdata mem[addr[9:2]]; // 读是组合逻辑单周期必须这样 always (posedge clk) begin if (we) mem[addr[9:2]] wdata; end endmodule读用assign、写用always (posedge clk)是单周期 CPU 的标准做法。如果读也写成时序的lw 指令拿到的就是上一拍的数据必须得多加一个周期等它——那就不叫单周期了。5. 避坑与排查单周期 CPU 在 Vivado 里最容易翻车的 5 个地方这个设计我从头到尾重写过三遍前两遍踩的坑基本都在这五条里。每条按「看到什么现象 → 为什么 → 怎么解决」写遇到问题时可以对着排查。5.1 波形上寄存器和 PC 全是 X现象Run Simulation 跑起来PC 一直是红色的 X寄存器堆里所有值也是 X$display打印出来是 x。原因两种可能。一是复位没接好testbench 里 rst_n 从头到尾是 0或者复位时间太短PC 还没被清零仿真就结束了二是寄存器堆没写 initial 块rf 数组在仿真开始时是未初始化的 X 态读出来自然是 X。解决把 testbench 里的复位拉低时间加到 100ns 以上确保覆盖至少 10 个时钟周期寄存器堆加上initial for (i0;i32;ii1) rf[i]0;。如果 PC 动了但某个寄存器还是 X那就是写使能或者写地址那一侧的问题顺着 reg_we 和 wa 两根线往回查。5.2 仿真一切正常综合后上板什么也不动现象行为仿真波形完美x3 确实是 30但生成 bitstream 上板后ILA 抓到的 PC 一直停在 0或者指令全是 00000000。原因$readmemh(inst.hex, rom)用的是相对路径。仿真时 Vivado 的工作目录是工程目录/工程名.sim/sim_1/behav/xsim综合时是工程目录/工程名.runs/synth_1两个地方都找不到 inst.hex于是 rom 里全是默认值 0。关键是这种情况下综合不会报错只在日志里留一条 cannot open file 的 warning。解决最稳妥的办法是把 inst.hex 复制一份到工程根目录和 .xpr 同级或者干脆在$readmemh里写绝对路径。综合完了一定要去 log 里搜一下 readmem确认没有 File not found。这个坑特别阴因为仿真能过你会本能地怀疑硬件而不是文件路径。5.3 综合报告里一堆 LATCG 警告时序怎么都过不去现象Run Synthesis 之后 Critical Warnings 里出现几十条 inferred latch打开 Schematic 一看控制单元变成了带反馈的结构。原因always (*)块里某个输出没有在所有分支中被赋值综合工具为了保证「值不变」就给你生成了一个锁存器。最常见的是 case 语句漏了 default或者像mem_to_reg这种只有 lw 用到的信号在某些分支里忘了给值。解决在这个 always 块的第一行把所有输出赋一遍默认值然后再进 case。第 4.3 节那段控制单元代码就是按这个套路写的。改完重新综合LATCG 警告应该一条不剩。数字逻辑课上讲的「组合逻辑不能有记忆」落到代码上就是这句话。5.4 beq 跳转到了错误的位置或者根本没跳现象程序里 beq $4,$3,2 明明两个寄存器相等PC 却跑到 PC4 上去了或者跳了但跳到了偏移量一半的位置。原因三种可能。一是 zero 信号没接对beq 时 ALU 的 ctrl 应该是减法0110如果错写成加法zero 永远不成立二是分支目标算成了pc (imm2)漏掉了 4三是 pc_next 的三目运算优先级写反把 pc_plus4 放在了最前面。解决先用 ILA 或者仿真波形单独看 zero 在 beq 那一拍是不是 1确认 ALU 的 ctrl 是 0110再把 branch_target 的表达式改成pc_plus4 (imm_ext 2)。如果跳转地址是目标地址的一半那就是偏移量没左移两位把 2补上。5.5 $0 寄存器被写脏后面的指令全乱现象仿真跑着跑着本来应该是add $3,$0,$1的指令算出来一个莫名其妙的值最后发现 $0 不等于 0 了。原因写口没有对 wa 做非零判断某条指令比如 addi $0,$0,5 这种手滑写出来的真的把 0 号寄存器写了进去。寄存器堆里的 0 号单元一旦被写就再也不会自动清零。解决写口加if (we (wa ! 5d0))读口加(ra1 5d0) ? 32b0 : rf[ra1]两道锁一起上。加了之后即使指令里真的写了 $0读出来还是 0。我一般还会在 testbench 里跑完之后$display(rf[0]%0d, u_cpu.u_rf.rf[0]);确认它是 0 才收工。6. 二次开发从加一条指令到一个能跑流水线的版本这套代码最容易上手的改造是加指令。以加一条slt小于则置 1为例整个流程只有四步先在 ctrl_unit 的 R 型内层 case 里加6b101010: alu_ctrl 4b0111;再在 alu 的 case 里加4b0111: y ($signed(a) $signed(b)) ? 32d1 : 32d0;然后在控制表文档里补一行最后在 inst.hex 里加两条测试指令跑一遍仿真看结果。整个过程不改任何连线因为 slt 走的是 R 型的标准通路。第二条路是改多周期。单周期最大的问题是关键路径太长取指要过 imem译码要过 regfile执行要过 ALU访存要过 dmem写回还要过 mux全串在一个周期里。我用 50MHz 时钟综合过一次WNS 直接是负的时序根本不收敛只能靠分频降到几 MHz 才能上板。改多周期的核心动作是把 PC 的更新逻辑拆成状态机把一条指令拆到 3 到 5 个周期里做每个周期只走一段组合逻辑频率立刻能上去。代价是控制单元要重写所有控制信号都得按时钟周期打拍。第三条路是流水线把五个阶段用寄存器隔开理想情况下每个周期都能完成一条指令。这条路工作量最大难点不在数据通路而在数据冒险相邻两条指令之间有依赖时要么插气泡要么做转发。转发路径我一般是先从 EX 阶段的结果直接送回 ALU 入口做起跑通之后再补 MEM 到 EX 的那一条一步一步来一次全上会调不出来。不管走哪条路验证方法都一样给每条指令写一条独立的测试程序用 tb 里的$display断言关键寄存器的值全部跑过再合并成一个长程序。别指望一个几十条指令的大程序能一次跑对出了错你连是哪条指令挂的都定位不到。从那以后我每次动 CPU 的控制逻辑都强制自己先把那张控制信号表填完整再动 Verilog改完先跑单指令测试再跑长程序。这个习惯救过我不止一次。希望帮到你。本文还有配套的精品资源点击获取