单周期CPU数据通路与控制单元实验:从连线到波形验证避坑 简介湖南大学计算机组成与原理实验课程配套的完整实验资料包面向计算机科学与技术专业学生以及希望巩固硬件基础的初学者覆盖从数字逻辑门、组合逻辑电路到存储器、CPU与I/O系统的完整实验链路可用于课程实验复现、期末复习与硬件设计入门练习。压缩包共136个文件、约15.88MB以bdf原理图、qmsg/qsf/qpf等Quartus工程与配置文件、vwf仿真波形、txt与doc实验记录为主另含少量编译报告与归档文件基本保留了各实验的设计源文件与验证过程。目前已有460人学习适合对照课程进度逐项搭建与调试。资源中可见乘法器、8位加法器、译码器等模块的原理图有助于理解组合逻辑电路的设计思路、仿真验证方法与工程组织方式也可作为存储器与微程序CPU实验的参考便于在复现时对照排错并整理实验报告。1. 一条 add 指令在第几个时钟沿写回计算机组成与原理实验真正在练的手感把一条add $t0, $t1, $t2放进自己搭的电路里波形全绿、报告写完答辩时被问一句「这条指令在第几个时钟沿写回寄存器堆、走的是哪个写口」当场卡住——这是计算机组成与原理实验里最典型的翻车姿势。这门实验真正练的是用一个一个门把取指、译码、执行、访存、写回串成一条能跑通的链子并非背诵指令集跑通之后你需要能指着波形说清每个控制信号为什么在那个时刻取那个值。适合两类人刚学完数字逻辑、要交单周期或多周期 CPU 课设的低年级学生以及想从零把硬件底子补一遍、以后往 FPGA 或体系结构方向走的开发者。后面的章节按「数据通路怎么连、控制信号怎么给、时钟怎么卡、错了怎么查」推进中间给可直接抄的 Verilog 骨架、字段表和参数约定末尾落到一份能自己跑的验证序列。2. 单周期 CPU 的数据通路把指令字段、ALU 与寄存器堆连成一条线单周期 CPU 是这门实验的主干。它把一条指令的全部动作塞进一个时钟周期里完成代价是周期长度必须迁就最慢的那条指令——通常是lw因为它要走「取指 → 读寄存器 → ALU 算地址 → 读存储器 → 写回寄存器」五段。很多人一上来就照着图连线连完发现波形对不上回头查半天问题往往出在字段切分和接口约定这两件最基础的事上。先把这条链子的骨架说清楚PC 输出字节地址给指令存储器取回 32 位指令指令的 rs、rt 字段送去寄存器堆的两个读口同时 opcode 和 funct 送去控制单元控制单元产生一组选择信号决定 ALU 的第二个操作数来自 rt 还是立即数、写回地址取 rd 还是 rt、写回数据来自 ALU 还是存储器ALU 结果既进数据存储器当地址也回送写回端。这条链上任何一个多路选择器的选择信号给反了现象都不是报错而是「值差了那么一点点」这才是组成实验最耗时间的地方。2.1 三种指令格式的字段切分与控制器信号表动手之前先把字段切分固定下来。MIPS 子集的三种格式里R 型靠 funct 区分运算I 型把 16 位立即数塞在低半段J 型直接把 26 位目标地址留在低位。表里这套切法必须在译码模块里严格对齐一旦某一位错位现象是「指令能跑但结果乱」比直接跑飞更难查。指令类型op[31:26]rs[25:21]rt[20:16]rd[15:11]shamt[10:6]funct[5:0]R 型000000源寄存器 1源寄存器 2目的寄存器移位量功能码I 型lw/sw/beq操作码基址寄存器目标/源寄存器立即数 imm[15:0] 占满低 16 位J 型操作码目标地址 [25:0] 占满低 26 位配套的控制信号表建议直接抄进实验报告它是后面写译码器的唯一依据。表里出现 X 的位置表示「该信号对这条指令无影响」但在实际电路里绝不能真的悬空必须给一个确定的默认值。指令RegDstALUSrcMemtoRegRegWriteMemReadMemWriteBranchJumpALUOpR 型1001000010lw0111100000swX1X0010000beqX0X0001001jXXX00001XX参数上还有两个必须提前定死的位宽约定寄存器堆是 32 个 32 位寄存器地址 5 位立即数在送进 ALU 之前要做符号扩展把 16 位补成 32 位否则lw -4($sp)这类负偏移会变成一个巨大的正数访问到完全不相干的地址。这个坑几乎每年都有人踩波形上看就是 ALU 输出突然变成一个0x0000FFFC之类的值。2.2 ALU 与寄存器堆的接口约定位宽、使能、写回口数据通路的两个核心部件是 ALU 和寄存器堆它们的接口约定一旦定下来后面所有连线都围绕它展开。ALU 除了算结果还要额外输出一个zero信号给分支指令用寄存器堆则必须提供两个异步读口和一个同步写口——读口是组合逻辑地址一变数据立刻出来写口挂在时钟上升沿上。module alu #(parameter W 32) ( input wire [W-1:0] a, input wire [W-1:0] b, input wire [3:0] alu_ctrl, output reg [W-1:0] y, output wire zero ); localparam ALU_AND 4b0000; localparam ALU_OR 4b0001; localparam ALU_ADD 4b0010; localparam ALU_SUB 4b0110; localparam ALU_SLT 4b0111; always (*) begin case (alu_ctrl) ALU_AND: y a b; ALU_OR : y a | b; ALU_ADD: y a b; ALU_SUB: y a - b; // slt 必须按有符号比较无符号比较会让负数判断全反 ALU_SLT: y ($signed(a) $signed(b)) ? {{(W-1){1b0}}, 1b1} : {W{1b0}}; default: y {W{1b0}}; endcase end // beq 靠这个信号判等注意它判的是 ALU 输出是否为零 assign zero (y {W{1b0}}); endmodule这段代码里有两处容易被忽略。第一处是always (*)配合case的写法所有分支都必须给y赋值否则综合工具会推断出锁存器仿真时看不出问题上板后表现为「输出保持上一次的值」是典型的玄学 bug。第二处是slt的有符号比较MIPS 的slt语义就是有符号用默认的无符号比较会让所有负数比较结果反过来而波形上看不出任何异常只有比对终态寄存器时才会暴露。寄存器堆的写法更要小心它同时承担「组合读」和「时序写」两种角色module regfile #(parameter W 32, parameter N 32) ( input wire clk, input wire rst, input wire we, // 写使能接控制单元的 RegWrite input wire [4:0] rs, input wire [4:0] rt, input wire [4:0] rd, // 写地址可能是 rd 也可能是 rt input wire [W-1:0] wdata, output wire [W-1:0] rdata_a, output wire [W-1:0] rdata_b ); reg [W-1:0] mem [0:N-1]; integer i; // 异步读地址一变数据立刻出来供同一周期的 ALU 使用 assign rdata_a (rs 5d0) ? {W{1b0}} : mem[rs]; assign rdata_b (rt 5d0) ? {W{1b0}} : mem[rt]; // 同步写只在时钟上升沿且写使能有效时更新 always (posedge clk or posedge rst) begin if (rst) begin for (i 0; i N; i i 1) mem[i] {W{1b0}}; end else if (we rd ! 5d0) begin mem[rd] wdata; // $zero 恒为 0屏蔽对 0 号寄存器的写入 end end endmodulerd ! 5d0这个判断是硬性要求。MIPS 规定 0 号寄存器恒为 0如果忘了屏蔽某条add $zero, $t1, $t2会把 0 号寄存器写脏后面所有以$zero为基址的访存全部错位而且错得非常隐蔽。另一个点是读口的rs 5d0判断它保证读 0 号寄存器时直接返回常数 0不依赖存储体里的实际内容这样即使复位不彻底也不会出错。2.3 用 add / lw / sw 三条指令做通路自检数据通路连完不要急着跑完整程序先用三条指令做最小自检add验证 ALU 和写回通路lw验证地址计算和存储器读sw验证存储器写。三条都过说明主干是通的剩下的指令只是控制信号的组合变化。具体步骤这样安排。第一步在指令存储器里手工写入三条指令的机器码add $t0, $t1, $t2对应0x012A4020sw $t0, 4($t1)对应0xAD280004lw $t3, 4($t1)对应0x8D2B0004其中$t1初值用测试平台预先置成0x00000010这样地址落在0x14避开 0 地址附近容易被复位逻辑污染的区域。第二步把 PC 的初值固定为 0让电路连续跑 5 个周期观察 PC 是否按 0、4、8、12 递增。第三步重点看三个检查点第 1 周期结束时$t0是否等于$t1 $t2第 2 周期数据存储器的0x14地址是否写入$t0的值第 3 周期$t3是否被写回同一个值。// 最小自检测试平台只跑三条指令逐周期打印关键状态 timescale 1ns/1ps module tb_smoke; reg clk 0, rst 1; wire [31:0] pc; // 这里省略 DUT 例化只保留观察逻辑 always #5 clk ~clk; // 10ns 周期方便对照波形 initial begin #12 rst 0; // 复位保持两个周期再释放 repeat (6) begin (posedge clk); #1; // 错开时钟沿避免采到竞争值 $display(t%0t pc%h t0%h t1%h t3%h, $time, pc, dut.rf.mem[8], dut.rf.mem[9], dut.rf.mem[11]); end $finish; end endmodule这段测试平台的逻辑说明#12 rst 0保证复位信号至少覆盖两个完整时钟沿避免复位释放时刻正好卡在建立时间窗口里#1的延迟是为了在时钟沿之后再采样否则会读到寄存器更新前的旧值这是一类典型的「波形看着对、打印出来不对」的假故障。参数上时钟周期取 10ns 只是为了让波形刻度好看实际仿真用 100ns 或 1ns 都不影响功能只要测试平台和 DUT 用的是同一个clk。三条指令都过了之后再往指令存储器里加beq和j这时候才需要关心分支地址的计算分支目标 PC 4 (符号扩展后的立即数 2)其中左移两位是因为立即数以字为单位、而 PC 是字节地址。这个移位忘了写现象是分支跳到一半的位置指令译码出一堆非法操作码波形上 PC 会跳到一个奇数地址非常好认。3. 控制单元的译码逻辑从真值表到可综合的表达式控制单元是整个 CPU 的「黑匣子」输入是指令的 opcode 和 funct输出是七八个选择信号和使能信号。它本身没有任何时序逻辑纯组合电路但它是实验里最容易出错的部分——因为错误不会让电路停摆只会让某条指令悄悄做错事。常见做法是把译码拆成两级主译码器只看 opcode负责产生除了 ALU 操作码以外的所有控制信号ALU 译码器看 ALUOp 和 funct只负责产生 4 位的 ALU 操作码。这样拆的理由很实际ALU 的操作码既受 opcode 影响lw/sw固定做加法算地址又受 funct 影响R 型靠 funct 区分加减与或如果塞进一个模块里真值表会膨胀到无法手写。3.1 主译码器与 ALU 译码器为什么要分开分开的第一个好处是可测。主译码器的输出可以用一张五行的表完整覆盖ALU 译码器的输出可以用一张两输入的表覆盖两者分别验证出错时能立刻定位到是哪一级。如果合并成一个模块一旦beq的行为不对你要在十几个信号里逐个排除效率差很多。第二个好处是可扩展。实验通常要求先做基础指令集再选做扩展指令比如addi、andi、jal。分开之后加一条 I 型指令只需要在主译码器里加一个 case 分支ALU 译码器完全不动如果合并每次扩展都要重排整张真值表。第三个好处跟综合有关。主译码器的输出大多是一位的使能信号适合写成case语句直接映射成多路选择ALU 译码器输出 4 位编码写成嵌套case更贴近它的语义层级。硬凑在一起代码可读性会掉得很厉害后期改一个信号要翻半天。3.2 用 case 语句写译码默认值必须先给写译码器有一条铁律always (*)块的开头先把所有输出赋成默认值再进case覆盖。原因很直接——Verilog 的case如果某个分支漏了赋值综合工具会推断出锁存器来保持旧值而这个锁存器在仿真里往往表现得「碰巧正确」直到某条指令连续出现两次才暴露。module ctrl ( input wire [5:0] opcode, output reg reg_dst, output reg alu_src, output reg mem_to_reg, output reg reg_write, output reg mem_read, output reg mem_write, output reg branch, output reg jump, output reg [1:0] alu_op ); localparam OP_RTYPE 6b000000; localparam OP_LW 6b100011; localparam OP_SW 6b101011; localparam OP_BEQ 6b000100; localparam OP_JUMP 6b000010; always (*) begin // 默认值所有使能关闭、所有选择置 0未定义指令不会产生副作用 {reg_dst, alu_src, mem_to_reg, reg_write, mem_read, mem_write, branch, jump} 8b0; alu_op 2b00; case (opcode) OP_RTYPE: begin reg_dst 1b1; // 写回地址取 rd reg_write 1b1; alu_op 2b10; // 交给 ALU 译码器看 funct end OP_LW: begin alu_src 1b1; // 第二操作数取符号扩展后的立即数 mem_to_reg 1b1; // 写回数据来自存储器 reg_write 1b1; mem_read 1b1; alu_op 2b00; end OP_SW: begin alu_src 1b1; mem_write 1b1; // 只写存储器不回写寄存器 alu_op 2b00; end OP_BEQ: begin branch 1b1; alu_op 2b01; // ALU 做减法zero 有效则跳转 end OP_JUMP: begin jump 1b1; end default: ; // 保持默认值非法指令不写任何状态 endcase end endmodule这段代码的关键在默认值那一行。mem_write默认 0保证除了sw之外的任何指令都不会误写存储器reg_write默认 0保证非法指令不会污染寄存器堆。把{reg_dst, ..., jump}打包成 8 位一次赋值是为了写法紧凑、避免漏项但要注意位序必须和声明顺序一一对应顺序错了不会报错只会让信号串位现象是「所有指令看起来都像另一条指令」。ALU 译码器是第二级输入是alu_op和funct输出 4 位操作码module alu_dec ( 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; // lw/sw只做加法算地址 2b01: alu_ctrl 4b0110; // beq做减法看 zero 2b10: 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; // 兜底成加法避免 X 传播 endcase default: alu_ctrl 4b0010; endcase end endmodule参数上要注意的是funct的取值必须和指令集文档严格一致add是0x20、sub是0x22、slt是0x2A这几个值写错的现象是「R 型指令全部退化成加法」因为兜底分支就是加法错得非常安静。建议在测试平台里对每条 R 型指令单独跑一遍直接看 ALU 的alu_ctrl输入比看终态结果快得多。3.3 控制信号默认值一个能让整条指令静默出错的地方讲过译码结构这里单独把「默认值」拎出来讲因为它的破坏力被严重低估。假设mem_write忘了给默认值那么当某条指令不写存储器时mem_write会保持上一次sw留下的 1。后果是只要程序里出现过一次sw后面所有指令的 ALU 结果都会被写进存储器地址还可能落在有效区域内。波形上看不出任何异样程序最终结果却全错查起来极其痛苦。同类问题还有reg_write、mem_read、branch。一个实用的做法是在测试平台里加一条断言式的检查每个时钟周期打印全部控制信号对照指令序列手工核一遍。三次之后你会形成肌肉记忆看到某条指令就知道它对应的信号组合该长什么样。这也是为什么前面强调先把控制信号表抄进报告——它不是形式主义是排错时的唯一参照。4. 时钟、复位与存储器时序仿真通过不等于上板通过组成实验里最让人不服气的一句话是「你仿真过了又不是真过了」。单周期 CPU 在行为级仿真里跑通只说明逻辑关系对一旦换成带延迟的门级网表或者真的烧进 FPGA时序问题就会集中爆发。这一章讲三个高频位置时钟沿、写使能冲突、存储器模型。4.1 时钟沿、建立保持时间在电路里的表现单周期 CPU 的设计前提是「一个周期内所有组合逻辑都稳定下来下一个上升沿统一采样」。这个前提要成立周期长度必须大于最长路径延迟加上寄存器堆的建立时间。行为级仿真里没有延迟这条约束形同虚设一旦综合出真实电路最长路径通常是lw的「取指 → 译码 → 读寄存器 → ALU → 读存储器 → 写回」如果超过周期写回端采到的就是半路的值。排查方法不复杂先确认综合报告里的关键路径延迟把时钟周期设成这个值的一点二倍以上再跑一次。如果上板后仍然偶发失败重点看两个地方——一是 PC 的更新是不是和寄存器堆写回共用同一个时钟沿二是存储器读出的数据有没有经过额外的组合逻辑才送回写回端。常见做法是在关键路径上插入一级流水寄存器代价是多一个周期这在单周期实验里通常不被允许那就只能压周期或者简化通路。注意仿真时用的timescale和实际板级时钟没有关系别用仿真里的时间刻度去推板上的频率两者没有可比性。4.2 寄存器堆写使能与读写冲突的三种场景寄存器堆是「组合读、时序写」这个结构天然会带来读写冲突。实验里会遇到三种典型场景处理方式各不相同。第一种是同周期内先写后读同一个寄存器。比如add $t0, $t0, $t1读的是$t0的旧值写的是新值因为读口是组合逻辑、在时钟沿之前就取走了旧值写口在沿之后才更新所以天然正确。这类指令不需要特殊处理但要在心里确认一遍别被波形上「读口在沿后变了」吓到。第二种是紧邻的两条指令有数据依赖第二条要读第一条的结果。单周期下这也没问题因为第一条在周期末已经写回第二条在下一个周期初读到的就是新值。真正需要担心的是流水线结构那是另一回事单周期阶段不用管。第三种是写使能和写地址的时序关系。如果写地址由RegDst多路选择器产生而RegDst又来自控制单元那么从指令变化到写地址稳定之间有一段组合延迟。如果这段延迟超过时钟沿到寄存器堆采样点的余量就会写错寄存器。解决方式通常是让控制信号尽早稳定别在写地址路径上串太多级选择逻辑。4.3 存储器模型的选择异步读、同步读与理想模型指令存储器和数据存储器的模型选择是单周期实验里最容易埋雷的地方。三种常见写法各有适用场景。模型类型读行为适用场景主要风险组合读异步地址变化立即出数据单周期 CPU 首选综合后可能生成长路径同步读时钟沿后一拍出数据多周期/流水线单周期下取指晚一拍PC 错乱理想模型无延迟、无容量限制早期功能验证掩盖真实的时序问题单周期 CPU 必须用组合读模型否则取指会晚一个周期PC 和指令对不上现象是「第一条指令正确后面全部错位」。多周期 CPU 反过来用同步读更贴近真实存储器也能自然地把一个周期拆成多个状态。// 单周期用组合读指令存储器地址一变指令立刻出现 module imem #(parameter W 32, parameter D 256) ( input wire [W-1:0] addr, output wire [W-1:0] instr ); localparam AW $clog2(D); reg [W-1:0] rom [0:D-1]; initial $readmemh(program.hex, rom); // PC 是字节地址右移两位换成字索引位宽按 D 自动收窄 assign instr rom[addr[AW1:2]]; endmodule这段代码里$clog2(D)自动算出索引位宽避免手写位宽和深度不匹配addr[AW1:2]同时完成了字对齐和位宽截断低两位直接丢弃因为 MIPS 指令必然四字节对齐。$readmemh读的program.hex必须是每行一个 32 位十六进制数格式不对不会报错只会读出一片 0现象是「PC 在跑但所有指令都是空操作」非常容易误判成译码器坏了。5. 计算机组成实验避坑清单五类让仿真和报告一起翻车的细节下面五条是带过多轮之后反复见到的故障每一条都在仿真阶段表现为「看起来正常」到核对终态或者上板时才暴露。按「现象 → 原因 → 解决」记下来能省掉大量盯波形的时间。5.1 lw 写回的值总是上一条指令的结果现象lw执行完之后目标寄存器的值等于前一条add的结果存储器里的值明明是对的。原因MemtoReg这个选择信号没有在always (*)开头赋默认值导致某个分支漏赋值时被综合成锁存器保持了上一拍的旧值。上一拍正好是add的写回数据于是看起来就像「写回值差了一拍」。解决把所有控制信号在case之前统一赋默认值并且用$display逐周期打印MemtoReg确认它在lw周期内确实为 1。这个习惯要养成别指望综合工具报警。5.2 beq 永远不跳转zero 一直是 0现象分支指令执行了zero信号却始终为 0PC 不加偏移。原因beq对应的alu_op给成了2b00加法ALU 算出的是两个操作数之和几乎不可能为 0。根子在 ALU 译码器的2b01分支被漏掉或者写错了操作码。解决核对 ALU 译码器里2b01是否映射到减法并确认zero取自 ALU 的输出y而不是某个中间信号。测试时单独跑beq $t0, $t0, 1自己和自己比必然相等看zero是否拉高一步定位。5.3 仿真通过上板后 PC 在奇数地址上乱跳现象行为仿真完全正常烧进板子之后 PC 的波形出现0x00000006这种非四字节对齐的地址指令全部译码失败。原因分支地址计算时漏了立即数左移两位或者左移之后没有和PC4做正确的位宽截断高位溢出污染了地址。解决把分支地址的计算单独写成一个组合模块输入PC4和符号扩展后的立即数输出目标地址并在测试平台里对正负偏移各测一次。负偏移尤其要测因为符号扩展写错时正偏移看起来完全正常。5.4 寄存器堆读出的值比预期晚一拍现象波形上读口的数据在时钟沿之后才变化导致 ALU 在同一个周期里拿到的是旧值。原因把寄存器堆的读口写成了同步读always (posedge clk)里给读数据赋值而单周期 CPU 要求组合读。解决读口改成assign语句直接索引存储体写口保留在时钟沿里。改完之后如果综合报「组合逻辑环路」检查是不是读地址和写地址用了同一个信号形成了反馈。5.5 sw 执行后存储器内容没变现象sw的控制信号看起来都对mem_write也拉高了但数据存储器对应地址的值始终是初值。原因写地址取错接的是 PC 而不是 ALU 的输出或者数据存储器的写使能被写成了低有效而控制单元输出的是高有效。解决在波形里同时抓mem_write、写地址、写数据三个信号确认地址落在预期区间。写使能的极性在模块声明时就注释清楚别靠记忆这是最没有技术含量却最容易反复踩的坑。6. 用一段基准指令序列验证 CPU从功能正确到周期数可解释功能跑通只是及格线真正能说明你的 CPU 是对的是「同一段机器码你的电路跑出的终态和一个独立参考模型完全一致且周期数能逐条解释」。我一般会写一个几十行的 Python 解释器当黄金参考它不关心时序只按指令语义逐条更新寄存器用来对冲电路里那些看不出来的错。# 黄金参考模型解释执行同一份机器码输出寄存器终态 REGFILE [0] * 32 MEM [0] * 256 def run(program, max_steps10000): pc, steps 0, 0 while pc len(program) and steps max_steps: ins program[pc] op (ins 26) 0x3F rs (ins 21) 0x1F rt (ins 16) 0x1F rd (ins 11) 0x1F imm ins 0xFFFF if imm 0x8000: # 16 位立即数按有符号扩展 imm - 0x10000 if op 0x00: # R 型 fn ins 0x3F if fn 0x20: REGFILE[rd] (REGFILE[rs] REGFILE[rt]) 0xFFFFFFFF elif fn 0x22: REGFILE[rd] (REGFILE[rs] - REGFILE[rt]) 0xFFFFFFFF elif fn 0x2A: REGFILE[rd] 1 if REGFILE[rs] REGFILE[rt] else 0 elif op 0x23: # lw REGFILE[rt] MEM[(REGFILE[rs] imm) 2] elif op 0x2B: # sw MEM[(REGFILE[rs] imm) 2] REGFILE[rt] elif op 0x04: # beq if REGFILE[rs] REGFILE[rt]: pc pc 1 imm continue REGFILE[0] 0 # 0 号寄存器恒为 0 pc 1 steps 1 return REGFILE, MEM用法很直接把同一份program.hex转成整数列表喂给这个模型把模型输出的 32 个寄存器值和电路仿真的终态逐项比对第一个不一致的寄存器编号就是问题定位点。注意REGFILE[0] 0这行必须放在每条指令执行之后否则add $zero, ...这种写法会在模型里留下脏值造成「模型和电路都对但就是不一致」的假象。比对通过之后再做第二层验证周期数。单周期 CPU 的周期数应该严格等于指令条数加上复位占用的那几个周期。如果你的设计里出现了「某条指令多花一拍」说明有地方偷偷引入了额外的状态。把周期计数打印出来和指令条数对一遍这个数字对不上多半是 PC 的更新条件写成了带使能的形式而那个使能又被某个控制信号误触发。最后一层是边界测试值得单独列一张表跑一遍$zero作为写入目标、lw访问地址 0、beq偏移为负、连续两条写同一寄存器、立即数为0x8000最小的负数。这五个用例覆盖了符号扩展、写屏蔽、分支地址计算三类最容易出错的逻辑跑完之后基本可以对这套电路放心。我自己带实验时最常犯的一个错误是急着把指令集铺满先把七八条指令全接上跑不通再回头一条条拆。后来改成先把add、lw、sw、beq四条跑透每条都配一个参考模型比对和一个边界用例剩下的指令基本是复制粘贴加改几个控制位反而快得多。组成实验的性价比就在这儿多花两小时在验证上能省掉两天的盯波形。希望帮到你。本文还有配套的精品资源点击获取