简介:杭州电子科技大学计算机组成原理课程实验项目合集,面向计算机专业本科生及需要动手实践硬件设计的学习者,覆盖从数字逻辑到MIPS指令执行的完整链路。内容包括全加器、超前进位加法器、多功能ALU、寄存器堆与存储器设计,以及MIPS汇编器与模拟器、取指译码和R型指令实现。压缩包共1519个文件,约34.06MB,以Verilog/VHDL源码、ISE/Xilinx工程文件、仿真脚本与PDF说明为主,另有docx与txt辅助资料,便于对照实验步骤复用和调试;工程目录按实验模块拆分,可快速定位对应设计与测试文件。目前已有76人学习,适合课程实验、期末复习或体系结构入门参考。整套材料不仅给出电路源码与工程配置,还包含汇编工具与项目文档,可帮助理解硬件设计、指令集与处理器执行过程之间的对应关系。
1. 计算机组成原理实验:从全加器到MIPS模拟器,这套资源能让你少走一半弯路
计算机组成原理这门课最容易被低估的是“从全加器到MIPS模拟器”这条实验线。很多学生以为加法器就是几个门,结果做到超前进位加法器才开始怀疑人生;以为 ALU 就是一堆选择器,结果寄存器堆读写端口没理清,整个数据通路全乱。杭州电子科技大学这套课程实验资源正好把九个环节串在一起:全加器、超前进位加法器、多功能 ALU、寄存器堆、存储器、MIPS 汇编器与模拟器、取指与指令译码、R 型指令实现,附带资源包和说明文件。它解决的正是“单个模块会做,连起来不会跑”的典型问题。适合正在跟唐朔飞或白中英教材学计算机组成原理、需要完成课程实验的本/专科生,也适合想用 Quartus 原理图或 Verilog 快速复现一套 CPU 骨架的工程师。
2. 全加器与超前进位加法器:先把算术单元的底子打牢
全加器是整条实验线的起点,它不复杂,但后面所有算术逻辑都建立在“进位怎么产生、怎么传播”这两个概念上。超前进位则是第一次让你感受到“改逻辑结构能换速度”的硬核实验。这一章先把全加器写对,再进到超前进位的 G/P 信号,最后对照实验任务说清楚参数怎么设。
2.1 全加器:从真值表到数据流描述
全加器处理三个输入:两个加数a、b,以及来自低位的进位cin。输出是sum和cout。真值表不写了,直接给结论:
sum = a ⊕ b ⊕ cincout = (a & b) | (cin & (a ^ b))
在 Quartus 里用 Verilog 写全加器,常见做法是直接数据流描述:
module full_adder ( input a, input b, input cin, output sum, output cout ); assign sum = a ^ b ^ cin; assign cout = (a & b) | (cin & (a ^ b)); endmodule这段代码里^是异或,&是与,|是或。sum用三个输入异或,本质就是看a、b、cin中 1 的个数是奇数还是偶数;cout由两部分组成:a和b同时为 1 时必然产生进位,或者a、b只有一个为 1 时,如果cin为 1,也会产生进位。这个写法比直接写成{cout,sum} = a+b+cin的算术写法更贴近门级结构,综合后不会因为综合器推断出奇怪加法器导致时序问题。
仿真时,建议 testbench 里把a、b、cin的 8 种组合都跑一遍,每种组合至少维持 100ns,再给输出加一点延迟观察窗口。不要用#10这种过密的信号切换,否则波形上很容易把毛刺当成真实进位。
2.2 超前进位加法器:用 G 和 P 信号消除串行进位
超前进位加法器的核心不是“加法”而是“进位”。串行加法器的进位是一级一级往上走的,4 位加法器最坏情况要等 4 级门延迟。超前进位把进位生成和进位传播拆成两个信号:
G_i = a_i & b_i,表示第 i 位必然产生进位P_i = a_i ^ b_i,表示第 i 位能把上一级进位传播出去
于是每一位的进位可以写成:
c_1 = G_0 | (P_0 & c_0)c_2 = G_1 | (P_1 & G_0) | (P_1 & P_0 & c_0)c_3 = G_2 | (P_2 & G_1) | (P_2 & P_1 & G_0) | (P_2 & P_1 & P_0 & c_0)
这样每一位的进位只与最初输入的c_0和所有低位的G、P有关,不需要等低位算完。4 位超前进位加法器 Verilog 可以这样写:
module cla_adder_4 ( input [3:0] a, input [3:0] b, input cin, output [3:0] sum, output cout ); wire [3:0] g, p; wire [3:0] c; assign g = a & b; assign p = a ^ b; assign c[0] = cin; assign c[1] = g[0] | (p[0] & c[0]); assign c[2] = g[1] | (p[1] & g[0]) | (p[1] & p[0] & c[0]); assign c[3] = g[2] | (p[2] & g[1]) | (p[2] & p[1] & g[0]) | (p[2] & p[1] & p[0] & c[0]); assign cout = g[3] | (p[3] & g[2]) | (p[3] & p[2] & g[1]) | (p[3] & p[2] & p[1] & g[0]) | (p[3] & p[2] & p[1] & p[0] & c[0]); assign sum = p ^ c[3:0]; endmodule这里g是生成信号,p是传播信号,c是每一位的进位。sum直接等于p异或进位,这一步用的是全加器的sum公式。注意c的每一位在组合逻辑里同时产生,不存在级联关系,这就是“超前”二字的来源。
实验里如果只用原理图方式,Quartus 中可以把G、P信号拉出来单独做成总线,再接到进位生成模块。建议先在仿真里观察g、p的波形,再观察c,这样定位错误时不会把加法结果和进位逻辑混在一起。
2.3 参数与实验任务对照:别把“能进位的加法器”当成超前进位
很多同学做实验时把超前进位加法器写成了assign {cout, sum} = a + b + cin。仿真结果完全正确,但这不是超前进位,这是综合器自动推断的加法器,可能用了串行进位,也可能用了进位选择。实验检查的关键点是看 RTL 视图里有没有明显的进位生成树,或者看 Report 里的逻辑单元数和关键路径延迟。
参数上,4 位超前进位的延迟主要由输入到输出的三级门构成:产生 G/P 一级,计算进位一级,计算和一级。如果你在 Quartus 里看到关键路径只有 2 级或超过 5 级,就要怀疑综合器是不是把你写的结构改掉了。要保留结构,可以在综合属性里设置keep或手动例化门级单元。
3. 加法器实验的避坑记录:进位失效、仿真延迟与波形误读
加法器是整个实验里第一个“看起来简单、实际容易翻车”的模块。我把自己和周围人踩过的坑整理成三条,每一条都按现象、原因、解决顺序写,做实验时直接对照检查。
3.1 现象:进位输出一直为 0,波形与真值表对不上
把全加器的 testbench 跑完,发现sum正确,但cout在所有cin=1的组合下都是 0。检查代码,cout表达式里写的是(a & b) | (cin & (a ^ b)),看起来没问题。再看 testbench,发现cin信号初始值为 0,而第一个测试向量给的是cin=0,后面没有正确驱动cin的变化,导致cin一直是 0。
原因是 testbench 里把cin当成普通 reg,只初始化没在 always 块里翻转。解决方法是把cin放进输入组合循环里,或者单独用initial块赋值。我一般会写一个三层嵌套循环,让a、b、cin遍历 8 种组合,每步打印当前值,避免漏信号。
3.2 现象:仿真通过,但上板后结果不稳定
全加器和超前进位加法器在仿真波形里输出完全正确,下载到板子上后,LED 显示的值偶尔会闪。检查后发现是按键或拨码开关作为输入时没有消抖,而且时钟和输入切换不同步。加法器是纯组合逻辑,输入抖动会直接反映到输出。解决方法是给输入加两级触发器同步,或者用 testbench 复位所有寄存器后再给输入,确保输入稳定后才采样。这个坑在后续 ALU 和寄存器堆实验里更常见,建议一开始就养成同步输入的习惯。
3.3 现象:把行为级加法当成超前进位,综合报告不达标
用assign {cout,sum} = a + b + cin写了加法器,仿真正确,但 Quartus 报告关键路径延迟远大于超前进位应有的水平,逻辑单元数也比参考值多。原因是行为级加法没有显式给出进位结构,综合器按面积或速度自动选择,结果可能是串行进位链。解决方法是像我 2.2 节那样显式写出g、p、c的表达式,或者用 Quartus 的Carry Chain设置强制使用超前进位结构。一个快速验证办法:在 RTL 视图里查c[1]到c[4]的连线,如果是串行依赖,说明没有超前。
4. 多功能 ALU 与寄存器堆设计:把数据通路串起来
加法器只是算术单元,ALU 和寄存器堆是连接数据通路的桥梁。这部分实验的难点不在单一模块,而在“控制信号怎么来、寄存器什么时候写”的时序关系。这一章会把 ALU 的功能表、寄存器堆的读写端口和时钟沿逐一讲透。
4.1 多功能 ALU:用功能码选运算,控制信号先列成表
ALU 设计通常要求支持加、减、与、或、异或、左移、右移等运算。常见做法是用一个 4 位或 5 位操作码alu_op作为选择信号,配合a、b两个 32 位或 8 位输入,输出result和零标志zero。
module alu #( parameter WIDTH = 8 ) ( input [WIDTH-1:0] a, input [WIDTH-1:0] b, input [3:0] alu_op, output reg [WIDTH-1:0] result, output zero ); always @(*) begin case (alu_op) 4'b0000: result = a + b; 4'b0001: result = a - b; 4'b0010: result = a & b; 4'b0011: result = a | b; 4'b0100: result = a ^ b; 4'b0101: result = a << 1; 4'b0110: result = a >> 1; default: result = {WIDTH{1'b0}}; endcase end assign zero = (result == 0); endmodule这段代码里alu_op是功能码,宽度可根据实验要求调整。zero标志位在后续做分支指令会用到,这里提前实现可以省一次返工。注意result用了reg类型,但always @(*)是组合逻辑块,不影响综合结果。减法实际上可以用a + (~b + 1)代替,但为了可读性,我一般允许直接写a - b,综合器会映射到加法器。
实验参数上,如果 ALU 要求支持 32 位,把WIDTH改成 32 即可。但要注意位宽越大,关键路径会变长,尤其是加法部分。建议先定好alu_op的编码表,在代码注释里列出来,方便后面控制信号复用。
4.2 寄存器堆:写优先调整、读端口固定,时钟沿要较真
寄存器堆实验的核心是理解读端口和写端口的关系。MIPS 架构通常要求两个读端口和一个写端口,寄存器堆内部用 RAM 实现,读是组合逻辑,写是时钟沿触发。
module regfile #( parameter DATA_WIDTH = 32, parameter ADDR_WIDTH = 5 ) ( input clk, input rst_n, input reg_write, input [ADDR_WIDTH-1:0] raddr1, input [ADDR_WIDTH-1:0] raddr2, input [ADDR_WIDTH-1:0] waddr, input [DATA_WIDTH-1:0] wdata, output [DATA_WIDTH-1:0] rdata1, output [DATA_WIDTH-1:0] rdata2 ); reg [DATA_WIDTH-1:0] mem [0:2**ADDR_WIDTH-1]; integer i; // 异步读 assign rdata1 = mem[raddr1]; assign rdata2 = mem[raddr2]; // 同步写,低电平复位 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin for (i = 0; i < 2**ADDR_WIDTH; i = i + 1) mem[i] = 0; end else if (reg_write) begin mem[waddr] <= wdata; end end endmodule这里的mem是二维寄存器数组,地址宽度 5 对应 32 个寄存器,数据宽度 32 位。rdata1、rdata2是只读组合逻辑,不依赖时钟;mem[waddr] <= wdata严格在时钟上升沿写入。注意一个参数选择:很多教材要求读端口在读操作时保持原值,而写端口在同一周期写入的值不能被读端口立刻读到,除非做“写优先”。如果你的实验要求写优先,需要在读数据时做额外判断:
assign rdata1 = (reg_write && raddr1 == waddr) ? wdata : mem[raddr1];这种“写优先”在 CPU 数据通路里很关键,尤其处理add $t0, $t1, $t0这类指令时,如果写回和读同周期,必须保证新值能被读到。具体是否启用写优先,以实验任务书为准。
4.3 常见误用:复位不可靠、数据出现 X 态
寄存器堆最常见的错是复位只清零了部分寄存器,或者忘记把rst_n接入。现象是仿真波形里wdata写入后,下次读取还是 X。解决方法是检查always块的敏感列表,以及复位时是否用了for循环清零全部寄存器。另一个坑是reg_write信号在测试时没拉高,导致写入根本没发生,但波形里能看到waddr有变化,误以为是写冲突。建议在 testbench 里先把reg_write固定为 1,再观察写地址和数据。
5. 存储器、取指译码与 MIPS 汇编器模拟器:构建完整指令周期
从这一章开始,实验进入“能用软件驱动硬件”的阶段。存储器负责指令和数据的存放,取指译码把指令变成控制信号,MIPS 汇编器负责把汇编文本变成机器码,模拟器则让你在没有硬件的情况下逐条执行指令。R 型指令实现则是把前面所有模块串起来的收官环节。
5.1 存储器设计:容量、字节使能与读写时序
存储器实验一般要求做一个同步 RAM,容量常见的是 128×32 位或 256×32 位。这里的“同步”指的是读写都受时钟控制,避免组合逻辑里的 latch 风险。注意,这个实验里做的是片上存储器的模型,不是虚拟存储器。虚拟存储器的页表、缺页处理属于操作系统课程范畴,这里不展开。
module mem_sync #( parameter DATA_WIDTH = 32, parameter ADDR_WIDTH = 7 ) ( input clk, input mem_read, input mem_write, input [ADDR_WIDTH-1:0] addr, input [DATA_WIDTH-1:0] wdata, output reg [DATA_WIDTH-1:0] rdata ); reg [DATA_WIDTH-1:0] ram [0:2**ADDR_WIDTH-1]; always @(posedge clk) begin if (mem_write) ram[addr] <= wdata; if (mem_read) rdata <= ram[addr]; end endmodule参数上,ADDR_WIDTH=7对应 128 个地址,每个地址 32 位,总容量 4K 比特。读操作是“先给地址,下一拍出数据”,这和寄存器堆的异步读不一样,接 CPU 数据通路时要注意时序差一拍。mem_read和mem_write不能同时为 1,否则读写冲突,这是实验任务里常见的限定条件。
在设计 CPU 与存储器连接时,我一般会把地址总线、数据总线、读写控制信号单独拉出来,做成总线接口。这样后续取指模块可以用同样的接口读取指令,数据和指令共用一块 RAM,只要错开时钟周期即可。
5.2 取指与指令译码:从 PC 到指令字段拆分
取指模块需要维护一个程序计数器PC,每个时钟周期把PC送到存储器地址端口,得到指令。指令译码则要把 32 位指令拆成 opcode、rs、rt、rd、shamt、funct 等字段。以一个 R 型指令为例,格式是:
[31:26]opcode[25:21]rs[20:16]rt[15:11]rd[10:6]shamt[5:0]funct
wire [5:0] opcode = instr[31:26]; wire [4:0] rs = instr[25:21]; wire [4:0] rt = instr[20:16]; wire [4:0] rd = instr[15:11]; wire [4:0] shamt = instr[10:6]; wire [5:0] funct = instr[5:0];译码阶段要做的第一件事是判断指令类型。R 型指令的 opcode 固定为 6'b000000,具体操作看 funct。比如funct=6'b100000表示 add,6'b100010表示 sub。译码输出包括reg_write、alu_op、mem_write、mem_read、mem_to_reg等控制信号。常见做法是用一个wire或reg组合逻辑块,直接对opcode和funct做 case 分支。
这里容易踩坑的是把opcode和funct搞混。R 型指令的操作类型由funct决定,但很多初学者用opcode去区分 add、sub,结果所有 R 型指令都走同一个分支。解决方法是先判断opcode==0,再用funct区分具体运算。
5.3 MIPS 汇编器与模拟器:文本到机器码,再逐条执行
汇编器的作用是把 MIPS 汇编指令文本翻译成 32 位机器码。我一般用 Python 做原型,因为字符串处理方便。一个最小的 R 型汇编器可以这样写:
import re # 寄存器编码表 reg_map = { '$zero': 0, '$t0': 8, '$t1': 9, '$t2': 10, '$s0': 16, '$s1': 17, '$a0': 4, '$a1': 5 } # R型指令 funct 编码 funct_map = {'add': 0x20, 'sub': 0x22, 'and': 0x24, 'or': 0x25} opcode = 0x00 # R型指令opcode固定为0 def assemble_rtype(line): parts = line.replace(',', ' ').split() instr = parts[0] rd = reg_map[parts[1]] rs = reg_map[parts[2]] rt = reg_map[parts[3]] funct = funct_map[instr] # 构造成32位机器码 return (opcode << 26) | (rs << 21) | (rt << 16) | (rd << 11) | (0 << 6) | funct # 示例:add $t0, $t1, $t2 print(hex(assemble_rtype('add $t0, $t1, $t2')))这个脚本里opcode << 26把 6 位 opcode 放到最高位,rs << 21、rt << 16、rd << 11依次左移。shamt置为 0,funct直接填入低 6 位。实际实验里如果支持 I 型指令,还需要处理立即数符号扩展,但 R 型指令没有立即数,所以只是移位和拼接。
模拟器则是一个循环,每轮取一条指令,根据操作类型修改寄存器或 PC。一个极简的 R 型指令模拟器:
def run_sim(instructions, regs): pc = 0 while pc < len(instructions): instr = instructions[pc] opcode = (instr >> 26) & 0x3F rs = (instr >> 21) & 0x1F rt = (instr >> 16) & 0x1F rd = (instr >> 11) & 0x1F funct = instr & 0x3F if opcode == 0: # R型 if funct == 0x20: regs[rd] = regs[rs] + regs[rt] elif funct == 0x22: regs[rd] = regs[rs] - regs[rt] pc += 1 return regs模拟器里最关键的是“先读寄存器,再写目的寄存器”。如果指令是add $t0, $t1, $t0,模拟时会先读$t1和$t0,然后计算结果写入$t0,这样实现是对的。如果你用了 regs[rd] 作为源,就会读出旧值,结果全错。这跟硬件里写优先寄存器的行为是相反的——硬件里同一周期写回的新值可以被读出,但模拟器是顺序执行,必须注意先后顺序。
5.4 R 型指令实现:数据通路与控制信号的配合
R 型指令实现是整个实验的“验收题”。数据通路需要:取指(PC→存储器)、译码(寄存器堆读端口)、执行(ALU)、写回(寄存器堆写端口)。控制信号来自译码输出,一行一行列出来:
| 控制信号 | add | sub | and |
|---|---|---|---|
| reg_write | 1 | 1 | 1 |
| alu_op | 加 | 减 | 与 |
| mem_write | 0 | 0 | 0 |
| mem_read | 0 | 0 | 0 |
| mem_to_reg | 0 | 0 | 0 |
核心逻辑是alu_op如何驱动 ALU。如果 ALU 的功能码正好和funct一致,可以直连;但更规范的做法是用控制逻辑把funct映射成alu_op。比如funct=0x20映射alu_op=4'b0000sum,funct=0x22映射alu_op=4'b0001sub。这一步不做映射,直接在 ALU 里判断funct,会让 ALU 模块背上指令集解析的职责,后期扩展 I 型指令时很麻烦。
R 型指令不访问存储器,所以mem_read和mem_write都为 0,mem_to_reg也置 0,写回数据直接来自 ALU 输出。实验里最容易错的是reg_write信号延迟一拍:寄存器堆写使能必须和写数据在同一时钟周期有效,如果reg_write在状态机里晚一拍,数据就到不了寄存器堆。
6. 验证技巧:从全加器到 MIPS 模拟器的回归测试习惯
九个实验全部做完后,最怕的是“模块单独都对,连起来就崩”。我的建议是做一个回归测试脚本,把每个模块的测试向量收集起来,每改一次顶层设计就重新跑一遍。对加法器,检查 8 种输入组合的cout;对 ALU,检查每种alu_op的输出;对寄存器堆,写几个地址再读回来;对 R 型指令,用汇编器生成机器码,再用模拟器执行,最后和 Verilog 仿真结果对比。
具体操作上,我会在 testbench 里加两个打印控制点。第一个是$display打印每条指令执行后的 PC 和寄存器值;第二个是$monitor监控存储器写操作。这样一旦某个寄存器值变成 X,立刻能找到是哪条指令写崩的。
参数上,模拟器和硬件的位宽必须一致。如果模拟器用 Python 的无限整数,而 Verilog 用 32 位有符号数,两者的移位和加减结果就会不一致。建议在模拟器里对每个寄存器设置掩码regs[i] &= 0xFFFFFFFF,并且把最高位当作符号位处理,否则验证 R 型指令的 sub 时会发现正负数对不上。
从那以后我每次做这类 CPU 实验,都强制走一遍“汇编器生成机器码 → 模拟器跑出寄存器序列 → 硬件仿真对比关键寄存器”的流程。三个环节只要有一个结果不一致,直接放弃调试单模块,先查中间数据在哪一步变了。这个习惯帮我挡掉了至少一半的时序类翻车。希望帮到你。
本文还有配套的精品资源,点击获取