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 条指令全实现结果译码表写到一半控制信号已经十几根仿真波形一片红。比较稳的做法是先砍到 810 条覆盖全部五类通路行为再往外扩。判断标准很简单指令集里必须同时存在「只用 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 标志 → 改 PCjj 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 口。链路最长的是 lwPC → 指令存储器 → 寄存器堆 → ALU → 数据存储器 → 寄存器堆写口。这一条决定了整个设计的最高主频第 6 章会拿它算具体数字。现在的关键是意识到单周期不是「简单」而是「把复杂度全压在时钟周期上」。2.3 控制信号真值表怎么推从 opcode 到 8 根线的映射控制单元不靠背靠反推。方法是拿每条指令问三个问题这条指令写不写寄存器ALU 的第二个操作数从哪来下一条 PC 会不会变三个答案基本就把控制信号定死了。指令opcodeRegDstALUSrcMemtoRegRegWriteMemReadMemWriteBranchJumpALUOpR 型0000001001000010addi0010000101000000lw1000110111100000sw101011x1x0010000beq000100x0x0001001j000010xxx00001xx表里的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不直接吃 functrtl/regfile.v32×32 寄存器堆$0 硬连 0写口同步、读口异步rtl/imem.v指令存储器$readmemh初始化综合成 BRAMrtl/dmem.v数据存储器只做 32 位字对齐访问rtl/control.v主控制单元纯组合块首给默认值防 latchrtl/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 32d0); // beq 靠它判断相等 always (*) begin case (alu_ctrl) 4b0010: y a b; // add/addi/lw/sw 算地址 4b0110: y a - b; // sub/beq 比较 4b0000: y a b; // and 4b0001: y a | b; // or 4b0111: y ($signed(a) $signed(b)) ? 32d1 : 32d0; // slt必须带 $signed default: y 32d0; endcase end endmodulezero用连续赋值从 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] 32d0; // 仅仿真有效上板靠复位初值 end assign rd1 (ra1 5d0) ? 32d0 : rf[ra1]; // 读口异步$0 硬连 assign rd2 (ra2 5d0) ? 32d0 : rf[ra2]; always (posedge clk) begin if (we3 wa3 ! 5d0) rf[wa3] wd3; // 写口同步且禁止写 $0 end endmoduleinitial块在 FPGA 上确实能被综合成上电初值多数工具支持但别指望它替代复位。血泪经验是上板后按一下复位键寄存器值应该是全 0如果不是说明复位链路没接对而不是 initial 没生效。3.3 控制单元与顶层连线把 8 根控制线接到正确位置控制单元照 2.3 的真值表直译块首整体清零防 latchmodule 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} 10b0; // 默认全 0防推断锁存器 case (opcode) 6b000000: begin reg_dst 1b1; reg_write 1b1; alu_op 2b10; end // R 型 6b001000: begin alu_src 1b1; reg_write 1b1; alu_op 2b00; end // addi 6b100011: begin alu_src 1b1; mem_to_reg 1b1; reg_write 1b1; mem_read 1b1; alu_op 2b00; end // lw 6b101011: begin alu_src 1b1; mem_write 1b1; alu_op 2b00; end // sw 6b000100: begin branch 1b1; alu_op 2b01; end // beq 6b000010: begin jump 1b1; end // j default: ; // 其余当 nop endcase end endmodule顶层就是把上面这些拼起来注意pc_next的三路选择优先级jump 最高其次 branch zero最后才是 pc4。写成一个嵌套三目表达式比用多个 always 块清楚// 分支目标以 pc4 为基准偏移量左移 2 位 wire [31:0] br_target pc_plus4 (sign_ext 2); wire [31:0] pc_next jump ? {pc_plus4[31:28], instr[25:0], 2b00} : (branch zero) ? br_target : pc_plus4; always (posedge clk or negedge rst_n) begin if (!rst_n) pc 32d0; else pc pc_next; // 非阻塞赋值别用 end{pc_plus4[31:28], instr[25:0], 2b00}这一段是 j 指令的地址拼接高 4 位取 pc4 的高 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, $2testbench 要做的就是产生时钟、拉复位、等几条指令退休、把寄存器值打印出来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 1b0; always #5 clk ~clk; // 100MHz 仿真时钟 initial begin rst_n 1b0; // 先拉低复位别留 x #20 rst_n 1b1; #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期望输出是x15 x23 x38。如果 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.5ns32 位加法器 2.5ns数据存储器读出 2.5ns写口建立时间 0.5ns加起来接近 9.5ns留 20% 余量就是 1112ns对应 8090MHz。但这是理想数字实际综合出来的分布逻辑和布线延迟会明显吃掉一部分。我的习惯是先用 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 17d0; else if (cnt 17d99_999) cnt 17d0; // 100MHz / 100000 1kHz else cnt cnt 1b1; end wire tick_1k (cnt 17d99_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 32h0000_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 但没给初值仿真开始时是 xnegedge rst_n这个事件永远等不到另一种情况是 PC 的 always 块里混用了阻塞赋值导致多个驱动源打架。解决tb 里initial块第一句就写rst_n 1b0;保持至少两个时钟周期再释放PC 更新一律用。如果还是不动把pc_next加进波形看它是不是恒等于 pc那就说明选择逻辑被优化掉了。5.2 add 算得对lw 写回的寄存器全错位现象R 型指令结果正确一旦跑 lw数据写进了一个完全不相干的寄存器寄存器堆里的值像被洗过一遍。原因写口地址wa3的选择写反了。R 型的目的寄存器在instr[15:11]rdlw 在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 的分支地址是pc4 (imm 2)有人用pc做基准有人忘了左移 2 位因为指令是 4 字节对齐立即数存的是字偏移还有人左移了 4 位。解决把这一行写成独立 wire 并加注释br_target pc_plus4 (sign_ext 2)。验证时手算一遍pc0x00、imm1目标应该是 0x08 而不是 0x04 或 0x10。5.4 综合报告里刷出一片 inferred latch现象综合完成Critical Warnings 里几十条 inferred latch上板后控制信号时好时坏。原因control或某个组合 always 块里没有覆盖所有分支工具就认为「信号需要保持原值」于是推断出锁存器。锁存器对时钟敏感、对毛刺敏感在 FPGA 里基本等于不可控。解决every 组合块的第一句就是整体清零像 3.3 那样{...} 10b0;或者每个 case 分支都补齐所有输出。改完重新综合警告清零才算过关。5.5 仿真时指令存储器读出全是 x现象testbench 跑起来instr全是 xPC 正常走但什么都没执行。原因$readmemh(imem.hex, rom)用的是相对路径Vivado 的 xsim 在proj.sim/sim_1/behav/xsim/目录下启动找不到工程根目录的那个 hex 文件而且这个失败很多时候只打一行 warning 就过去了。解决把 hex 文件加进 sim_1 fileset或者用绝对路径调试、相对路径交付更稳的做法是在 tb 里判断if (u_imem.rom[0] 32hx) $display(hex 没加载上);让它主动报出来。上板时 Vivado 综合会把$readmemh变成 BRAM 初值这一条依然有效。6. 进阶把 CPI 与关键路径算清楚再决定要不要碰流水线单周期 CPU 的 CPI 恒等于 1这个数字很好看但它掩盖了真正的问题——时钟周期被最慢的那条指令拖着。程序执行时间 指令数 × CPI × 周期CPI 已经是 1 了能动的只剩周期。先把 lw 那条路的延迟拆开估一遍数量级参考实际以工具报告为准路径段典型延迟nsPC 触发器 clk-to-q0.5指令存储器读出2.0寄存器堆读出1.532 位 ALU2.5数据存储器读出2.5写口建立时间0.5合计≈9.59.5ns 对应理论 105MHz但综合报告的 WNS 通常会把实际可用频率压到 6080MHz。真实数字只能从工具里拿跑完实现open_run impl_1之后执行report_timing_summary -delay_type max -max_paths 10看最差那条路径的 Slack 和 Data Path Delay把 Data Path Delay 加上 clock uncertainty 就是实际周期下限。拿这个数去和另外两种结构对比才知道要不要改多周期 CPU 把一条指令拆成 35 拍周期能压到 ALU 那一段的延迟约 4ns但 CPI 变成 3 以上流水线把 CPI 拉回 1 附近周期也能压到 4ns 左右代价是要处理数据冒险、控制冒险和转发。粗略算一下单周期 70MHz × CPI 1 70M 指令/秒五级流水 200MHz × CPI 1.2 ≈ 166M 指令/秒。差一倍多这就是为什么课上讲完单周期必然要讲流水线。从那以后我每次写单周期 CPU都强制自己先把 lw 的关键路径手算一遍、再拿report_timing_summary对一遍两个数字对不上就说明我对设计的理解有偏差绝不带着这个偏差去改流水线——不然冒险处理只会把错误放大十倍。希望帮到你。本文还有配套的精品资源点击获取