基于FPGA的MIPS CPU设计:从Verilog单周期处理器到板级调试
2026/9/12 11:09:48 网站建设 项目流程

简介:基于FPGA的MIPS架构CPU设计项目源码包,面向计算机、软件工程、人工智能、通信工程、自动化、电子信息等专业在校学生与课程设计场景,提供一套经过导师指导认可、答辩评审分达95分的高分课程设计项目。压缩包整体约38.72MB,包含完整工程源码与配套资料,已在macOS、Windows 10/11、Linux多平台测试运行成功,功能稳定可直接使用。项目核心覆盖MIPS处理器架构设计,涉及指令流水线、ALU运算、寄存器堆、存储器接口等模块,既可作为计算机组成原理、FPGA开发等课程的课设作业,也适合初学者对照学习CPU内部工作机制,对有一定数字电路基础的学生,可通过源码理解流水线控制信号生成与数据通路连接。已有55人学习/下载,资源目录组织清晰,可直接复用于项目初期演示,也可在此基础上二次开发实现扩展功能,是兼具教学价值与工程参考意义的FPGA设计资源。

1. MIPS CPU 课设不是“写代码”,而是把计算机组成原理做回真实硬件

计算机组成原理课上反复背过的五大部件,在 FPGA 上落地时才会暴露真实脾气:存储器初始化、时钟抖动、复位毛刺、阻塞赋值与非阻塞赋值,随便一样都足以让课程设计卡一周。基于 FPGA 的 MIPS 架构 CPU 设计之所以值得做,是因为 MIPS 指令格式规则、单周期结构清楚,源码量远小于 ARM,又比纯 Logisim 仿真更接近真实芯片。这套资源里包含完整 Verilog 源码、测试平台、板级约束和设计文档,覆盖从仿真到板级验证的完整闭环,特别适合计算机组成原理、嵌入式系统、FPGA 开发的课程设计选型。

2. 微架构选型:单周期数据通路与 MIPS 指令子集裁剪

2.1 指令子集先削一刀:保留能形成回路的指令

拿到 MIPS 资料包别急着改代码,先看源码里实现了哪些指令。一个课程设计级别的 CPU 不需要完整 MIPS 指令集。完整指令集里有乘除法、特权模式、Cache 操作,一旦混进单周期设计,控制信号表会变成一团乱麻。资源源码能通过仿真测试,通常只保留一组核心指令组合:

指令格式opcodefunct说明
add / adduR 型000000100000 / 100001寄存器相加
sub / subuR 型000000100010 / 100011寄存器相减
and / orR 型000000100100 / 100101逻辑与或
sltR 型000000101010比较大小
addiI 型001000加立即数
lwI 型100011从内存读字
swI 型101011写内存字
beqI 型000100相等则跳转
jJ 型000010无条件跳转

这个子集的好处是:R 型覆盖 ALU,I 型覆盖访存和分支,J 型覆盖长跳转。测试程序可以用luiori拼出任意 32 位立即数,但真正打进 CPU 的机器码只有上面九条。MIPS 的立即数操作数只有 16 位,要初始化 32 位寄存器必然要两条指令组合,这是课程设计里很容易被忽略的细节。

为什么保留这么少?因为单周期 CPU 要求每条指令在同一个时钟周期内完成取指、读寄存器、ALU 计算、写结果。功能越多,组合逻辑关键路径越长,时序越难收敛。对 FPGA 课程设计来说,能把九条指令跑到 50MHz 不出现时序违规,已经说明数据通路和控制器是自洽的。后续想扩指令,只需要改控制信号真值表和 ALU 控制模块,结构不用推倒重来。

2.2 控制信号表:CPU 设计的第一张助记图

单周期 MIPS 的控制器本质是一张真值表,输入是 opcode,输出是各个多路选择器和写使能。课程设计里最容易改错的就是这里。看源码时把控制信号表理出来,比直接读代码快很多:

指令RegDstALUSrcMemtoRegRegWriteMemWriteBranchJumpALUOp
R 型100100010
lw011100000
swx1x010000
beqx0x001001
jxxx0001xx

ALUSrc 控制 ALU 的第二个输入选寄存器堆出来的 RD2 还是符号扩展后的立即数;MemtoReg 控制写回寄存器的是 ALU 结果还是内存读数据;Branch 和 ALU 的 zero 信号做与运算后得到 PCSrc。注意 j 指令不关心 ALUOp,因为跳转地址由{PC[31:28], instr[25:0], 2'b00}拼接得到,不经过 ALU,也完全不需要读取寄存器值。

在写控制器代码时,我建议先把 opcode 常量定义成宏:

localparam OP_R_TYPE = 6'b000000; localparam OP_LW = 6'b100011; localparam OP_SW = 6'b101011; localparam OP_BEQ = 6'b000100; localparam OP_J = 6'b000010; localparam OP_ADDI = 6'b001000;

然后在always @(*)块里用 case 给所有控制信号赋默认值,再逐一覆盖。默认值要生成在 0 状态而不是 x,否则仿真时某个指令没覆盖到的信号会变成 x,接着把 PC 带坏,综合时又会多出一批无谓的触发器。这是源码里最常见的资源浪费来源。

2.3 寄存器堆和 ALU:先从两个小模块开始验证

资源包里真正决定仿真能不能一次跑通的,不是 CPU 顶层,而是寄存器堆和 ALU 两个底层模块。寄存器堆既要满足“写寄存器 0 永远无效”的 MIPS 约定,又不能在刚加复位时输出 x。

module regfile #( parameter AW = 5, parameter DW = 32 )( input wire clk, input wire rst_n, input wire we3, input wire [AW-1:0] ra1, input wire [AW-1:0] ra2, input wire [AW-1:0] wa3, input wire [DW-1:0] wd3, output reg [DW-1:0] rd1, output reg [DW-1:0] rd2 ); reg [DW-1:0] mem [0:(1<<AW)-1]; integer i; always @(*) begin rd1 = (ra1 == 0) ? 32'd0 : mem[ra1]; rd2 = (ra2 == 0) ? 32'd0 : mem[ra2]; end always @(posedge clk or negedge rst_n) begin if (!rst_n) begin for (i = 0; i < (1<<AW); i = i + 1) mem[i] <= 32'd0; end else if (we3 && (wa3 != 0)) begin mem[wa3] <= wd3; end end endmodule

这段代码里,读端口用组合逻辑即时输出 regs 里的值,保证单周期 CPU 能在同一个周期内完成“读寄存器→算 ALU→写结果”。写端口同步在时钟上升沿,且 wa3 为 0 时不写入,这是 MIPS 的硬性规定。仿真刚开始时 regs 是 x,如果不单独判 ra==0,x 会一路传播到 ALU 再传到零判断,导致 beq 永远不跳转,看起来像是控制器 bug,实际上数据通路有 x 态污染。

ALU 模块相对直接,但要注意 slt 的有符号问题:

module alu32 #( parameter DW = 32 )( input wire [DW-1:0] a, input wire [DW-1:0] b, input wire [3:0] ctrl, output reg [DW-1:0] result, output wire zero ); always @(*) begin case (ctrl) 4'b0000: result = a & b; 4'b0001: result = a | b; 4'b0010: result = a + b; 4'b0110: result = a - b; 4'b0111: result = $signed(a) < $signed(b); default: result = {DW{1'bx}}; endcase end assign zero = (result == 32'd0); endmodule

这里 a-b 实际上是a + (~b + 1),综合工具会自动推断进位电路,不需要手写减法器。slt 用$signed比较会把两个 32 位向量解释为补码,如果课程设计只考虑正数,或者 MARS 端没开无符号比较,这条指令对照不上会在联调时花很长时间,所以一开始就要和测试代码的语义对齐。可以在 testbench 里单独调用regfilealu32做单元测试,比从顶层点波形定位快得多。

2.4 顶层连线与仿真测试:让 PC 真正动起来

单周期 CPU 的顶层连线有固定套路:PC → 指令存储器 → 控制器和寄存器堆;寄存器堆和立即数扩展 → ALU;ALU 结果和内存读数据 → 写回多路选择器。跳转逻辑里 PCSrc 等于Branch & zero,Jump 独立于 ALU。这个结构在任何一本计算机组成原理教材里都有,FPGA 上的实现差异主要在存储器初始化方式。

比如在 RTL 里直接声明一块只读指令存储器,并读取 hex 文件:

reg [31:0] instr_mem [0:4095]; wire [31:0] pc_addr = pc[31:2]; always @(*) begin instr = instr_mem[pc_addr]; end

这里pc[31:2]是因为 MIPS 地址按字节寻址,而存储器数组按 32 位字编址,CPU 输出的 PC 每个周期加 4,地址低两位恒为 0。课程设计中不少人把instr_mem[pc]直接写进去,仿真时地址越界,读回来全是 x,这是最基本的妨害。

Testbench 里除了生成时钟,还要做一个初始化完再释放复位的序列:

initial begin clk = 0; forever #10 clk = ~clk; end initial begin rst_n = 1'b0; #25 rst_n = 1'b1; #5000 $display("TEST PASS, pc=%h", top.pc); $finish; end

第一次跑如果不能马上看到 PC 按 4 递增,不要去看数据通路,先把时钟、复位、存储器初始化这三样确认掉。资源里的 testbench 大概率已经带好 hex 文件路径,把它替换成自己用 MARS 导出的程序,就能把整个 CPU 的启动流程重新验证一遍。

3. 从仿真进入板级:FPGA 工程的时钟复位、约束与调试

3.1 工程目录结构:源码和资料的摆放也有门道

这套课程设计资料包解压后并不是一个单一.v文件,源码按 RTL、仿真、约束、文档分类。我通常会把工程按典型 FPGA 开发流程再拆一版:

mips_cpu_prj/ ├── rtl/ │ ├── mips_top.v │ ├── regfile.v │ ├── alu.v │ ├── alu_ctrl.v │ ├── controller.v │ ├── pc.v │ ├── instr_rom.v │ └── data_ram.v ├── tb/ │ ├── mips_top_tb.v │ └── program.hex ├── sim/ │ └── run_msim.do ├── constraints/ │ └── board.xdc ├── tools/ │ └── hex2bin.py └── docs/ └── 设计报告.docx

为什么要把 RTL 分这么细?因为答辩时老师一定会问“控制器在哪一个文件,数据通路在哪一个文件”,控制器、ALU、寄存器堆独立成文件,也能让 Quartus / Vivado 的综合报告更清楚地把资源占用归到各个模块。指令存储器和数据存储器分开写,后续接外设、接 UART 时不用改顶层接口,只改mips_top内部的地址译码。

回到“存储器与 CPU 的连接”这个点上:在 FPGA 工程里,指令存储器和数据存储器一般用 IP 核或 RTL 推断的 Block RAM。RTL 里写一个同步写、异步读的 RAM 数组,综合工具会直接推断成分布式 RAM,占 LUT;如果写reg [31:0] mem [0:1023],并加上同步写逻辑,在 Xilinx 上更可能落成 Block RAM。二者的读写时序不一样,课程设计仿真验证通过了,上板前还得重新检查 IP 核的时钟使能和读延迟,否则会出现仿真和板级行为不一致。

3.2 时钟和复位:最容易在板级被忽略的第一坑

FPGA 板上的外部时钟通常是 50MHz 或 100MHz 的单端/差分信号,先要经过 PLL/MMCM 产生系统时钟。单周期 MIPS 的时钟频率不要求多高,通常由锁相环把输入时钟分频到一个合适的频率。但板级设计和仿真不同,输入给 CPU 的rst_n按键信号存在机械抖动,直接放在异步复位端会让内部寄存器在一个毛刺上复位一半、另外一半保持原状。

资源源码里如果直接用了always @(posedge clk or negedge rst_n),那至少要在顶层把按键输入打两拍,做一个复位同步器:

reg [1:0] rst_sync; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin rst_sync <= 2'b00; end else begin rst_sync <= {rst_sync[0], 1'b1}; end end wire rst_n_sync = rst_sync[1];

这个同步器把异步复位变成同步释放,CPU 内部模块统一使用rst_n_sync。代码逻辑是:第一拍采样按键的低电平,第二拍生成一个干净的复位释放信号,两级触发器把亚稳态概率压到可接受范围。使用复位同步器时,所有时序逻辑仍然可以沿用negedge rst_n_sync,但不再直接把按键引脚接到每个模块的复位端。板级调试时,如果发现 CPU 上电有时候起不来,有时候中途跑飞,优先查的就是这里。

对应的 XDC 约束模板可以写成:

create_clock -period 20.0 -name sys_clk [get_ports clk] set_property IOSTANDARD LVCMOS33 [get_ports {clk}] set_property IOSTANDARD LVCMOS33 [get_ports {rst_n}] set_property PACKAGE_PIN E3 [get_ports {clk}] set_property PACKAGE_PIN A7 [get_ports {rst_n}]

这个只是模板,不是可以照抄的开发板引脚,实际使用必须按开发板手册替换 PACKAGE_PIN。课程设计里经常听到引脚约束报错,大多是把时钟引脚接到了普通 IO 上,或者引用了不存在的引脚名。Vivado 在综合时不会立刻报,到 Implementation 阶段才暴露,所以上板前一定要先跑一次 IO Plan。

3.3 综合、实现与资源占用:不是灯亮了就行了

把 CPU 综合到 FPGA 上之后,最先看的是综合报告里的资源占用,而不是直接生成比特流。一个典型单周期 MIPS 的资源占用有大致规律:

资源项常见范围主要用途
LUT500~1500ALU 运算、控制器、多路选择器
FF200~800PC、寄存器堆写地址、外设寄存器
Block RAM1~4 个指令 ROM、数据 RAM
IO视引脚而定时钟、复位、LED、按键

如果 LUT 用量异常偏高,很大程度是 ALU 写了乘除法、或者寄存器堆用数组推断成了大量分布式 RAM。MIPS 课程设计里基本不会用到乘法器,即使要用 32 位乘法也会走片上 DSP,而不是通用逻辑实现。资源报告里如果出现 DSP 用量,先检查是不是把*运算写死了。

在 Vivado 里做完综合后,对着时序报告看 WNS(Worst Negative Slack)。单周期 CPU 的时序路径主要是:PC → 指令 ROM → 寄存器堆读 → ALU → 数据 RAM → 写回寄存器。这条路径上每一级都是组合逻辑,时钟频率上不去很正常。课程设计目标一般是 20~50MHz,如果约束 100MHz 出现负 slack,可以把时钟周期放宽到 33MHz,或者把数据 RAM 的写地址寄存器打一拍,不必强行追逐高频。

3.4 用 ILA/SignalTap 抓内部信号,别再只靠 LED 猜

上板之后最先出的问题通常不是逻辑错误,而是“程序到底有没有跑起来”。看 LED 只能看到最终结果,中间 PC 是否跳转、数据存储器是否写进去,必须抓波形。Xilinx 侧用 ILA,Intel 侧叫 SignalTap II。

常见做法是在 RTL 中给要观察的线网加综合属性:

(* mark_debug = "true" *) wire [31:0] dbg_pc; (* mark_debug = "true" *) wire [31:0] dbg_alu_out;

然后在综合之后的工程里运行 Set Up Debug 向导,把这两个信号加进 ILA,深度选 1024 就够了。触发条件可以设为dbg_pc == 32'h0000000c,表示程序执行到某一条指令时开始采样。抓回来的波形和 ModelSim 仿真波形放一起对比,如果板级 PC 序列和仿真对不上,立刻能看到是在哪一次跳转处错开的。

ILA 不是越多越好,每加一个信号都会消耗 BRAM。课设里只抓 pc、alu_out、mem_write、branch 四条线,基本上所有单周期数据通路问题都能定位。上板调试时要养成熟练“先抓波形再改代码”的习惯,否则很容易在一个假复位问题上反复量产比特流。

4. 答辩前必查:用 MARS 交叉验证、存储器边界与复位 X 态

4.1 用 MARS 跑同样的汇编,得到 CPU 应该输出的结果

FPGA 课程设计答辩时,老师最常问的是“你怎么验证 CPU 是正确的?”回答“仿真波形全绿”并不够。更标准的做法是:把同一段 MIPS 汇编代码放进 MARS 模拟器里运行,记录寄存器和内存的最终值,再把这批值作为 ModelSim/Questa 仿真测试平台的期望值。

先写一段能在 CPU 上跑通的汇编程序:

.text .globl main main: lui $t0, 0x1234 ori $t0, $t0, 0x5678 addi $t1, $zero, 2 add $t2, $t0, $t1 sw $t2, 0x0($gp) loop: j loop

这段程序先把 0x1234 装入 $t0 高 16 位,再用 ori 把低 16 位拼成 0x12345678,加 2 之后写入$gp(全局指针 0x10000000)指向的内存。MARS 里跑完,$t2等于 0x1234567A,内存地址 0x10000000 的内容也是 0x1234567A。把这组值作为 testbench 断言,CPU 仿真结果如果一致,说明 lw、sw、addi、lui、ori 这条链路是通的。

源码里读程序文件一般用$readmemh,配套资料里大概率有一个 hex 文件。需要注意 MARS 导出的内存镜像和 Verilog 的readmemh逐字顺序,二者要对应。稳妥做法是用工具脚本转换,而不是手敲。

下面是一段生成初始化文件的 Python 逻辑:

import struct insts = [0x3c081234, 0x35085678, 0x21290002, 0x01095020, 0xafaa0000, 0x08000000] with open("program.hex", "w") as f: for inst in insts: f.write(format(inst, "08x") + "\n")

脚本先定义指令机器码列表,再按每行一个十六进制数写入,直接喂给$readmemh。不需要额外编译器,也不用依赖 Vivado GUI,还能在 hex 文件里追加注释行,适合快速生成测试用例。如果源码里已经有现成的 hex 文件,可以直接跳过,只保留仿真断言部分。

4.2 三个高频假错:X 态、字节地址和阻塞赋值

$readmemh初始化指令存储器时,要确认 RTL 在声明数组时没有赋初值,否则仿真器会先执行数组初始化,再执行$readmemh,连续两次写可能产生竞争。更常见的问题是reg [31:0] mem [0:4095]只声明了地址范围,PC 却可能是未复位状态 x,那么mem[x]读出来也是 x,进而让取指、控制信号、PC 更新全部乱掉。所以第一条规则:在 testbench 里先复位满一个周期,再释放。第二条规则:把所有指令存储器的读端口做成组合读,PC 变了马上出指令,控制器的输出才会在一个周期内稳定。

字节地址是 MIPS 课程设计里另一个高频坑。CPU 的 PC 按字节计,因此 PC+4,PC 低两位是 00。数据存储器的addr到底按什么写,要先看源码内部定义。如果数据 RAM 是reg [31:0] data_mem [0:1023],那它实际是按字寻址,CPU 取数据时要把alu_result[31:0]的低两位丢弃,只把高 30 位送给 RAM,否则 sw 写进去的地址和 MARS 里看到的地址会差四倍。答辩时老师喜欢挑这个点,源码里如果已经对了,也要能解释清楚“为什么地址低两位不参与寻址”。

阻塞赋值的问题最隐蔽。寄存器堆写入如果写成always @(posedge clk) begin mem[wa3] = wd3; end,仿真器在时钟上升沿会先把 wd3 采集,再考虑是否覆盖 mem[wa3],在单周期数据通路中可能正好没有表现差异,但一旦把数据 RAM 也接到同一条写数据总线,多个阻塞赋值之间会出现写覆盖顺序不同导致的竞争。课程设计代码里所有时序逻辑应统一使用非阻塞赋值<=,组合逻辑用阻塞赋值=,这是 FPGA 入门阶段的底线,也是答辩时老师扫代码的第一眼目标。

4.3 仿真波形检查清单:不是每一拍都要看

最后给出一个我复现时一直在用的波形检查清单:

检查项期望结果失败时先查
PC 加 4每拍 pc 增加 4复位信号、时钟边沿、PCSrc 是否被错误拉高
R 型指令rd 写入的寄存器值等于 ALU 结果RegWrite、RegDst、ALUSrc
lw写回值是内存读数据MemtoReg、存储器读使能、字节地址转换
sw内存地址处数据变化MemWrite、地址线高位、数据 RAM 使能
beq相等时下一拍 pc 跳转zero 信号、ALUOp 是否被设置为减法
j 跳转直接用立即数拼接出新 PCJump 控制、指令的 [25:0] 字段

把这六行按顺序对完,CPU 基本可以放心拿去答辩了。对波形时不要一开始就从第一条指令慢慢看,直接把程序一直跑到最后一个循环,如果最后的寄存器值和 MARS 一致,再把中间关键路径补验一笔。

5. 把外设挂到 MIPS 地址空间:一个直接能用的 GPIO 扩展技巧

5.1 用高位地址做简单译码,给 CPU 增加第一个外设

课程设计做到“CPU 能跑程序”还只是及格,想拿高分需要让它在真实硬件上做点事。最简单且不引入复杂总线的方案是:在地址高位加译码,把 GPIO 映射到一个不会跟数据 RAM 冲突的地址段。比如 RAM 地址从 0x00000000 开始,那 GPIO 基地址就选 0xF0000000,地址的 [31:28] 全为 1 时访问 GPIO。

mips_top内部加一段地址译码:

wire mem_access = memwrite; wire cs_gpio = (alu_result[31:28] == 4'hF); wire cs_ram = (alu_result[31:28] != 4'hF); always @(posedge clk or negedge rst_n_sync) begin if (!rst_n_sync) led_out <= 8'b0; else if (mem_access && cs_gpio) led_out <= alu_result[7:0]; end

地址使用alu_result,因为 sw 指令写入内存的地址就是 ALU 计算结果。把cs_ram接到数据 RAM 的使能端,cs_gpio用于写 LED 寄存器,两个区域互不干扰。这样做不用改控制器的真值表,也不会影响原有 lw/sw 的访存逻辑,从 CPU 的角度看,GPIO 只是一块“写进去就会发光”的存储器。

5.2 用一段汇编点亮流水灯,验证 CPU 和 FPGA 的完整链路

外设挂好后,写一段汇编让 LED 循环左移:

lui $t0, 0xF000 ori $t0, $t0, 0x0000 lui $t1, 0x0001 ori $t1, $t1, 0x0001 again: sw $t1, 0($t0) sll $t1, $t1, 1 andi $t1, $t1, 0x00FF beq $zero, $zero, again

如果源码的指令集里已经包含sllandi,那 LED 流水灯每步切换只需要三四种运算指令。sw写 GPIO 之前,CPU 已经把 GPIO 地址拼好,所以luiori的配合正好检验了立即数扩展。上板后 LED 以肉眼可见速度移动,就说明“CPU 取指→译码→ALU 运算→存储器映射外设→板上输出”整条链路是通的。

这个技巧还能直接扩展成按键输入或 UART 发送:再加一个cs_key = (alu_result[31:28] == 4'hE)的译码分支,把按键寄存器映射到另一个基地址,CPU 就能用lw读到外部电平。答辩时展示这种从纯仿真变成可交互系统的过程,比单看波形更有说服力。

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

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

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

立即咨询