简介:这份《LS-CPU-EXB-003 CPU设计与体系结构实验指导手册v1.0(教师版)》面向计算机专业本科生、研究生及授课教师,服务于《数字逻辑》《计算机组成原理》《计算机体系结构》等课程的实验教学,帮助解决从基础运算单元到完整CPU实现缺乏系统实验路径的问题。资源为单个PDF文档,压缩包约6.09MB,内容围绕龙芯教学实验平台LS-CPU-EXB-002展开,依次覆盖定点加法、定点乘法、寄存器堆、ALU模块、存储器、单周期CPU与多周期CPU七个实验,并延伸至静态5级流水线CPU课程设计与CPU系统优化拓展。手册还附有MIPS指令集及CP0寄存器说明,便于对照指令执行过程理解数据通路。读者可据此完成从逻辑门搭建到流水线设计的完整实践,掌握Verilog电路设计、Vivado开发流程与FPGA平台调试方法,教师亦可直接借鉴实验组织与任务设置。目前已有681人学习,适合需要系统开展CPU组成与体系结构实验的师生参考。
1. 从一块只能点亮的实验箱说起:CPU设计与体系结构实验到底要交付什么
大部分人在做 CPU 课设时卡住的地方,往往不是写不出一个 32 位加法器。真正难受的是把一堆模块拼起来之后,说不清下一步该干什么:单周期跑通了、多周期也跑通了,最后要交静态 5 级流水线课程设计时,才发现自己不知道每一级阶段寄存器里到底该存什么。这份 CPU 设计与体系结构实验指导手册的价值,在于它把整门课拆成了九个可以被单独验收的节点:从定点加法这块敲门砖开始,依次是定点乘法、寄存器堆、ALU、存储器、单周期 CPU、多周期 CPU,最后落到静态 5 级流水线课程设计与优化拓展。每个节点都配了参考实现、仿真方法和上板验证机制。这套方案适合要交组成原理或体系结构课程设计的学生,也适合需要一套现成实验编排顺序来带实验的教师。它真正给出的能力,是让每个实验环节都能被验证、被复现、被讲清楚。
2. 实验体系拆解:九个节点构成的能力阶梯与工具链准备
2.1 一条不能跳的依赖链:从全加器到五级流水
这套实验的编排逻辑不是按知识点排的,而是按依赖关系排的。前一个实验交付的模块,往往是后一个实验的零件;如果跳过某个节点,后面的问题就会以“玄学”的形式出现,波形上看不出错,上板就是不对。按依赖关系整理出来是这样一张表:
| 实验节点 | 主要交付物 | 依赖的前序成果 | 验收方式 |
|---|---|---|---|
| 实验一 定点加法 | 32 位加法器 + 触摸屏外围展示模块 | 无(同时完成平台入门) | 仿真波形 + 上板触摸输入演示 |
| 实验二 定点乘法 | 移位累加结构的乘法器 | 加法器、寄存器基本概念 | 仿真波形 + 上板结果比对 |
| 实验三 寄存器堆 | 双读单写寄存器堆 | 时序逻辑、写使能概念 | 写后读波形、0 号寄存器恒零 |
| 实验四 ALU | 多操作码运算单元 + zero 标志 | 加法器、乘法器、移位器 | 逐操作码仿真覆盖 |
| 实验五 存储器 | 指令存储器 / 数据存储器模型 | 地址译码、读写时序 | 读写一致性、边界地址 |
| 实验六 单周期 CPU | 一条指令一个周期的完整数据通路 | 前述全部模块 | 跑通一段指令序列 |
| 实验七 多周期 CPU | 带主状态机的分拍数据通路 | 单周期 CPU | 每条指令的节拍数正确 |
| 课程设计 静态 5 级流水 | IF/ID/EX/MEM/WB 五级 + 冒险处理 | 多周期 CPU、时序分析意识 | 流水线吞吐对比、冒险用例 |
| 课程设计拓展 优化 CPU | 指令集扩展或结构优化 | 五级流水 | 性能或面积对比数据 |
这张表最该被重视的是最后一列。很多实验报告写得很厚,但通篇是代码截图,没有一条能被验证的结论,答辩时被问一句“你怎么证明它是对的”就会卡住。比较稳妥的做法是每完成一个节点,就把“输入是什么、期望输出是什么、实际波形是什么”这三样固定下来,形成一条证据链,后面的课程设计直接复用。
再说顺序为什么不能乱。实验一表面上只是让你写一个加法器,实际上它同时解决了两件事:一是把综合工具、仿真工具、约束文件、下载流程这整条工具链跑通,二是把“上板验证机制”这个习惯建立起来。实验箱配了 LCD 触摸屏,并且把触摸屏驱动封装成了不需要处理器核就能工作的模块,这意味着你不需要先有 CPU 就能输入 32 位数、显示 32 位数。这个设计很关键:32 位加法如果要靠拨码开关输入,需要 64 个拨码加 32 个 LED,板上根本没有这么多 IO 资源。所以从第一个实验开始,你的验证手段就是触摸屏,而不是拨码开关,这个习惯会一直沿用到五级流水线。
实验二到实验五是把 CPU 的四大件单独拆出来练。定点乘法练的是移位和累加的组合逻辑思维;寄存器堆练的是“读是组合的、写是时序的”这个最容易搞混的时序边界;ALU 练的是用操作码选择运算,以及 zero 这类供分支指令直接使用的标志位;存储器练的是地址译码和访存时序。这四块单独做的时候都不难,难的是它们要被同一个时钟驱动、共享同一套控制信号。
实验六和实验七是分水岭。单周期 CPU 把所有操作塞进一个时钟周期,逻辑直白但周期长;多周期 CPU 把指令拆到若干节拍,需要一个主状态机来调度,控制复杂度上升但资源复用度提高。这两个实验做完,你对“指令是怎么一条条走完的”才算真正有感觉。而课程设计的静态 5 级流水线,本质上是在多周期的基础上把“串行分拍”改成“并行重叠”,代价是要处理数据冒险、控制冒险和结构冒险。
2.2 工具链与平台资源:工程模板、黑盒网表与约束文件
工具链这块,我一般建议在实验一就把工程结构固定下来,后面所有实验直接复制目录,不要每次重建。推荐的目录组织是这样:
cpu_lab/ ├── rtl/ # 所有自己写的 .v 源文件 │ ├── adder.v │ ├── adder_display.v │ ├── regfile.v │ ├── alu.v │ └── cpu_top.v ├── sim/ # 所有 testbench │ └── testbench.v ├── constr/ # 约束文件 │ └── board.xdc ├── ip/ # 配套提供的黑盒网表(如触摸屏模块) │ └── lcd_module.dcp └── doc/ # 波形截图、上板照片、实验记录新建工程的流程在综合工具里是固定的:新建工程向导 → 填工程名和路径 → 选 RTL Project 并勾选“暂不指定源文件” → 在器件筛选里按实验箱说明书给出的器件族与封装选定型号 → 完成。器件型号这一步不要自己拍脑袋选,选错了后面引脚绑定会全部对不上,编译能过但上板一定没有任何反应,这是新手最容易浪费时间的地方。
源文件添加分两类。自己写的.v走“Add or create design sources”;触摸屏模块是配套资源提供的黑盒网表文件,走同样的添加流程,但添加后它在层次视图里会显示为问号,表示只有端口声明没有可见实现。这是刻意设计的:把 LCD 驱动封装成黑盒,学生只需要按端口调用,不至于陷进触摸屏时序里出不来。添加时注意工具版本差异,较老版本的综合工具需要先把网表文件解压再添加。
约束文件是把顶层模块的端口和板上物理引脚绑起来,后缀是.xdc。写法是“先绑引脚,再声明电平标准”,这两件事必须成对出现,只绑引脚不声明 IO 标准,工具会报错或给出警告后忽略。
# 时钟与复位:按实验箱引脚表填写实际引脚号 set_property PACKAGE_PIN <时钟引脚> [get_ports clk] set_property IOSTANDARD LVCMOS33 [get_ports clk] set_property PACKAGE_PIN <复位引脚> [get_ports resetn] set_property IOSTANDARD LVCMOS33 [get_ports resetn] # 演示用的拨码与 LED:进位用一颗灯指示,最直观 set_property PACKAGE_PIN <拨码引脚> [get_ports sw_cin] set_property IOSTANDARD LVCMOS33 [get_ports sw_cin] set_property PACKAGE_PIN <LED引脚> [get_ports led_cout] set_property IOSTANDARD LVCMOS33 [get_ports led_cout] # LCD 触摸屏:这一组信号必须整组绑定,漏一个都点不亮 set_property PACKAGE_PIN <引脚> [get_ports lcd_rst] set_property IOSTANDARD LVCMOS33 [get_ports lcd_rst] set_property PACKAGE_PIN <引脚> [get_ports {lcd_data_io[0]}] set_property IOSTANDARD LVCMOS33 [get_ports {lcd_data_io[0]}]这里要说明三个参数层面的点。第一,引脚号必须来自实验箱配套的引脚表,不能照抄别人的工程,不同批次的板子布线可能不同。第二,总线信号要写成{lcd_data_io[0]}这种带花括号的形式,逐位绑定,写成lcd_data_io[0]而不加花括号在部分工具版本里会解析失败。第三,LVCMOS33是 3.3V 电平标准,板上 IO bank 供电不同时要相应调整,声明错了轻则不工作,重则长期使用损伤接口。
工具链跑通的标志不是“编译通过”,而是“下载后板上行为符合预期”。所以实验一的上板验证机制必须在第一个实验就完整走一遍:触摸输入两个加数 → 板上显示结果 → 拨码给进位 → LED 指示进位。这套流程跑顺了,后面七个实验的上板环节都是同一套动作,只是换了顶层模块。
3. 基础模块实现:加法器、乘法器、寄存器堆与 ALU 的落地写法
3.1 定点加法:主体模块与触摸屏外围模块的分层
实验一给出的参考设计有一个很值得学的结构:把一个功能模块和它的展示模块彻底分开。加法器只管算,不管显示;展示模块负责调用加法器和触摸屏。这样加法器可以被后面的 ALU 直接复用,而展示模块在每个实验里换一次就行。
先看主体代码,32 位带进位的加法器:
`timescale 1ns / 1ps // 32 位定点加法器:只有运算,没有任何显示逻辑 module adder( input [31:0] operand1, // 加数 1 input [31:0] operand2, // 加数 2 input cin, // 低位进位 output [31:0] result, // 和 output cout // 向高位的进位 ); // 拼接后整体赋值,carry 不会在赋值过程中丢掉 assign {cout, result} = operand1 + operand2 + cin; endmodule这段代码最核心的一行是拼接赋值。如果把cout和result分开写,很容易写出assign result = a + b; assign cout = ...这种丢进位的版本,做 32 位加法时高位溢出就查不出来了。{cout, result}组成一个 33 位向量,加法的第 33 位自然落进cout,从结构上就杜绝了这个问题。
cin这个端口现在看起来多余,但它是为后面的减法准备的:减法用“取反加一”实现时,把减数取反、cin置 1,就能复用同一个加法器。所以在实验一阶段就把它留在端口上,后面做 ALU 时不用改接口。
参考设计里直接用+号而不是自己拿全加器搭,这一点值得说清楚。综合工具对+的处理是调用内部库里的加法器 IP,在面积和时序上通常优于手写的行波进位结构。手搭全加器链在功能上没问题,但 32 位行波进位的延时是线性累加的,做五级流水线时这条路径很可能成为关键路径,逼着你回头改。所以除非教师明确要求练习门级结构,否则用+更省事,把精力留给后面的数据通路。
再看外围展示模块的骨架:
// 顶层展示模块:调用加法器 + 调用触摸屏黑盒 module adder_display( input clk, input resetn, input input_sel, // 切换"输入加数1 / 输入加数2 / 看结果"三种界面 input sw_cin, // 拨码给低位进位 output led_cout, // 进位用一颗 LED 指示 output lcd_rst, output lcd_cs, output lcd_rs, output lcd_wr, output lcd_rd, output lcd_bl_ctr, inout [15:0] lcd_data_io, output ct_int, output ct_sda, output ct_scl, output ct_rstn ); wire [31:0] a, b, sum; wire c; // 加法主体:这里只关心端口,内部实现可以随时替换 adder u_adder( .operand1(a), .operand2(b), .cin(sw_cin), .result(sum), .cout(c) ); assign led_cout = c; // 触摸屏:配套提供的黑盒网表,只按端口调用,不展开实现 lcd_module u_lcd( .clk(clk), .resetn(resetn), .input_sel(input_sel), .data_a(a), .data_b(b), .data_sum(sum) ); endmodule端口名以配套网表给出的黑盒声明为准,上面是结构示意。这个模块的设计要点有三个。input_sel是一个三态界面切换信号,触摸屏上分屏显示“输入加数 1”“输入加数 2”“显示结果”,用一颗按键或拨码切换,这样有限的屏幕面积才够用。a和b是从触摸屏模块回送出来的寄存器值,不是直接连到加法器输入上的,中间隔了一层寄存器,触摸输入才稳定。led_cout把进位单独引到 LED 上,是因为屏幕显示 32 位结果时进位容易被忽略,而进位恰恰是验证边界情况最直接的证据。
功能仿真这一步不要省。实验一给的 testbench 很简洁,但足够用:
`timescale 1ns / 1ps module testbench; reg [31:0] operand1; reg [31:0] operand2; reg cin; wire [31:0] result; wire cout; adder uut( .operand1(operand1), .operand2(operand2), .cin(cin), .result(result), .cout(cout) ); initial begin operand1 = 32'h0000_0000; operand2 = 32'h0000_0000; cin = 1'b0; #100; // 等全局复位稳定再开始激励 end always #10 operand1 = $random; // 每 10ns 换一个随机加数 always #10 operand2 = $random; always #10 cin = {$random} % 2; // 拼接符保证取到非负数再取余 endmodule$random产生的是 32 位随机数,覆盖率高但不可复现,所以随机激励之外一定要手工核对几组定点值,尤其是进位边界,比如两个接近全 1 的数相加、cin为 1 的情况。波形窗口默认按二进制显示,32 位数看着极其费劲,在信号名上右键把 Radix 改成十六进制,一眼就能看出结果对不对。另外,添加新信号到波形窗口之后必须重新启动仿真才能看到数据,这个操作细节每年都有人卡住。
3.2 寄存器堆与 ALU:端口定义、读写时序与仿真激励
寄存器堆是第一个真正带时序的模块,也是最容易把读写时序搞反的地方。MIPS 风格的设计里,寄存器堆是“双读单写”:两个读端口是组合逻辑,给地址立刻出数据;写端口是时序逻辑,只在时钟上升沿生效。
module regfile( input clk, input we, // 写使能,由控制单元给出 input [4:0] waddr, // 写地址 0~31 input [31:0] wdata, input [4:0] raddr1, input [4:0] raddr2, output [31:0] rdata1, output [31:0] rdata2 ); 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 assign rdata1 = (raddr1 == 5'd0) ? 32'b0 : rf[raddr1]; assign rdata2 = (raddr2 == 5'd0) ? 32'b0 : rf[raddr2]; always @(posedge clk) begin if (we && waddr != 5'd0) // 0 号寄存器写入无效 rf[waddr] <= wdata; // 非阻塞赋值,写口只在上升沿生效 end endmodule三个参数层面的关键点。we是写使能,必须由控制单元给出,不能默认一直写,否则每条指令都会污染寄存器内容,这是单周期 CPU 调试时最常见的翻车原因之一。raddr和waddr是 5 位宽,对应 32 个寄存器,位宽写错会导致地址截断。0 号寄存器恒为零是 MIPS 的硬性约定,读的时候要判断、写的时候要屏蔽,两处都做,否则$zero会被意外改写,所有依赖它的指令全部出错。
写完寄存器堆必须仿真验证,testbench 的做法是:先给we=1往几个地址写值,再改地址读出来看是否一致,最后专门往 0 号地址写一次,确认读出来还是 0。波形上要看到的是“写操作只在时钟上升沿发生、读操作在地址变化后立刻变化”这两条,看反了说明赋值方式用错了。
ALU 的写法是典型的操作码选择结构:
module alu( input [31:0] a, input [31:0] b, input [3:0] alu_op, // 由控制单元译码得到 output reg [31:0] y, output zero ); assign zero = (y == 32'b0); // 供 beq / bne 直接使用 always @(*) begin case (alu_op) 4'b0000: y = a + b; // add / addu 4'b0001: y = a - b; // sub / 比较 4'b0010: y = a & b; // and 4'b0011: y = a | b; // or 4'b0100: y = a ^ b; // xor 4'b0101: y = b << a[4:0]; // sllv,移位量只取低 5 位 4'b0110: y = b >> a[4:0]; // srlv,逻辑右移 4'b0111: y = $signed(b) >>> a[4:0]; // srav,算术右移要保持符号 4'b1000: y = ($signed(a) < $signed(b)) ? 32'd1 : 32'd0; // slt default: y = 32'b0; endcase end endmodulezero做成独立输出而不是让控制单元自己去比较y,是因为分支指令判断跳转条件时可以直接用它,省一级比较逻辑,在五级流水线里这条路径的时延也很关键。移位量取a[4:0]是硬件约束的体现:32 位数最多移 31 位,5 位就够,取整个 32 位会产生不可综合或不必要的宽位运算。
算术右移必须用$signed()转换,这是一个很容易踩的坑。Verilog 默认按无符号处理,>>>在无符号数上退化成了逻辑右移,高位补 0 而不是补符号位,遇到负数结果就错了,而波形上看着还挺正常,只有把操作数换成负数才暴露出来。slt同理,有符号比较和无符号比较结果不同,必须显式转换。
ALU 的仿真要按操作码逐个覆盖,而不是随机激励跑一遍了事。每一个alu_op至少准备三组输入:正常值、边界值(全 0、全 1)、负数。测试用例写成表贴在实验报告里,比贴一屏波形更有说服力。
4. CPU 主线:单周期、多周期与静态五级流水的数据通路演进
4.1 单周期与多周期:控制信号与主状态机的两种组织方式
单周期 CPU 的特点是“一条指令一个时钟周期”,所有操作在同一个周期内串完。它的控制单元是纯组合逻辑:把指令的操作码和功能码送进去,直接译码出所有控制信号。写起来直观,但周期长度必须按最慢的那条指令(通常是访存指令)来定,导致简单指令也要等满一个长周期。
单周期阶段最该被整理清楚的是控制信号表,而不是代码。表里每一行是一条指令,每一列是一个控制信号:
| 指令类型 | RegWrite | MemRead | MemWrite | ALUSrc | MemtoReg | Branch | ALUOp |
|---|---|---|---|---|---|---|---|
| R 型运算 | 1 | 0 | 0 | 0 | 0 | 0 | 按功能码 |
| lw | 1 | 1 | 0 | 1 | 1 | 0 | 加法 |
| sw | 0 | 0 | 1 | 1 | x | 0 | 加法 |
| beq | 0 | 0 | 0 | 0 | x | 1 | 减法 |
| addi | 1 | 0 | 0 | 1 | 0 | 0 | 加法 |
| j | 0 | 0 | 0 | x | x | 0 | x |
这张表是单周期 CPU 的“黑匣子说明书”。调试时如果某条指令行为异常,先来这张表对一遍控制信号,八成能定位到是哪个信号译码错了。比如 lw 写回的数据来自存储器而不是 ALU,MemtoReg必须是 1;如果它被写成 0,寄存器里存的就变成了地址,波形上看寄存器确实被写了,但值完全不对,很容易误判成存储器模块有问题。
多周期 CPU 把一条指令拆成取指、译码、执行、访存、写回几个节拍,控制信号不再由组合逻辑一次性给出,而是由主状态机按当前状态输出。这样指令存储器和数据存储器可以合并成一个存储器,ALU 也可以复用,硬件资源明显省下来。
// 多周期 CPU 主状态机:把一条指令拆到若干节拍 parameter S_IF = 3'd0, S_ID = 3'd1, S_EXE = 3'd2, S_MEM = 3'd3, S_WB = 3'd4, S_BR = 3'd5; always @(posedge clk or negedge resetn) begin if (!resetn) state <= S_IF; // 复位后从取指开始 else state <= next_state; end always @(*) begin case (state) S_IF : next_state = S_ID; // 取指后统一进译码 S_ID : next_state = S_EXE; // 译码后进执行 S_EXE: next_state = (is_lw || is_sw) ? S_MEM : // 访存类指令才去 MEM is_branch ? S_BR : // 分支单独一拍算目标地址 S_WB; // 其余指令直接写回 S_MEM: next_state = is_lw ? S_WB : S_IF; // sw 访存完就结束 S_BR : next_state = S_IF; S_WB : next_state = S_IF; default: next_state = S_IF; endcase end状态机的关键不在状态编码,而在每个状态里“哪些寄存器允许写、哪些允许读”。PC 只在取指节拍更新,指令寄存器只在取指节拍装载,这些使能条件写错,表现就是指令跳着执行或者原地打转,波形上 PC 的变化规律一眼就能看出来。
4.2 静态五级流水:阶段寄存器划分与三类冒险的处理
课程设计的静态 5 级流水线,本质是把多周期的串行分拍改成并行重叠:IF 取指、ID 译码、EX 执行、MEM 访存、WB 写回,五条指令同时在流水线上,每个阶段之间插一组阶段寄存器。吞吐量理论上提升接近 5 倍,代价是要处理冒险。
阶段寄存器的写法是流水线的基础,也是最容易漏信号的地方:
// IF/ID 阶段寄存器:指令与 PC+4 一起打一拍 always @(posedge clk or negedge resetn) begin if (!resetn) begin id_instr <= 32'b0; id_pc4 <= 32'b0; end else if (flush_if_id) begin // 分支跳转时清空,灌入气泡 id_instr <= 32'b0; id_pc4 <= 32'b0; end else if (!stall_if) begin // 只有不阻塞时才更新 id_instr <= if_instr; id_pc4 <= if_pc + 32'd4; end end这里有三个控制信号必须分清楚。stall_if是停顿,用于 load-use 这种必须等一拍的情况,此时阶段寄存器保持原值不动。flush_if_id是清空,用于分支跳转成功后丢弃已经取进来的错误指令,此时阶段寄存器被清零,相当于插入气泡。两者作用完全相反,写成同一个信号会导致要么不停顿、要么永远停顿。第三个是id_pc4这类“顺手带下去”的信号:PC+4 在 IF 阶段算出来,必须一路传到 EX 甚至 MEM 才能用于分支目标计算和写回,漏传一个就会出现跳转地址差 4 的经典错误。
数据冒险用前递解决,优先级是 MEM 级高于 WB 级:
// 前递单元:EX 级要用的源操作数,如果前面刚算出来就直接抓过来 always @(*) begin fwd_a = 2'b00; // 默认用寄存器堆读出的值 if (ex_rs != 5'd0 && ex_rs == mem_rd && mem_regwrite) fwd_a = 2'b10; // 来自 MEM 级,优先级更高 else if (ex_rs != 5'd0 && ex_rs == wb_rd && wb_regwrite) fwd_a = 2'b01; // 来自 WB 级 end优先级不能反。如果 EX 级要用的寄存器同时被 MEM 级和 WB 级写,MEM 级的数据更新,用 WB 级的就是旧值。ex_rs != 5'd0这个判断也不能省,0 号寄存器恒为零,如果参与前递,会把刚算出的非零值前递进去,$zero就被污染了。
load-use 冒险是前递解决不了的特例:lw 指令的数据要到 MEM 级结束才拿得到,而下一条指令在 EX 级就要用,中间差一拍,只能停顿。处理方式是在 ID 级检测“当前指令的源寄存器 == EX 级目标寄存器 且 EX 级是 lw”,命中就拉高stall_if并清空 EX 级,插入一个气泡。
控制冒险在静态流水线里通常用延迟槽或者分支预测失败清空来处理。静态预测“不跳转”的实现最简单:分支指令在 EX 级算出结果,如果确实要跳,就把 IF/ID 和 ID/EX 两级都清空,重新从目标地址取指。代价是每次跳转浪费两个周期,但逻辑清晰、不容易出错,课程设计阶段建议先用这个方案跑通,再考虑优化。
结构冒险在这个设计里主要出现在存储器上:IF 要取指、MEM 要访存,如果只有一个存储器端口就会冲突。解决办法是指令存储器和数据存储器分开,或者用同一个存储器的双端口模型。实验五已经做过存储器,这里直接把两个实例化出来即可。
五级流水跑通之后,课程设计的拓展部分才有意义:指令集扩展、旁路优化、分支预测,这些都是在一条已经能正确工作的流水线上做增量。流水线本身还没稳定就去加优化,最后出了问题根本分不清是原有 bug 还是新加的优化引入的。
5. 避坑与排查:上板、仿真与约束里最容易翻车的五个点
5.1 五类高频问题的现象、原因与处置
现象一:仿真波形完全正确,下载到板上毫无反应,屏幕不亮、灯不闪。原因通常不在逻辑,而在约束。最常见的是引脚绑定用了别人工程的引脚号,或者顶层模块的端口名和约束文件里的名字对不上,工具把未绑定的端口默默忽略掉,编译照样通过。另一种是 IO 电平标准没声明,工具报警告后跳过。 解决方式:先看综合报告里的 IO 约束部分,确认每一个顶层端口都有对应的引脚和电平标准;再核对引脚号来源必须是本台实验箱的引脚表。上板无反应时,第一步永远是先把一个最简单的“时钟分频驱动 LED 闪烁”工程下载进去,确认板子和下载链路是好的,再去怀疑自己的逻辑。
现象二:加法器仿真时结果对,一加大数就错,或者进位丢失。原因多半是位宽没对齐。把cout和result分开赋值,或者中间某一步用了 32 位中间变量接 33 位结果,高位被截断。 解决方式:坚持用拼接赋值{cout, result} = a + b + cin,让工具替你保证位宽;所有中间信号显式声明位宽,不要依赖隐式扩展。
现象三:寄存器堆写入之后读出来还是旧值,或者所有寄存器像是被同一个值覆盖。原因是写使能没接对,或者写地址位宽被截断。we常被直接接成常量 1,导致每个周期都在写;waddr如果只连了低位,高位地址的写入会落到低位寄存器上,看起来就像“全被覆盖了”。 解决方式:把we一路从控制单元追到寄存器堆端口,确认只在需要写回的指令上为 1;检查waddr是否完整 5 位。
现象四:多周期 CPU 跑起来 PC 不动,或者指令反复执行同一条。原因是状态机的转移条件写错,或者 PC 写使能的节拍不对。常见的是把所有状态的next_state都写成回到S_IF,导致每条指令只走一拍;也有 PC 在每个节拍都更新,导致一条指令被拆到多个地址上执行。 解决方式:先把状态机单独仿真,只看state和next_state两个信号,确认状态转移序列和设计文档一致;再确认 PC 的写使能只在取指节拍有效。
现象五:五级流水线单条指令测试全对,连起来跑就错,且错误位置飘忽不定。这是典型的数据冒险没处理干净。前递优先级写反、漏了 0 号寄存器判断、load-use 停顿条件少判了一种情况,都会表现成“单独测都对,连着跑就错”。 解决方式:构造专门的冒险用例,比如连续三条指令形成“写后读”链、lw 紧跟一条使用其结果的指令、分支跳转后紧跟一条指令,把这几组用例固定成回归测试,每次改流水线都跑一遍。错误位置飘忽说明是时序相关,优先怀疑前递和停顿,不要去改 ALU。
5.2 上板前的自检流程
上板这一步翻车成本最高,因为看不到内部信号。我一般会在下载之前强制走一遍清单,把能在仿真阶段发现的问题全部拦在前面:
# 上板前自检:按顺序过,任何一步不过就不下载 # 1. 综合无 error,warning 逐条看过(尤其是位宽截断和未连接端口) # 2. 所有顶层端口在约束文件里都有引脚 + 电平标准 # 3. 时钟引脚绑的是板上真实晶振,不是随手写的引脚号 # 4. 复位信号极性正确(低有效还是高有效,和代码里的判断一致) # 5. 时序报告里没有未约束的时钟路径,建立时间违例为 0 # 6. 仿真已覆盖:正常值、边界值、负数、连续读写第 4 条最容易被忽略。代码里写negedge resetn,约束里却把复位接到了一个高有效的按键上,结果就是按一下复位没反应、松开才复位,或者干脆一直处于复位状态,表现为“板子完全没反应”,和约束错误的现象一模一样,排查时会绕很大一圈。第 5 条则是流水线阶段特有的:加了阶段寄存器之后路径变多,如果时钟约束没写对,工具不会报错,但实际跑起来时序不稳定,表现是“大多数时候对,偶尔错一条”,这类问题在仿真里永远复现不出来。
6. 验收与答辩:把一堆波形截图变成能自证的证据链
答辩现场最常见的失分点,是学生把实验报告做成了代码汇编:几十页源码截图,问一句“你怎么证明这个 CPU 是对的”就答不上来。我的建议是把验收材料按“证据链”组织,每一条结论都配一个可复现的证据。
具体做法是每个实验节点固定产出三样东西:输入激励(testbench 或者操作步骤)、期望输出(手工算出的值或者指令执行后的寄存器状态)、实际证据(关键信号的波形截图或者上板照片)。三样凑齐才算这个节点完成。这套材料积累到课程设计阶段,就自然形成一条从加法器到五级流水的完整链条,答辩时按链条讲,比按代码文件讲有说服力得多。
下面这张表可以直接当成验收清单用:
| 验收项 | 需要拿出的证据 | 答辩时大概率被问的问题 |
|---|---|---|
| 定点加法 | 进位边界的波形 + 上板触摸输入演示 | 为什么用+而不是手搭全加器 |
| 寄存器堆 | 写入后读出的波形 + 0 号寄存器写入无效的用例 | 读为什么是组合的、写为什么是时序的 |
| ALU | 每个操作码至少三组输入的测试表 | 算术右移和逻辑右移的区别 |
| 单周期 CPU | 一段指令序列执行完后的寄存器与存储器终值 | 周期长度由哪条指令决定 |
| 多周期 CPU | 每条指令的状态转移序列 | 状态机怎么保证 PC 只在取指节拍更新 |
| 五级流水 | 冒险用例的波形 + 与多周期的吞吐对比数据 | 前递和停顿分别解决什么问题 |
流水线的对比数据尤其值得认真做。同一段指令序列,在多周期 CPU 上跑了多少个周期、在五级流水上跑了多少个周期,这个比值是课程设计里唯一一个“性能”层面的硬指标。不要只报一个理论加速比,实测出来的数字哪怕只有 3 倍多,也比空口说 5 倍可信。测试序列里要故意混入 lw 紧跟使用、分支跳转这类会引入停顿和清空的指令,这样测出来的才是真实吞吐,而不是理想情况。
还有一个答辩技巧:主动说出自己设计里的取舍。比如“分支处理我选了静态不跳转加清空,因为实现简单、容易验证,代价是每次跳转浪费两个周期,如果要优化可以换成延迟槽”。这种表述传递的信息是你知道方案的边界在哪里,比声称“我的设计没有问题”可信得多。评审最怕的不是设计有短板,而是设计者不知道自己有短板。
最后说一个我自己的习惯。从那以后我每次带课程设计,都强制要求学生先交一页纸的“数据通路图 + 控制信号表”,图画完了、表填满了,才允许开始写代码。因为绝大多数返工都不是 Verilog 语法问题,而是动手之前没想清楚某个信号在哪个阶段有效、某条数据从哪来、到哪去。图想清楚了,代码只是誊写;图没想清楚,写多少行都是白写。希望这套从加法器一路走到五级流水的路径,能帮你少返工几次。
本文还有配套的精品资源,点击获取