简介:湖南大学计算机组成与原理实验资源包,面向计算机科学与技术专业学生及计组课程自学人群,针对实验环节中逻辑电路、存储系统、CPU设计与I/O交互等核心内容提供完整参考。压缩包共136个文件,大小约15.88MB,涵盖bdf原理图、vwf仿真波形、doc实验文档、txt配置说明、qsf工程设置等类型,目录清晰,可满足从电路搭建到功能验证的多环节需求。当前已有458人学习/浏览,适合课程同步练习、考前集中复盘或实验验收前自查。资源为已完成实验的成套参考,覆盖逻辑门、编码器、选择器、RAM/ROM及Cache存储层次、微程序控制CPU与中断DMA等知识点;并保留完整工程文件、实验文档与波形仿真结果,配合使用可辅助理解硬件工作机制、核对设计思路、撰写实验报告,是一份便于自行对照开展的计组实验参考资料。
1. 计组实验方案从哪下手:指令集、数据通路和仿真验证
做计算机组成与原理实验,很多人第一反应是打开开发环境写代码。我见过A同学花三周时间搭了一个“看起来能跑”的单周期CPU,结果一改指令,波形全乱。计组实验和软件工程不同,难点不在写代码,而在于把指令系统设计、数据通路规划和控制器状态机按顺序理清楚。这套方案解决的核心问题是:拿到某高校计算机组成与原理实验的任务大纲后,不知道从哪入手。
它适合正在做计组课设的学生,也适合想快速拉通“设计—仿真—上板”闭环的从业者。设计细节都围绕可验证性展开,每写一个模块,就配一段波形或状态检查点。整体思路很直接:先定义指令集,再画数据通路,实现寄存器堆、ALU和控制器,最后用testbench验证。过程中会穿插几个容易被忽视的细节,比如写优先逻辑、标志位延迟、X态传播。
我习惯把实验拆成四个交付节点:寄存器堆和ALU、单周期指令执行、存储器读写、跳转指令。每个节点都能单独仿真,出问题时能定位到具体模块,而不是在最后面对一片乱波形。下面按这个顺序展开。
2. 总体架构设计:指令集规划、数据通路与状态机三层拆分
一套计组实验如果一上来就写模块,后期一定会在信号连接上翻车。我体验过最稳的做法是先做三件事:定指令集格式、画数据通路图、列状态转移表。这层做透了,写代码只是翻译。
2.1 指令集设计:先固定一条核心指令的格式和字段
指令系统是计组实验的“需求文档”。我一般先定义16位指令字,操作码占高4位,后续的寄存器地址占中4位,立即数占低8位。最少需要三到四条指令撑起最小闭环:加法(ADD)、载入(LOAD)、存储(STORE)和跳转(JMP)。
| 指令 | 操作码 | 功能 | 数据通路行为 |
|---|---|---|---|
| ADD | 0000 | R[rd] = R[rs1] + R[rs2] | ALU计算,写寄存器堆 |
| LOAD | 0001 | R[rd] = mem[imm] | 存储器读,写寄存器堆 |
| STORE | 0010 | mem[imm] = R[rs1] | 读寄存器堆,写存储器 |
| JMP | 0011 | pc = imm | 更新PC,不碰寄存器和存储器 |
为什么要从ADD开始?因为它只用ALU和寄存器堆,不碰存储器,仿真时最容易对波形。LOAD和STORE再加入存储器读写,JMP最后加入,用于调整PC。这种阶梯式设计,相当于给自己留了三个验收节点。如果一上来就尝试支持八条指令的复杂指令集,控制信号堆在一起,仿真报错时根本分不清是译码问题还是数据通路问题。
注意立即数的位宽设计:LOAD/STORE的地址范围是0到255,如果你的实验要求设备带超过256字节,就需要改为“寄存器寄存器”寻址或扩展立即数拼接。这个取舍在写实验报告时最好写明,它是设计权衡的一部分。
2.2 数据通路划分:存储器、寄存器堆与ALU的职责边界
典型单周期数据通路的走向是:PC寄存器的值作为地址送指令存储器取指令,指令字中的操作码字段送控制单元,寄存器字段送到寄存器堆,寄存器堆的读数据送ALU,ALU结果或存储器读数据再写回寄存器堆。JMP指令不写寄存器,只把立即数送给PC更新逻辑。
建议先列一张控制信号表,把每条指令在什么节拍使能哪些信号写清楚,作为控制器的设计依据。下面是我当时画的信号组合示意表,不以绝对值为准,只看结构:
| 控制信号 | ADD | LOAD | STORE | JMP |
|---|---|---|---|---|
| reg_write | 1 | 1 | 0 | 0 |
| alu_src | 0 | 1 | 1 | 0 |
| mem_write | 0 | 0 | 1 | 0 |
| pc_src | 0 | 0 | 0 | 1 |
| mem_to_reg | 0 | 1 | 0 | 0 |
其中alu_src决定ALU的第二个操作数来自寄存器堆还是立即数;mem_to_reg决定写回寄存器堆的数据来自ALU还是数据存储器。这两对信号经常被漏掉,特别是在LOAD指令里。如果mem_to_reg不接,LOAD指令会把地址值写回寄存器而不是内存里的数据,仿真时对不上。
设计数据通路的第二个要领是明确“谁写,谁读”。同一时钟上升沿,只允许一个部件写PC、一个部件写寄存器堆、一个部件写存储器。如果两个执行单元都想写同一个目标,就需要加优先级或改成多周期,否则会生成锁存器或出现未知状态。
2.3 控制器状态机设计与状态转移表
单周期CPU的状态机比较短,但不要因为短就省略。一般会定义一个“复位态”和一个“取指态”。复位后停在一个待机状态,拿到启动信号后进入取指,再根据操作码落到对应执行分支。以下是我常用的三段式状态划分:
- 待机:等待启动,复位PC和寄存器堆。
- 取指:PC作为地址读指令存储器,同时PC自增指向下一条。
- 执行:根据操作码分成ADD、LOAD、STORE、JMP四个分支,完成后回到取指。
跳转指令会修改FETCH阶段的PC自增结果:在执行分支里把立即数写入PC,下一个时钟沿再回到取指,这样就不会在跳转后再多执行一条无关指令。多周期CPU会把取指和执行拆成更多状态,如IF、ID、EX、MEM、WB,但单周期实验只要三层就够了。如果实验要求做流水线,可以在这个基础上把写回推迟一个周期。
这里还有一个容易被忽略的点:PC自增在取指状态就要发生,否则每条指令执行完后PC不动,CPU只能反复执行同一条指令。我见过不少同学把PC + 1放在执行状态,结果程序永远停在第一条指令。控制器的每个状态里,哪些信号有效、哪些信号保持,应该和阶梯式设计一样逐条对一遍。
2.4 顶层模块接口设计:信号命名与位宽约定
项目数量多了之后,信号名不一致是最难查的。我一般在设计阶段就列一张顶层端口表,统一所有模块的位宽。例如:
| 信号名 | 方向 | 位宽 | 说明 |
|---|---|---|---|
| clk | input | 1 | 时钟信号 |
| rst_n | input | 1 | 异步复位,低有效 |
| pc_out | output | 16 | 当前PC值,调试用 |
| reg_write | output | 1 | 寄存器写使能,调试用 |
| alu_result | output | 16 | ALU计算结果,调试用 |
命名规则尽量统一为“模块_信号”的形式,例如regfile_read_data1、alu_result、pc_next。这样仿真时从波形窗口里一眼就能找到目标信号,不用反复猜名字。位宽以16位为基准,如果后续实验要求扩展到32位,只需要改参数而不用改逻辑。
3. 核心模块实现:寄存器堆、ALU和单周期控制器实战
这一章直接给三个关键模块的Verilog实现。三个模块都能独立仿真,也是实验报告里必须展示的代码部分。
3.1 寄存器堆:写优先逻辑与读写时序
module register_file #( parameter DATA_WIDTH = 16, parameter ADDR_WIDTH = 4 ) ( input clk, input rst_n, input reg_write, input [ADDR_WIDTH-1:0] read_addr1, input [ADDR_WIDTH-1:0] read_addr2, input [ADDR_WIDTH-1:0] write_addr, input [DATA_WIDTH-1:0] write_data, output [DATA_WIDTH-1:0] read_data1, output [DATA_WIDTH-1:0] read_data2 ); reg [DATA_WIDTH-1:0] mem [0:(1<<ADDR_WIDTH)-1]; integer i; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin for (i = 0; i < (1<<ADDR_WIDTH); i = i + 1) mem[i] <= {DATA_WIDTH{1'b0}}; end else if (reg_write) begin mem[write_addr] <= write_data; end end // 写优先:同拍读到写地址时,把新值直接前递出去 assign read_data1 = (reg_write && read_addr1 == write_addr) ? write_data : mem[read_addr1]; assign read_data2 = (reg_write && read_addr2 == write_addr) ? write_data : mem[read_addr2]; endmodule这段代码里最关键的写法是写优先(write-forwarding)。当一条指令在同一个时钟沿写寄存器堆,而另一条指令要读同一个地址时,假如只返回mem[read_addr],就会读到旧值,仿真波形里看起来就是“晚一拍”。把比较逻辑放在组合读路径上,可以避免这个时差。这样在单周期CPU里问题还不明显,但如果之后做流水线,这个前递逻辑就是初级前递的雏形。
参数说明:DATA_WIDTH设为16,对应16位指令字和16位数据宽度;ADDR_WIDTH设为4,可访问16个通用寄存器。如果你使用的实验板要求32位数据,可以把参数改成32。注意综合时for循环只在复位使用,工具会展开为寄存器的异步复位逻辑。
3.2 ALU与标志位:运算单元和标志位的生成条件
module alu ( input [15:0] a, input [15:0] b, input [2:0] op, output [15:0] result, output zero, output overflow ); reg [15:0] result_reg; wire signed [15:0] a_s = a; wire signed [15:0] b_s = b; always @(*) begin case (op) 3'b000: result_reg = a + b; 3'b001: result_reg = a - b; 3'b010: result_reg = a & b; 3'b011: result_reg = a | b; default: result_reg = a; endcase end assign result = result_reg; assign zero = (result == 16'b0); assign overflow = (op == 3'b000) ? (a_s[15] == b_s[15]) && (result[15] != a_s[15]) : 1'b0; endmodule这里zero标志是纯组合逻辑,直接从运算结果判断。overflow我留了加法检测,用被加数和结果的符号位比较:两个同符号数相加得到相反符号,说明超出了16位补码能表示的数值范围。如果不做有符号运算,这个判断没有意义,所以只在指令的语义是有符号加法时使用。
设计说明:op3位就能覆盖加减与逻辑运算,16个通用寄存器的16位运算。注意减法不要使用a - b而是保留为补码,因为减法本质上是a + (~b + 1),如果后续要做溢出检测,建议在case分支里显式写a + (~b + 1),而不是写a - b——二者的逻辑综合结果可能不同。这一章里可以先保持行为一致,但上面的op码记住,以后做流水线时不会错位。
3.3 单周期控制器:指令译码与状态跳转
module controller ( input [15:0] instr, input clk, input rst_n, output reg reg_write, output reg mem_write, output reg alu_src, output reg mem_to_reg, output reg pc_src ); reg [2:0] state; localparam IDLE = 3'd0, FETCH = 3'd1, EXEC = 3'd2; wire [3:0] opcode = instr[15:12]; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; reg_write <= 1'b0; mem_write <= 1'b0; alu_src <= 1'b0; mem_to_reg <= 1'b0; pc_src <= 1'b0; end else begin case (state) IDLE: state <= FETCH; FETCH: state <= EXEC; EXEC: begin case (opcode) 4'b0000: begin // ADD reg_write <= 1'b1; mem_write <= 1'b0; alu_src <= 1'b0; mem_to_reg <= 1'b0; pc_src <= 1'b0; end 4'b0001: begin // LOAD reg_write <= 1'b1; mem_write <= 1'b0; alu_src <= 1'b1; mem_to_reg <= 1'b1; pc_src <= 1'b0; end 4'b0010: begin // STORE reg_write <= 1'b0; mem_write <= 1'b1; alu_src <= 1'b1; pc_src <= 1'b0; end 4'b0011: begin // JMP reg_write <= 1'b0; mem_write <= 1'b0; pc_src <= 1'b1; end default: begin end endcase state <= FETCH; end endcase end end endmodule说明:状态转移很清晰,IDLE->FETCH->EXEC回FETCH。单周期架构不一定非要有状态机,但保留状态使仿真信号更可读。注意opcode是instr[15:12],和第二章指令表对应。控制信号在EXEC阶段里产生,经过组合逻辑稳定后,寄存器和存储器在下一个时钟沿写入。如果你的实验板对时序要求严格,可以把EXEC状态再拆成MEM_ACCESS和WRITE_BACK,但这样就变成了多周期方案。
这里的关键调试点是:每个指令分支都要独立复位所有控制信号。比如STORE分支只改mem_write,其他信号要看清楚是否残留上一次的值。很多翻车都源于分支内漏写reg_write <= 1'b0导致寄存器堆多写。
4. 避坑指南:计组实验里最容易翻车的问题与排查
这一章写五个我在实际调试里反复遇到的问题,全都按“现象、原因、解决”三件套来展开。
4.1 读写冲突与写优先:寄存器读数总比预想晚一拍
现象:仿真波形里,同一时钟沿读和写同一个寄存器,读出的是旧值,导致下一条指令计算错误。
原因:寄存器堆读端口是纯组合逻辑,读信号可能出现在寄存器写信号之前,如果没有前递,真实硬件里读到的是旧值。
解决:在寄存器堆模块里加写优先逻辑,也就是读地址等于写地址且写使能时,读数据直接接写入数据。这个细节会让整个波形错位一次,后来把“读优先/写优先”写进设计文档,才明白这属于数据相关性,不是玄学。
4.2 控制信号不齐:指令一多波形里全是Z和X
现象:只有ADD指令时波形正常,一加LOAD/STORE,看到总线是X态,寄存器堆写数据也全是X。
原因:某个控制信号没有在指令里定义默认值,或者模块端口悬空。另一个常见原因是状态机的default分支里没有给全部控制信号赋值,导致综合时生成锁存器。
解决:给控制器的输出信号设置复位后的默认值,并在每个分支里把所有信号都列全。如果信号太多,用一个“总线型控制字”代替单个信号,比如在EXEC分支里用连续位赋值{reg_write, mem_write, alu_src, mem_to_reg},这样一眼能看出哪个指令分支漏了哪一位。
4.3 异步复位与X态:复位后波形大面积未知
现象:仿真开始后,除了复位期间,PC和寄存器堆全是X,testbench里的指令内存看起来是空的。
原因:指令存储器或寄存器堆没有初始化复位,或者仿真器对未初始化变量输出X;另一个原因是复位信号在时钟边沿释放,导致亚稳态。
解决:把指令存储器用$readmemh从外部文件初始化,寄存器堆在复位里清零,PC在复位后指向固定地址。复位释放要放在时钟下降沿附近,这样时钟上升沿到达时信号已经稳定。
4.4 跳转指令与PC:程序总是在循环里打转
现象:JMP执行后下一拍PC没有变化,程序一直重复同一段指令。
原因:PC更新逻辑里只写了pc = pc + 1,忽略跳转目标的写入;或者执行分支里没有把pc_src置为1。
解决:在控制器里加pc_src,执行分支里为JMP置1,PC模块里用pc_src选择imm还是pc+1。仿真时用$monitor("PC = %d", pc)快速确认跳转是否生效。我一般会把PC更新写成独立模块,便于单独测试。
4.5 阻塞赋值与非阻塞赋值:仿真通过但上板翻车
现象:仿真波形完全正确,下载到实验板后程序跑飞。
原因:某些模块用了阻塞赋值=完成时序逻辑,综合后变成组合环或产生锁存器,仿真器按Verilog语义算出的结果和真实电路不一致。
解决:用时序逻辑的地方一律使用<=,组合逻辑用=。检查寄存器写入路径时,如果always块里有posedge clk,内部赋值就全部换成<=,这个习惯越早养越好,不要靠仿真通过来兜底。
5. 仿真与板级验证:从testbench到上板的完整流程
5.1 Testbench结构:时钟、复位和指令序列
module tb_cpu; reg clk; reg rst_n; reg [15:0] mem_ins [0:15]; initial begin $readmemh("program.hex", mem_ins); clk = 0; rst_n = 0; #20 rst_n = 1; end always #5 clk = ~clk; initial begin $dumpfile("cpu_wave.vcd"); $dumpvars; #250 $finish; end endmoduleprogram.hex是预先生成的指令序列文件,每一行是一个16进制指令字。always #5生成10ns周期的时钟,复位保持20ns。如果使用ModelSim或Vivado仿真,可以不写dumpvars,直接用工程自带波形窗口。注意看:复位释放的时刻是20ns处,复位释放后PC才开始变化,所以断言检查也要把前20ns排除。
一个和这个testbench配套的指令序列可以是这样:
0000 // NOP占位 1001 // LOAD R1, imm 0000 // ADD前面被LOAD进来的数人工翻译机器码容易出错,建议把汇编写在一行注释里,保证报告可读性。
5.2 波形与信号追踪:先看PC,再看寄存器堆
打开波形后,不要漫无目的地看全部信号。我的调试顺序是:先看PC是否从0逐步增加;再看状态机在IDLE/FETCH/EXEC之间切换;最后看reg_write与write_data。如果PC跳变正常,大概率数据通路没问题;如果某条指令执行后寄存器堆没变化,就用cursor定位到那个时钟沿,查看状态是否进入对应指令分支,再检查alu_result是不是期望值。
小技巧:在仿真中加一个断言,例如“LOAD执行后 R0 应为 0x1234”,不满足立马报错,而不等波形肉眼慢慢找。我每次都会把这个断言做成$error输出,作为实验报告中自动化验证的证据。这个动作虽然只多几行代码,但能让“验证通过”变成一句可复现的结论,而不是一句口头说明。
5.3 上板调试:从仿真到硬件的三个转型点
仿真通过不等于上板通过。上板前要处理三个差异:
第一,初始值。仿真里FPGA寄存器上电是随机值,必须在复位阶段清零,PC起始地址也要受复位控制,否则第一拍PC是随机数。第二,时钟频率。实验板上的时钟可能到50MHz,组合逻辑延时可能不满足时序,通常先降频或用PLL分频到5MHz,验证功能后再提频。第三,显示验证。不要只靠LED,把低16位输出接到数码管或者串口打印,否则看不到中间状态。
我一般会把“显示模块”也纳入实验范围:把寄存器堆的R[0]和R[1]的值映射到两组数码管,手动按按钮触发单步执行,每一步都能看到寄存器变化。这样演示时老师能直接看到功能,比看波形更直观。单步模式同时也是调试跳转指令最快的手段。
6. 验收与答辩:四件套交付与三个高频提问
6.1 准备好四件套,验收时让老师一眼看到设计边界
验收时老师关心的不是代码行数,而是设计依据和功能边界。我会准备四样东西:完整的仿真波形图、一张指令集说明表、一份测试程序注释清单,以及一个能单步执行的演示脚本。演示脚本控制在两步内能看到结果:复位后LOAD一条立即数,再ADD寄存器,数码管上显示最终结果。这个流程不需要把全部指令跑一遍,但能把寄存器堆、ALU、控制器、存储器读写全部串起来。
答辩时最常被问的三个问题是:为什么选择16位指令字而不是32位?直接答:因为实验目标是指令通路验证,16位足够表达四条指令和寄存器地址,同时减少存储器资源。溢出标志和zero标志是怎么生成的?答:zero是结果组合比较,overflow是对加法两侧符号位做检查。如果指令改成32位,哪里需要改?答:指令存储器位宽、ALU位宽、寄存器堆的数据宽度,以及控制器的操作码截取位置。能说出这三个改动点,说明你理解的是架构而不是某一套代码。
复盘一个教训:以前我没做单步演示,答辩时老师让我连续跑三条指令,结果第三条跳到循环里出不来,整整停了两分钟。那之后我每次都会把“单步模式”做成实验板的标配,先用按键单步跑通,再连续运行,确保连续模式只是单步模式的重复。希望这个习惯也能帮你少踩一次类似的坑。
本文还有配套的精品资源,点击获取