ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从全加器到MIPS模拟器:构建CPU数据通路的实验指南

从全加器到MIPS模拟器:构建CPU数据通路的实验指南

简介:杭州电子科技大学计算机组成原理课程实验合集,覆盖全加器、超前进位加法器、多功能算术逻辑单元、寄存器堆、存储器、MIPS汇编器与模拟器、取指令与译码、R型指令实现等核心实验,适合计算机专业本科生、考研复习者及希望从底层理解处理器设计的学习者,可直接用于课程作业、实验报告或备赛练习。压缩包共1519个文件,大小约34.06MB,以硬件描述语言源码、FPGA工程文件、脚本文件、存储器初始化文件、PDF说明和Word笔记为主要类型,按实验模块分目录组织,便于按需查找与对比学习。目前已有76人学习下载。材料中既包含从全加器到超前进位加法器的门级实现,也涵盖算术逻辑单元、寄存器堆和存储器的完整设计,还提供了MIPS汇编器模拟器及指令译码相关源码,可对照文档梳理数据通路与控制信号,利用仿真脚本快速验证各模块功能,是一套能帮助读者将书本理论落到工程实践的综合性实验资源。

1. 从全加器到MIPS模拟器:杭电计组实验项目在帮你打通哪条链路

如果你是正在上计算机组成原理课程的本科生,或者在软件行业干了一两年想回来补硬件课,这套实验项目的价值不是“交完八个Lab拿学分”,而是让你在几周内亲手搭出一条完整的数据通路:从全加器到超前进位加法器,再到多功能ALU、寄存器堆、存储器,最后用自己写的MIPS汇编器和模拟器把一段汇编代码翻译成二进制,再取指、译码、执行R型指令。整个过程相当于把唐朔飞教材里的框图一张张变成能跑的东西。适合谁?两类人:一类是实验课被卡在仿真波形里出不来的学生,另一类是面试被问到“CPU怎么执行一条add指令”时只能背答案、没真正看过信号怎么跳的从业者。本文按实验顺序拆解每个环节的设计思路、参数选择和调试方法,最后一章给出我自己的联调验证手法。

2. 组合逻辑三连关:全加器、超前进位加法器与多功能ALU

2.1 全加器:先弄懂“1+1+1”为什么是三位输入

全加器是整个实验套件的起点,也是很多人第一个熬夜点。最常见的翻车不是逻辑表达式写错,而是没想明白第三个输入cin是从哪来的。两个二进制位相加,正常只需要两个输入端,但低位产生的进位要进到高位,所以每一个二进制位实际上是在做三个1位的加法:a、b、cin。表达式就两行:

assign sum = a ^ b ^ cin; assign cout = (a & b) | (a & cin) | (b & cin);

逻辑说明:sum是三个输入的异或,因为奇数个1结果为1;cout在任意两个输入为1时输出1。参数上不需要额外设置,纯组合逻辑,但建议在模块端口定义里把位宽写成parameter化形式,后面扩展成多位加法器时不用改端口。

之所以强调这一步,是因为后续的超前进位加法器会直接复用这里的产生位g和传播位p。很多同学在第二个实验里重新写了一遍全加器,实际上应该把全加器拆成“半加和”与“进位产生逻辑”两个部分,否则超前进位公式里的g、p信号根本提不出来。

在Quartus里用原理图方式做过一次全加器之后,建议立刻用Verilog再写一遍,两种描述方式对照着看。原理图方式能帮你建立门级直觉,Verilog方式能帮你理解行为级描述。杭电实验通常允许二选一,但我见过太多只会拖原理图、后面ALU实验里被一个三态门卡住的同学。

从全加器开始就要养成一个习惯:每个模块单独建一个工程文件,用ModelSim或Quartus自带的仿真器把真值表全跑一遍再往上层走。全加器只有8种输入组合,5分钟就能验证完,这里省下来的时间会在后面ALU调试时加倍还给你。

2.2 超前进位加法器:用组间串行进位压缩关键路径

第二个实验是超前进位加法器。教材里讲得很清楚:串行加法器每个位的进位都要等低一位算完,16位加法器在最坏情况下的延迟是16个全加器的延迟叠加。超前进位的思路是把进位链变成并行计算的逻辑,核心就是两组信号:产生位g = a & b,传播位p = a ^ b。有了g和p,第i位的进位可以写成:

wire [3:0] g = a & b; wire [3:0] p = a ^ b; wire [3:0] c; assign c[0] = cin; assign c[1] = g[0] | (p[0] & cin); assign c[2] = g[1] | (p[1] & g[0]) | (p[1] & p[0] & cin); assign c[3] = g[2] | (p[2] & g[1]) | (p[2] & p[1] & g[0]) | (p[2] & p[1] & p[0] & cin);

参数说明:这里以4位一组为例,c[0]直接吃cin,后面的每一位进位都用门级逻辑并行算出来,不等低位。实验要求做的通常是16位超前进位加法器,常见做法是4位一组做超前进位,组间用串行进位连接,也就是热词里说的“组间串行进位”。这样折中了延迟和布线复杂度。

坑在于:不要试图写一个16位的超前进位全展开,那个逻辑表达式会膨胀到没法看,综合器也会很吃力。我一般会先写一个4位超前进位加法器模块,然后例化4份,把低组的cout连到高组的cin。这个结构既能在仿真里看清进位波形,又能应付实验报告里“分析关键路径”的要求。

验证超前进位加法器时,别只测几组随机数。要专门构造进位链最长的情况:0xFFFF + 0x0001,还有0xFFFF + 0xFFFF,这两种输入会让每一级的进位都实际发生。用波形对比串行加法器和超前进位加法器的cout时序,能直观看到延迟差异。

2.3 多功能ALU:如何在组合逻辑里同时塞进加减与逻辑运算

第三个实验是多功能ALU,也是组合逻辑部分的收口。常见的功能表是:加、减、与、或、异或、左移、右移、比较。到这里,前面做的超前进位加法器变成了ALU里的一个子模块。

设计上要注意一个原则:ALU是纯组合逻辑,不存状态。所以所有运算必须在一个always块或连续赋值语句里算出结果,最后用多路选择器根据ALUop信号选输出。减法不需要重新写一个减法器,用补码:a - b = a + (~b) + 1,把超前进位加法器的cin置1,b取反,就完成了减法。

wire [15:0] add_result = a + b + {15'b0, cin}; wire [15:0] sub_result = a + (~b) + 1; wire [15:0] and_result = a & b; wire [15:0] or_result = a | b; wire [15:0] xor_result = a ^ b; wire [15:0] slt_result = {15'b0, sub_result[15]}; // 用减法符号位判断大小 always @(*) begin case (alu_op) 3'b000: alu_out = add_result; 3'b001: alu_out = sub_result; 3'b010: alu_out = and_result; 3'b011: alu_out = or_result; 3'b100: alu_out = xor_result; 3'b101: alu_out = slt_result; default: alu_out = 16'h0; endcase end

参数说明:slt(set less than)的写法利用减法结果的符号位来判断a是否小于b,前提是a、b都是补码表示的有符号数。减法是复用加法器资源,这也是ALU电路面积能控制住的根本原因。

ALU的坑主要在case语句的default分支。如果漏了default,综合器会生成一个锁存器,时序仿真里看不出问题,但上板之后出现莫名其妙的输出。这一点经验值得记下来:组合逻辑里的case一定要写全default。另外移位操作在Verilog里直接用<<和>>,但逻辑右移和算术右移要区分,>的符号决定高位补0还是补符号位。

多功能ALU验证时用穷举法不现实,16位输入有2的32次方组合。常见做法是选边界值:0x0000、0xFFFF、0x8000、0x7FFF,再配合随机数。减法和slt在验证时要用有符号的十六进制形式看波形,无符号显示会导致slt结果看起来“不对”,其实只是进制显示的问题。

3. 时序电路的两块地基:寄存器堆与存储器设计

3.1 寄存器堆:读写端口的数量为什么会决定你的数据通路走向

寄存器堆实验是第一个真正的时序电路设计。MIPS架构里寄存器堆有两个读端口和一个写端口,因为R型指令需要同时读两个源寄存器,而写回只有一个目标寄存器。这个端口数量不是随便定的,它直接决定了你的数据通路能不能在单周期里完成一条指令。

module regfile ( input clk, input rst_n, input [4:0] raddr1, input [4:0] raddr2, input [4:0] waddr, input [31:0] wdata, input we, output [31:0] rdata1, output [31:0] rdata2 ); reg [31:0] mem [31:0]; // 读操作是组合逻辑,不需要时钟 assign rdata1 = (raddr1 == 5'b0) ? 32'h0 : mem[raddr1]; assign rdata2 = (raddr2 == 5'b0) ? 32'h0 : mem[raddr2]; // 写操作是时序逻辑,只在时钟上升沿写入 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin integer i; for (i = 0; i < 32; i = i + 1) mem[i] = 32'h0; end else if (we && waddr != 5'b0) begin mem[waddr] <= wdata; end end endmodule

参数说明:读端口用组合逻辑是为了让数据在同一个时钟周期内到达ALU输入,不需要额外等待一拍。写端口用同步写,避免异步写带来的毛刺进入内部寄存器。x0寄存器强制读0,这是MIPS架构的约定——x0永远为0,即使写了也丢弃。

这里有几个必踩的坑。第一个是读x0时没有做强制置0处理,导致初始化不干净的仿真环境里x0读出随机值。第二个是fallthrough问题:同一条指令里先读后写同一个寄存器,由于写是上升沿触发,读是组合逻辑,如果在一个时钟周期里又写又读同一个地址,读到的可能是旧值。MIPS的单周期设计不允许这样,但多周期或流水线设计里这就变成了需要forwarding解决的问题。

寄存器堆的实验报告里,老师通常还要求写明“为什么读端口不需要读使能信号”。答案是寄存器堆读取不影响内部状态,任何时候给出地址返回数据即可,读动作本身没有副作用。

3.2 存储器:单端口RAM和多端口RAM的设计取舍

存储器实验一般会扩展到RAM或者cache。这里以单端口RAM为例,因为大多数实验用的FPGA内部的RAM IP核都是单口或简单双口。

module ram_single ( input clk, input we, input [9:0] addr, input [31:0] din, output reg [31:0] dout ); reg [31:0] mem [0:1023]; always @(posedge clk) begin if (we) mem[addr] <= din; dout <= mem[addr]; // 读也是时序的,输出延迟一拍 end endmodule

参数说明:这个RAM的读操作是同步的,dout在addr变化后的下一个时钟上升沿才会更新。如果实验里要求的是异步读(地址一变化立刻出数据),就改成dout连续赋值。

存储器设计里最常见的争论是:指令存储器和数据存储器要不要分开。MIPS架构在教材里默认用哈佛结构,指令和数据分开。但实验板上的FPGA资源有限,所以很多设计会把指令和数据合在一起用单端口RAM。这么做的后果是:load指令和取指令不能同时进行,单周期CPU会崩。解决办法是让取指令在时钟上升沿,load/store也在时钟上升沿,但把两个操作分到不同阶段(多周期)或者干脆分开存储。

我建议做实验时一开始就按分离的指令存储器+数据存储器设计,虽然多占一点block RAM,但后面联调时省下来的排错时间远远值回来。这条经验是实验课里用血泪换来的——合并RAM之后,每条指令的执行都要小心翼翼的排队访问,非常痛苦。

3.3 时钟与复位:边沿触发设计的三个常识

寄存器堆和存储器做完之后,你已经接触到了时序设计的全部关键概念。这里把容易犯的常识性错误集中说一遍,都是实验报告里老师爱问的点。

第一,同步复位和异步复位的区别。上面代码里用的是异步复位,复位信号一有效立刻复位所有寄存器。同步复位则要求复位信号在时钟上升沿才生效。FPGA对异步复位有专门的全局复位网络,但推荐做法还是同步复位,避免复位释放时的亚稳态。如果实验环境里复位信号抖动,异步复位会导致寄存器复位不一致。

第二,时钟域问题。实验里一般只有一个主时钟,但要注意ROM、RAM和寄存器堆的时钟相位。如果你的存储器和CPU逻辑共用一个时钟,所有时序逻辑都对齐到同一个上升沿,这是最简单的设计。不要图省事在内部用下降沿采样,混合上下边沿会让综合工具无法优化时钟树,而且时序收敛困难。

第三,不要用always @(a or b)这种旧式敏感列表写组合逻辑。Verilog-2001虽然没有hard error,但仿真器对敏感列表不全的情况会严格区分。老老实实写always @(*),让工具自动推断敏感列表。

4. 软件侧补位:MIPS汇编器与模拟器的实现路线

4.1 汇编器:把助记符翻译成机器码的指令表设计

MIPS汇编器实验是整个套件里第一个软件任务。要做的事情很明确:输入MIPS汇编文本,输出二进制指令,格式为coe文件或者hex文件。这个实验的价值不在编译原理,而在于让你理解指令编码格式。

R型指令的编码格式是:opcode占6位,rs占5位,rt占5位,rd占5位,shamt占5位,funct占6位。比如add指令,opcode=0x00,funct=0x20。汇编器要维护一张指令表,把助记符映射到编码。

import struct R_TYPE_INSTRUCTIONS = { 'add': {'funct': 0x20, 'syscall': False}, 'sub': {'funct': 0x22, 'syscall': False}, 'and': {'funct': 0x24, 'syscall': False}, 'or': {'funct': 0x25, 'syscall': False}, 'sll': {'funct': 0x00, 'syscall': False}, } def encode_r_type(rs, rt, rd, shamt, funct): return (0x00 << 26) | (rs << 21) | (rt << 16) | (rd << 11) | (shamt << 6) | funct def assemble(lines): machine_codes = [] for line in lines: parts = line.strip().replace(',', ' ').split() mnemonic = parts[0] if mnemonic in R_TYPE_INSTRUCTIONS: rd = register_number(parts[1]) rs = register_number(parts[2]) rt = register_number(parts[3]) code = encode_r_type(rs, rt, rd, 0, R_TYPE_INSTRUCTIONS[mnemonic]['funct']) machine_codes.append(code) return machine_codes

参数说明:寄存器编号用通用函数解析,$t0映射到8,$s0映射到16,这个表最好单独做成数据文件,否则后期扩展I型指令时改动太大。shamt字段只在移位指令里用到,非移位指令置0。

汇编器最容易翻车的是寄存器编号表。MIPS寄存器命名有两套,数字编号和助记符名称,$zero=0、$at=1、$v0=2、$a0=4、$t0=8、$s0=16,很多人的映射表把$t0和$s0的编号搞混。这就是为什么汇编器要单独做一个函数处理寄存器名解析,不要到处硬编码。

指令表设计时建议用Python dict加JSON描述文件的方式,而不是把每个指令都写成一个if分支。因为后面还需要支持lw、sw、beq这些I型指令,指令表本质上是数据,不是代码。

4.2 模拟器:用软件把数据通路预演一遍

模拟器的任务是在PC端模拟CPU执行MIPS指令的过程。这个实验的价值在于让你把数据通路上的每一步信号变化在软件里重现,验证汇编器生成的机器码正确性。

class MIPSSimulator: def __init__(self, memory_size=65536): self.regs = [0] * 32 self.pc = 0x0000_0000 self.memory = bytearray(memory_size) self.instructions = [] def fetch(self): instr = int.from_bytes(self.memory[self.pc:self.pc+4], 'big') self.pc += 4 return instr def execute(self, instr): opcode = (instr >> 26) & 0x3F rs = (instr >> 21) & 0x1F rt = (instr >> 16) & 0x1F rd = (instr >> 11) & 0x1F shamt = (instr >> 6) & 0x1F funct = instr & 0x3F if opcode == 0x00 and funct == 0x20: # add self.regs[rd] = self.regs[rs] + self.regs[rt] print(f"add ${rd}, ${rs}, ${rt} -> {self.regs[rd]}")

参数说明:模拟器里regs列表的下标直接对应寄存器编号,pc按字节递增,每次取4字节。大端序读取,因为MIPS是big-endian。模拟器里最容易搞错的是pc更新时机——取指之后立即自增,执行阶段用的已经是指令本身了。

模拟器还应该实现一个单步调试功能:每执行一条指令打印当前PC、寄存器变化值和目标寄存器位置。这个功能在后续取指令与译码实验联调时几乎是必需的。你可以把模拟器的输出作为golden model,和FPGA仿真波形逐条对比。

4.3 汇编器与模拟器的联调策略

两个软件组件加起来并不难,难的是对齐它们和硬件的行为。

联调时我习惯按这个顺序来:先用汇编器生成一段机器的十六进制文本,然后在模拟器里跑一遍拿到寄存器最终值列表,最后把这批十六进制文件加载到FPGA的指令ROM里,用ModelSim跑同一个测试程序,对拍两者的寄存器堆变化。

对拍过程中遇到不一致,先检查汇编器的编码,再检查模拟器的指令语义,最后才是硬件问题。因为软件改起来快,硬件查起来慢。这个排查顺序可以救你一整晚的睡眠。

5. 取指、译码到执行R型指令:联调与常见问题排查

5.1 取指阶段:PC的更新时机决定你看到的是哪条指令

取指令与指令译码实验把你前面所有的模块串起来。第一个容易出问题的就是PC。

module pc ( input wire clk, input wire rst_n, input wire [31:0] next_pc, output reg [31:0] current_pc ); always @(posedge clk or negedge rst_n) begin if (!rst_n) current_pc <= 32'h0000_0000; else current_pc <= next_pc; end endmodule

参数说明:PC在时钟上升沿更新,next_pc通常由组合逻辑算出。单周期设计里next_pc = current_pc + 4,因为一条指令4字节。注意这里用的是非阻塞赋值,由时序逻辑实现。

常见错误是把PC更新写成了always @(*)的组合逻辑,导致PC在仿真里每个delta cycle都自增,波形乱成一团。PC必须是时序逻辑,因为它本身是状态。

还有一个细节:指令存储器ROM的读地址是按字节还是按字。MIPS按字节编址,PC每次加4。如果你的ROM地址线直接连PC[9:0],而PC是32位的,你需要把PC的低两位截掉,用PC[9:2]作为ROM地址。很多同学在这一步卡了很久,指令永远取不对。

5.2 译码阶段:控制信号的默认值是一张“后悔药”清单

译码阶段的任务是根据指令的opcode和funct生成控制信号。R型指令的控制信号是:RegDst=1(rd作为目标寄存器)、ALUSrc=0(ALU的第二个输入来自寄存器,不是立即数)、RegWrite=1(写寄存器堆)、ALUop=具体运算类型。

always @(*) begin // 默认值是全0,相当于NOP reg_write = 1'b0; alu_src = 1'b0; reg_dst = 1'b0; mem_read = 1'b0; mem_write = 1'b0; mem_to_reg = 1'b0; case (opcode) 6'b000000: begin // R-type reg_write = 1'b1; reg_dst = 1'b1; // alu_op由funct字段决定 end // 其他opcode的I型指令写在这里 default: begin // 保留默认值,相当于执行NOP end endcase end

参数说明:每个控制信号先赋默认值,再根据opcode改写。这个写法可以防止case条件覆盖不全时生成锁存器,比单独写每个信号的赋值要安全。default分支保留所有信号为0,等价于一条NOP指令。

译码阶段最迷惑的行为是:R型指令的RegDst信号为1,意味着目标寄存器写rd字段;I型指令写rt字段。如果把这两个混了,你会看到add指令的结果写到了一个完全不对的寄存器里,而且仿真波形里查半天查不出来,因为寄存器堆模块本身是好的。

5.3 常见问题排查:三组容易翻车的信号竞争场景

这一节是全文最实用的部分,也是我自己实验课里踩过最多坑的地方。

现象一:波形里PC和指令之间始终差一条

原因:指令ROM的读是异步的,地址一变化输出跟着变,但非阻塞赋值导致PC更新发生在时钟上升沿,而ROM输出是组合逻辑。仿真器里看到的是PC在上升沿更新后的下一个仿真时间点,ROM输出才会变化。如果波形采样间隔设得太大,看起来就像指令总是慢一拍。

解决:把PC信号和instruction信号放在同一个波形窗口里,采样精度设为门级延迟级别;或者让ROM也变成同步读,即地址在时钟上升沿锁存,这样PC和指令严格在同一个沿更新。

现象二:R型指令执行一次后,寄存器堆里出现两个值

原因:RegWrite信号的脉冲宽度不对。如果你把RegWrite写成组合逻辑输出,而它在时钟上升沿的setup时间内没有稳定,寄存器堆写入端可能采到不稳定值。解决:控制信号必须在数据通路稳定之后再有效,不要在时钟沿附近切换。

现象三:减法指令结果正确,加法指令结果为0

原因:ALU的加法器被请复用成减法的模块时,减法把ALUop信号编码搞冲突了。查一下你的ALUop编码,确认加法和减法不是同一个编码。这种情况常发生在多功能ALU实验里用了“1=加、2=减”,到数据通路联调时忘了把ALUop的位宽从2位扩到3位。

我这套避坑清单是根据实验室里最常出现的三种症状总结的,前两个是时序问题,第三个是编码一致性问题。遇到类似现象,一定先把控制信号的波形拉出来看清,不要去改全加器的逻辑——那是最后一层才会怀疑的地方。

6. 打穿整条链路的验证技巧:自测汇编程序的选择

最后一个实验阶段,所有模块都放在了一起,这时候最有用的是准备一个覆盖面足够的自测汇编程序。不要只写一串add指令,那只验证了数据通路的一段。我建议自测程序包含:读写x0、add和sub之间切换、连续5条R型指令的依赖链、slt和分支指令各一条。依赖链测试尤其重要,连续两条指令第三条依赖前两条的结果,能验证数据通路是否能在单周期内正确完成读后写。

add $t0, $zero, $zero # t0 = 0 addi $t1, $zero, 5 # t1 = 5 add $t2, $t0, $t1 # t2 = 5 sub $t3, $t2, $t0 # t3 = 5 slt $t4, $t1, $t2 # t4 = 0,因为5不小于5 sll $t5, $t1, 2 # t5 = 20

用这段程序跑完后,检查寄存器堆里t0到t5的值是否符合预期。如果全对,你的数据通路、译码、ALU、寄存器堆在R型指令范围内已经全部打通。然后把这组值保存下来,以后改任何模块时都重新跑一遍作为回归测试。整个实验的最终收尾,是看着自己写的汇编程序在自己设计的数据通路上执行到正确结果,那种感觉和调通一个软件工程完全不同——从全加器的门级逻辑到最后一条指令写回寄存器堆,是你亲手把它们焊在一起的。希望这套拆解能帮你少熬几个夜,希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进