
简介面向计算机科学与技术专业本科生杭州电子科技大学计算机组成原理课程的实验项目完整资料包覆盖从门级算术单元到MIPS处理器指令执行的系列实验。实验内容包括全加器与超前进位加法器设计、多功能ALU、寄存器堆、存储器构建以及MIPS汇编器/模拟器开发、取指令与译码、R型指令实现等适合用来同步课程实验、准备考研复试或自研学习。压缩包共1519个文件主要包含C语言源码、VHDL/Verilog硬件描述、Xilinx工程配置、仿真脚本及说明文档从代码到工程文件均较完整便于对照设计与调试。资源包大小约34MB已有76人学习下载透过这些实验的层次化设计与硬件描述实现可系统掌握组合逻辑优化、时序器件组织及指令流水执行等计算机组成原理关键知识点且目录结构按实验模块划分能快速定位全加器到MIPS模拟器各环节的参考实现。1. 为什么杭电计组实验要从全加器一路做到MIPS模拟器拿到这份实验包的第一反应多半是“怎么这么多”全加器、超前进位加法器、多功能ALU、寄存器堆、存储器、MIPS汇编器与模拟器、取指令与指令译码、实现R型指令。八件事从组合逻辑一路打到软硬协同但真正做过的人都知道最后的上板往往不是最难的最翻车的恰是前几个看似简单的模块——进位没接对、标志位算错、寄存器堆读端口慢半拍后面的MIPS跑起来全是玄学。这条链路本质上是一条从门电路到数据通路的打磨线每一步的输出都是下一步的输入。适合正在赶课程实验的本科生也适合想用最小代价把MIPS单周期CPU亲手搭一遍的自学者。2. 全加器与超前进位加法器组合逻辑的两道坎2.1 全加器设计真值表、卡诺图与Quartus原理图输入全加器是整条实验链的起点。它接收两个加数a、b和低位进位cin输出本位和s与进位cout。真值表只有八行化简后是两个核心表达式s a ^ b ^ cincout (a b) | (cin (a ^ b))。第一次做不要急着写高级写法建议在 Quartus 里用原理图输入过一次新建 Block Schematic 文件从库里的and2、or2、xor门里拖出来连线顺便把not、xnor这些门的位置记熟后面多功能ALU实验还会用到。原理图输入的好处是让你肉眼看到信号怎么走缺点是改一次逻辑要重拖一次线可维护性差。原理图跑通之后我建议立刻换 Verilog 再写一遍因为后续实验基本全用 Verilog。一个可综合的全加器module full_adder( input wire a, input wire b, input wire cin, output wire s, output wire cout ); wire ab_xor; assign ab_xor a ^ b; assign s ab_xor ^ cin; assign cout (a b) | (cin ab_xor); endmodule这里的ab_xor是公共项cout复用了s的中间异或结果综合后门数比直接写(ab)|(acin)|(bcin)更省。门级延迟也能算得比较清楚s经过两级门cout也经过两级门。Quartus 里编译后可以把全加器打包成 symbol之后在顶层设计里直接以一个模块的方式复用这一步是为后面拼接超前进位加法器做准备的。验证建议写一个覆盖全部八种输入的 testbenchtimescale 1ns/1ps module tb_full_adder; reg a, b, cin; wire s, cout; full_adder dut(a, b, cin, s, cout); integer i; initial begin for (i 0; i 8; i i 1) begin {a, b, cin} i; #10; $display(a%b b%b cin%b - s%b cout%b, a, b, cin, s, cout); end $finish; end endmodule{a, b, cin} i是位拼接赋值i从 0 到 7 正好覆盖真值表全部八行。跑完仿真看到八条打印都符合预期这个模块才算过关。这里有一个新手常踩的坑在 Modelsim 或 Quartus 自带的仿真器里如果打印结果全是x先查 testbench 里有没有把a/b/cin赋初值reg类型默认是x态初始化语句写在initial里且要放在#10之前。2.2 超前进位加法器进位链延迟拆解与位数扩展为什么全加器做完了还不让直接拼 32 位行波进位加法器因为延迟。一个四位行波进位加法器的进位要依次经过四级cout→cin最坏路径是a[0]/b[0]进到cout[3]门延迟与位宽成正比。等到了实验里要做 16 位或 32 位加法时如果还是逐级 ripple关键路径会吃掉大半个时钟周期上板跑高频一点就翻车。所以实验安排超前进位加法器是在教你把进位链“算出来”而不是“传下去”。对每一位定义进位生成g a b和进位传播p a | b则cout_i g_i | (p_i cin_i)。把每一项展开高位进位就不再依赖低位逐级运算。四位超前进位加法器核心逻辑module cla4( input wire [3:0] a, input wire [3:0] b, input wire cin, output wire [3:0] s, output wire cout ); wire [3:0] g, p, 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 s a ^ b ^ c; endmodule看到c[3]和cout的表达式了吗它们把g[0]、p[0]一路展开到cin所以cout[3]只需要三级门延迟就能算出来与位宽没有线性关系。代价是p的多项式项数随着位宽指数增长所以工业上不会写一个 64 位纯超前进位而是用“组内超前、组间串行”的结构。实验里常见的做法是把四位 CLA 做成模块四个组串行连成 16 位也就是常说的组间串行进位组内最多三级门延迟组间只有三条进位线整体关键路径远优于 16 位行波进位。结构4 位16 位32 位行波进位约 8 级门约 32 级门约 64 级门超前进位4 位组约 3 级门约 6 级门约 8 级门调这个模块时最容易出问题的是p和g的位宽与a/b没对齐仿真里出现x多半是某个c[i]没驱动先把c[0]cin这条 assign 固定住再查其他位。用 Quartus 编译后可以看 Compilation Report 里的 Timing Analysis对比行波进位和超前进位两种写法的Data Delay最大路径这是最直观的验收数据。3. 多功能ALU与寄存器堆数据通路核心件的设计取舍3.1 多功能ALU功能表、标志位与溢出判断ALU 是整个 CPU 的算力心脏。实验要求的多功能 ALU 一般至少覆盖加减、逻辑与或非、小于比较。设计上先定功能表再写代码。我习惯用 4 位alu_ctrl对应十余种运算编码不是随便定的——R 型指令里的funct字段最终要映射到这一组控制信号上功能表取什么编码直接决定后面指令译码逻辑的复杂度。alu_ctrl运算对应 MIPS 指令0000src1 src2and0001src1 | src2or0010src1 src2add0110src1 - src2sub0111$signed(src1) $signed(src2)slt1100~(src1 | src2)normodule alu( input wire [31:0] src1, input wire [31:0] src2, input wire [3:0] alu_ctrl, output reg [31:0] result, output wire zero, output wire overflow ); wire [31:0] sum; wire [31:0] diff; assign sum src1 src2; assign diff src1 - src2; always (*) begin case (alu_ctrl) 4b0000: result src1 src2; 4b0001: result src1 | src2; 4b0010: result sum; 4b0110: result diff; 4b0111: result ($signed(src1) $signed(src2)) ? 32d1 : 32d0; 4b1100: result ~(src1 | src2); default: result 32b0; endcase end assign zero (result 32b0); assign overflow (alu_ctrl 4b0010) ? ((~src1[31] ~src2[31] sum[31]) | (src1[31] src2[31] ~sum[31])) : (alu_ctrl 4b0110) ? ((~src1[31] src2[31] diff[31]) | (src1[31] ~src2[31] ~diff[31])) : 1b0; endmodule注意overflow的加法判断两个正数相加结果符号位变成 1或两个负数相加结果符号位变成 0就是有符号溢出。减法溢出判断单独写被减数为正、减数为负、结果却为负或反过来都是溢出。zero标志是组合逻辑ALU 结果在这个周期算出 0zero立刻拉高后面实现beq分支指令时会用到。result是reg是因为case语句在always块内但它是组合逻辑用(*)触发不带时钟。这里有个比代码本身更值得注意的细节slt在 MIPS 指令集里是有符号比较而sltu是无符号比较。实验如果只做 R 型指令slt用$signed()包裹两个操作数如果以后扩展sltu要把$signed()去掉。很多人在这里翻车因为默认的wire [31:0]是无符号数不包裹$signedslt就会用无符号逻辑比较负数之间的比较结果全反。3.2 寄存器堆端口设计、时钟沿与异步复位寄存器堆是 CPU 的临时变量区。MIPS 有 32 个 32 位寄存器实验要求一般是两读一写两个读端口分别读rs、rt一个写端口写回rd。读是组合逻辑因为同一个时钟周期内 ALU 必须拿到操作数进行计算写是时序逻辑只在时钟边沿写入。这个读写性质的差异是后面各种“差一拍”现象的根源。module regfile( input wire clk, input wire rst_n, input wire reg_write, input wire [4:0] raddr1, input wire [4:0] raddr2, input wire [4:0] waddr, input wire [31:0] wdata, output wire [31:0] rdata1, output wire [31:0] rdata2 ); reg [31:0] regs [0:31]; integer i; always (posedge clk or negedge rst_n) begin if (!rst_n) begin for (i 0; i 32; i i 1) regs[i] 32b0; end else if (reg_write) begin regs[waddr] wdata; end end assign rdata1 (raddr1 5d0) ? 32b0 : regs[raddr1]; assign rdata2 (raddr2 5d0) ? 32b0 : regs[raddr2]; endmodule写端口用非阻塞赋值这是时序逻辑的标准写法保证多个寄存器在同一个时钟沿“同时”更新而不是串行更新。读端口用assign直接输出是组合逻辑只要地址变了输出立刻跟着变不等时钟。寄存器 0 的处理必须单独做MIPS 的$0恒为 0任何写入都要被忽略所以在读端口上做了地址判断写端口不需要特殊处理写$0也不影响读。实现上的一个现实问题这个 32x32 的寄存器堆在 FPGA 里会被综合成什么如果带异步复位综合器只能把它做成 1024 个触发器加译码逻辑占大量逻辑单元如果不带异步复位综合器可能会推断成分布式 RAM 或 BRAM。实验规模小触发器方案没问题但如果后续扩展成更大的寄存器堆就得考虑去掉异步复位换同步复位或者直接用 IP 核不然资源会爆。还有一个很容易忽略的语法点regs是二维数组读写同一个地址waddr时组合读端口在读的是一个还没更新的值因为写结果要到时钟沿后才可见。这个“先读后写”的语义和模拟器里的行为必须一致否则后面 Python 模拟器与 Verilog 仿真结果对不上。4. 存储器设计、MIPS汇编器与模拟器先软后硬跑通指令4.1 存储器设计RAM选型、初始化与CPU连接存储器是 CPU 保存指令和数据的场所。实验里通常拆成两个模块指令存储器只读与数据存储器读写。设计上最简单的选择是单端口 RAM一个地址端口读和写共享一条数据总线。单周期 CPU 里一条 R 型指令的访存路径并不需要同时读两个地址单端口完全够用双端口 RAM 反而会让综合资源翻倍。手写同步 RAM 的推荐写法module data_mem( input wire clk, input wire mem_write, input wire [31:0] addr, input wire [31:0] wdata, output wire [31:0] rdata ); reg [31:0] mem [0:1023]; always (posedge clk) begin if (mem_write) mem[addr[31:2]] wdata; end assign rdata mem[addr[31:2]]; endmoduleaddr[31:2]是这里最关键的参数MIPS 按字节编址但一条指令或一个字访问的是 4 字节对齐的地址所以字地址要右移两位。如果不加这个[31:2]数组下标直接拿字节地址去用轻则访问错位重则越界。写端口是同步的只在时钟沿且mem_write为高时写入读端口是组合的rdata立刻反映当前地址的内容。这个“写同步、读异步”的结构在实验规模下没问题但如果要用 FPGA 的 BRAM 硬核读端口会被迫打一拍变成同步读那时就要改总线协议属于另一个话题了。指令存储器的初始化是下一个大坑。常见做法是$readmemh直接把 hex 文件装进数组reg [31:0] rom [0:1023]; initial $readmemh(instr.hex, rom);Quartus 和 Modelsim 仿真时的工作目录经常不在工程目录下相对路径很容易找不到文件而$readmemh找不到文件不报错只让数组保持初始值或是x。所以要养成习惯初始化后立刻打印rom[0]确认装载成功。也可以用mif文件在 Quartus 里配置 RAM 初始化但换一次指令就要重新编译一次比$readmemh慢。存储器和 CPU 连接时数据存储器只挂数据总线指令存储器挂在取指地址总线上读写控制信号分开避免一条总线上多个驱动源导致三态冲突。4.2 汇编器与模拟器指令编码与单周期验证实验里说的汇编器不是让你写一个 GNU 工具链而是写一个工具把你实验里用到的几条 R 型指令翻译成机器码。Python 最方便。MIPS R 型指令格式是opcode(6位) rs(5位) rt(5位) rd(5位) shamt(5位) funct(6位)。R 型指令 opcode 固定为 0具体功能由 funct 决定。指令funct语义add0x20rd rs rtsub0x22rd rs - rtand0x24rd rs rtor0x25rd rs | rtslt0x2ard (rs rt) ? 1 : 0REG_MAP {f$r{i}: i for i in range(32)} FUNCT {add: 0x20, sub: 0x22, and: 0x24, or: 0x25, slt: 0x2a} def assemble(line): tokens line.replace(,, ).split() inst, regs tokens[0], tokens[1:] rd, rs, rt (REG_MAP[r] for r in regs) word (rs 21) | (rt 16) | (rd 11) | FUNCT[inst] return f{word:08x} asm_program [ add $1 $2 $3, sub $4 $1 $2, and $5 $1 $4, ] with open(instr.hex, w) as f: for line in asm_program: f.write(assemble(line) \n)这里的寄存器顺序是按教材写法add $1, $2, $3表示$1 $2 $3所以解析出的rd1, rs2, rt3。编码时rs左移 21 位、rt左移 16 位、rd左移 11 位、funct 直接放最低 6 位。shamt 在 R 型算术逻辑指令里为 0所以不参与移位。模拟器的作用是在写 Verilog 之前先把指令语义验证掉。对着指令格式逐位拆解模拟寄存器堆和内存def simulator(rom, steps20): regs [0] * 32 pc 0 for _ in range(steps): instr rom[pc // 4] op (instr 26) 0x3f rs (instr 21) 0x1f rt (instr 16) 0x1f rd (instr 11) 0x1f funct instr 0x3f if op 0 and funct 0x20: # add regs[rd] (regs[rs] regs[rt]) 0xffffffff elif op 0 and funct 0x22: # sub regs[rd] (regs[rs] - regs[rt]) 0xffffffff elif op 0 and funct 0x24: # and regs[rd] regs[rs] regs[rt] pc 4 return regsPython 的整数是无限宽的而真实 CPU 的加法器只有 32 位做完加减法后用 0xffffffff做回绕否则正数相加变负数这种行为会和硬件对不上。这个模拟器跑完一组指令后把寄存器堆最终值记下来后续 Verilog 仿真跑到同一组指令时直接对照这就是整个实验的“标准答案”。5. 计组实验排查Quartus警告、仿真相差与上板翻车5.1 现象一仿真时序正确上板就是没输出现象Modelsim 里波形全对下载到板子后 LED 或数码管没反应。原因分三种。第一种是引脚没分配Quartus 默认把所有顶层端口交给自动分配综合器随便挑位置物理上根本不对应开发板的 LED。第二种是时钟没分频开发板通常是 50MHz 晶振一个 LED 的亮灭变化远远超出肉眼分辨率你以为没输出其实是在快速闪烁。第三种是复位极性搞反板载按键常常是按下为低而设计里复位是高有效按键按下的瞬间才有效松开就失效采样点很难碰上。解决先写一个最简的计数器分频模块把 50MHz 分到 1Hz 再驱动一个 LED从上板第一步就把时钟通路验证掉然后逐级替换成自己的模块。引脚分配要去 Pin Planner 里手动指定不要靠自动分配。Quartus 里看Pin Planner的Location列确认每个端口都绑定了开发板原理图上的物理管脚号这个步骤省不了。5.2 现象二寄存器堆读出数据总是差一拍现象写入寄存器后下一个周期读读出来的还是旧值波形看起来慢半拍。原因把读端口写成了时序逻辑。常见的错误写法是always (posedge clk) begin rdata1 regs[raddr1]; end这样读数据也要等到时钟沿相当于给读端口加了一级流水。在单周期数据通路里ALU 等这个结果去计算可 ALU 本身就是组合逻辑等一个时钟周期会导致整个执行时间错乱表现出来就是所有指令的结果都偏慢。更隐蔽的是有些同学把rdata1声明成reg后编译警告里有 latch 提示也不当回事。解决读端口用assign组合输出写端口保持时序逻辑。读端口等的是地址和寄存器数组本身不是时钟。这样 MIPS 的“同一周期读到旧值时钟沿写入新值”语义才是对的。检查方法也简单把模块里所有的always块列出来只要包含posedge clk的块里出现了rdata赋值就是问题所在。5.3 现象三指令存储器初始化全零程序跑不起来现象仿真里指令存储器读出来全是 0decode 不停地执行空指令寄存器堆永远不变。原因$readmemh文件路径找不到或者 hex 文件的字节序与$readmemh的填充顺序不一致。$readmemh按行读入每一行是一个 32 位字的原始值如果你在 Python 里按大端输出而它期望的是字节流指令就会被拆得面目全非。另外$readmemh找不到文件时不报错只警告数组以x或 0 初始化这个失败特别隐蔽。解决在测试平台里强制打印。初始化后立即执行$display(%h, rom[0]);与 Python 生成的 hex 文件第一行对比看是不是同一个数。如果字节序反了改 Python 输出时把每 4 个字节逆序或者直接让 Python 按$readmemh期望的顺序输出一劳永逸。还有个习惯值得养成$readmemh的路径从仿真工程根目录开始写不要用相对上一级目录的写法仿真工具对相对路径的解析结果各不相同。5.4 现象四组合逻辑被综合器优化掉仿真有波形上板没有现象仿真时某个内部信号会翻转全编译后 Quartus 报告“优化掉了多少个逻辑单元”上板后功能缺失。原因综合器发现某段组合逻辑的输出没有被任何寄存器采样、也没有连接任何顶层输出认为它是死逻辑直接优化。实验里经常发生在中间连接信号上——你明明写了wire carry ...但它除了跑到另一个 wire 的输入之外什么都没接综合器就把整条链剪掉。解决把中间信号引到调试 LED或者用综合属性保住信号(* keep true *) wire carry;。更彻底的做法是设计时先想清楚每个信号的消费方——如果某个 wire 在数据通路上没有下游要么它本来就是冗余的要么你画错了连接。上板前检查 Quaruts 的Compilation Report → Analysis Synthesis → Warning看到类似carry is inferred as a wire but has no driver的提示基本就是这条。5.5 现象五异步复位抖动导致偶发跑飞现象上板后有时正常有时乱跳复位键按下去的那一刻特别容易出错。原因机械按键的抖动会让复位信号在几十毫秒内多次跳变。如果设计用的是异步复位negedge rst_n抖动的每一个下降沿都会触发一次复位模块反复复位释放内部状态不确定。解决上板调试时用“异步复位、同步释放”的经典结构把外部复位信号先打两拍再进设计内部reg [1:0] rst_meta; always (posedge clk) begin rst_meta[0] rst_n; rst_meta[1] rst_meta[0]; end wire rst_sync_n rst_meta[1];rst_sync_n既有异步复位的低有效语义又不会因为抖动反复触发。这个结构本质上是把异步信号用两级触发器同步化是 FPGA 设计的标准做法。加入之后大部分复位相关怪问题都能消失。6. 取指令与指令译码实现R型指令并完成上板验证6.1 取指令单元PC更新、指令存储器与单周期时序取指令单元是把前面所有零件组装成机器的关键一步。PC 寄存器在每个时钟上边沿按pc pc 4前进MIPS 按字节编址一条指令占 4 字节。PC 的低 2 位不参与寻址指令存储器的地址取pc[31:2]。上板验证时如果想让执行过程肉眼可见记得把系统时钟分频到低频或者用按键手动产生单步时钟。6.2 R型指令译码与控制信号数据通路整合与验证R 型指令的关键控制信号有三个RegWrite是否写寄存器、ALUSrcALU 第二操作数选rt还是立即数R 型必选rt、ALUOp送到 ALU 功能译码的编码。因为 R 型 opcode 固定为 0译码逻辑非常简单assign reg_write (opcode 6b000000) ? 1b1 : 1b0; assign alu_src (opcode 6b000000) ? 1b0 : 1b1;opcode从指令的高 6 位取R 型为 0 时RegWrite1、ALUSrc0。ALUOp传给 ALU 后由funct进一步映射到具体的alu_ctrl编码例如funct0x20 (add)映射到4b0010。整条路径是取指 → 译码 → 读寄存器 → ALU 运算 → 写回寄存器堆全部在一个时钟周期内完成。验证方法我建议走一遍软硬对照先用 Python 模拟器生成一组 R 型指令并记录寄存器堆终值再把这些指令用汇编器转成 hex 加载进指令 ROM跑 Verilog 仿真几十个周期对比两个寄存器堆的值。我自己带实验这些年养成的习惯是任何指令都必须先在软件层拿到“标准答案”再动硬件这样可以省掉大半的调试时间。这一步跑通之后你的单周期数据通路就真正闭环了。希望帮到你。本文还有配套的精品资源点击获取