简介:这份教师版实验指导手册围绕龙芯 LS-CPU-EXB-002 教学实验平台展开,面向开设数字逻辑、计算机组成原理与计算机体系结构课程的高校师生,用于在 FPGA 实验箱上完成从基础运算部件到完整 CPU 的验证型与综合型实验。全文以实验驱动,依次讲解定点加法与定点乘法、寄存器堆、ALU 模块、存储器、单周期与多周期 CPU 的设计实现,并延伸到静态 5 级流水线 CPU 的课程设计与 CPU 系统优化拓展,附录还整理了所实现的 MIPS 指令集与 CP0 寄存器,便于对照查阅指令格式与执行流程。资源包为 1 个 PDF 文件,约 6.09MB,可直接打印或屏幕阅读,目录层级清晰,按实验序号即可定位到实验目的、设备、任务与设计步骤。目前已有 681 人学习,适合需要备课的教师与做课程设计的学生按图索骥地搭建实验方案。
1. 一份能上板的 CPU 实验手册,值不值得照着做一遍
很多组成原理课设卡在一个很尴尬的位置:理论课讲清楚了数据通路,图也画了,代码也写了,一到 Vivado 综合就红了,或者干脆下载到板子上 LCD 一片黑。这份 LS-CPU-EXB-002 的《CPU 设计与体系结构实验指导手册》解决的就是这段“从图纸到板子”的落差。它把七个实验加两个课程设计串成一条线:定点加法、定点乘法、寄存器堆、ALU、存储器、单周期 CPU、多周期 CPU,最后推到静态 5 级流水线。每个实验都给了可综合的 verilog 代码、testbench、引脚约束,以及一个用 LCD 触摸屏做输入输出的展示外壳。适合三类人:正在做 CPU 课设的本科生、要带这门实验的助教、以及想补一补数字逻辑落地经验的后端/嵌入式工程师。它不教你计算机体系结构的原理,它教你怎么让一条 MIPS 指令在一块 Artix-7 上真的跑出结果。
2. 先把工程骨架立住:器件型号、黑盒网表与引脚约束的三处硬要求
拿到手册先别急着看 CPU,先把一个 32 位加法器完整走一遍“建工程 → 写 RTL → 仿真 → 加约束 → 上板”,这条链路跑通了,后面七个实验就是往里换模块。这一步里最容易翻车的不是代码,是工程配置。器件型号选错、黑盒网表没加进去、约束文件漏了电平标准,任何一个都会让综合或实现阶段直接失败。
2.1 平台拆解:FPGA 板、LCD 黑盒与工具链版本
实验箱里能用的东西就四样:一块 Artix-7 的 FPGA 实验板、一根下载线、一根带 USB 转串口的串口线、一个电源适配器。板上有一块 LCD 触摸屏,手册里明确说了,触摸屏的驱动被封装成一个黑盒网表lcd_module.dcp,不开放源码。这个设计是有意为之——让你把注意力放在 CPU 上,而不是陷进 LCD 时序里。你要做的只是例化它、接对端口。
工具链是 Xilinx Vivado,器件型号锁定xc7a200tfbg676-2(Artix-7 系列、fbg676 封装、速度等级 -2)。手册里提到一个版本差异:用 Vivado 2012.3 时需要先把 dcp 文件解压再添加,之后的版本直接添加.dcp即可。这个细节看着小,但在老机房环境里就是“加不进去”和“加得进去”的区别。
提示:先确认机房的 Vivado 版本,再决定 dcp 要不要解压。版本对不上时,综合阶段会报黑盒模块找不到,而不是在添加文件时报错,排查方向很容易跑偏。
2.2 新建工程:用 tcl 把配置固化下来
图形界面点一遍当然可以,但每次实验都重来一遍容易漏项。我一般会把工程创建写成 tcl 脚本,换机器时直接source一次,省掉“这次忘了勾 Do not specify sources”的重复劳动。
# create_project.tcl # 在 Vivado 的 Tcl Console 里 source 这个文件,一键重建工程骨架 create_project cpu_lab ./cpu_lab -part xc7a200tfbg676-2 -force # 顶层语言锁定 Verilog,避免混入 SystemVerilog 语法后综合报错 set_property target_language Verilog [current_project] # 设计源文件 add_files -norecurse ./rtl/adder.v add_files -norecurse ./rtl/adder_display.v # LCD 黑盒网表,必须作为设计文件加入,不能放进仿真文件集 add_files -norecurse ./ip/lcd_module.dcp # 仿真文件集与约束文件集分开添加 add_files -fileset sim_1 -norecurse ./sim/testbench.v add_files -fileset constrs_1 -norecurse ./constr/adder.xdc # 顶层设置:综合顶层是展示模块,仿真顶层是 testbench set_property top adder_display [current_fileset] set_property top testbench [get_filesets sim_1] # 刷新编译顺序,让子模块先于顶层被解析 update_compile_order -fileset sources_1这段脚本的关键参数有三个。-part xc7a200tfbg676-2决定后面所有原语和 IP 的可用性,写错的话综合能过但实现会报无可用资源。-fileset sim_1和-fileset constrs_1把仿真文件和约束文件分到不同文件集,混在一起会导致综合时把 testbench 也当成可综合逻辑。最后的update_compile_order不做的话,模块层次里lcd_module会一直显示问号,因为工具还没解析到它。
工程建好后,在 Project Manager 里应该看到这样的层次:顶层adder_display下面挂两个子模块,一个是adder,一个是lcd_module。如果lcd_module前面带问号,说明 dcp 没加对,先解决这个再往下走。
2.3 引脚约束:PACKAGE_PIN 与 IOSTANDARD 必须成对出现
约束文件是把顶层端口绑到 FPGA 物理引脚上的唯一手段,后缀.xdc。这份手册给的加法器约束文件里,每个端口都写了两行:一行指定引脚号,一行指定电平标准。少写第二行,实现阶段会直接报 UCIO-1/NSTD-1 这类错误。
# adder.xdc 片段:时钟、复位、LED 与拨码开关 set_property PACKAGE_PIN AC19 [get_ports clk] set_property PACKAGE_PIN Y3 [get_ports resetn] set_property PACKAGE_PIN A3 [get_ports led_cout] set_property PACKAGE_PIN AC21 [get_ports input_sel] set_property PACKAGE_PIN Y6 [get_ports sw_cin] set_property IOSTANDARD LVCMOS33 [get_ports clk] set_property IOSTANDARD LVCMOS33 [get_ports resetn] set_property IOSTANDARD LVCMOS33 [get_ports led_cout] set_property IOSTANDARD LVCMOS33 [get_ports input_sel] set_property IOSTANDARD LVCMOS33 [get_ports sw_cin] # LCD 触摸屏:16 位双向数据线要按位展开绑定 set_property PACKAGE_PIN J25 [get_ports lcd_rst] set_property PACKAGE_PIN H18 [get_ports lcd_cs] set_property PACKAGE_PIN K16 [get_ports lcd_rs] set_property PACKAGE_PIN L8 [get_ports lcd_wr] set_property PACKAGE_PIN K8 [get_ports lcd_rd] set_property PACKAGE_PIN J15 [get_ports lcd_bl_ctr] set_property PACKAGE_PIN H9 [get_ports {lcd_data_io[0]}] set_property PACKAGE_PIN K17 [get_ports {lcd_data_io[1]}] # …… lcd_data_io[2] 到 [15] 按手册引脚表逐位补齐 set_property IOSTANDARD LVCMOS33 [get_ports lcd_rst] set_property IOSTANDARD LVCMOS33 [get_ports lcd_cs]参数上有两点值得说。LVCMOS33表示 3.3V 电平,这块板子的 IO bank 电压就是 3.3V,写成LVCMOS18不会报错但上板行为不可预期。总线端口必须写成{lcd_data_io[0]}这种带花括号的逐位形式,直接写lcd_data_io会报端口位宽不匹配。
2.4 仿真流程:从 Scopes 到波形进制
代码加完先别综合,跑行为级仿真。点左侧导航栏的 Run Simulation → Run Behavioral Simulation,没有语法错误就会打开波形窗口。左侧 Scopes 列选源文件对象,中间 Objects 列列出该对象下所有信号,选中后右键 Add To Wave Window 加进波形。加完新信号必须点一次 Relaunch,否则波形窗口里看不到它——这是新手最常问的“为什么我加了信号还是空的”。
默认波形是二进制,32 位的加法结果看二进制基本没法读。在 Name 列选中信号右键 → Radix → Hexadecimal,切成 16 进制。手册里给了一组对照数据:b897be71 + 42f24186 + 1 = fb89fff7,可以先手工验算一遍再对波形。
3. 数据运算两连击:32 位加法器与移位累加乘法器的代码与波形
数据运算这两个实验看着简单,实际是整条链路的地基。加法器教你“怎么写才能让综合器给出好结果”,乘法器教你“怎么用状态机把一个多周期运算拆开”。这两个能力直接对应后面的 ALU 和流水线。
3.1 加法器:为什么直接用 “+” 比自己搭全加器更划算
手册给的参考实现只有一行核心逻辑:
// adder.v:32 位加法器,带低位进位输入 `timescale 1ns / 1ps module adder ( input wire [31:0] operand1, input wire [31:0] operand2, input wire cin, output wire [31:0] result, output wire cout ); // 左侧拼接成 33 位,右侧运算会在 33 位上下文中进行, // 因此最高位的进位不会丢,直接落到 cout 上 assign {cout, result} = operand1 + operand2 + cin; endmodule逻辑说明:{cout, result}是 33 位的拼接,赋值语句的上下文位宽会向上扩展到 33 位,所以operand1 + operand2 + cin这三个 32 位操作数参与运算时也按 33 位处理,第 33 位正好是进位输出。如果写成assign result = operand1 + operand2 + cin;,进位会被截掉,cout永远是 0。
参数说明:两个 32 位操作数加一个 1 位进位输入,输出 32 位结果加 1 位进位,这是标准的行波进位加法器接口。手册里明确说,直接写+综合工具会调用内部库里的加法 IP,面积和时序都比自己用全加器搭更优。自己搭也不是不行,但手写的行波进位链在 32 位下关键路径太长,时序很难收敛。
3.2 testbench:只给激励不够,要能自动比对
手册给的原始 testbench 只产生随机激励,靠人眼看波形判断对错。这种写法在 32 位加法下勉强能用,到了多周期 CPU 就完全不可靠。我一般会加一段自动比对逻辑:
// testbench.v:带自动比对的加法器测试平台 `timescale 1ns / 1ps module testbench; reg [31:0] operand1, operand2; reg cin; wire [31:0] result; wire cout; adder uut ( .operand1(operand1), .operand2(operand2), .cin (cin), .result (result), .cout (cout) ); integer i; reg [32:0] expect; // 33 位,用来保存期望的 {cout, result} initial begin operand1 = 32'b0; operand2 = 32'b0; cin = 1'b0; #100; // 等全局复位稳定 for (i = 0; i < 200; i = i + 1) begin operand1 = $random; // 32 位随机数 operand2 = $random; cin = {$random} % 2; // 拼接符保证结果非负 #10; expect = {1'b0, operand1} + {1'b0, operand2} + cin; if ({cout, result} !== expect) begin $display("MISMATCH #%0d: %h + %h + %b = %h_%b, expect %h", i, operand1, operand2, cin, cout, result, expect); $stop; end end $display("ALL PASS"); $finish; end endmodule逻辑说明:expect声明为 33 位,两个操作数先各自补一位 0 扩展到 33 位再相加,这样期望值的进位不会被截断。比较用的是!==而不是!=,因为四值逻辑下!=遇到 X 会返回 X,条件判断会漏掉错误。$stop让仿真在第一次不匹配时暂停,方便直接在波形里定位那组数据。
参数说明:#10是每个测试向量之间的间隔,10 个时间单位,对应timescale里的 1ns。200 组随机向量足够覆盖进位链的边界情况,如果只想跑手册里那组固定数据,把循环去掉、直接赋值b897be71和42f24186即可。
3.3 定点乘法:移位累加状态机与 64 位结果
乘法比加法多两个约束:结果要 64 位,运算需要多个周期。手册里实验二是定点乘法,常见做法是用移位累加,32 个时钟周期出一组结果。
// mult_seq.v:32x32 无符号乘法,移位累加实现 module mult_seq ( input wire clk, input wire rstn, input wire start, // 单周期脉冲,启动一次乘法 input wire [31:0] a, input wire [31:0] b, output reg [63:0] p, // 64 位乘积 output reg done // 计算完成标志,保持一拍 ); reg [4:0] cnt; // 剩余迭代次数,从 32 递减到 0 reg [63:0] acc; // 累加器,同时承担左移 reg [31:0] mcand; // 被乘数,每拍左移一位 reg [31:0] mplier; // 乘数,每拍右移一位 reg busy; always @(posedge clk or negedge rstn) begin if (!rstn) begin acc <= 64'b0; cnt <= 5'd0; busy <= 1'b0; done <= 1'b0; p <= 64'b0; end else if (start && !busy) begin acc <= 64'b0; mcand <= a; mplier <= b; cnt <= 5'd32; busy <= 1'b1; done <= 1'b0; end else if (busy) begin if (cnt == 5'd0) begin busy <= 1'b0; done <= 1'b1; p <= acc; end else begin if (mplier[0]) acc <= acc + {32'b0, mcand}; mcand <= {mcand[30:0], 1'b0}; mplier <= {1'b0, mplier[31:1]}; cnt <= cnt - 5'd1; end end end endmodule逻辑说明:核心是三件事同步进行——乘数最低位为 1 就把被乘数加到累加器,被乘数左移一位,乘数右移一位。迭代 32 次后累加器里就是完整的 64 位乘积。start用单周期脉冲触发,busy防止计算中途被重新启动,done保持一拍供上层采样。
参数说明:cnt用 5 位是因为要表示 32(5'd32),4 位放不下。这里实现的是无符号乘法,如果要做有符号乘法,需要先取绝对值、记录符号位、最后再补符号,或者直接用$signed让综合器处理,代价是面积变大。
3.4 乘法器的三种实现与选型
同一个乘法,在这块板子上至少有三种做法,面积和时序差得很远:
| 实现方式 | 延迟 | 资源占用 | 适用场景 |
|---|---|---|---|
| 移位累加状态机 | 32 个时钟周期 | 一个 64 位加法器 + 若干寄存器 | 实验教学,看清每一步 |
| 组合阵列乘法器 | 单周期 | 大量全加器,关键路径长 | 单周期 CPU 里的mult指令 |
| 调用 DSP 硬核 | 1~3 个时钟周期 | 一个 DSP48 块 | 追求时序收敛的流水线设计 |
手册里的实验二落在第一行,目的是让你把移位和累加的过程摆到波形上看清楚。等做到课程设计的流水线 CPU 时,乘法一般会改成调用 DSP 或者拆成多周期,否则关键路径根本过不去。这个取舍在做实验六、实验七的时候会变得非常具体——单周期 CPU 里如果塞一个组合乘法器,时钟频率会被压得很低。
4. 从寄存器堆到整机:ALU、存储器与单周期/多周期数据通路怎么拼
三个部件实验(寄存器堆、ALU、存储器)是零件,单周期和多周期是把零件拼成整机。零件阶段的接口定义错了,整机阶段就要返工,所以这一章的重点是接口和时序,而不是功能。
4.1 寄存器堆:双读一写与 $0 恒零
MIPS 的寄存器堆是 32 个 32 位寄存器,两个组合读端口、一个时序写端口。看起来简单,但$0的处理和同拍读写的行为是两个真实的坑。
// regfile.v:32x32 寄存器堆,双读一写 module regfile ( input wire clk, input wire we, // 写使能 input wire [4:0] raddr1, input wire [4:0] raddr2, input wire [4:0] waddr, input wire [31:0] wdata, output wire [31:0] rdata1, output wire [31:0] rdata2 ); reg [31:0] r [0:31]; integer i; // 读端口是组合逻辑,地址一变数据就跟着变 assign rdata1 = (raddr1 == 5'd0) ? 32'b0 : r[raddr1]; assign rdata2 = (raddr2 == 5'd0) ? 32'b0 : r[raddr2]; // 写端口在时钟上升沿生效,$0 不允许被写入 always @(posedge clk) begin if (we && (waddr != 5'd0)) r[waddr] <= wdata; end // 上电初始化,仿真时避免读出 X initial begin for (i = 0; i < 32; i = i + 1) r[i] = 32'b0; end endmodule逻辑说明:读端口必须对raddr == 0做显式判断,因为物理上的r[0]可能被写过(如果写使能没挡住),显式返回 0 才能保证 MIPS 语义。写端口用非阻塞赋值,保证和读端口在同一拍内的行为可预期。
参数说明:we是写使能,由控制器给出;waddr != 5'd0是第二道保险,防止写$0。initial块只用于仿真初始化,综合时会忽略,上板时寄存器初值是 FPGA 的默认值(通常是 0),但不能依赖它,CPU 的复位逻辑要自己写。
同一个时钟沿写入、读端口读同一地址,读出来的是旧值还是新值?在纯组合读的实现里读到的是旧值。如果后面的流水线需要“写后读”在同一拍生效,要么在写回级加旁路,要么在译码级做转发。这个细节在单周期里不明显,在流水线里是数据冒险的源头之一。
4.2 ALU:操作码译码与标志位
ALU 是纯组合逻辑,关键是case写全、默认值给上,避免综合出 latch。
// alu.v:支持加减、逻辑、移位与比较 module alu ( input wire [31:0] a, input wire [31:0] b, input wire [3:0] op, output reg [31:0] y, output wire zero ); wire [32:0] sum = {1'b0, a} + {1'b0, b}; wire [32:0] dif = {1'b0, a} - {1'b0, b}; always @(*) begin case (op) 4'b0000: y = sum[31:0]; // add 4'b0001: y = dif[31:0]; // 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; // 必须写,否则综合出 latch endcase end assign zero = (y == 32'b0); endmodule逻辑说明:sum和dif都扩展到 33 位,是为了在需要进位或借位的地方能取到最高位,这里只用了低 32 位,但扩展到 33 位能避免减法溢出时的误判。移位量取a[4:0]是因为 32 位数据最多移 31 位,高位的值无意义。
参数说明:op是 4 位操作码,具体编码要和控制器里的译码保持一致,改一处就要改另一处,建议在文件头用注释把编码表固定下来。zero是组合输出,分支指令直接用它做判断。
4.3 存储器:分布式 RAM、块 RAM 与地址对齐
数据存储器实验要关注两件事:综合成什么资源、地址怎么对齐。
// dmem.v:256 字的数据存储器,按字寻址 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]; // addr[1:0] 忽略,实现字对齐;addr[9:2] 作为 RAM 索引 assign rdata = mem[addr[9:2]]; always @(posedge clk) begin if (we) mem[addr[9:2]] <= wdata; end // 仿真时从文件加载初值,上板时由 bitstream 固化 initial begin $readmemh("dmem_init.hex", mem); end endmodule逻辑说明:addr[1:0]被直接丢弃,因为按字访问时低两位没有意义。读是异步的、写是同步的,这种写法在 Vivado 里会被推断成分布式 RAM(LUTRAM)而不是块 RAM(BRAM)。如果想用 BRAM,读端口必须加时钟——加一拍读延迟,但能省下大量 LUT。
参数说明:256 个字对应 1KB 容量,索引位宽 8 位,所以取addr[9:2]。$readmemh加载的是十六进制文本,每行一个 32 位数,综合时会被忽略,上板前要把初始化数据写进 bitstream 或者用 boot ROM 的方式处理。
4.4 单周期 CPU:数据通路与关键路径
单周期 CPU 的拼法是把 PC、指令存储器、寄存器堆、ALU、数据存储器、控制器用组合逻辑连起来,每条指令在一个时钟周期内走完全部阶段。手册里实验六的目标是跑通一批基础指令,附录 A1 列了要实现的指令清单。
单周期的问题不在功能,在时序。时钟周期必须容纳最长的那条指令——通常是lw,它要经过 PC → 指令存储器 → 寄存器堆读 → ALU 计算地址 → 数据存储器读 → 寄存器堆写,一条组合路径串到底。如果这条路径延迟 20ns,时钟周期就只能做到 20ns 以上,其他短指令全在浪费。
| 部件 | 输入 | 输出 | 时序属性 |
|---|---|---|---|
| PC | clk, rstn, npc | pc | 时序 |
| 指令存储器 | pc | instr | 组合(或加一拍) |
| 寄存器堆 | raddr1/2, waddr, wdata, we | rdata1/2 | 读组合、写时序 |
| ALU | a, b, op | y, zero | 组合 |
| 数据存储器 | addr, wdata, we | rdata | 读组合、写时序 |
| 控制器 | op, funct | 各控制信号 | 组合 |
4.5 多周期 CPU:状态划分与 CPI 的账
多周期把一条指令拆成若干步,每步一个时钟周期,部件可以复用。手册里实验七的目标是在单周期的基础上新增指令(附录 A2),同时把数据通路改成状态机驱动。
典型划分是五个状态:取指(IF)、译码(ID)、执行(EX)、访存(MEM)、写回(WB)。不是每条指令都走满五步,add到 EX 就结束,lw要走到 WB,beq在 EX 就算完分支地址。状态转移由控制器里的case (state)决定,下一状态取决于当前状态和指令类型。
// ctrl_multi.v:多周期控制器的状态划分骨架 localparam S_IF = 3'd0, S_ID = 3'd1, S_EX = 3'd2, S_MEM = 3'd3, S_WB = 3'd4; always @(posedge clk or negedge rstn) begin if (!rstn) state <= S_IF; else begin case (state) S_IF: state <= S_ID; S_ID: state <= S_EX; S_EX: state <= (is_lw || is_sw) ? S_MEM : S_WB; S_MEM: state <= S_WB; S_WB: state <= S_IF; default: state <= S_IF; endcase end end逻辑说明:状态转移只看当前状态和指令类型,不看数据,这是多周期控制器的基本结构。is_lw、is_sw这类标志由译码阶段产生,存到寄存器里供后续状态使用。
参数说明:状态编码用 3 位,五个状态够用,多出来的编码在default里回到S_IF,防止跑飞。多周期的 CPI 不是 1,lw是 5 个周期,add是 4 个周期,平均 CPI 要按指令 mix 算,这也是它相对单周期的优势所在——单周期 CPI 恒为 1 但时钟周期长,多周期时钟周期短但 CPI 大于 1,两条路的性能账要分开算。
5. 避坑排查:综合报错、波形全 X、上板黑屏的五个现场
下面五条是这条实验链路上出现频率最高的问题,按“现象 → 原因 → 解决”拆开写。每一条我都实际遇到过或者见人踩过,按这个顺序排查能省不少时间。
5.1 综合与实现阶段的两个硬报错
坑一:层次里lcd_module一直显示问号,综合报黑盒未解析。
现象:工程建好后 Project Manager 里lcd_module前面带问号,综合阶段报类似 “module lcd_module not found” 的错误,或者综合能过但实现阶段报黑盒端口不匹配。
原因:.dcp文件没有被正确加入设计文件集,或者版本不匹配。Vivado 2012.3 添加 dcp 前需要先解压,后续版本可以直接添加;另外 dcp 必须放进sources_1而不是sim_1,放错文件集时综合阶段根本看不到它。
解决:在 Tcl Console 里执行get_files lcd_module.dcp确认文件在工程里,再用get_property FILESET [get_files lcd_module.dcp]确认它属于sources_1。版本不符就按手册说明解压后重新添加,添加完执行一次update_compile_order。
坑二:实现阶段报 UCIO-1 / NSTD-1,说引脚未约束或电平标准缺失。
现象:综合能过,实现阶段报 “Unconstrained logical port” 或者 “I/O standard not specified”,严重时直接终止。
原因:.xdc里只写了PACKAGE_PIN没写IOSTANDARD,或者顶层模块新增了端口但约束文件没同步更新。手册里的约束文件是对着adder_display的端口写的,改顶层端口名之后必须同步改约束。
解决:每个PACKAGE_PIN后面紧跟一行IOSTANDARD LVCMOS33,端口名要和顶层模块的端口声明逐字一致。改完在 Tcl Console 跑一次report_io,把未约束的端口列出来逐个补。
5.2 仿真与上板阶段的三个现象
坑三:波形窗口里信号全是 X,或者一直是 0。
现象:仿真跑起来了,但加进波形的信号没有跳变,或者从头到尾是红线(X 态)。
原因:三种可能——激励没产生(initial块里没赋值,或者#100等待时间太长看起来像没动)、信号加进波形后没点 Relaunch、testbench 没有设为仿真顶层。
解决:先确认 testbench 文件前面有三个方形的 top 标志,没有就右键 Set as Top;再加信号后点一次 Relaunch;最后检查initial块里的赋值和#延迟,把#100临时改小到#10看有没有跳变。
坑四:仿真完全正确,下载到板子上 LCD 不亮或者加法结果不对。
现象:行为仿真波形一切正常,生成 bitstream 下载后,LCD 屏幕黑屏、或者显示的加法和预期不符。
原因:按概率排,第一是约束文件里的引脚号和实际板子不符(尤其是 LCD 的 16 位数据线,错一位就整屏不显示),第二是复位信号极性搞反(resetn是低有效,接成高有效就一直处于复位),第三是时钟引脚没绑对导致整个设计没有时钟。
解决:先拿一个最简单的 LED 闪烁工程验证约束文件和下载链路是通的,再换回加法器工程。LCD 不亮时优先核对lcd_data_io[0]到[15]这 16 个引脚,逐个对照手册引脚表。复位极性用report_property或者直接看代码里always @(posedge clk or negedge rstn)的敏感列表。
坑五:多周期 CPU 跑飞,状态机停在某个状态不动。
现象:多周期 CPU 仿真时,状态机进入某个状态后不再跳转,PC 不更新,波形上state信号一条直线。
原因:状态转移的case语句漏了分支,某个指令类型进来后没有对应的下一状态,落进default又被拉回S_IF,形成死循环;或者is_lw/is_sw这类标志寄存器没有在译码阶段正确锁存,导致条件判断永远为假。
解决:在case的default分支里加$display打印当前状态和指令,仿真时看看到底是哪条指令进来导致的;把is_lw、is_sw这些中间信号加到波形窗口,确认它们在 ID 阶段被正确赋值并在后续状态保持。
注意:这五条里,坑二和坑四属于“代码没错但上不了板”,排查成本最高。我一般会在每次换顶层模块之后先跑一次
report_io和一次 LED 闪烁验证,把这类问题和逻辑错误分开。
6. 5 级流水的验证账:冒险处理与指令序列对比的具体做法
课程设计部分的静态 5 级流水线是把前面所有实验合起来,再插入四级流水寄存器(IF/ID、ID/EX、EX/MEM、MEM/WB)。功能上它和单周期等价,难的是三件事:数据冒险、控制冒险、以及验证。
数据冒险用转发加重载解决。EX 级的操作数优先从 EX/MEM 和 MEM/WB 两级流水寄存器转发,转发命中就不阻塞;碰到 load-use,也就是上一条是lw、下一条立刻要用它的结果,必须阻塞一个周期。控制冒险在 EX 级判定分支,需要冲刷 IF/ID 和 ID/EX 两级。这三条规则写在纸上一页,落到代码里是几十行旁路逻辑,任何一条写漏都会表现为“单条指令对、指令序列错”。
| 冒险类型 | 触发条件 | 处理手段 | 代价 |
|---|---|---|---|
| RAW 数据冒险 | 后一条指令读前一条的写结果 | EX/MEM、MEM/WB 转发 | 0 周期 |
| load-use | lw后紧接使用其目标寄存器 | 阻塞一拍 + 转发 | 1 周期 |
| 控制冒险 | 分支指令在 EX 级判定 | 冲刷两级流水寄存器 | 2 周期 |
| 结构冒险 | 取指和访存同时用存储器 | 指令/数据存储器分离 | 0 周期 |
验证方法我推荐“分两级对比”。第一级是单指令验证:把附录 A3 里新增的每条指令单独跑一遍,波形上确认写回值正确,这一步把指令实现的错误筛掉。第二级是指令序列对比:写一段十几条指令的小程序,手工算出每条指令执行后的寄存器值,和仿真波形里regfile的内容逐条比对。这一步能抓出转发和阻塞的时序错误——单条指令全对、序列一出错,问题基本都在旁路逻辑。
再补一个技巧:把 CPI 和时序报告放在一起看。流水线跑通之后,用report_timing_summary看关键路径落在哪一级,如果落在 ALU 或者乘法器上,说明这一级的组合逻辑太长,可以考虑把乘法拆成两拍;如果落在转发网络上,说明旁路的选择逻辑写得太复杂,可以用优先级编码简化。CPI 的算法是总周期数除以指令数,理想值接近 1,实际会因为 load-use 和控制冒险略高,算出来和预期对不上,先查冒险处理的阻塞条件,再查状态机有没有多走。
血泪经验是:不要等整条流水线写完再仿真。每加一级流水寄存器就先跑一遍单指令,每加一条转发路径就补一组序列测试。从那以后我每次做流水线类的设计,都强制先跑一遍单指令再跑序列,宁可前期多花两小时,也不想在最后阶段对着几百个周期的波形找一个转发的选择条件。希望帮到你。
本文还有配套的精品资源,点击获取