单周期CPU设计实战:Verilog数据通路与Vivado仿真上板

发布时间:2026/10/26 10:40:11
单周期CPU设计实战:Verilog数据通路与Vivado仿真上板

简介:面向计算机组成原理、FPGA入门与Verilog实践学习者的单周期CPU设计工程,围绕取指、译码、执行、写回四个阶段构建简化处理器模型,可在Vivado中完成代码组织、行为仿真、综合、实现与下载验证,适合课程设计、实验课及自学处理器工作原理的人群。压缩包共107个文件,约1.31MB,以Verilog源码、Vivado工程文件为主,辅以日志、仿真数据库、批处理脚本和综合报告等调试产物,能够还原从工程建立到上板验证的完整设计痕迹。已有3635人学习,说明该案例在入门群体中具有一定参考价值。读者可据此梳理程序计数器、指令寄存器、译码器、ALU、寄存器堆与控制单元的连接关系,理解单周期时序约束和指令扩展的修改位置,并借助日志与报告排查综合、仿真中的常见问题,形成可复用的FPGA开发流程认知。

1. 单周期 CPU 到底是什么:一条 add 指令在一个时钟周期里走完全部链路

前几年帮人看课程设计,最常见的一幕是:Vivado 工程建好了,几个 .v 文件也加进去了,仿真跑起来 PC 每拍都在动,可寄存器堆里的值就是不更新,学生盯着波形看了两小时,最后发现是写回地址接错了。单周期 CPU 这类项目,难的地方从来不是某一句话写不出来,而是你要在动手前就把「一条指令在一个时钟周期内依次经过取指、译码、执行、访存、写回,全靠组合逻辑串起来」这件事想清楚。这份单周期 CPU 设计用 Verilog 实现了一套 MIPS 子集,覆盖 add、sub、and、or、lw、sw、beq、addi、j 九条指令,配套 Vivado 工程能做行为仿真、综合和上板。适合刚学完数字逻辑、想拿一套能跑通的完整代码练手的人,也适合要带课设、需要一份干净参考实现的人。

2. 工程结构与数据通路:先看清单周期 CPU 的模块划分

拿到一个 CPU 工程,我的习惯是先不看代码,先看目录。因为单周期 CPU 的模块划分非常固定,目录结构基本能反映出作者有没有把「控制通路」和「数据通路」这两条线分开。分不开的工程,后面加一条指令就要动五个文件,改到最后自己都不认识。

2.1 目录与文件划分:一个模块一个文件

这套工程的组织方式是这样的,rtl 下每个 .v 只放一个 module,顶层 cpu_top.v 只做例化和连线,不写任何 always 块:

single_cycle_cpu/ ├── rtl/ │ ├── cpu_top.v # 顶层,只做例化和 mux 连线 │ ├── pc_reg.v # 程序计数器 │ ├── imem.v # 指令存储器(ROM,$readmemh 初始化) │ ├── regfile.v # 32x32 寄存器堆,双读单写 │ ├── alu.v # 算术逻辑单元 │ ├── ctrl_unit.v # 主控制单元,译码出全部控制信号 │ └── dmem.v # 数据存储器(RAM) ├── sim/ │ ├── tb_cpu.v # 仿真顶层 │ └── inst.hex # 机器码,一行一条指令 ├── constr/ │ └── cpu.xdc # 引脚与时序约束 └── vivado/ └── cpu.xpr # 工程文件

这么拆的好处很直接:加指令只改 ctrl_unit 的 case 和 alu 的 case;换存储器只改 imem/dmem;想换成流水线,顶层推倒重连,下面五个模块一个都不用动。血泪经验是,千万别把寄存器堆和 ALU 塞进顶层一个 always 里图省事,等到要加转发或者要上板抓 ILA,你会发现自己连个能单独例化的探针都挂不上去。

要注意 inst.hex 这份文件,仿真时 Vivado 在 sim 目录下找它,综合时却在工程根目录下找,路径不对综合不会报错,只会悄悄生成一块全 0 的 ROM。这个坑放到第 5 章细说。

2.2 数据通路的五段式连法:把线画在纸上再写代码

单周期 CPU 的数据通路是一条单向往前的链,没有任何反馈(除了 PC 自己)。写代码之前我一般会在纸上把这几个节点的位宽标出来:

信号位宽来源去向
pc32pc_regimem、pc+4 加法器
instr32imem译码、控制单元、立即数扩展
rd1 / rd232regfileALU 的 A 口 / 数据存储器写口
imm_ext32立即数扩展ALU 的 B 口(经 ALUSrc 选择)
alu_y32aludmem 地址、写回 mux
mem_rdata32dmem写回 mux
wb_data32写回 muxregfile 写口

这条链上有三个关键的多路选择器,是整个设计的「关节」:ALUSrc 决定 ALU 的 B 口吃寄存器还是吃立即数(lw/sw/addi 吃立即数,R 型和 beq 吃寄存器);MemToReg 决定写回的是 ALU 结果还是存储器读出的数据(lw 走后者);RegDst 决定写的是 rd 还是 rt(R 型写 rd,lw/addi 写 rt)。很多工程跑不对,问题就出在这三个 mux 的选择信号接反了或者干脆没接。

PC 的下一条指令也有三条路可选,优先级是 jump > (branch & zero) > pc+4。这个优先级不是随便定的:j 指令是无条件跳,跳完就不该再看 beq 的判断;beq 只有 ALU 减出 zero=1 时才跳;都不满足才顺序执行。写成一行三目运算最清楚:

assign pc_next = jump ? jump_target : (branch & zero) ? branch_target : pc_plus4;

分支目标地址是pc_plus4 + (imm_ext << 2),注意是 pc+4 再加偏移,不是 pc 加偏移——因为 MIPS 的分支偏移是相对于下一条指令的。这个 +4 漏掉,跳转就会整体偏一条指令,波形上看起来「跳了但跳歪了」,特别难查。

2.3 控制信号表:先列表再写 Verilog

控制单元本质上就是一张查找表。我建议先在文档里把表填满,再照着表写 case,比边写边想靠谱得多。这套实现的指令子集对应的表是这样:

指令opcodeRegDstRegWriteALUSrcMemWriteMemToRegBranchJumpALUCtrl
R 型0000001100000由 funct 定
lw10001101101000010
sw101011x011x000010
beq000100x000x100110
addi00100001100000010
j000010x0x0x01xxxx

ALUCtrl 的编码我自己习惯用四位:0000 与、0001 或、0010 加、0110 减。选 0110 当减法不是为了好看,是为了让「加」和「减」只差一位,当年有些教材用 0010/0011 表示加减,结果做 ALU 位切片实验时容易看混。这里不用纠结,全工程统一就行,关键是 ctrl_unit 和 alu 两边的 case 必须一字不差地对上。

表里那些标 x 的项,在 Verilog 里最好也给个确定值(一般给 0),不要真的写4'bx。综合工具遇到 x 会当成 don't care 去优化,仿真和上板行为可能不一致,这是典型的「仿真对、板子跑飞」来源之一。

3. Vivado 工程搭建与仿真:从新建工程到波形对上

代码写完了不等于跑通了。Vivado 这套工具链的坑,一半在综合设置,一半在仿真顶层没设对。这一章按我实际的操作顺序走一遍,从新建工程到上板看到寄存器值变化。

3.1 新建工程与文件加入顺序

新建工程时选 RTL Project,勾上「Do not specify sources at this time」,先把工程建空。原因很简单:如果建工程时一次性把文件加进去,Vivado 会自动去猜哪个是顶层,猜错了后面要手动改,还容易在综合时留下残余的 top 设置。

工程建好后分三步:

  1. Add Sources → Add or create design sources,把 rtl 下 7 个 .v 全部加进来;
  2. 右键 cpu_top.v → Set as Top,确认 Sources 窗口里 cpu_top 是加粗的;
  3. Add Sources → Add or create simulation sources,加入 sim/tb_cpu.v,然后右键它 → Set as Top(这一步是在 Simulation Sources 那一栏里操作,不是 Design Sources)。

第 3 步是最容易漏的。Design Sources 的 top 和 Simulation Sources 的 top 是两个独立设置,只设了前者,点 Run Simulation 时 Vivado 会把 cpu_top 当仿真顶层跑,它没有激励,波形上全是 X,你会以为是复位逻辑写错了。

另外顺手把仿真时间改一下。默认 1000ns 对单周期 CPU 来说太短,一条指令一个周期,跑十来条指令就结束了。在 Settings → Simulation 里把 xsim.simulate.runtime 改成 10us 或者直接 all,省得反复重跑。

3.2 testbench 与机器码:让 CPU 有事可做

仿真顶层不需要太花哨,给时钟、给复位、跑一段时间后把关键寄存器打印出来就够了。下面这份 tb 我一直在用:

`timescale 1ns / 1ps module tb_cpu; reg clk; reg rst_n; // 例化被测设计 cpu_top u_cpu ( .clk (clk), .rst_n (rst_n) ); // 10ns 周期,对应 100MHz,先跑通再说降频的事 initial clk = 1'b0; always #5 clk = ~clk; initial begin rst_n = 1'b0; #100 rst_n = 1'b1; // 复位保持 10 个周期,确保 PC 归零 #2000; // 跑 200 个周期,足够执行完测试程序 $display("x1=%0d x2=%0d x3=%0d x4=%0d", u_cpu.u_rf.rf[1], u_cpu.u_rf.rf[2], u_cpu.u_rf.rf[3], u_cpu.u_rf.rf[4]); $display("mem[0]=%0d", u_cpu.u_dmem.mem[0]); $finish; end endmodule

这段 tb 有三个地方值得说。复位拉低 100ns 是为了避开 xsim 的初始化时序,有些版本里复位只给一两个周期,PC 还没来得及清零仿真就往下走了。u_cpu.u_rf.rf[1]这种层次化引用只在仿真里能用,综合时会报错,所以只写在 tb 里,绝不写进 rtl。最后$display打印的是十进制,比在波形里数位宽快得多。

配套的 inst.hex 是一段能验证加减、访存和分支的小程序,每行一个 32 位机器码:

2001000A 20020014 00221820 AC030000 8C040000 10830002 00000000 00000000

逐行对一下:2001000A是 addi $1,$0,10,20020014是 addi $2,$0,20,00221820是 add $3,$1,$2,AC030000是 sw $3,0($0),8C040000是 lw $4,0($0),10830002是 beq $4,$3,+2(跳过后两条)。跑完这段,理想结果是 x1=10、x2=20、x3=30、x4=30、mem[0]=30。如果 x4 是 0,说明 lw 的写回路径断了;如果 x3 是 0,说明 R 型的 RegDst 没打开。

机器码可以手算,也可以用汇编器生成,但手算一遍的价值在于你会真正理解 R 型指令那六个字段是怎么拼进 32 位的。第一次做这个项目,建议至少手动拼三条。

3.3 综合、约束与上板验证

仿真波形对上之后才轮到综合。先点 Run Synthesis,看综合报告里有没有 latch 推断的警告——有的话别急着上板,回第 5 章看第三条。综合过了再点 Implementation,最后 Generate Bitstream。

引脚约束这块,不同板子的引脚号完全不一样,下面这份 XDC 只展示写法,两个 PACKAGE_PIN 一定要换成你自己板子原理图上的编号:

# 时钟约束:50MHz 输入,先不追求频率,跑通最重要 create_clock -period 20.000 -name sys_clk [get_ports clk] # 引脚分配,按你手上板子的原理图替换 L16 / R2 set_property -dict {PACKAGE_PIN L16 IOSTANDARD LVCMOS33} [get_ports clk] set_property -dict {PACKAGE_PIN R2 IOSTANDARD LVCMOS18} [get_ports rst_n]

上板之后怎么知道 CPU 真的在跑?最省事的办法是把{u_cpu.pc, u_cpu.u_rf.rf[3]}接出来,用 ILA 抓。做法是在 rtl 里给想看的信号加一句(* mark_debug = "true" *),综合后在 Synthesized Design 里 Set Up Debug,把 PC 和 x3 拉进 ILA。抓到的波形应该是 PC 每次加 4,x3 在第 3 条指令之后跳到 30。

用 ILA 而不是用 LED 看结果,原因是 LED 只能告诉你「是 30」或者「不是 30」,而 ILA 能告诉你「在第几拍变成 30 的」。单周期 CPU 里 PC 是一条绝对可靠的时间轴,任何异常都可以拿 PC 去对指令,一抓一个准。

4. 核心模块代码拆解:PC、译码、ALU 与寄存器堆逐段讲

模块划分和数据通路都清楚了,这一章直接进代码。我按数据流的顺序拆,每个模块给一段能直接编译的实现,然后说清楚关键语句为什么这么写、参数怎么改。

4.1 程序计数器与指令存储器:取指这一段

PC 就是一个带异步复位和使能的 32 位寄存器,写起来没什么花头,但复位方式选错会直接影响上板表现:

module pc_reg ( input wire clk, input wire rst_n, // 低电平有效异步复位 input wire [31:0] pc_next, output reg [31:0] pc ); always @(posedge clk or negedge rst_n) begin if (!rst_n) pc <= 32'h0000_0000; // 复位后从 0 地址开始取指 else pc <= pc_next; end endmodule

这里用异步复位,是为了让板子上按一下复位键就能立刻归零,不用等下一个时钟沿。代价是复位释放的瞬间要保证时钟稳定,所以 testbench 里复位信号最好同步于时钟下降沿释放。如果后面要做多周期或者流水线,我会把它改成同步复位,那样时序分析更干净。

指令存储器用 ROM 实现,靠$readmemh从文件加载机器码:

module imem ( input wire [31:0] addr, output wire [31:0] instr ); reg [31:0] rom [0:255]; // 256 条指令,够课设用了 initial begin $readmemh("inst.hex", rom); // 综合时会在工程根目录找这个文件 end assign instr = rom[addr[9:2]]; // 字对齐,忽略低两位 endmodule

addr[9:2]这八位是这么来的:256 个单元需要 8 位索引,而 PC 是按字节寻址、指令是按字对齐的,所以低两位直接丢掉,用第 9 位到第 2 位正好。如果你的程序超过 256 条,把数组开大、索引位宽跟着改,比如 1024 条就用addr[11:2]。

关于$readmemh有两点必须强调:一是它写在 initial 块里,综合工具能识别并把它转成 BRAM 的初始化数据,这是可综合的;二是文件路径是相对于 Vivado 工程的当前工作目录,不是相对于 .v 文件,所以综合和仿真找的目录不一样。第 5 章会专门讲这个坑。

4.2 寄存器堆与立即数扩展:译码段的两件事

寄存器堆是双读单写的三端口存储器,写法上最大的争议是「读要不要时序」。单周期 CPU 为了在一个周期内走完全程,读口必须用组合逻辑,写口才用时钟沿:

module regfile ( input wire clk, input wire we, // 写使能,来自控制单元 input wire [4:0] ra1, ra2, // 两个读地址 input wire [4:0] wa, // 写地址 input wire [31:0] wd, // 写数据 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'b0; // 仿真初值,避免波形一片红 end // $0 恒为 0,读的时候直接返回 0,不依赖数组内容 assign rd1 = (ra1 == 5'd0) ? 32'b0 : rf[ra1]; assign rd2 = (ra2 == 5'd0) ? 32'b0 : rf[ra2]; always @(posedge clk) begin if (we && (wa != 5'd0)) rf[wa] <= wd; // 写使能有效且不是 $0 才写 end endmodule

三个细节。ra1 == 5'd0的三目判断不能省,虽然 initial 里已经把 rf[0] 清成 0,但只要有一条指令往 $0 写成功,它就再也回不到 0 了。wa != 5'd0这道锁也是同一个原因,双保险。initial 块负责给仿真一个确定的初值,不然波形里 x1 到 x31 全是 X,你根本分不清是没写进去还是写了但看不到。

立即数扩展模块一般单独写成组合逻辑,也可以直接塞在顶层:

// 16 位立即数符号扩展到 32 位,addi/lw/sw/beq 共用 assign imm_ext = {{16{instr[15]}}, instr[15:0]};

{{16{instr[15]}}, instr[15:0]}这个写法是 Verilog 的位拼接加复制,把最高位复制 16 份填到高半部分。这样正数保持正数,负数也保持负数。新手常犯的错是写成{16'b0, instr[15:0]},lw 取负偏移地址时会算出一个极大的正数,访问到存储器外面去,仿真直接越界。

4.3 ALU 与控制单元:译码结果是控制信号的集合

ALU 是纯组合逻辑,用 case 实现四种运算。这里有个小设计选择:减法用a - b而不是a + (~b) + 1,虽然两者综合出来的电路基本一样,但写减法可读性好得多,出问题的时候不需要在脑子里做补码变换:

module alu ( input wire [31:0] a, b, input wire [3:0] ctrl, output reg [31:0] y, output wire zero ); always @(*) begin case (ctrl) 4'b0000: y = a & b; // and 4'b0001: y = a | b; // or 4'b0010: y = a + b; // add / lw / sw / addi 4'b0110: y = a - b; // sub / beq default: y = 32'b0; // 必须给默认值,否则综合出锁存器 endcase end assign zero = (y == 32'b0); // beq 靠这个信号判断是否相等 endmodule

default分支不是可选项。写了 default,综合出来是纯粹的组合逻辑;不写,工具会认为 ctrl 取到未列举值时输出应该「保持」,于是推断出一个锁存器,然后给你一条 LATCG 警告。锁存器在这个设计里没有任何用处,还会让时序分析变得很难看。

控制单元按第 2 章那张表来实现,核心是一个双层 case:外层看 opcode,R 型再进内层看 funct:

always @(*) begin // ---- 先给全部输出赋默认值,这一句省掉就是一堆 latch ---- reg_dst = 1'b0; reg_we = 1'b0; alu_src = 1'b0; mem_we = 1'b0; mem_to_reg = 1'b0; branch = 1'b0; jump = 1'b0; alu_ctrl = 4'b0010; case (opcode) 6'b000000: begin // R 型指令 reg_dst = 1'b1; reg_we = 1'b1; case (funct) 6'b100000: alu_ctrl = 4'b0010; // add 6'b100010: alu_ctrl = 4'b0110; // sub 6'b100100: alu_ctrl = 4'b0000; // and 6'b100101: alu_ctrl = 4'b0001; // or default: alu_ctrl = 4'b0010; endcase end 6'b100011: begin // lw reg_we = 1'b1; alu_src = 1'b1; mem_to_reg = 1'b1; alu_ctrl = 4'b0010; end 6'b101011: begin // sw alu_src = 1'b1; mem_we = 1'b1; alu_ctrl = 4'b0010; end 6'b000100: begin // beq branch = 1'b1; alu_ctrl = 4'b0110; end 6'b001000: begin // addi reg_we = 1'b1; alu_src = 1'b1; alu_ctrl = 4'b0010; end 6'b000010: jump = 1'b1; // j default: ; // 未定义指令,全默认值 endcase end

块首那一排默认赋值是整个控制单元最重要的几句话。Verilog 的 always @(*) 会推断组合逻辑,任何一个输出在所有分支里没有赋值,综合工具就认为它需要保持原值,于是生成锁存器。把默认值写在最前面,后面每个分支只覆盖需要改的信号,既不用每次都写全,又不会漏。

default: ;这个空分支是为了让未定义指令落到「什么也不做」的状态,而不是让工具去猜。有些实现会在这里做非法指令异常,那是后面扩展的事。

4.4 顶层连线:把 mux 和写回路径接起来

顶层除了例化,主要工作就是把三个 mux 和两个地址加法器接出来:

module cpu_top ( input wire clk, input wire rst_n ); wire [31:0] pc, pc_next, pc_plus4, instr; wire [31:0] rd1, rd2, imm_ext, alu_b, alu_y, wb_data, mem_rdata; wire [4:0] rs, rt, rd, wa; wire [31:0] branch_target, jump_target; wire zero; wire [3:0] alu_ctrl; wire reg_dst, reg_we, alu_src, mem_we, mem_to_reg, branch, jump; // 指令字段切分,只切一次,后面到处用 assign rs = instr[25:21]; assign rt = instr[20:16]; assign rd = instr[15:11]; // 三个关键选择器 assign wa = reg_dst ? rd : rt; // 写哪个寄存器 assign alu_b = alu_src ? imm_ext: rd2; // ALU B 口吃什么 assign wb_data = mem_to_reg ? mem_rdata : alu_y; // 写回什么数据 assign imm_ext = {{16{instr[15]}}, instr[15:0]}; assign pc_plus4 = pc + 32'd4; assign branch_target = pc_plus4 + (imm_ext << 2); assign jump_target = {pc_plus4[31:28], instr[25:0], 2'b00}; // 下一条 PC 的优先级:jump > branch > 顺序执行 assign pc_next = jump ? jump_target : (branch & zero) ? branch_target : pc_plus4; // 例化各模块 pc_reg u_pc (.clk(clk), .rst_n(rst_n), .pc_next(pc_next), .pc(pc)); imem u_im (.addr(pc), .instr(instr)); regfile u_rf (.clk(clk), .we(reg_we), .ra1(rs), .ra2(rt), .wa(wa), .wd(wb_data), .rd1(rd1), .rd2(rd2)); ctrl_unit u_ctl(.opcode(instr[31:26]), .funct(instr[5:0]), .reg_dst(reg_dst), .reg_we(reg_we), .alu_src(alu_src), .mem_we(mem_we), .mem_to_reg(mem_to_reg), .branch(branch), .jump(jump), .alu_ctrl(alu_ctrl)); alu u_alu (.a(rd1), .b(alu_b), .ctrl(alu_ctrl), .y(alu_y), .zero(zero)); dmem u_dm (.clk(clk), .we(mem_we), .addr(alu_y), .wdata(rd2), .rdata(mem_rdata)); endmodule

assign jump_target = {pc_plus4[31:28], instr[25:0], 2'b00};这一句是 MIPS 的 J 型指令格式决定的:目标地址的高 4 位取 PC+4 的高 4 位,中间 26 位来自指令字段,低 2 位补 0(字对齐)。如果你的程序全在低地址段,高 4 位其实一直是 0,直接写{4'b0, instr[25:0], 2'b00}也能跑,但那是凑巧,换个大点的程序就废了。

数据存储器的写法跟 imem 类似,只不过它有写口:

module dmem ( input wire clk, input wire we, input wire [31:0] addr, input wire [31:0] wdata, output wire [31:0] rdata ); reg [31:0] mem [0:255]; assign rdata = mem[addr[9:2]]; // 读是组合逻辑,单周期必须这样 always @(posedge clk) begin if (we) mem[addr[9:2]] <= wdata; end endmodule

读用assign、写用always @(posedge clk),是单周期 CPU 的标准做法。如果读也写成时序的,lw 指令拿到的就是上一拍的数据,必须得多加一个周期等它——那就不叫单周期了。

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

这个设计我从头到尾重写过三遍,前两遍踩的坑基本都在这五条里。每条按「看到什么现象 → 为什么 → 怎么解决」写,遇到问题时可以对着排查。

5.1 波形上寄存器和 PC 全是 X

现象:Run Simulation 跑起来,PC 一直是红色的 X,寄存器堆里所有值也是 X,$display打印出来是 x。

原因:两种可能。一是复位没接好,testbench 里 rst_n 从头到尾是 0,或者复位时间太短,PC 还没被清零仿真就结束了;二是寄存器堆没写 initial 块,rf 数组在仿真开始时是未初始化的 X 态,读出来自然是 X。

解决:把 testbench 里的复位拉低时间加到 100ns 以上,确保覆盖至少 10 个时钟周期;寄存器堆加上initial for (i=0;i<32;i=i+1) rf[i]=0;。如果 PC 动了但某个寄存器还是 X,那就是写使能或者写地址那一侧的问题,顺着 reg_we 和 wa 两根线往回查。

5.2 仿真一切正常,综合后上板什么也不动

现象:行为仿真波形完美,x3 确实是 30,但生成 bitstream 上板后,ILA 抓到的 PC 一直停在 0,或者指令全是 00000000。

原因:$readmemh("inst.hex", rom)用的是相对路径。仿真时 Vivado 的工作目录是工程目录/工程名.sim/sim_1/behav/xsim,综合时是工程目录/工程名.runs/synth_1,两个地方都找不到 inst.hex,于是 rom 里全是默认值 0。关键是这种情况下综合不会报错,只在日志里留一条 "cannot open file" 的 warning。

解决:最稳妥的办法是把 inst.hex 复制一份到工程根目录(和 .xpr 同级),或者干脆在$readmemh里写绝对路径。综合完了一定要去 log 里搜一下 readmem,确认没有 "File not found"。这个坑特别阴,因为仿真能过,你会本能地怀疑硬件而不是文件路径。

5.3 综合报告里一堆 LATCG 警告,时序怎么都过不去

现象:Run Synthesis 之后 Critical Warnings 里出现几十条 "inferred latch",打开 Schematic 一看,控制单元变成了带反馈的结构。

原因:always @(*)块里某个输出没有在所有分支中被赋值,综合工具为了保证「值不变」,就给你生成了一个锁存器。最常见的是 case 语句漏了 default,或者像mem_to_reg这种只有 lw 用到的信号,在某些分支里忘了给值。

解决:在这个 always 块的第一行把所有输出赋一遍默认值,然后再进 case。第 4.3 节那段控制单元代码就是按这个套路写的。改完重新综合,LATCG 警告应该一条不剩。数字逻辑课上讲的「组合逻辑不能有记忆」,落到代码上就是这句话。

5.4 beq 跳转到了错误的位置,或者根本没跳

现象:程序里 beq $4,$3,+2 明明两个寄存器相等,PC 却跑到 PC+4 上去了;或者跳了,但跳到了偏移量一半的位置。

原因:三种可能。一是 zero 信号没接对,beq 时 ALU 的 ctrl 应该是减法(0110),如果错写成加法,zero 永远不成立;二是分支目标算成了pc + (imm<<2),漏掉了 +4;三是 pc_next 的三目运算优先级写反,把 pc_plus4 放在了最前面。

解决:先用 ILA 或者仿真波形单独看 zero 在 beq 那一拍是不是 1,确认 ALU 的 ctrl 是 0110;再把 branch_target 的表达式改成pc_plus4 + (imm_ext << 2)。如果跳转地址是目标地址的一半,那就是偏移量没左移两位,把<< 2补上。

5.5 $0 寄存器被写脏,后面的指令全乱

现象:仿真跑着跑着,本来应该是add $3,$0,$1的指令算出来一个莫名其妙的值,最后发现 $0 不等于 0 了。

原因:写口没有对 wa 做非零判断,某条指令(比如 addi $0,$0,5 这种手滑写出来的)真的把 0 号寄存器写了进去。寄存器堆里的 0 号单元一旦被写,就再也不会自动清零。

解决:写口加if (we && (wa != 5'd0)),读口加(ra1 == 5'd0) ? 32'b0 : rf[ra1],两道锁一起上。加了之后即使指令里真的写了 $0,读出来还是 0。我一般还会在 testbench 里跑完之后$display("rf[0]=%0d", u_cpu.u_rf.rf[0]);,确认它是 0 才收工。

6. 二次开发:从加一条指令到一个能跑流水线的版本

这套代码最容易上手的改造,是加指令。以加一条slt(小于则置 1)为例,整个流程只有四步:先在 ctrl_unit 的 R 型内层 case 里加6'b101010: alu_ctrl = 4'b0111;,再在 alu 的 case 里加4'b0111: y = ($signed(a) < $signed(b)) ? 32'd1 : 32'd0;,然后在控制表文档里补一行,最后在 inst.hex 里加两条测试指令,跑一遍仿真看结果。整个过程不改任何连线,因为 slt 走的是 R 型的标准通路。

第二条路是改多周期。单周期最大的问题是关键路径太长:取指要过 imem,译码要过 regfile,执行要过 ALU,访存要过 dmem,写回还要过 mux,全串在一个周期里。我用 50MHz 时钟综合过一次,WNS 直接是负的,时序根本不收敛,只能靠分频降到几 MHz 才能上板。改多周期的核心动作是把 PC 的更新逻辑拆成状态机,把一条指令拆到 3 到 5 个周期里做,每个周期只走一段组合逻辑,频率立刻能上去。代价是控制单元要重写,所有控制信号都得按时钟周期打拍。

第三条路是流水线,把五个阶段用寄存器隔开,理想情况下每个周期都能完成一条指令。这条路工作量最大,难点不在数据通路,而在数据冒险:相邻两条指令之间有依赖时,要么插气泡,要么做转发。转发路径我一般是先从 EX 阶段的结果直接送回 ALU 入口做起,跑通之后再补 MEM 到 EX 的那一条,一步一步来,一次全上会调不出来。

不管走哪条路,验证方法都一样:给每条指令写一条独立的测试程序,用 tb 里的$display断言关键寄存器的值,全部跑过再合并成一个长程序。别指望一个几十条指令的大程序能一次跑对,出了错你连是哪条指令挂的都定位不到。

从那以后我每次动 CPU 的控制逻辑,都强制自己先把那张控制信号表填完整,再动 Verilog,改完先跑单指令测试,再跑长程序。这个习惯救过我不止一次。希望帮到你。

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