☰
从全加器到MIPS单周期CPU:Verilog数据通路设计与上板验证
2026/10/31 10:13:09 网站建设 项目流程

简介:面向计算机科学与技术专业本科生,杭州电子科技大学计算机组成原理课程的实验项目完整资料包,覆盖从门级算术单元到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 ^ cin,cout = (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的中间异或结果,综合后门数比直接写(a&b)|(a&cin)|(b&cin)更省。门级延迟也能算得比较清楚:s经过两级门,cout也经过两级门。Quartus 里编译后可以把全加器打包成 symbol,之后在顶层设计里直接以一个模块的方式复用,这一步是为后面拼接超前进位加法器做准备的。

验证建议写一个覆盖全部八种输入的 testbench:

`timescale 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 & src2and
0001src1 | src2or
0010src1 + src2add
0110src1 - src2sub
0111$signed(src1) < $signed(src2)slt
1100~(src1 | src2)nor
module 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) 4'b0000: result = src1 & src2; 4'b0001: result = src1 | src2; 4'b0010: result = sum; 4'b0110: result = diff; 4'b0111: result = ($signed(src1) < $signed(src2)) ? 32'd1 : 32'd0; 4'b1100: result = ~(src1 | src2); default: result = 32'b0; endcase end assign zero = (result == 32'b0); assign overflow = (alu_ctrl == 4'b0010) ? ((~src1[31] & ~src2[31] & sum[31]) | (src1[31] & src2[31] & ~sum[31])) : (alu_ctrl == 4'b0110) ? ((~src1[31] & src2[31] & diff[31]) | (src1[31] & ~src2[31] & ~diff[31])) : 1'b0; endmodule

注意overflow的加法判断:两个正数相加结果符号位变成 1,或两个负数相加结果符号位变成 0,就是有符号溢出。减法溢出判断单独写:被减数为正、减数为负、结果却为负,或反过来,都是溢出。zero标志是组合逻辑,ALU 结果在这个周期算出 0,zero立刻拉高,后面实现beq分支指令时会用到。result是reg是因为case语句在always块内,但它是组合逻辑,用@(*)触发,不带时钟。

这里有个比代码本身更值得注意的细节:slt在 MIPS 指令集里是有符号比较,而sltu是无符号比较。实验如果只做 R 型指令,slt用$signed()包裹两个操作数;如果以后扩展sltu,要把$signed()去掉。很多人在这里翻车,因为默认的wire [31:0]是无符号数,不包裹$signed,slt就会用无符号逻辑比较,负数之间的比较结果全反。

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] <= 32'b0; end else if (reg_write) begin regs[waddr] <= wdata; end end assign rdata1 = (raddr1 == 5'd0) ? 32'b0 : regs[raddr1]; assign rdata2 = (raddr2 == 5'd0) ? 32'b0 : 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]]; endmodule

addr[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 + rt
sub0x22rd = rs - rt
and0x24rd = rs & rt
or0x25rd = rs | rt
slt0x2ard = (rs < rt) ? 1 : 0
REG_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,所以解析出的rd=1, rs=2, rt=3。编码时rs左移 21 位、rt左移 16 位、rd左移 11 位、funct 直接放最低 6 位。shamt 在 R 型算术逻辑指令里为 0,所以不参与移位。

模拟器的作用是在写 Verilog 之前先把指令语义验证掉。对着指令格式逐位拆解,模拟寄存器堆和内存:

def simulator(rom, steps=20): 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 regs

Python 的整数是无限宽的,而真实 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 现象三:指令存储器初始化全零,程序跑不起来

现象:仿真里指令存储器读出来全是 0,decode 不停地执行空指令,寄存器堆永远不变。

原因:$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(是否写寄存器)、ALUSrc(ALU 第二操作数选rt还是立即数,R 型必选rt)、ALUOp(送到 ALU 功能译码的编码)。因为 R 型 opcode 固定为 0,译码逻辑非常简单:

assign reg_write = (opcode == 6'b000000) ? 1'b1 : 1'b0; assign alu_src = (opcode == 6'b000000) ? 1'b0 : 1'b1;

opcode从指令的高 6 位取,R 型为 0 时RegWrite=1、ALUSrc=0。ALUOp传给 ALU 后,由funct进一步映射到具体的alu_ctrl编码,例如funct=0x20 (add)映射到4'b0010。整条路径是:取指 → 译码 → 读寄存器 → ALU 运算 → 写回寄存器堆,全部在一个时钟周期内完成。验证方法我建议走一遍软硬对照:先用 Python 模拟器生成一组 R 型指令并记录寄存器堆终值,再把这些指令用汇编器转成 hex 加载进指令 ROM,跑 Verilog 仿真几十个周期,对比两个寄存器堆的值。我自己带实验这些年养成的习惯是,任何指令都必须先在软件层拿到“标准答案”再动硬件,这样可以省掉大半的调试时间。这一步跑通之后,你的单周期数据通路就真正闭环了。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询