☰
计组实验工程:单周期CPU数据通路与golden model对拍
2026/10/29 14:43:03 网站建设 项目流程

简介:面向计算机组成与原理课程学习者的湖南大学计组实验资料包,汇集已完成实验工程与配套文件,围绕数字逻辑、存储系统、CPU 与 I/O 四大实验模块展开,适合需要对照完整工程、理解硬件实现流程的本科生与自学者。压缩包共 136 个文件,约 15.88MB,包含 bdf 原理图、bsf 元件符号、qsf 与 sof 工程下载配置、vwf 仿真波形、qmsg 消息记录以及 txt、doc 说明文档等,覆盖从逻辑门、编码器、译码器、数据选择器到 RAM/ROM、Cache、微程序 CPU 和中断/DMA 接口的实践材料。已有 460 人学习,可作为实验复现与排错参考。读者可从中查看已完成实验的顶层设计、子模块划分、引脚分配与仿真结果,借助波形、报告和消息记录理解二进制运算、存储器访问及指令执行时序,并对照自身工程定位连线、编译与仿真问题,提升硬件调试效率,也便于按实验模块检索复盘。

1. 从一份已跑通的计组实验说起

期末前三周,实验室里最常见的场景是:Logisim 电路连线看着没问题,时钟一打,波形纹丝不动,或者 PC 直接跑飞。计组实验最难的地方从来不是单个门电路,而是从 ALU 到数据通路再到控制信号,任何一根线接错,现象都会以“全错”的形式出现,排查时几乎没有中间态可看。这份计算机组成原理实验工程是已经完成、可供对照参考的版本,覆盖组合逻辑部件、寄存器堆、单周期 CPU 与多周期实现,配套可仿真的源码与激励文件,还带了几段能直接灌进指令存储器的机器码程序。适合正在做实验、卡在某一环想找人对照数据通路的人,也适合想拿一份“参考答案”反向校验自己连线的人。

2. 拆开这份计组实验工程:三层模块与阅读顺序

拿到一个别人做完的计组实验工程,最怕的就是打开目录后一脸懵:十几个.v文件、几个.circ、一堆.hex和.asm,不知道从哪儿下手。我一般的做法是不急着编译,先按“门级 → 功能部件级 → CPU 级”三层把文件归一遍类,再决定先读哪一个。这个顺序决定了你后面调试时是“知道该看哪根线”,还是“全选重跑碰运气”。

2.1 三层划分:从门电路到 CPU 的台阶

第一层是门级和组合逻辑,这部分在工程里通常以 Logisim 电路文件或独立的 Verilog 模块出现,对应译码器、多路选择器、加法器、比较器和 ALU 本体。第二层是功能部件级,典型的是寄存器堆(regfile)、指令存储器(imem)、数据存储器(dmem)和 ALU 控制单元(alu_control),它们的共同点是接口清晰、可以单独写 testbench 验证。第三层才是 CPU 级,把前面两层用数据通路连起来,加上主控制器(controller)和 PC 更新逻辑。

这么分层不是为了好看,而是为了把“排错粒度”降下来。一个单周期 CPU 跑不对,如果寄存器堆本身没验证过,你就没法判断是写口时序错了还是写回通路选错了。工程里如果这三层文件是分开的,说明作者是按可验证的方式做的;如果所有逻辑都堆在一个大文件里,那参考价值会打个折扣,因为你只能整体对照,没法逐段取用。

Logisim 那部分要特别注意一点:不同分支的 Logisim 保存的.circ格式有差异,用错分支打开会看到子电路缺失或者连线颜色异常,但文件本身没坏。常见的处理方式是先用你课程要求的那个分支打开,确认子电路列表完整,再去动里面的连线。这一步不做,后面所有“我明明连对了”的困惑都从这里开始。

2.2 先看哪三个文件:顶层端口、控制器真值表、数据通路图

我的阅读顺序固定是三个文件,顺序不能换。第一个是 CPU 顶层模块,只看端口:时钟、复位、指令地址输出、外设观测口(比如数码管段选、LED)。端口定了,你才知道这个 CPU 对外暴露了什么调试手段。第二个是控制器模块,直接看它的case或真值表,把每条指令对应的RegDst、ALUSrc、MemtoReg、RegWrite、MemRead、MemWrite、Branch、Jump、ALUOp抄成一张表。第三个才是数据通路,拿着这张表逐条线去核。

// rtl/cpu.v 只看端口,先不关心内部连线 module cpu ( input wire clk, input wire rst_n, output wire [31:0] pc_out, // 观测用:当前指令地址 output wire [31:0] alu_result, // 观测用:ALU 输出,接数码管或 LED output wire [31:0] write_data // 观测用:写回数据 );

这段端口声明是读工程的入口。pc_out用来判断取指是否正常推进,alu_result用来判断运算类指令是否算对,write_data用来判断写回的是 ALU 结果还是存储器数据。三个观测口齐全的工程,调试成本会低一个量级;缺的话建议你自己在顶层补上,综合工具不会因为多引出一根线报错。

控制信号那张表是整篇文章里最值得抄下来的东西。它的作用不是背,而是当你发现某条lw指令写回的值是 0 时,能立刻定位到是MemtoReg选错了还是MemRead没拉高。没有这张表,你只能在波形里从 PC 一路往下翻,翻到第 200 个周期才可能发现问题。

2.3 工具链对齐:编译参数和波形窗口

Verilog 部分我一般用 iverilog 配 gtkwave,轻量、启动快,改一版代码几秒钟就能看到波形。编译时加-g2012是为了让always_ff、logic这类写法能过;如果你的工程里用了$readmemh,工作目录必须设对,否则存储器初始化会静默失败,读出来全是 x。

# 在工程根目录执行,先把所有 rtl 和 tb 编到一起 iverilog -g2012 -o sim/cpu_tb.vvp \ rtl/*.v \ tb/cpu_tb.v # 跑仿真,波形文件由 tb 里的 $dumpfile 决定 cd sim && vvp cpu_tb.vvp # 打开波形,把 pc_out、alu_result、write_data 拖进去 gtkwave cpu_tb.vcd &

三个参数值得说清楚。-g2012打开的是语言版本,不开的话always_ff @(posedge clk)会直接报语法错误。-o指定输出文件,建议单独放sim/目录,避免和源码混在一起。vvp是运行时,它不负责编译,只负责执行,所以改了.v必须重新走一遍 iverilog。$dumpfile和$dumpvars写在 testbench 里,前者决定波形文件名,后者决定 dump 的层次深度——写$dumpvars(0, cpu_tb)是全层次,波形文件会大一些,但排查跨模块信号时省事。

Vivado 或 Quartus 那套流程适合最后上板,不适合日常调试。综合一次几十秒到几分钟,而 iverilog 改一行跑一次只要一两秒,迭代效率差一个数量级。我通常的做法是:功能仿真全在 iverilog 里做完,确认指令序列跑通、寄存器终值对得上,再拿去做综合和上板,把上板环节压缩成“只验时序和引脚”。

3. ALU 与寄存器堆:先把两块地基跑通

CPU 出问题,八成能追溯到这两个部件。ALU 决定了运算类指令对不对,寄存器堆决定了所有带写回的指令对不对。它们的共同好处是接口简单、可以脱离 CPU 单独验证,所以正确的做法是先让这两块各自通过 testbench,再去连数据通路。跳过这一步直接调 CPU,等于把两个未知数塞进一个方程。

3.1 ALU 控制码:2 位 ALUOp 加 6 位 funct 落到 3 位

主控制器给出的ALUOp只有 2 位,它表达的是“大概要做哪类运算”,真正的运算类型要靠指令低 6 位的funct字段补全。这两级译码是计组实验里第一个容易绕晕的地方:lw、sw、addi这类 I 型指令没有funct,ALUOp直接决定运算;R 型指令ALUOp给10,再由funct细分。

ALUOpfunct运算ALU 控制码
00无关加法(取指/地址计算)010
01无关减法(分支比较)110
10100000add010
10100010sub110
10100100and000
10100101or001
10101010slt111
// rtl/alu_control.v module alu_control ( input wire [1:0] alu_op, input wire [5:0] funct, output reg [2:0] alu_ctrl ); always @(*) begin case (alu_op) 2'b00: alu_ctrl = 3'b010; // lw/sw/addi 走加法 2'b01: alu_ctrl = 3'b110; // beq 走减法 2'b10: begin case (funct) 6'b100000: alu_ctrl = 3'b010; // add 6'b100010: alu_ctrl = 3'b110; // sub 6'b100100: alu_ctrl = 3'b000; // and 6'b100101: alu_ctrl = 3'b001; // or 6'b101010: alu_ctrl = 3'b111; // slt default: alu_ctrl = 3'b010; endcase end default: alu_ctrl = 3'b010; endcase end endmodule

这段case的关键在嵌套结构:外层按ALUOp分流,只有2'b10才进内层看funct。default分支不能省,综合工具对未覆盖分支的处理是生成锁存器或者填无关值,仿真里可能碰巧对,上板就变成玄学。slt给111之后,ALU 本体要把减法结果的高位取出来当作 0/1 输出,这一步单独实现,别指望控制码自己完成。

3.2 寄存器堆:写口为什么挂在时钟下降沿

MIPS 单周期数据通路里有个经典约束:同一个周期内既要读寄存器,又要写寄存器。如果写口挂在上升沿,读出来的可能是刚写进去的新值,寄存器堆就退化成组合逻辑了;挂在下降沿,读发生在上升沿采样的时刻,写发生在周期后半段,读写互不干扰。这个设计不是随便定的,教材里这么画是有原因的。

// rtl/regfile.v module regfile ( input wire clk, input wire we, // 写使能,来自控制器的 RegWrite input wire [4:0] ra1, ra2, wa, input wire [31:0] wd, output wire [31:0] rd1, rd2 ); reg [31:0] regs [0:31]; assign rd1 = (ra1 == 5'd0) ? 32'd0 : regs[ra1]; assign rd2 = (ra2 == 5'd0) ? 32'd0 : regs[ra2]; always @(negedge clk) begin if (we && wa != 5'd0) regs[wa] <= wd; end endmodule

三个参数要留意。we是写使能,$zero寄存器必须硬连 0,所以ra1 == 0的三目判断不能省,否则初始化后regs[0]是 x,所有引用$zero的指令全废。wa != 5'd0是第二道保险,防止写使能误开时污染 0 号寄存器。negedge是时序关键,改成posedge之后lw后紧跟使用该寄存器的指令会读到旧值,现象是“程序结果总是差一条指令”。

3.3 testbench 自证:自动比对替代肉眼看波形

肉眼看波形只能确认“大概在动”,不能确认“每个值都对”。我的习惯是给每个功能部件写一个带期望值的 testbench,跑完直接打印 PASS/FAIL,而不是拖一堆信号到 gtkwave 里逐段比对。

// tb/alu_tb.v `timescale 1ns/1ps module alu_tb; reg [31:0] a, b; reg [2:0] aluc; wire [31:0] y; wire zero; integer err = 0; alu dut (.a(a), .b(b), .aluc(aluc), .y(y), .zero(zero)); task check(input [31:0] ea, input [31:0] eb, input [2:0] ec, input [31:0] ey); begin a = ea; b = eb; aluc = ec; #1; if (y !== ey) begin $display("FAIL: a=%h b=%h c=%b y=%h expect=%h", a, b, ec, y, ey); err = err + 1; end end endtask initial begin check(32'd10, 32'd20, 3'b010, 32'd30); // add check(32'd10, 32'd20, 3'b110, -32'd10); // sub check(32'hFF00, 32'h0F0F, 3'b000, 32'h0F00); // and check(32'd5, 32'd9, 3'b111, 32'd1); // slt: 5 < 9 if (err == 0) $display("ALU ALL PASS"); else $display("ALU FAIL COUNT = %0d", err); $finish; end endmodule

task check把“赋值—等待—比较”三步封在一起,#1是给组合逻辑的传播延迟留的窗口,组合逻辑不加延迟直接比会拿到上一拍的旧值。!==而不是!=是为了把 x 和 z 也算作失败,x 值在仿真里会被!=判成不确定,很容易漏掉未初始化的问题。err计数器比$stop好用,一次跑完能看到所有失败用例,不用改一版发现一个错、再改一版再发现一个。

寄存器堆的 testbench 同理,重点是测三件事:写后读能不能读出、写 0 号寄存器有没有被拦住、同一周期读写有没有冲突。第三条要靠negedge clk配合,在上升沿之后立刻检查读口,才能确认时序没有被破坏。

4. 单周期 CPU:从取指到写回的连线核对

功能部件都过了之后,剩下的活就是把它们连起来。单周期 CPU 的特点是每条指令一个周期走完全部阶段,所以数据通路上的每条线在同一个周期里都是“活”的,任何一处多路选择器选错,现象都会立刻反映在写回值上。这一章的重点不是画图,而是拿到一份已完成工程后怎么逐条指令核对。

4.1 指令字段拆解与控制信号表

MIPS 指令固定 32 位,R 型和 I 型字段布局不同,这套拆法是后面所有核对的基准。

指令类型opcodersrtrdshamtfunctimm
R 型31:2625:2120:1615:1110:65:0无
I 型31:2625:2120:16无无无15:0
J 型31:2625:0(target)

控制信号表我建议手抄一遍,抄的过程就是理解的过程。核心九个信号:RegDst决定写哪个寄存器,ALUSrc决定 ALU 第二操作数来自寄存器还是立即数,MemtoReg决定写回数据来自 ALU 还是存储器,RegWrite/MemRead/MemWrite/Branch/Jump各自是一个使能,ALUOp送进上一章的 ALU 控制单元。

易错点在RegDst:R 型写rd(15:11),I 型写rt(20:16),位段完全不同。很多人第一次连数据通路时把写地址直接接到rd上,结果 R 型全对、lw全错,因为lw的rt被当成了rd去写,写的寄存器号完全不对。

4.2 五类指令的路径逐条核对

核对不是从头到尾看一遍连线,而是拿一条指令,从 PC 开始沿数据通路走一圈,看每个多路选择器在这个周期选哪一路。我一般按 R 型、lw、sw、beq、j 的顺序走,因为这五条基本覆盖了所有多路选择器的分支。

R 型走rs、rt读,ALU 做运算,结果经MemtoReg=0写回rd。这条路径验证的是 ALU 通路和RegDst=1。lw 走rs读、立即数经符号扩展进 ALU 算地址、MemRead拉高、存储器输出经MemtoReg=1写回rt,验证的是访存通路和RegDst=0。sw 走rs读、立即数算地址、rt的值直接送到存储器写口,注意这条指令不写寄存器,RegWrite必须是 0,漏了这一条会让 sw 顺手把地址写进rt,现象是后续指令全乱。

beq 走rs、rt相减,zero有效时 PC 走分支地址,无效时 PC+4。这里有个新手常翻车的点:分支地址是PC+4 + (sign_ext(imm) << 2),用的是 PC+4 而不是 PC,因为取指阶段 PC 已经加过了。j 指令最简单,{PC+4[31:28], target, 2'b00},高 4 位保留,容易漏。

我习惯在波形里给这五条指令各跑一次,每次都把pc_out、alu_result、write_data、RegWrite四个信号拖在一屏,逐周期确认。单周期的好处就在这里:一条指令一个周期,波形上一格就是一条指令,对齐得非常干净。

4.3 灌一段机器码,跑通第一条程序

连线核完,要有一段真实程序来收口。我一般用五行左右的短程序,覆盖算术、访存和写回三类路径。

// tb/imem_init.vh 里的存储器初始化 // asm/prog1.hex 内容,每行一条 32 位机器码 // 20080005 addi $t0, $zero, 5 // 20090007 addi $t1, $zero, 7 // 01095020 add $t2, $t0, $t1 // AC0A0000 sw $t2, 0($zero) // 8C0B0000 lw $t3, 0($zero)
// rtl/imem.v module imem ( input wire [31:0] addr, output wire [31:0] instr ); reg [31:0] mem [0:63]; initial begin $readmemh("asm/prog1.hex", mem); end assign instr = mem[addr[31:2]]; // 字对齐,低 2 位丢弃 endmodule

$readmemh的路径是相对仿真运行目录的,不是相对源文件目录。这是最常见的“读了但全是 x”的原因:文件明明在,vvp在sim/里跑,相对路径就找不到了。解决方式要么用绝对路径,要么把sim/和asm/放平级、跑之前先cd到正确位置。

addr[31:2]这个索引写法是把字节地址转成字地址,因为每条指令 4 字节。低 2 位直接丢弃,前提是 PC 保证 4 字节对齐;如果你的 PC 更新逻辑写成PC+1,这里会读出一堆重复指令,现象是“程序卡在同一条指令上不动”。

这段程序的预期终值是:$t0=5、$t1=7、$t2=12、mem[0]=12、$t3=12。跑完之后在 testbench 里直接打印这几个寄存器,五个值全对,说明取指、译码、执行、访存、写回五级通路都通了。有一个不对,就按 4.2 的路径表倒着查,别从头翻波形。

5. 避坑清单:计组实验里最容易翻车的五处

这些坑都有一个共同特征:现象看起来像“整个 CPU 坏了”,实际原因只有一根线或者一个时序。我按出现频率排了序,每条都按“现象 → 原因 → 解决”写,方便你直接对号入座。

5.1 波形里全是 X,PC 不动

现象:仿真跑起来,pc_out是xxxxxxxx,所有寄存器初值也是 x,波形一片红线。

原因:复位没接或者复位极性搞反了。很多工程里复位是低有效(rst_n),testbench 里却给了rst = 1,等于一直不复位;另一种情况是寄存器堆和 PC 都没有initial初值,仿真器不会自动清零。

解决:testbench 开头先拉低复位保持几个周期再释放,同时给 PC 和寄存器堆加initial初值。注意initial只对仿真和 FPGA 上电有效,ASIC 流程不能依赖它,但计组实验这个场景够用。

initial begin rst_n = 1'b0; #20 rst_n = 1'b1; // 低有效复位,保持 20ns 再释放 end

5.2 分支指令跑飞,跳到了奇怪的地址

现象:beq之后 PC 跳到一个既不是顺序地址也不是目标地址的地方,程序直接跑飞。

原因:分支目标算错,最常见的是用了PC而不是PC+4做基址,或者忘记把立即数左移 2 位。立即数是字偏移,不左移的话目标地址会落在指令中间。

解决:把分支加法器的两个输入在波形里拉出来,确认一个是PC+4、另一个是sign_ext(imm) << 2。再核对zero信号,beq成立时zero必须是 1,不成立时是 0,反了就变成“相等时不跳、不等时跳”。

5.3 lw 读回来的值永远是 0

现象:sw写进去的值在存储器里能查到,lw读出来却是 0。

原因:三种可能。MemtoReg选错,写回的是 ALU 结果而不是存储器输出;MemRead没拉高,存储器输出保持上次的值;地址没做字对齐,读到了空区域。第三种最隐蔽,因为波形上看地址是在变的。

解决:先在波形里看write_data,如果是 ALU 结果,问题在MemtoReg;如果write_data是对的但寄存器没更新,问题在RegWrite或者写地址接错。数据存储器建议做成同步读,读延迟一拍,写回阶段正好用上,和单周期的节拍对得齐。

5.4 多周期 CPU 状态机卡死

现象:多周期实现下,状态机停在第 2 或第 3 个状态不再跳转,PC 冻结。

原因:状态转移的条件不完整。多周期里每个状态的下一个状态由当前状态和指令 opcode 共同决定,case里漏了某个 opcode 分支,综合后落到默认分支原地打转;另一种是IR写使能没在取指状态拉高,指令寄存器一直是旧值。

解决:把状态机的case补全,default指回取指状态而不是停在原地。同时在每个状态加一个观测输出,跑到卡死时看停在哪个状态,再回去查这个状态的转移条件。

5.5 上板之后数码管乱码或者不亮

现象:仿真全对,综合没报错,上板后数码管显示乱码、闪烁或者完全不亮。

原因:时钟频率太高,人眼看不清动态扫描;引脚约束和实际板子对不上;数码管是共阴还是共阳搞反。这三条里,第一条最容易被忽略——仿真里clk是 10ns 周期的理想时钟,上板是几十 MHz 的晶振,分频没做的话扫描频率高到无法显示。

解决:加一个分频计数器,把系统时钟分到 1kHz 左右再驱动扫描;引脚约束文件逐条对照板子手册;共阴共阳用段选电平试一次就能确定,别猜。上板环节本身不复杂,复杂的是把“仿真对”和“板上对”之间的差异一条条排掉。

6. 用 golden model 对拍:让“看起来对”变成“确实对”

单周期 CPU 跑通一段五行程序,只能证明这五条指令的路径是对的。换个二十行的程序,很可能在某个slt后面紧跟beq的组合上翻车。靠人工看波形验证不了这个量级,我后来固定用一个笨办法:写一个指令级 golden model,把同一段机器码在高级语言里跑一遍,拿寄存器终值和仿真结果逐项对拍。

golden model 不需要时序,只按指令语义逐条执行,二十行 Python 足够覆盖实验用的指令子集。它和 Verilog 仿真共用同一份.hex,输入一致,输出就可以直接比。

# tools/golden.py:只做指令语义,不管时序 import sys REGS = [0] * 32 MEM = {} def run(prog): pc = 0 while pc < len(prog): ir = prog[pc] op = (ir >> 26) & 0x3F rs = (ir >> 21) & 0x1F rt = (ir >> 16) & 0x1F rd = (ir >> 11) & 0x1F imm = ir & 0xFFFF if imm & 0x8000: # 符号扩展 imm -= 0x10000 if op == 0x00: # R 型 funct = ir & 0x3F if funct == 0x20: REGS[rd] = REGS[rs] + REGS[rt] elif funct == 0x22: REGS[rd] = REGS[rs] - REGS[rt] elif funct == 0x24: REGS[rd] = REGS[rs] & REGS[rt] elif funct == 0x25: REGS[rd] = REGS[rs] | REGS[rt] elif funct == 0x2A: REGS[rd] = int(REGS[rs] < REGS[rt]) elif op == 0x08: REGS[rt] = REGS[rs] + imm # addi elif op == 0x23: REGS[rt] = MEM.get(REGS[rs] + imm, 0) # lw elif op == 0x2B: MEM[REGS[rs] + imm] = REGS[rt] # sw elif op == 0x04: # beq if REGS[rs] == REGS[rt]: pc += imm; continue REGS[0] = 0 pc += 1 if __name__ == "__main__": words = [int(l, 16) for l in open(sys.argv[1]) if l.strip()] run(words) print(" ".join(f"{i}:{v}" for i, v in enumerate(REGS) if v))

这个脚本只做一件事:把机器码逐条解释执行,输出非零寄存器和存储器内容。跑完和 Verilog testbench 打印的寄存器终值直接 diff,一致就说明数据通路和指令语义对上了,不一致就说明某条指令的实现和语义有偏差。REGS[0] = 0每轮强制清零,用来模拟$zero硬连 0 的约束,漏了这行addi $zero, $zero, 5这种非法写法会让两边的结果对不上。

真正有价值的是它能批量跑。我把实验里所有给的测试程序都转成.hex,一条脚本循环跑,两边结果自动 diff,跑完输出一张对比表。第一次跑出七个不一致,其中五个是我数据通路的 bug,另外两个是 golden model 自己把符号扩展写错了。这个比例很典型:对拍的价值一半在找 CPU 的错,一半在逼你把指令语义想清楚。

从那以后我每次拿到一份新的计组工程,都是先写 golden model 再跑波形,而不是先拖信号。人眼在几十条指令的波形里找异常,效率低到不可接受,而且容易看漏;机器对拍一秒出结果,错了还能精确定位到第几条指令、哪个寄存器。希望这份对拍思路能帮到你,也欢迎把你自己的测试程序加进去,跑出更多不一致,那才是真正在进步。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询