简介:湖南大学计算机组成与原理实验课程配套资料,面向计组课程学习者与需完成硬件实验的本科生,提供可参考的已完成工程与实验记录,覆盖逻辑门、组合逻辑、加法器/译码器等数字逻辑基础,以及存储系统、微程序CPU、ALU与I/O中断/DMA等核心实验环节。压缩包共136个文件,约15.88MB,以Quartus工程相关的bdf、bsf、hdb、cdb、vwf波形文件为主,辅以rpt报告、doc文档和txt说明,便于对照原理图、引脚分配、仿真波形与实验结果进行复盘。现有460人学习,适合在搭建实验环境、排查仿真与下载问题时作样例参考,也可用于整理实验报告、理解软硬件交互和查漏补缺。
1. 计算机组成与原理实验:先让一条 add 指令在波形里跑通
计算机组成与原理实验最容易被当成“画电路图”的课,真正上手才发现,难点不在连线,而在你根本不知道某根线在某个时钟沿该是什么值。某高校的课程设计里,我见过太多数据通路画得规整漂亮、一跑指令就满屏红线的例子。这套实验真正的价值,是逼着人把取指、译码、执行、访存、写回从课本方框图变成一组可以被仿真器逐周期检查的信号。它适合刚学完数字逻辑、想搞明白处理器内部到底怎么动的人,也适合准备做小型处理器验证的读者。后面按工具选型、单周期通路、流水线冒险、排查记录、随机指令验证的顺序走,每一步都能直接复现。
2. 实验环境与工具链选型:图形化仿真、HDL 仿真、上板三条路怎么选
动手之前先想清楚在哪个载体上做实验,这一步选错,后面返工的可能不只是电路,还有整套调试习惯。带过几轮课设之后,我基本固定一条路线:先图形化建直觉,再切 HDL 做单周期,最后上现场可编程门阵列板子验证时序。跳过前两步直接写代码,往往连 ALU 的输出该在哪个沿稳定都说不清;一直停在图形化工具里,又没法做批量回归测试,规模一大就被卡住。
2.1 三种实验载体的能力边界对比
| 维度 | 图形化数字电路仿真 | HDL 仿真 | 上板验证 |
|---|---|---|---|
| 上手成本 | 低,拖拽连线即可 | 中,需要掌握语言与测试平台 | 高,还要懂引脚约束和时钟 |
| 可调试性 | 直观,但规模大后信号难找 | 波形全可见,可脚本化 | 主要靠指示灯和逻辑分析仪 |
| 回归测试 | 基本靠手点 | 一个脚本跑几十条用例 | 跑一次要重新综合烧写 |
| 规模上限 | 几百个门就开始卡 | 数万门仍可仿真 | 受器件资源限制 |
| 适合阶段 | 组合逻辑、时序逻辑入门 | 单周期、流水线主体开发 | 时序收敛、板级联调 |
图形化工具的优势是“看得见”,一条线连错立刻能顺着颜色找回去,但它的价格是效率:改一个控制信号要重新拖一遍,测试十几条指令就得手动改输入再点运行。HDL 仿真的优势是“可重复”,同一份代码可以在命令行里编译、跑波形、比对结果,天然适合做回归。上板则是另一回事,它验证的是真实时序,时钟树、布线延迟、引脚电平这些问题在仿真里根本不会出现,但定位成本最高,我一般把它放在最后,只用来确认设计在真实器件上不崩。
这里有个常见误区:有人觉得 HDL 仿真“不够真实”,非要一步到位上板。实际上单周期处理器在仿真里跑通,已经能排除 95% 的逻辑错误,剩下的时序问题留到上板阶段再处理,效率高得多。还有一类读者习惯在图形化工具里做完所有实验,最后写报告时才发现没法做“换一条指令自动重跑”的验证,只能一条条手点,进度拖得很慢。
提示:实验报告里如果要求给出波形截图,HDL 仿真导出的波形文件可以直接定位到具体周期,比图形化工具截图更容易说明问题。
2.2 用开源仿真器搭一个可复现的最小验证环境
环境不用复杂,一个目录、两条命令就够。下面这套结构我用了很多次,rtl 放设计代码,tb 放测试平台,build 放编译产物和波形,互不干扰。
# 建立目录骨架 mkdir -p rtl tb build # 编译:-g2012 指定语言标准,-o 指定输出文件,-s 指定顶层模块 iverilog -g2012 -s tb_top -o build/sim.out rtl/*.v tb/tb_top.v # 运行仿真,波形由 testbench 里的 $dumpfile 决定 vvp build/sim.out # 打开波形查看器 gtkwave build/wave.vcd &这几行命令的含义值得说清楚:-g2012让仿真器按较新的语言标准解析,避免一些语法写法报错;-s tb_top显式指定顶层,防止多个模块都有同名端口时混淆;vvp是执行编译产物的运行时。真正决定波形能不能出来的,是 testbench 里那两行$dumpfile和$dumpvars,前者给文件路径,后者声明要记录哪些层次的信号,参数 0 表示递归记录整棵层次树。
配套的测试平台只需要做三件事:产生时钟、释放复位、在固定周期后检查结果。
`timescale 1ns/1ps module tb_top; reg clk = 1'b0; reg rst_n = 1'b0; // 半周期 5ns,即 10ns 周期,对应 100MHz always #5 clk = ~clk; // 例化被测设计,端口名按实际顶层调整 top u_top ( .clk (clk), .rst_n (rst_n) ); initial begin $dumpfile("build/wave.vcd"); // 波形输出路径 $dumpvars(0, tb_top); // 0 表示记录 tb_top 以下所有层次 repeat (4) @(posedge clk); // 保持复位若干个周期 rst_n = 1'b1; // 同步释放,避免复位撤除时刻不确定 repeat (200) @(posedge clk); // 给足够周期让程序跑完 $display("x5 = %0d", u_top.u_regfile.mem[5]); // 直接观察关键寄存器 $finish; end endmodule时钟的写法决定了仿真速度和波形密度。半周期 5ns 只是习惯值,如果设计里有分频或长时间计数,把这个值调大能显著缩短仿真时间;反过来,如果只想看单个时钟沿附近的竞争,把时间精度设到皮秒级更合适。复位用同步释放而不是异步撤销,是因为异步撤销的一瞬间如果正好撞上时钟沿,不同寄存器可能进入不同状态,波形上看起来像“随机翻车”,排查起来非常费劲。
2.3 一条命令跑回归:把看波形变成比结果
波形看多了会形成依赖,人眼扫几十个周期还行,扫几千个周期必然漏。更稳的做法是让测试平台自己判断对错,脚本只负责收集退出码。
#!/usr/bin/env bash # run_regress.sh:编译并依次跑多个测试程序 set -e iverilog -g2012 -s tb_top -o build/sim.out rtl/*.v tb/tb_top.v for prog in tests/*.hex; do echo "== running ${prog} ==" vvp build/sim.out +prog="${prog}" | tee "build/$(basename "${prog}").log" grep -q "TEST PASS" "build/$(basename "${prog}").log" || { echo "FAILED: ${prog}"; exit 1; } done echo "ALL PASS"set -e让脚本在任意一步失败时立即停止,避免错误被后面的输出刷掉;+prog=是传给仿真器的自定义参数,测试平台里用$value$plusargs读取后决定加载哪份指令存储器初始化文件;tee同时保留屏幕输出和日志,出问题时能回看。这套东西的价值在于,改一处控制逻辑之后不用手动重跑,几十秒就能知道有没有把别的指令带崩。
注意:回归脚本里不要把编译和运行的退出码吞掉,
| tee会让管道最后一环的退出码覆盖前面,必要时用set -o pipefail补上。
3. 单周期数据通路:把一条指令拆成五个可以单独测的部件
单周期处理器的教学价值在于“一个时钟周期做完一条指令”,所有信号在一个周期内稳定,没有冒险问题,适合把数据通路的每一段先独立验证。真正动手时,我建议按“部件—控制—整机”三层推进,每层都有明确的验收标准,别一上来就画整张图。
3.1 先定接口再画电路:ALU 与寄存器堆的端口约定
接口定错,后面所有模块都要跟着改。ALU 的端口我一般固定为两个数据输入、一个操作码、一个结果输出和一个零标志;寄存器堆固定为两个读端口、一个写端口,写使能单独引出。这样的划分让每个部件都能用一个小测试平台单独跑。
module alu #(parameter W = 32) ( input wire [W-1:0] a, input wire [W-1:0] b, input wire [3:0] alu_op, output reg [W-1:0] y, output wire zero ); assign zero = (y == {W{1'b0}}); // 零标志供分支指令使用 always @(*) begin case (alu_op) 4'b0000: y = a + b; // add 4'b0001: y = a - b; // sub 4'b0010: y = a & b; // and 4'b0011: y = a | b; // or 4'b0100: y = a ^ b; // xor 4'b0101: y = ($signed(a) < $signed(b)) ? {{W-1{1'b0}}, 1'b1} : {W{1'b0}}; // slt 有符号比较 default: y = {W{1'b0}}; endcase end endmodule这里最容易出错的是slt。直接写a < b得到的是无符号比较结果,拿负数去比会得到完全相反的答案,而波形上两个操作数看起来都“正常”,属于典型的玄学问题。加$signed之后语义才正确。另一个细节是zero用连续赋值从y派生,综合工具会自动生成比较逻辑,不需要在always里再写一遍。
寄存器堆的关键约定是 0 号寄存器恒为零且不可写。很多实验要求里会明确写这一条,但实现时容易漏掉写保护,结果某条指令往 0 号寄存器写了值,后面所有依赖零寄存器的指令全部错位。
module regfile #(parameter W = 32) ( input wire clk, input wire we, // 写使能,由控制单元产生 input wire [4:0] rs1, input wire [4:0] rs2, input wire [4:0] rd, input wire [W-1:0] wd, output wire [W-1:0] rd1, output wire [W-1:0] rd2 ); reg [W-1:0] mem [0:31]; integer i; initial begin for (i = 0; i < 32; i = i + 1) mem[i] = {W{1'b0}}; end // 读端口组合输出,0 号寄存器强制返回 0 assign rd1 = (rs1 == 5'd0) ? {W{1'b0}} : mem[rs1]; assign rd2 = (rs2 == 5'd0) ? {W{1'b0}} : mem[rs2]; // 写端口在时钟上升沿生效,0 号寄存器忽略写入 always @(posedge clk) begin if (we && (rd != 5'd0)) mem[rd] <= wd; end endmodule写端口用非阻塞赋值是硬性要求,否则同一个always块里多个寄存器之间可能出现顺序依赖。读端口用组合逻辑而不是时序逻辑,是因为单周期数据通路要求同一周期内读出的操作数立刻送进 ALU。
3.2 控制信号真值表:别再靠试
控制单元的输出不是拍脑袋定的,每条指令对应一组固定信号,列成表之后逻辑就清楚了。
| 指令类型 | RegWrite | MemRead | MemWrite | ALUSrc | Branch | ALUOp |
|---|---|---|---|---|---|---|
| R 型运算 | 1 | 0 | 0 | 0 | 0 | 10 |
| lw | 1 | 1 | 0 | 1 | 0 | 00 |
| sw | 0 | 0 | 1 | 1 | 0 | 00 |
| beq | 0 | 0 | 0 | 0 | 1 | 01 |
| addi | 1 | 0 | 0 | 1 | 0 | 00 |
表里ALUOp是粗粒度的操作提示,真正送进 ALU 的操作码还要结合指令的 funct 字段二次译码,这是 R 型指令和立即数指令共用同一条通路时的常见做法。ALUSrc决定 ALU 的第二个操作数来自寄存器还是立即数扩展单元,Branch决定是否把零标志和分支条件结合后写回程序计数器。
提示:真值表写完先用纸面推演三条指令,再写代码,能省掉大量“波形不对但不知道哪不对”的时间。
真值表转代码最稳的写法是case,不要用一堆与或门硬拼。原因是case的默认分支可以兜底,遇到未定义操作码时输出一组安全信号,而硬拼逻辑在综合后可能产生锁存器警告,仿真时表现为某些控制线出现高阻态。
3.3 把 add、lw、beq 三条指令跑通就算过半
整机联调不要贪多,先跑三条覆盖不同通路的指令:add 走纯运算通路,lw 走访存和写回,beq 走分支和程序计数器更新。三条都对了,说明取指、译码、执行、访存、写回五段都通了。
// 指令存储器初始化片段,一条 add + 一条 lw + 一条 beq // 地址按字节编址,每条指令 4 字节 initial begin imem[0] = 32'h00500093; // addi x1, x0, 5 imem[1] = 32'h00A00113; // addi x2, x0, 10 imem[2] = 32'h002081B3; // add x3, x1, x2 -> x3 = 15 imem[3] = 32'h00402023; // sw x3, 0(x0) -> 写内存 imem[4] = 32'h00002203; // lw x4, 0(x0) -> x4 = 15 imem[5] = 32'h00418463; // beq x3, x4, +8 -> 相等则跳过下一条 imem[6] = 32'h00100073; // 不应执行到的陷阱指令 imem[7] = 32'h00000013; // nop end测试平台在跑完之后检查x3和x4是否都为 15,同时确认程序计数器没有走进第 6 条。这一步的验收标准要写死,不要用“看起来对”来判断。如果x3对而x4是 0,问题多半出在写回阶段的寄存器选择信号;如果x3就是 0,先查 ALU 的第二个操作数有没有被ALUSrc正确切换。
4. 五级流水线:冒险处理写不对,波形永远对不上
单周期跑通以后,流水线是下一个坎。它把一条指令拆到五个周期并行执行,吞吐上去了,代价是同一时刻有多条指令在飞,任何一处冒险没处理,波形上表现出的就是“寄存器值莫名其妙差一条指令”。这一章的重点不是画流水线图,而是把冒险检测写成可验证的表达式。
4.1 流水寄存器的插入位置与命名规范
五级流水需要四组流水寄存器,分别是 IF/ID、ID/EX、EX/MEM、MEM/WB。每组要存什么,取决于下一级还需要什么,漏存一个控制信号,整条流水线就会在某个特定指令上错。
| 流水寄存器 | 必须携带的内容 |
|---|---|
| IF/ID | 指令字、程序计数器值 |
| ID/EX | 两个源操作数、立即数、目标寄存器号、各级控制信号 |
| EX/MEM | ALU 结果、第二操作数(供存储指令写数据)、目标寄存器号、写回与访存控制 |
| MEM/WB | 访存结果或 ALU 结果、目标寄存器号、写回使能 |
控制信号必须跟着指令一起往后走,不能在需要的时候从当前指令重新译码。这是新手最容易省掉的一步,省掉之后 add 后面跟 lw 时,写回使能会错误地跟着 lw 走,导致 add 的结果没写进寄存器。
// IF/ID 流水寄存器,注意三个优先级的顺序 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin id_pc <= 32'd0; id_instr <= 32'd0; end else if (flush_if_id) begin id_instr <= 32'd0; // 分支跳转时插入气泡 end else if (!stall_if_id) begin id_pc <= pc; id_instr <= imem_rdata; end end这段代码里,复位的优先级最高,清空其次,最后才是正常更新。如果把清空和更新写成两个并列的if,综合工具可能推断出意外的锁存器。flush_if_id在分支跳转成立时拉高,把刚取进来的错误指令清成空操作;stall_if_id在 load-use 冒险时拉高,冻结当前内容不更新。
4.2 数据冒险:forwarding 的三种来源与优先级
数据冒险的本质是“后面要用前面还没写回的值”。转发机制把这几个值从流水线中间直接引到 ALU 输入端,绕过寄存器堆。来源有三个:EX/MEM 的 ALU 结果、MEM/WB 的写回值,以及寄存器堆本身。优先级上,EX/MEM 最近,优先级最高。
always @(*) begin // 默认使用译码阶段读出的源操作数 alu_a = ex_rs1_data; alu_b = ex_rs2_data; // 来源一:EX/MEM 级,ALU 结果尚未写回,优先级最高 if (ex_mem_regwrite && (ex_mem_rd != 5'd0) && (ex_mem_rd == ex_rs1)) alu_a = ex_mem_alu_result; // 来源二:MEM/WB 级,写回值已经产生 else if (mem_wb_regwrite && (mem_wb_rd != 5'd0) && (mem_wb_rd == ex_rs1)) alu_a = mem_wb_wb_data; // 第二个操作数同理,注意排除 ALUSrc 选择立即数的情况 if (ex_mem_regwrite && (ex_mem_rd != 5'd0) && (ex_mem_rd == ex_rs2)) alu_b = ex_mem_alu_result; else if (mem_wb_regwrite && (mem_wb_rd != 5'd0) && (mem_wb_rd == ex_rs2)) alu_b = mem_wb_wb_data; end这段代码里else if的顺序就是优先级,写反了会在两条相邻指令同时命中转发条件时选错值。另一个细节是必须排除目标寄存器为 0 的情况,否则任何一条目标为 x0 的指令都会错误地覆盖操作数。转发的判断依据是指令携带的目标寄存器号,而不是当前正在执行的指令类型,这一点想清楚之后,条件表达式就不容易写漏。
4.3 load-use 冒险为什么要单独停一拍
lw 指令的数据要到 MEM 级末尾才从存储器取出来,而紧接着的下一条指令在 EX 级就要用这个值,转发通路根本来不及。这时候唯一的办法是停一拍,让 lw 走完 MEM 级,下一条指令的 EX 级推迟到下一个周期。
// load-use 冒险检测:EX 级是 load,且 ID 级指令要用它的目的寄存器 assign load_use_hazard = id_ex_memread && ((id_ex_rd == if_id_rs1) || (id_ex_rd == if_id_rs2)); // 停一拍时的动作:冻结取指和译码,清空 ID/EX assign stall_pc = load_use_hazard; assign stall_if_id = load_use_hazard; assign flush_id_ex = load_use_hazard;三个动作缺一不可。冻结程序计数器防止重复取指,冻结 IF/ID 防止译码结果被覆盖,清空 ID/EX 则在 EX 级插入一个气泡,让 lw 顺利通过。只做其中一两个,波形上会看到指令重复执行或操作数被清成零,现象是寄存器值比预期少一次运算。
注意:分支跳转和 load-use 同时发生时,清空和冻结的优先级要提前想清楚,一般约定分支清空优先,否则跳转目标会被停顿逻辑拖住,形成死循环。
5. 避坑与排查:六条波形图上找不到答案的血泪记录
下面这些是按“现象、原因、解决”整理的真实踩坑路径,每条都对应一类在波形上很难一眼看出的问题。实验环境里没有黑匣子,所有错误最后都要落到某个信号在某个时刻的取值上。
第一条:所有寄存器初值都是不定态。波形里x1到x31全是红色的x,程序跑多久都不变。原因是寄存器堆的初始值没有初始化,仿真器默认把未赋值的存储单元当作不定态,任何算术运算碰到它都会传染成x。解决办法是在initial块里用循环把所有存储单元清零,或者给一个复位信号在复位期间统一清零。上板时这个问题不会出现,因为真实触发器上电后是确定电平,所以它属于纯仿真环境的坑。
第二条:单周期能跑,流水线一开就整体错位。单周期版本验证过的程序,切到流水线后第一条指令的结果就对不上。原因多半是流水寄存器漏存了控制信号,比如 ID/EX 里只存了操作数没存写回使能,导致后面几级不知道要不要写寄存器。排查方法是把每一级流水寄存器的内容列成清单,逐项和下一级需要的输入对照,缺什么补什么。这个错误的特点是“错得很整齐”,所有指令的结果都偏一条,看到这种模式就该怀疑控制信号没跟着走。
第三条:仿真全对,上板结果随机。仿真里跑几十条指令都正确,烧到板子上按复位后结果时对时错。常见原因是时钟用了组合逻辑分频,比如用计数器输出直接当时钟,布线延迟和毛刺会让不同触发器看到不同的时钟沿。解决办法是改用器件自带的时钟管理资源做分频,或者在单个时钟域里用使能信号控制节奏,不要派生新时钟。这类问题在仿真里几乎不会暴露,属于上板阶段的典型翻车点。
第四条:lw 后面紧跟使用该寄存器,结果差一条指令。现象是lw x1, 0(x0)之后紧接add x2, x1, x1,x2拿到的是x1的旧值。原因是 load-use 冒险没有检测,或者检测了但没有同时冻结取指和清空 ID/EX。解决方法是把三个动作绑在一起,缺一个都会让气泡插不进去。验证方式是在波形里看 ID/EX 的有效信号是否在 lw 之后空了一拍。
第五条:存储器写后立刻读,读到旧值。同一条指令序列里先sw再lw同一地址,读出来的值是写之前的。原因是写存储器和读存储器用了同一个时钟沿,非阻塞赋值让读操作拿到的是沿前的值。解决办法是明确存储器模型的读写时序:写用时钟沿,读用组合逻辑或者下一周期,二者不能在同一沿竞争。如果实验要求里规定了存储器行为,严格按它写,不要自己发挥。
第六条:测试平台的时间对齐错了半个周期。波形上所有信号看起来都对,但测试平台在时钟上升沿采样,采到的正好是寄存器更新前的值,于是判定失败。原因是测试平台用#10这种绝对延时去对齐,而不是用@(posedge clk)跟随时钟。解决办法是所有采样都写成时钟沿触发的形式,需要观察稳定值时在沿后加一个很小的延迟再打印。
6. 进阶验证:用随机指令流和差分测试给自己的处理器上强度
手写测试程序能覆盖的指令组合很有限,真正能暴露问题的,是让机器自己生成大量合法指令序列,再用一个独立的参考模型算出期望结果,两边比对。这套方法在工业界叫差分测试,在课程实验里同样好用。
先写一个指令生成器,只生成不会触发异常的合法指令,同时用 Python 维护一份寄存器状态的参考模型。
import random REGS = list(range(1, 32)) # 不生成 x0 作为目的寄存器 def rand_r_type(): rd = random.choice(REGS) rs1 = random.choice(REGS + [0]) rs2 = random.choice(REGS + [0]) return ("add", rd, rs1, rs2) def gen_program(n=500): prog, model = [], [0] * 32 for _ in range(n): op, rd, rs1, rs2 = rand_r_type() prog.append((op, rd, rs1, rs2)) model[rd] = (model[rs1] + model[rs2]) & 0xFFFFFFFF # 参考模型同步更新 return prog, model生成器只做一件事:保证指令序列合法且参考模型能同步推演。真正的比对发生在仿真结束后,把寄存器堆的最终值导出,和model逐项比较。差异出现的位置往往能直接指向某类冒险没处理干净,比如连续三条指令都写同一个寄存器时结果错乱,多半是转发优先级写反了。
| 验证手段 | 覆盖能力 | 发现问题的类型 |
|---|---|---|
| 手写三条指令 | 单条通路 | 部件接错、控制信号缺失 |
| 手写十条混合指令 | 通路切换 | 转发条件、分支清空 |
| 随机指令流 500 条 | 组合爆炸 | 冒险优先级、写回冲突 |
| 随机 + 边界值(0、最大值) | 数值边界 | 溢出、有符号比较 |
这套方法我一般在单周期和流水线两个版本上各跑一遍,同一个随机种子生成同一份程序,两个版本的最终寄存器值必须完全一致。第一次跑的时候大概率会出现几处不一致,别急着改代码,先把不一致的指令位置和当时的流水线状态打出来,多半能定位到某一级流水寄存器的内容不对。
说个自己的教训:早期做流水线时,我习惯先看波形再改代码,结果一个转发优先级的错误查了整整两天,波形上每一级看起来都“合理”。后来改成先让随机测试报出第一条不一致的指令编号,再带着编号去波形里定位那个周期,同样的错误半小时就找到了。调试顺序比调试工具更重要,希望帮到你。
本文还有配套的精品资源,点击获取