简介湖南大学计算机组成与原理实验课程配套资料面向计组课程学习者与需完成硬件实验的本科生提供可参考的已完成工程与实验记录覆盖逻辑门、组合逻辑、加法器/译码器等数字逻辑基础以及存储系统、微程序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 1b0; reg rst_n 1b0; // 半周期 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 1b1; // 同步释放避免复位撤除时刻不确定 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 PASSset -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{1b0}}); // 零标志供分支指令使用 always (*) begin case (alu_op) 4b0000: y a b; // add 4b0001: y a - b; // sub 4b0010: y a b; // and 4b0011: y a | b; // or 4b0100: y a ^ b; // xor 4b0101: y ($signed(a) $signed(b)) ? {{W-1{1b0}}, 1b1} : {W{1b0}}; // slt 有符号比较 default: y {W{1b0}}; 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{1b0}}; end // 读端口组合输出0 号寄存器强制返回 0 assign rd1 (rs1 5d0) ? {W{1b0}} : mem[rs1]; assign rd2 (rs2 5d0) ? {W{1b0}} : mem[rs2]; // 写端口在时钟上升沿生效0 号寄存器忽略写入 always (posedge clk) begin if (we (rd ! 5d0)) mem[rd] wd; end endmodule写端口用非阻塞赋值是硬性要求否则同一个always块里多个寄存器之间可能出现顺序依赖。读端口用组合逻辑而不是时序逻辑是因为单周期数据通路要求同一周期内读出的操作数立刻送进 ALU。3.2 控制信号真值表别再靠试控制单元的输出不是拍脑袋定的每条指令对应一组固定信号列成表之后逻辑就清楚了。指令类型RegWriteMemReadMemWriteALUSrcBranchALUOpR 型运算1000010lw1101000sw0011000beq0000101addi1001000表里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] 32h00500093; // addi x1, x0, 5 imem[1] 32h00A00113; // addi x2, x0, 10 imem[2] 32h002081B3; // add x3, x1, x2 - x3 15 imem[3] 32h00402023; // sw x3, 0(x0) - 写内存 imem[4] 32h00002203; // lw x4, 0(x0) - x4 15 imem[5] 32h00418463; // beq x3, x4, 8 - 相等则跳过下一条 imem[6] 32h00100073; // 不应执行到的陷阱指令 imem[7] 32h00000013; // 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/MEMALU 结果、第二操作数供存储指令写数据、目标寄存器号、写回与访存控制MEM/WB访存结果或 ALU 结果、目标寄存器号、写回使能控制信号必须跟着指令一起往后走不能在需要的时候从当前指令重新译码。这是新手最容易省掉的一步省掉之后 add 后面跟 lw 时写回使能会错误地跟着 lw 走导致 add 的结果没写进寄存器。// IF/ID 流水寄存器注意三个优先级的顺序 always (posedge clk or negedge rst_n) begin if (!rst_n) begin id_pc 32d0; id_instr 32d0; end else if (flush_if_id) begin id_instr 32d0; // 分支跳转时插入气泡 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 ! 5d0) (ex_mem_rd ex_rs1)) alu_a ex_mem_alu_result; // 来源二MEM/WB 级写回值已经产生 else if (mem_wb_regwrite (mem_wb_rd ! 5d0) (mem_wb_rd ex_rs1)) alu_a mem_wb_wb_data; // 第二个操作数同理注意排除 ALUSrc 选择立即数的情况 if (ex_mem_regwrite (ex_mem_rd ! 5d0) (ex_mem_rd ex_rs2)) alu_b ex_mem_alu_result; else if (mem_wb_regwrite (mem_wb_rd ! 5d0) (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, x1x2拿到的是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(n500): 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、最大值数值边界溢出、有符号比较这套方法我一般在单周期和流水线两个版本上各跑一遍同一个随机种子生成同一份程序两个版本的最终寄存器值必须完全一致。第一次跑的时候大概率会出现几处不一致别急着改代码先把不一致的指令位置和当时的流水线状态打出来多半能定位到某一级流水寄存器的内容不对。说个自己的教训早期做流水线时我习惯先看波形再改代码结果一个转发优先级的错误查了整整两天波形上每一级看起来都“合理”。后来改成先让随机测试报出第一条不一致的指令编号再带着编号去波形里定位那个周期同样的错误半小时就找到了。调试顺序比调试工具更重要希望帮到你。本文还有配套的精品资源点击获取