简介:来自电子科技大学的一份RISC 32位单周期CPU设计PDF,面向计算机体系结构课程设计和FPGA实践,适合作CPU课程报告、实验参考或复习资料。文档以完整设计报告形式呈现,共1个PDF文件,压缩包约730KB;内容覆盖CPU整体电路、指令格式、基本功能部件、控制器设计及FPGA测试。功能部件部分详细说明32位2选1/4选1多路选择器、5位2选1多路选择器、D触发器、移位器和32位加/减法器,并给出关键Verilog模块代码;指令部分梳理18条MIPS指令的R型、I型、J型格式,以及下一条指令地址、ALU输入端、寄存器堆输入端的控制信号选择逻辑,便于理解单周期CPU在一个时钟周期内完成指令的数据通路设计。当前已有1657人学习下载。
1. 精简指令集 32 位单周期 CPU 设计:一份可直接落地的 Verilog 工程拆解
做体系结构课程设计或者想把手头的 RISC 概念落到可综合代码上的人,大概率会遇到这份资源——一个 32 位单周期 CPU,指令集是 MIPS 风格的精简指令集,总共 18 条指令,覆盖算术、逻辑、访存、分支和跳转,配套 FPGA 测试工程。它好就好在每个模块都有现成 Verilog 代码,从 2 选 1 选择器到控制器再到顶层封装,不是那种只画框图不给源码的抽象课件;麻烦也在这,单周期下所有事都挤在一个时钟沿完成,控制信号一旦译码错一位,整条数据路径就全乱。这篇笔记把指令格式、数据路径、各功能部件代码和 FPGA 上板验证按可复现的顺序拆开讲,重点标出我在仿真和上板时踩过的坑。适合刚做完数字逻辑实验、准备开始写 CPU 的本科高年级学生,也适合想快速搭一个最小可运行 RISC 核的工程师照着改。
2. RISC 指令格式与数据路径:18 条 MIPS 指令怎么走通单周期
2.1 下一条指令地址的三种来源与 pcsource 选择逻辑
单周期 CPU 最核心的一个问题是:每个时钟沿到来时,下一条指令的地址从哪来。这个设计里用了一个 4 选 1 多路选择器来产生下一跳地址 npc,控制信号是 pcsource[1:0],由控制器根据 op、func 和 ALU 的 zero 输出共同决定。
第一种情况是程序顺序执行,也就是 PC + 4。这里不直接用加法器拼一个 32 位常量,而是调用了一个 32 位加法器 cla32,把 PC 和 32'h4 相加。第二种情况是 beq 或 bne 转移命中,目标地址为 PC + 4 + (符号扩展立即数 << 2)。第三种是 j 指令跳转,目标地址为 {PC+4[31:28], inst[25:0], 2'b00},也就是用 PC+4 的高 4 位和指令低 26 位左移 2 位后的结果拼接。
数据路径上对应代码如下:
wire [31:0] offset = {imm[13:0], inst[15:0], 2'b00}; cla32 pcplus4 (pc, 32'h4, 1'b0, p4); cla32 br_adr(p4, offset, 1'b0, adr); wire [31:0] jpc = {p4[31:28], inst[25:0], 2'b00}; MUX32_4_1 nextpc(p4, adr, ra, jpc, pcsource, npc);这里 offset 的拼接值得注意。指令的低 16 位是立即数,左移 2 位后低 2 位补 0,同时把符号扩展后 32 位立即数的高 14 位截出来放到高 14 位。这个操作相当于把 16 位偏移量符号扩展后左移两位,算出的结果已经是字节地址。我见过有人在仿真时 beq 跳不准,多数是这里没有把偏移量先符号扩展再移位,而是直接用 16 位立即数做拼接,导致负数偏移全部错乱。
pcsource 的产生逻辑在控制器里是两段 assign。pcsource[1] 为 jr、j、jal 三种跳转指令的或;pcsource[0] 为 beq 且 zero 为 1、bne 且 zero 为 0、j、jal 的或。jr 指令比较特殊,它的跳转地址来自寄存器堆的 A 端口数据 ra,这也是为什么 nextpc 的四个输入里有一个直接接了 ra。
2.2 ALU 输入端和寄存器堆输入端的控制信号组合
ALU 有两个输入端 A 和 B,各接一个 2 选 1 选择器。A 端选择寄存器堆 A_data 还是移位位数 sa,由 shift 信号控制;B 端选择寄存器堆 B_data 还是立即数,由 aluimm 控制。这里 sa 不是直接给 ALU 的,而是先把指令的 [10:6] 字段零扩展成 32 位,再通过选择器接到 ALU 的 A 端。
wire [31:0] sa = {27'b0, inst[10:6]}; MUX32_2_1 alu_a(ra, sa, shift, alua); MUX32_2_1 alu_b(Data, immdiate, aluimm, alub);aluimm 为 1 的指令有 addi、andi、ori、xori、lw、lui、sw 七条。shift 为 1 的指令只有 sll、srl、sra 三条。这两个信号在控制器里是独立译码的,互不干扰。比如 sll 属于 R 型指令,不需要立即数,但 ALU 的 A 端必须选择位移位数,所以 shift 必须拉高。
寄存器堆的写地址 wn 不是直接选一个端口,而是先用一个 5 位 2 选 1 选择器在 inst[15:11] 和 inst[20:16] 之间选,再由寄存器号 reg_dest 和 jal 做一次按位或。这个按位或的 Verilog 写法很巧妙:
MUX5_2_1 reg_wn(inst[15:11], inst[20:16], regrt, reg_dest); assign wn = reg_dest | {5{jal}};当 jal 为 1 时,{5{jal}} 是 5 位全 1,与任何 reg_dest 按位或后结果都是 5'b11111,也就是寄存器 31。这正好满足 jal 指令需要把返回地址写入 $31 的要求。我一开始没想明白为什么要用按位或而不是直接用一个 2 选 1,后来才意识到这样写避免了为 jal 单独增加一路 MUX 输入。
寄存器堆的数据输入 Data 有四路来源:ALU 运算结果、存储器读出数据 mem、PC+4 的值 p4。选择逻辑用两个 2 选 1 串联实现:第一个在 ALU 结果和 mem 之间选,由 m2reg 控制;第二个在第一步结果和 p4 之间选,由 jal 控制。
MUX32_2_1 res_mem(Result, mem, m2reg, alu_mem); MUX32_2_1 link(alu_mem, p4, jal, res);2.3 立即数扩展与移位拼接的位操作细节
I 型指令的立即数扩展是这块设计里最容易出错的点。资源里用了一个很紧凑的写法:
wire e = sext & inst[15]; wire [15:0] imm = {16{e}}; wire [31:0] immdiate = {imm, inst[15:0]};sext 为 1 表示需要符号扩展,此时扩展位的值等于指令第 15 位(符号位),把 16 位全部填充为符号位,拼上原始 16 位立即数得到 32 位结果。sext 为 0 时是零扩展,扩展位全为 0。需要符号扩展的指令是 addi、lw、sw、beq、bne;需要零扩展的是 andi、ori、xori、lui。这个表格在控制器代码里对应得非常清晰:
assign sext = i_addi | i_lw | i_sw | i_beq | i_bne;lui 指令比较特殊,虽然它属于 I 型指令且 aluimm 为 1,但它不需要符号扩展,因为这条指令的语义是把立即数放到结果的高 16 位,低 16 位补零。ALU 里专门有一条路径处理 lui:
wire [31:0] d_lui = {B[15:0], 16'h0};这里 B 是经过零扩展后的立即数,取低 16 位放到结果的高 16 位,低 16 位填 0。如果把 lui 的立即数走符号扩展路径,得到的结果就会变成全 1 的高 16 位,仿真时能直接看出来结果异常。
J 型指令的地址拼接也容易翻车。j 指令的低 26 位左移 2 位后产生 28 位地址,与 PC+4 的高 4 位拼成 32 位。这个操作是字节地址对齐的前提,因为 MIPS 所有指令都是 4 字节对齐的,所以低 2 位永远是 0,不需要从指令中取。
3. 核心模块实现:从 2 选 1 选择器到控制器,一份可复用的 Verilog 代码
3.1 基础部件代码:选择器、D 触发器、移位器、加/减法器
这些基础部件的代码虽然看起来简单,却是整个 CPU 的地基,建议先验证再往上搭。32 位 2 选 1 选择器只有一行 assign,4 选 1 用嵌套三元表达式,逻辑很直观。5 位 2 选 1 选择和 32 位一样,只是位宽不同,用来在寄存器堆写地址端口之间切换。
D 触发器用的是异步复位方式,这在 FPGA 上是安全的,因为开发板的按键复位信号往往是电平信号,异步复位不需要等时钟沿就能立刻让寄存器清零:
module dff32( input [31:0] D, input Clock, input Reset, output reg [31:0] Q ); always @(posedge Clock or negedge Reset) begin if (Reset == 0) Q <= 0; else Q <= D; end endmodule低电平复位,复位信号按下时 Q 清零。这个触发器在代码里用来做 PC 寄存器,也用来做通用寄存器堆的存储单元。
移位器模块让我觉得设计得比较聪明的是用三个信号组合出四种移位操作。Right 为 0 时左移;Right 为 1 且 Arith 为 0 时逻辑右移;Right 为 1 且 Arith 为 1 时算术右移。算术右移用 $signed(D) >>> Sa 实现,Verilog 里对无符号数做逻辑移位,要用 $signed 转换成有符号类型才能得到正确的算术右移,不然负数右移高位补 0,结果完全不对:
module Shifter( input [31:0] D, input [4:0] Sa, input Right, input Arith, output reg [31:0] O ); always @(*) begin if (!Right) O = D << Sa; else if (!Arith) O = D >> Sa; else O = $signed(D) >>> Sa; end endmodule32 位加减法器这里用了超前进位加法器的模块名,但实际上例化时直接用的是行为级相加,依靠综合工具自己优化。这样做在 FPGA 上没问题,但如果要流片就需要换成真正的 CLA 结构。
module cla32( input [31:0] a, input [31:0] b, input c, output [31:0] s ); assign s = a + b + c; endmodule减法实现是靠把 B 端做按位异或后加{32{sub}}。B^{32{sub}}在 sub 为 1 时对 B 取反,加 1 后实现补码减法;sub 为 0 时直接加。这个技巧在很多指令集实现里都能看到,省掉了一个独立的减法器。
3.2 ALU 真值表与控制码映射
ALU 的设计采用 4 位操作码 ALU_Operation,高 2 位选大类,低 2 位在小类里选具体操作。这样做的好处是可以用一个 4 选 1 选择器完成最后的结果选择:
| ALU_Operation[3:0] | 功能 |
|---|---|
| x000 / x100 | Add |
| x001 / x101 | Sub |
| x010 | And / Or |
| x110 | Xor / Lui |
| x011 | Sll |
| x111 | Srl / Sra |
ALU 内部先把各种运算的中间结果都算出来,然后用选择器挑。d_and_or 根据 ALU_Operation[2] 在 And 和 Or 之间选;d_xor_lui 同理在 Xor 和 Lui 之间选。加法和减法用 addsub32 完成,移位用 Shifter 完成,最终由 MUX32_4_1 在四种结果间做最终选择。
wire [31:0] d_and_or = ALU_Operation[2] ? d_or : d_and; wire [31:0] d_xor_lui = ALU_Operation[2] ? d_lui : d_xor; MUX32_4_1 sel(d_as, d_and_or, d_xor_lui, d_sh, ALU_Operation[1:0], Result); assign Zero = ~|Result;Zero 信号是 ALU 对外的关键输出。~|Result是对 32 位结果做按位或后取反,结果为 0 时 Zero 为 1。beq 和 bne 就是靠 Zero 判断转移条件的。注意 Zero 和进位标志不同,它不关心运算是否溢出,只关心结果是否为 0,这一点在判断beq时不会出错,但如果你以后扩展出 slt 指令就会需要单独的符号比较逻辑。
3.3 控制器:op/func 译码到 12 个控制信号
控制器是本设计的重点,它的代码风格也很适合当作教学示例。先把每个指令的功能用一条 wire 表达出来,再用这些中间信号生成所有控制信号。比如 i_add 为 1 表示当前指令是 R 型加法:
wire i_add = (op == 6'b000000 & func == 6'b100000) ? 1 : 0;这种写法比直接在一个 always 块里用 case 写状态机要清晰得多,每个控制信号都可以明确追溯到是哪几条指令贡献的。比如 wreg 的赋值:
assign wreg = i_add | i_sub | i_and | i_or | i_xor | i_sll | i_srl | i_sra | i_addi | i_andi | i_ori | i_xori | i_lw | i_lui | i_jal;注意这里没有包含 i_sw、i_beq、i_bne、i_j、i_jr,因为它们不写寄存器。这个清单我核对过一遍,和每类指令的语义是对应得上的。aluc[3] 只在 sra 时为 1,用来通知移位器做算术右移;aluc[2] 由 sub、or、srl、sra、ori、lui 贡献,对应 ALU_Operation[2];aluc[1] 由 xor、sll、srl、sra、xori、beq、bne、lui 贡献;aluc[0] 由 and、or、sll、srl、sra、andi、ori 贡献。这四位组合起来正好和 ALU 的真值表一一对应。
一个容易被忽略的控制信号是 regrt。它表示目的寄存器是 rt 还是 rd。R 型指令的 rd 在 inst[15:11],I 型指令的 rt 在 inst[20:16],所以 regrt 直接选中了需要写回的指令:
assign regrt = i_addi | i_andi | i_ori | i_xori | i_lw | i_lui;j 和 jal 指令没有出现在 regrt 里,因为 jal 用的是独立的寄存器号拼接逻辑,j 不写寄存器。
4. 避坑与常见排查:仿真波形对不上、上板不跑、控制信号失效的六种典型场景
4.1 寄存器 0 写保护缺失导致的数据竞争
现象:仿真时写寄存器 0 后,后续指令读 $0 出现非零值,整个程序行为错乱。
原因:MIPS 规范要求寄存器 0 恒为 0。寄存器堆代码里读数据时对地址 0 做了特判,直接输出 0,但写数据时的判断条件是(Write) && (W_addr != 0),写地址 0 不会写入。按理说这个特判已经够了,问题是如果你自己扩展指令或者修改控制器,把 W_addr == 0 的指令也拉高了 wreg,写保护就被绕过了。
解决:我后来在顶层加了断言,一旦 wreg 为 1 且 wn 为 0 就报错。更彻底的做法是在控制器译码阶段就排除这类指令,确保没有任何指令会同时产生写使能和地址 0。
4.2 立即数符号扩展与零扩展错位
现象:执行 addi 时,立即数为负数时计算结果完全不对,正数正常。
原因:sext 信号是 1 时符号扩展,0 时零扩展。但控制器的 sext 是基于独立译码信号赋值的,如果指令列表里新增了类似 slti 的带符号比较指令,很容易忘记把它加进 sext 的赋值链,导致这个指令走零扩展。
解决:在仿真的 testbench 里把所有带立即数的指令都列出来,手动算一遍期望值,比对波形。不要凭感觉信任控制信号,符号扩展位的值等于 inst[15],可以在波形里直接数这一位的值来验证。如果这一步错了,后续所有访存指令的地址都会偏 4GB 的倍数。
4.3 beq/bne 转移地址偏移量拼接错误
现象:beq 指令向前转移(偏移量为负)时,跳转地址错误,程序跑飞。
原因:偏移量的符号扩展和左移两位是分开做的。资源里的写法是先把符号扩展后的完整 32 位立即数 immdiate 取高 14 位,拼上原始 inst[15:0] 再左移 2 位。如果直接把 inst[15:0] 左移两位再符号扩展,负偏移量的符号位就跑到错误的位置上了。
解决:严格按资源里的顺序来:符号扩展得到 32 位立即数,再左移 2 位,或者用{imm[13:0], inst[15:0], 2'b00}这种拼接方式。这个写法实际上等效于先左移两位再做符号扩展,因为符号位是 inst[15],它拼接后落在 offset[17] 的位置。
4.4 移位指令 sa 字段取错导致 ALU A 端数据错乱
现象:sll 指令执行结果不是预期的移位效果,有时是寄存器数据被移了位,有时直接算出奇怪结果。
原因:sa 是 5 位数据,来自 inst[10:6]。但这部分代码中用的是{27'b0, inst[10:6]}拼成 32 位,一旦你写成了{27'b0, inst[9:5]}或者忘了零扩展直接传给 ALU,移位位数就会变成一个巨大的无符号数,导致移位操作直接归零。
解决:在仿真里给 sll 传一个明显的移位位数,比如 4,然后看结果是否是左移 4 位。如果结果为 0 或者移位量不对,优先检查 sa 的位段索引。另外注意,这个 sa 是零扩展的,如果你的 CPU 以后支持变位移位,这里需要改用寄存器值。
4.5 上板时钟复位顺序问题
现象:下载到 FPGA 后按复位键可以运行,但手动逐步执行时,第一条指令经常是一条无效指令或者 PC 已经跳到了错误的地址。
原因:开发板上的时钟按键是边沿触发的,复位释放和时钟上升沿的时序没有保证。如果复位刚刚释放,时钟上升沿正好在释放瞬间,PC 寄存器可能采到一个不确定值。
解决方法:上板前先看一下开发板手册,确认时钟按键是消抖后的单脉冲输出,还是原始机械按键。如果是原始按键,建议先触发复位,多按几次时钟,观察数码管显示的 PC 值是否按 4 递增。我一般会做一个简单的防抖电路,用一个计数器滤掉机械抖动,再送给 CPU。效果是复位后 PC 稳定为 0,后续每次按键 PC 严格 +4。
4.6 mem 读出的数据和 ALU 结果混接
现象:lw 指令读出的数据不是预期的值,有时把 ALU 结果写回寄存器了。
原因:m2reg 信号是拉高时选择存储器数据,但数据存储器模块的 dataout 是组合逻辑输出的,没有时序同步。当 CPU 单步执行时,mem 数据在时钟沿之后是稳定的,但如果你用了不同时钟域,或者指令和数据存储器共用同一个时钟使能,就会出现数据竞争。
解决:把数据存储器的读端口改成组合输出,不做寄存器缓存;写端口始终在时钟沿触发写入。这也是资源里数据存储器代码的做法,我实际测试时发现这样最稳定。上板时用数码管看 memout 的值,要和仿真波形完全一致才算过。
5. FPGA 上板验证:数码管调试法的细节与进阶用法
5.1 指令存储器与数据存储器测试代码
上板前先写好指令存储器,把测试程序的 32 位机器码填进去。这个工程里用行为级数组初始化:
module Inst_mem( input [31:0] address, output [31:0] inst ); wire [31:0] ram [0:31]; assign ram[5'h00] = 32'h3c011234; // lui r1, 0x1234 assign ram[5'h01] = 32'h34215678; // ori r1, r1, 0x5678 // ... assign inst = ram[address[6:2]]; endmoduleaddress[6:2] 是因为指令是 32 位对齐的,PC 的低 2 位永远为 0,取 [6:2] 相当于按指令序号寻址。这里ram[5'h00]到ram[5'h1f]共 32 条指令,每条都是完整 32 位机器码。如果你只有 18 条指令,地址空间规划在 0 到 17 就够。
数据存储器也类似,但带了写使能:
module Data_mem( input Clock, output [31:0] dataout, input [31:0] datain, input [31:0] addr, input we, input inclk, input outclk ); reg [31:0] ram [0:31]; assign dataout = ram[addr[6:2]]; always @(posedge Clock) begin if (we) ram[addr[6:2]] = datain; end integer i; initial begin for (i = 0; i <= 31; i = i + 1) ram[i] = i * i; end endmodule这种初始化方式在 FPGA 综合时不一定能生成 ROM,因为它用了 initial 块。在仿真里没问题,上板时如果你的工具不支持 initial 初始化存储器,就要把测试程序放到一个单独的 .coe 文件或者用 $readmemh 读入。
5.2 数码管显示电路与关键调试习惯
顶层显示电路用一个 4 位选择器在 inst、pc、aluout、memout 之间切换显示内容,再用 SW[2] 选择高 16 位还是低 16 位。这个设计很实用,因为 ALU 结果是 32 位,数码管只有 4 位十六进制显示,必须分高低位查看。Hex7seg_decode 模块的clkdiv[19:18]用来做数码管扫描,4 位数据轮流点亮,人眼看到的就是稳定的 4 位十六进制数。
我在上板时形成了几个固定的验证习惯。先把 SW 切到显示 PC 的模式,复位后确认 PC 显示 0,之后每手动按一次时钟,PC 按 4、8、C 递增。再把 SW 切到显示 ALU 结果,跑完程序后确认最终结果是预期值。这个顺序能快速定位是数据路径问题还是指令存储器问题。
5.3 进阶验证:十分钟自检法
如果你拿到这份资源想快速验证自己的理解,可以在仿真里做一个小自检。把测试程序的 18 条指令全部跑一遍,在每条指令结束后检查 PC、Result、Zero 三个信号的期望值。我会把这些期望值写进 testbench,用 assert 语句自动比对,只要有一条不匹配就立刻报错。从那以后我每次搭单周期 CPU 都强制走一遍这个自动化比对流程,比自己盯着波形图翻页要快得多,也更容易发现控制器译码的隐藏问题。希望帮到你。
本文还有配套的精品资源,点击获取