ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Vivado+Verilog单周期CPU设计:MIPS数据通路与上板调试

Vivado+Verilog单周期CPU设计:MIPS数据通路与上板调试

简介:面向计算机组成原理课程设计与FPGA入门学习者的单周期CPU工程,用Verilog在Vivado中实现取指、译码、执行、写回的数据通路,涵盖指令寄存器、译码器、ALU、通用寄存器组、控制单元、存储器接口与程序计数器等模块,可帮助理解控制信号与时序的配合,也适合作为课程实验的练手项目。压缩包共107个文件、约1.31MB,以12个.v源文件为核心,辅以.bat批处理、.tcl与.xpr工程文件、.jou和.log运行日志及仿真配置数据库,覆盖编译、仿真、综合、实现到下载的流程,日志可用于复现环境与排查语法、时序问题。已有3635人学习下载,工程结构完整、可直接打开运行,便于对照源码逐模块分析数据流向,并借助既有仿真结果验证指令执行是否正确。

1. 单周期CPU为什么值得用 Vivado 从头搭一遍:一份能直接跑通的设计包

课程设计里最尴尬的一幕,是数据通路图能默画出来,真打开 Vivado 新建工程却不知道第一个 .v 文件该写什么。这份资源针对的就是这个断层:一套用 Verilog 写的单周期 CPU 完整工程,配套 Vivado 工程脚本、testbench、指令机器码文件和引脚约束模板,覆盖取指、译码、执行、访存、写回五段组合通路,指令集取 MIPS 子集,从 addi 起步一路做到 beq 和 j。它适合两类人:刚学完组成原理想真动手的,和带课设需要一份能逐条对拍的参考实现的。下面按「指令集怎么砍、模块怎么写、仿真怎么跑、上板怎么验、哪几处最容易翻车」的顺序拆开讲。

2. 指令集裁剪与数据通路划分:20 条指令怎么砍到 8 条先跑通

2.1 先定指令子集:8 条能不能撑起一条完整数据通路

很多人一上手就想把 MIPS 32 条指令全实现,结果译码表写到一半,控制信号已经十几根,仿真波形一片红。比较稳的做法是先砍到 8~10 条,覆盖全部五类通路行为,再往外扩。判断标准很简单:指令集里必须同时存在「只用 ALU 的」「要读存储器的」「要写存储器的」「要改 PC 的」四类,缺一类,数据通路就是残的。

这份工程选的基础子集如下,每条都对应一条独立通路段:

指令汇编示例格式打通的通路段
add / subadd $3,$1,$2R寄存器堆双读 → ALU → 写回
and / or / sltslt $3,$1,$2R同上,ALU 控制不同
addiaddi $1,$0,5I立即数扩展 → ALU → 写回
lwlw $4,0($1)IALU 算地址 → 读存储器 → 写回
swsw $4,0($1)IALU 算地址 → 写存储器
beqbeq $1,$2,labelIALU 做减法 → zero 标志 → 改 PC
jj targetJ直接拼接 PC 高位

sub在单周期里值得单独留一条,因为它同时喂给 beq 的 zero 判断——beq 本质上就是「ALU 做减法,看结果是不是 0」。把这条复用关系想通,控制单元的设计会瞬间简单一半。slt 则要注意 Verilog 的有符号比较,<默认按无符号处理,必须写成$signed(a) < $signed(b),否则负数比较会翻车。

2.2 数据通路为什么是「五段式」而不是随手连

单周期 CPU 的「单周期」三个字,含义是每条指令在一个时钟周期内走完整条组合通路,周期长度由最长的那条路决定。工程上把这条路切成五段,不是为了流水,纯粹是为了把连线关系讲清楚、把模块边界划干净:

  • 取指段:PC 寄存器 → 指令存储器 → 当前指令。PC 的更新值是下一拍的输入,逻辑上是「组合算、时钟沿锁」。
  • 译码段:指令字段拆成 rs1 / rs2 / rd,读寄存器堆;同时把 16 位立即数做符号扩展成 32 位。
  • 执行段:ALU 的两个操作数一个来自寄存器堆读口 1,另一个由 ALUSrc 在「读口 2」和「扩展后立即数」之间选。
  • 访存段:只有 lw / sw 真正用到数据存储器,其余指令的 ALU 结果直接进写回。
  • 写回段:MemtoReg 在「ALU 结果」和「存储器读出值」之间选一个,写回寄存器堆的 wa3 口。

链路最长的是 lw:PC → 指令存储器 → 寄存器堆 → ALU → 数据存储器 → 寄存器堆写口。这一条决定了整个设计的最高主频,第 6 章会拿它算具体数字。现在的关键是意识到:单周期不是「简单」,而是「把复杂度全压在时钟周期上」。

2.3 控制信号真值表怎么推:从 opcode 到 8 根线的映射

控制单元不靠背,靠反推。方法是拿每条指令问三个问题:这条指令写不写寄存器?ALU 的第二个操作数从哪来?下一条 PC 会不会变?三个答案基本就把控制信号定死了。

指令opcodeRegDstALUSrcMemtoRegRegWriteMemReadMemWriteBranchJumpALUOp
R 型0000001001000010
addi0010000101000000
lw1000110111100000
sw101011x1x0010000
beq000100x0x0001001
j000010xxx00001xx

表里的x是无关项,工程上不必真的写 x,统一填 0 更省事,还能避免综合工具把无关项优化出奇怪结构。ALUOp 只做粗分类(00 加法、01 减法、10 交给 funct 再译码),真正的 ALU 操作码在执行段二次译码,这样控制单元只有 6 位输入、10 位输出,写起来不费劲。

提示:RegDst 为 0 时写口选instr[20:16](rt),为 1 时选instr[15:11](rd)。这两段位域写反是新手第一大坑,第 5 章单独说。

3. Verilog 模块落地:从 alu.v 到 mips_top.v 的六次综合

3.1 工程目录与综合源/仿真源分离

先把目录定死,后面加指令、加外设都不会乱。Vivado 的 sources_1 和 sim_1 两个 fileset 一定要分开,testbench 混进综合源里会在综合阶段报一堆「模块未例化」的警告,看着烦还容易掩盖真问题。

文件作用关键点
rtl/alu.v算术逻辑单元吃 4 位 alu_ctrl,不直接吃 funct
rtl/regfile.v32×32 寄存器堆$0 硬连 0,写口同步、读口异步
rtl/imem.v指令存储器$readmemh初始化,综合成 BRAM
rtl/dmem.v数据存储器只做 32 位字对齐访问
rtl/control.v主控制单元纯组合,块首给默认值防 latch
rtl/mips_top.v顶层连线五段信号在这里汇合
sim/tb_mips_top.vtestbench产生时钟复位,打印寄存器值
sim/imem.hex指令机器码每行一条 32 位十六进制
constr/top.xdc引脚与时序约束每个端口都要 IOSTANDARD

工程可以用 Tcl 脚本一次建好,比点鼠标快,也方便反复重建:

# 存成 build.tcl,在工程根目录执行:vivado -mode batch -source build.tcl create_project single_cycle ./proj -part <按你的器件型号填> add_files -norecurse [glob ./rtl/*.v] add_files -fileset sim_1 -norecurse ./sim/tb_mips_top.v add_files -fileset constrs_1 -norecurse ./constr/top.xdc set_property top mips_top [current_fileset] set_property top tb_mips_top [get_filesets sim_1] update_compile_order -fileset sources_1 launch_runs synth_1 -jobs 8 wait_on_run synth_1

-norecurse表示只加当前目录、不递归子目录,避免把备份文件也扫进去。<按你的器件型号填>这一处必须换成实际器件,否则后面生成 bitstream 会直接失败。update_compile_order不能省,Vivado 靠它重排编译顺序,手动 add_files 之后不刷新经常出现「模块找不到」的假报错。

3.2 ALU 与寄存器堆:两处最容易综合出意外电路的地方

ALU 写成纯组合,用一个 4 位控制码而不是在内部直接解 funct,好处是控制码可以在别的模块里被复用和打印观察:

module alu( input wire [31:0] a, input wire [31:0] b, input wire [3:0] alu_ctrl, output reg [31:0] y, output wire zero ); assign zero = (y == 32'd0); // beq 靠它判断相等 always @(*) begin case (alu_ctrl) 4'b0010: y = a + b; // add/addi/lw/sw 算地址 4'b0110: y = a - b; // sub/beq 比较 4'b0000: y = a & b; // and 4'b0001: y = a | b; // or 4'b0111: y = ($signed(a) < $signed(b)) ? 32'd1 : 32'd0; // slt,必须带 $signed default: y = 32'd0; endcase end endmodule

zero用连续赋值从 y 上取,综合出来就是一根 32 输入或非门,延迟不大但会挂在关键路径上,第 6 章算 CPI 时会提到。default分支别省,一是防 latch,二是仿真时遇到未定义控制码不会把 y 拖成 x。

寄存器堆的坑集中在两个地方:$0 必须恒为 0,写口要挡住对 $0 的写:

module regfile( input wire clk, input wire we3, input wire [4:0] ra1, ra2, wa3, input wire [31:0] wd3, output wire [31:0] rd1, rd2 ); reg [31:0] rf [0:31]; integer i; initial begin for (i = 0; i < 32; i = i + 1) rf[i] = 32'd0; // 仅仿真有效,上板靠复位初值 end assign rd1 = (ra1 == 5'd0) ? 32'd0 : rf[ra1]; // 读口异步,$0 硬连 assign rd2 = (ra2 == 5'd0) ? 32'd0 : rf[ra2]; always @(posedge clk) begin if (we3 && wa3 != 5'd0) rf[wa3] <= wd3; // 写口同步,且禁止写 $0 end endmodule

initial块在 FPGA 上确实能被综合成上电初值(多数工具支持),但别指望它替代复位。血泪经验是:上板后按一下复位键,寄存器值应该是全 0,如果不是,说明复位链路没接对,而不是 initial 没生效。

3.3 控制单元与顶层连线:把 8 根控制线接到正确位置

控制单元照 2.3 的真值表直译,块首整体清零防 latch:

module control( input wire [5:0] opcode, output reg reg_dst, alu_src, mem_to_reg, reg_write, output reg mem_read, mem_write, branch, jump, output reg [1:0] alu_op ); always @(*) begin {reg_dst, alu_src, mem_to_reg, reg_write, mem_read, mem_write, branch, jump, alu_op} = 10'b0; // 默认全 0,防推断锁存器 case (opcode) 6'b000000: begin reg_dst = 1'b1; reg_write = 1'b1; alu_op = 2'b10; end // R 型 6'b001000: begin alu_src = 1'b1; reg_write = 1'b1; alu_op = 2'b00; end // addi 6'b100011: begin alu_src = 1'b1; mem_to_reg = 1'b1; reg_write = 1'b1; mem_read = 1'b1; alu_op = 2'b00; end // lw 6'b101011: begin alu_src = 1'b1; mem_write = 1'b1; alu_op = 2'b00; end // sw 6'b000100: begin branch = 1'b1; alu_op = 2'b01; end // beq 6'b000010: begin jump = 1'b1; end // j default: ; // 其余当 nop endcase end endmodule

顶层就是把上面这些拼起来,注意pc_next的三路选择优先级:jump 最高,其次 branch & zero,最后才是 pc+4。写成一个嵌套三目表达式比用多个 always 块清楚:

// 分支目标:以 pc+4 为基准,偏移量左移 2 位 wire [31:0] br_target = pc_plus4 + (sign_ext << 2); wire [31:0] pc_next = jump ? {pc_plus4[31:28], instr[25:0], 2'b00} : (branch & zero) ? br_target : pc_plus4; always @(posedge clk or negedge rst_n) begin if (!rst_n) pc <= 32'd0; else pc <= pc_next; // 非阻塞赋值,别用 = end

{pc_plus4[31:28], instr[25:0], 2'b00}这一段是 j 指令的地址拼接,高 4 位取 pc+4 的高 4 位,低 2 位补 0。有人只拼instr[25:0]忘了补位,跳转地址就会错到别处去。

3.4 用 testbench 把 addi + add 跑出第一个正确值

先手写三条指令的机器码,验证「立即数 → 加法 → 写回」这条最短链路:

20010005 // addi $1, $0, 5 20020003 // addi $2, $0, 3 00221820 // add $3, $1, $2

testbench 要做的就是产生时钟、拉复位、等几条指令退休、把寄存器值打印出来:

`timescale 1ns/1ps module tb_mips_top; reg clk, rst_n; mips_top u_top(.clk(clk), .rst_n(rst_n), .pc_debug()); initial clk = 1'b0; always #5 clk = ~clk; // 100MHz 仿真时钟 initial begin rst_n = 1'b0; // 先拉低复位,别留 x #20 rst_n = 1'b1; #200; // 20 个周期,足够三条指令走完 $display("x1=%0d x2=%0d x3=%0d", u_top.u_rf.rf[1], u_top.u_rf.rf[2], u_top.u_rf.rf[3]); $finish; end endmodule

期望输出是x1=5 x2=3 x3=8。如果 x3 是 0,先看 PC 有没有往前走;如果 PC 走了但寄存器没变,看 RegWrite 和 wa3;如果 x1 就是 0,回去查 addi 的立即数符号扩展。这条链路跑通之后,再加 lw / sw / beq,每次只加一条,波形对照着看,比一次性写十条再debug要快得多。

4. 仿真与上板:testbench、XDC 约束和 ILA 抓波形的完整链路

4.1 时钟与复位:单周期 CPU 的时钟周期到底给多少

单周期的时钟周期不能凭感觉填 10ns。按第 2 章那条 lw 的最长路径估一下:PC 触发器 clk-to-q 约 0.5ns,指令存储器读出 2ns 左右,寄存器堆读出 1.5ns,32 位加法器 2.5ns,数据存储器读出 2.5ns,写口建立时间 0.5ns,加起来接近 9.5ns,留 20% 余量就是 11~12ns,对应 80~90MHz。但这是理想数字,实际综合出来的分布逻辑和布线延迟会明显吃掉一部分。

我的习惯是先用 50MHz(周期 20ns)跑通功能,功能全对了再一档一档往上顶,每次顶之前看一遍时序报告。约束这么写:

# 引脚号用占位符,务必对照开发板原理图换成实际值 set_property -dict {PACKAGE_PIN PIN_CLK IOSTANDARD LVCMOS33} [get_ports clk_100m] set_property -dict {PACKAGE_PIN PIN_RST IOSTANDARD LVCMOS33} [get_ports rst_n] # 先按 50MHz 约束,跑通了再改成 10.000 create_clock -period 20.000 -name sys_clk [get_ports clk_100m] # 异步输入要告诉工具,否则会报一堆虚假的时序违例 set_false_path -from [get_ports rst_n]

create_clock的-period单位是纳秒,这一行决定了工具按什么标准判断 WNS(最差负裕量)。set_false_path只对真正的异步输入用,别图省事把整个设计都设成 false_path,那样时序报告就变成黑匣子,什么都看不出来。

复位建议用按键 + 同步释放:按键信号先打两拍再用,避免异步复位释放时刻正好撞上时钟沿,出现「上板有概率起不来」的玄学现象。

4.2 XDC 引脚约束与数码管显示寄存器值

功能验证完,最直观的观察手段是把某个寄存器的值显示到数码管或 LED 上。别直接把 100MHz 时钟接到数码管扫描逻辑,切换太快会出现重影,看着就是一堆乱码:

// 100MHz 分频出 1kHz 扫描节拍,供数码管位选使用 reg [16:0] cnt; always @(posedge clk_100m or negedge rst_n) begin if (!rst_n) cnt <= 17'd0; else if (cnt == 17'd99_999) cnt <= 17'd0; // 100MHz / 100000 = 1kHz else cnt <= cnt + 1'b1; end wire tick_1k = (cnt == 17'd99_999); // 每 1ms 来一个脉冲

99_999这个数是这么来的:系统时钟 100MHz,除以 100000 得到 1kHz,计数器从 0 数到 99999。要显示 8 位十六进制,就每来一个 tick_1k 切换一位位选,段选查表输出。这套逻辑跟 CPU 本身完全解耦,用value端口接u_rf.rf[3]之类的层次引用即可,不会影响综合结果。

LED 那边同理,接线时注意 IOSTANDARD 必须和板子电平匹配,LVCMOS33 接 1.8V 的 bank 会直接不亮,而且不一定报错。

4.3 用 mark_debug + ILA 看 pc 和 instr

仿真里能看到一切,上板之后就只剩几个 LED,这时候 ILA 是唯一的后悔药。Vivado 里最省事的做法不是手写 ILA IP,而是在 RTL 里给要观察的信号打标记:

// 在 mips_top.v 里加这几行,把关键信号暴露给调试核 (* mark_debug = "true" *) wire [31:0] pc_dbg = pc; (* mark_debug = "true" *) wire [31:0] instr_dbg = instr; (* mark_debug = "true" *) wire zero_dbg = zero; (* mark_debug = "true" *) wire [31:0] alu_dbg = alu_y;

流程是:综合(不实现)→ 打开综合后设计 → Set Up Debug 向导 → 采样时钟选sys_clk→ 深度填 1024 → 保存 → 重新实现并生成 bitstream。采样深度 1024 已经够看清几十条指令的退休过程,再大就会把 BRAM 吃光。

抓波形时把pc_dbg设成触发条件,比如pc_dbg == 32'h0000_000C,就能精确停在第 4 条指令上。ILA 的采样时钟必须是真正驱动被测逻辑的那个时钟,接错时钟会抓到一堆恒定值,看着像设计死了。

综合完之后顺手跑一次report_timing_summary -delay_type max,看 WNS 是正还是负。WNS 为负说明约束定高了,先降频率把功能验完,别在时序上死磕。

5. 避坑与排查:单周期CPU最容易翻车的五个地方

5.1 PC 一直是 x 或者停在 0 不动

现象:仿真波形里pc从 0 时刻起就是红色 x,或者复位释放后一直保持 0x00000000。

原因:testbench 里rst_n声明成 reg 但没给初值,仿真开始时是 x,negedge rst_n这个事件永远等不到;另一种情况是 PC 的 always 块里混用了阻塞赋值,导致多个驱动源打架。

解决:tb 里initial块第一句就写rst_n = 1'b0;,保持至少两个时钟周期再释放;PC 更新一律用<=。如果还是不动,把pc_next加进波形,看它是不是恒等于 pc,那就说明选择逻辑被优化掉了。

5.2 add 算得对,lw 写回的寄存器全错位

现象:R 型指令结果正确,一旦跑 lw,数据写进了一个完全不相干的寄存器,寄存器堆里的值像被洗过一遍。

原因:写口地址wa3的选择写反了。R 型的目的寄存器在instr[15:11](rd),lw 在instr[20:16](rt),这两个位段只差 5 位,肉眼扫代码很难发现。

解决:assign wa3 = reg_dst ? instr[15:11] : instr[20:16];单独抽成一行,别塞进寄存器堆例化里。验证方法是只跑一条lw $4,0($1),在波形里对着 instr 的位段一位一位数。

5.3 beq 跳转目标差 4 字节,或者跳到半条指令上

现象:条件成立时 PC 跳过去,但落点比预期早 4 字节或晚了两个字节,后面的指令全乱。

原因:分支偏移的基准搞错了。MIPS 的分支地址是pc+4 + (imm << 2),有人用pc做基准,有人忘了左移 2 位(因为指令是 4 字节对齐,立即数存的是字偏移),还有人左移了 4 位。

解决:把这一行写成独立 wire 并加注释,br_target = pc_plus4 + (sign_ext << 2)。验证时手算一遍:pc=0x00、imm=1,目标应该是 0x08 而不是 0x04 或 0x10。

5.4 综合报告里刷出一片 inferred latch

现象:综合完成,Critical Warnings 里几十条 "inferred latch",上板后控制信号时好时坏。

原因:control或某个组合 always 块里没有覆盖所有分支,工具就认为「信号需要保持原值」,于是推断出锁存器。锁存器对时钟敏感、对毛刺敏感,在 FPGA 里基本等于不可控。

解决:every 组合块的第一句就是整体清零(像 3.3 那样{...} = 10'b0;),或者每个 case 分支都补齐所有输出。改完重新综合,警告清零才算过关。

5.5 仿真时指令存储器读出全是 x

现象:testbench 跑起来,instr全是 x,PC 正常走但什么都没执行。

原因:$readmemh("imem.hex", rom)用的是相对路径,Vivado 的 xsim 在proj.sim/sim_1/behav/xsim/目录下启动,找不到工程根目录的那个 hex 文件,而且这个失败很多时候只打一行 warning 就过去了。

解决:把 hex 文件加进 sim_1 fileset,或者用绝对路径调试、相对路径交付;更稳的做法是在 tb 里判断if (u_imem.rom[0] === 32'hx) $display("hex 没加载上");,让它主动报出来。上板时 Vivado 综合会把$readmemh变成 BRAM 初值,这一条依然有效。

6. 进阶:把 CPI 与关键路径算清楚,再决定要不要碰流水线

单周期 CPU 的 CPI 恒等于 1,这个数字很好看,但它掩盖了真正的问题——时钟周期被最慢的那条指令拖着。程序执行时间 = 指令数 × CPI × 周期,CPI 已经是 1 了,能动的只剩周期。

先把 lw 那条路的延迟拆开估一遍(数量级参考,实际以工具报告为准):

路径段典型延迟(ns)
PC 触发器 clk-to-q0.5
指令存储器读出2.0
寄存器堆读出1.5
32 位 ALU2.5
数据存储器读出2.5
写口建立时间0.5
合计≈9.5

9.5ns 对应理论 105MHz,但综合报告的 WNS 通常会把实际可用频率压到 60~80MHz。真实数字只能从工具里拿:跑完实现,open_run impl_1之后执行report_timing_summary -delay_type max -max_paths 10,看最差那条路径的 Slack 和 Data Path Delay,把 Data Path Delay 加上 clock uncertainty 就是实际周期下限。

拿这个数去和另外两种结构对比,才知道要不要改:多周期 CPU 把一条指令拆成 3~5 拍,周期能压到 ALU 那一段的延迟(约 4ns),但 CPI 变成 3 以上;流水线把 CPI 拉回 1 附近,周期也能压到 4ns 左右,代价是要处理数据冒险、控制冒险和转发。粗略算一下:单周期 70MHz × CPI 1 = 70M 指令/秒;五级流水 200MHz × CPI 1.2 ≈ 166M 指令/秒。差一倍多,这就是为什么课上讲完单周期必然要讲流水线。

从那以后我每次写单周期 CPU,都强制自己先把 lw 的关键路径手算一遍、再拿report_timing_summary对一遍,两个数字对不上就说明我对设计的理解有偏差,绝不带着这个偏差去改流水线——不然冒险处理只会把错误放大十倍。希望帮到你。

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

RELATED READING

延伸阅读

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