FPGA 调试实录:LED 闪烁程序从踩坑到仿真验证的完整经验总结
从"灯闪得不对"到"仿真没波形",这篇文章记录了一个 FPGA 新手在 LED 工程里踩过的坑。文章按「先给完整代码 → 再逐行解释 → 最后集中回答重点问题」的顺序组织,方便查阅。
一、最终代码
1.1 LED.v
verilog
`timescale 1ns / 1ps
//////////////////////////////////////////////////////////////////////////////////
// Module Name: LED
// Description: 两个 LED 每秒交替闪烁(200MHz 时钟)
// 带 CNT_MAX 参数,仿真时可覆盖成小值
// Target: Xilinx Artix-7 XC7A35T-FGG484
//////////////////////////////////////////////////////////////////////////////////
module LED #(
parameter CNT_MAX = 200000000 // 默认 2 亿(1 秒 @ 200MHz)
)(
input sys_clk_p,
input sys_clk_n,
input sys_rstn,
output reg [1:0] led
);
// ===== 差分时钟转单端 =====
wire clk;
`ifdef SIMULATION
// 仿真时:直接用 sys_clk_p 当时钟,绕开 IBUFDS
assign clk = sys_clk_p;
`else
// 综合时:用 IBUFDS 转差分时钟
IBUFDS #(
.DIFF_TERM("FALSE"),
.IBUF_LOW_PWR("TRUE"),
.IOSTANDARD("DEFAULT")
) IBUFDS_inst (
.O (clk),
.I (sys_clk_p),
.IB(sys_clk_n)
);
`endif
// ===== 计数器 =====
reg [31:0] cnt;
always @(posedge clk) begin
if(!sys_rstn)
cnt <= 0;
else if(cnt < CNT_MAX - 1)
cnt <= cnt + 1;
else
cnt <= 0;
end
// ===== LED 状态切换 =====
reg i;
always @(posedge clk) begin
if(!sys_rstn) begin
led <= 2'b01;
i <= 0;
end
else if(cnt == CNT_MAX - 1) begin
if(i == 0) begin
led <= 2'b10;
i <= 1;
end
else begin
led <= 2'b01;
i <= 0;
end
end
// 其他时刻不写 led,自动保持
end
endmodule
1.2 LED_tb.v
verilog
`timescale 1ns / 1ps
`define SIMULATION // ← 关键:启用仿真分支,绕开 IBUFDS
module LED_tb();
reg sys_clk_p;
reg sys_rstn;
wire [1:0] led;
// ===== 例化被测模块,CNT_MAX 改小,仿真瞬间看到切换 =====
LED #(
.CNT_MAX(10) // 仿真时只数 10 个周期 = 50ns
) u_LED (
.sys_clk_p(sys_clk_p),
.sys_clk_n(~sys_clk_p), // 反相模拟差分负端
.sys_rstn (sys_rstn),
.led (led)
);
// ===== 产生 200MHz 时钟(周期 5ns)=====
initial begin
sys_clk_p = 0;
forever #2.5 sys_clk_p = ~sys_clk_p;
end
// ===== 复位激励 =====
initial begin
sys_rstn = 0;
#100; // 复位保持 100ns
sys_rstn = 1; // 释放复位
#1000; // 跑 1us,足够看 20 次切换
$stop; // 停止仿真
end
// ===== 监视输出 =====
initial begin
$monitor("Time = %0t ns, led = %b", $time, led);
end
endmodule
1.3 配套 XDC
tcl
# ===== 配置属性 =====
set_property CFGBVS VCCO [current_design]
set_property CONFIG_VOLTAGE 3.3 [current_design]
set_property BITSTREAM.GENERAL.COMPRESS true [current_design]
set_property BITSTREAM.CONFIG.CONFIGRATE 50 [current_design]
set_property BITSTREAM.CONFIG.SPI_BUSWIDTH 4 [current_design]
set_property BITSTREAM.CONFIG.SPI_FULL_EDGE Yes [current_design]
# ===== 时钟约束(关键!)=====
create_clock -period 5.000 -name sys_clk [get_ports sys_clk_p]
# ===== 引脚约束 =====
set_property -dict {PACKAGE_PIN R4 IOSTANDARD DIFF_SSTL15} [get_ports sys_clk_p]
set_property -dict {PACKAGE_PIN R14 IOSTANDARD LVCMOS33} [get_ports sys_rstn]
set_property -dict {PACKAGE_PIN W22 IOSTANDARD LVCMOS33} [get_ports {led[0]}]
set_property -dict {PACKAGE_PIN Y22 IOSTANDARD LVCMOS33} [get_ports {led[1]}]
二、逐行解释
2.1 LED.v 逐行分析
verilog
`timescale 1ns / 1ps
第 1 行: 时间单位 1ns,精度 1ps。仿真时 #数字 以此为基准。综合忽略。
verilog
module LED #(
parameter CNT_MAX = 200000000 // 默认 2 亿(1 秒 @ 200MHz)
)(
input sys_clk_p,
input sys_clk_n,
input sys_rstn,
output reg [1:0] led
);
第 3~10 行: 模块定义 + 参数 + 端口。
| 端口 | 方向 | 说明 |
|---|---|---|
sys_clk_p |
input | 差分时钟正端(200MHz) |
sys_clk_n |
input | 差分时钟负端 |
sys_rstn |
input | 复位,低有效 |
led[1:0] |
output reg | 2 位 LED 输出 |
parameter CNT_MAX = 200000000:默认值 2 亿,仿真时可通过 #(.CNT_MAX(10)) 覆盖。
verilog
wire clk;
第 13 行: 声明内部单端时钟线,用于驱动后续 always 块。
verilog
`ifdef SIMULATION
assign clk = sys_clk_p;
`else
IBUFDS #(
.DIFF_TERM("FALSE"),
.IBUF_LOW_PWR("TRUE"),
.IOSTANDARD("DEFAULT")
) IBUFDS_inst (
.O (clk),
.I (sys_clk_p),
.IB(sys_clk_n)
);
`endif
第 15~27 行: 差分时钟处理,分两条路。
| 条件 | 走哪条 | 原因 |
|---|---|---|
| ```ifdef SIMULATION`` 成立 | assign clk = sys_clk_p; |
仿真时绕过 IBUFDS |
| 不成立(综合) | IBUFDS 原语 |
硬件差分输入缓冲 |
IBUFDS 参数:
| 参数 | 值 | 含义 |
|---|---|---|
DIFF_TERM |
"FALSE" |
不用内部 100Ω 终端电阻 |
IBUF_LOW_PWR |
"TRUE" |
低功耗模式 |
IOSTANDARD |
"DEFAULT" |
默认电平,由 XDC 指定 |
端口:
| 端口 | 方向 | 连接 |
|---|---|---|
.O |
输出 | clk |
.I |
输入 | sys_clk_p |
.IB |
输入 | sys_clk_n(B = Bar,反相端) |
verilog
reg [31:0] cnt;
always @(posedge clk) begin
if(!sys_rstn)
cnt <= 0;
else if(cnt < CNT_MAX - 1)
cnt <= cnt + 1;
else
cnt <= 0;
end
第 29~38 行: 计数器。
| 条件 | 动作 | 说明 |
|---|---|---|
| 复位有效 | cnt <= 0 |
清零 |
cnt < CNT_MAX - 1 |
cnt <= cnt + 1 |
累加 |
cnt == CNT_MAX - 1 |
cnt <= 0 |
归零,完成一轮 |
关键: 数到 CNT_MAX - 1 就归零,所以一轮是 CNT_MAX 个周期。
verilog
reg i;
always @(posedge clk) begin
if(!sys_rstn) begin
led <= 2'b01;
i <= 0;
end
else if(cnt == CNT_MAX - 1) begin
if(i == 0) begin
led <= 2'b10;
i <= 1;
end
else begin
led <= 2'b01;
i <= 0;
end
end
end
第 40~57 行: LED 状态切换。
| 条件 | 动作 | 说明 |
|---|---|---|
| 复位有效 | led = 01, i = 0 |
初始状态 |
cnt == CNT_MAX - 1 且 i == 0 |
led = 10, i = 1 |
切到 LED2 |
cnt == CNT_MAX - 1 且 i == 1 |
led = 01, i = 0 |
切回 LED1 |
| 其他 | 不写 led | 自动保持 |
关键: i 只在 0 和 1 之间交替,输出 01 ↔ 10,不会出现 00。
verilog
endmodule
第 59 行: 模块结束。
2.2 LED_tb.v 逐行分析
verilog
`timescale 1ns / 1ps
第 1 行: 时间单位和精度,同 LED.v。
verilog
`define SIMULATION
第 2 行: 关键! 定义仿真宏,让 LED.v 里的 ```ifdef SIMULATION`` 成立,走 assign clk = sys_clk_p;,绕开 IBUFDS。
⚠️ 必须写在
module外面,文件最顶部,否则不生效。
verilog
module LED_tb();
第 4 行: 测试模块定义,没有端口。Testbench 不和外部通信,只产生激励。
verilog
reg sys_clk_p;
reg sys_rstn;
wire [1:0] led;
第 6~8 行: 信号声明。
| 信号 | 类型 | 为什么 |
|---|---|---|
sys_clk_p |
reg |
TB 主动产生时钟,必须能赋值 |
sys_rstn |
reg |
TB 主动产生复位,必须能赋值 |
led |
wire |
由被测模块驱动,TB 只观察 |
verilog
LED #(
.CNT_MAX(10)
) u_LED (
.sys_clk_p(sys_clk_p),
.sys_clk_n(~sys_clk_p),
.sys_rstn (sys_rstn),
.led (led)
);
第 10~17 行: 例化被测模块。
| 部分 | 值 | 说明 |
|---|---|---|
LED |
模块名 | 要例化的模块类型 |
#(.CNT_MAX(10)) |
参数覆盖 | 仿真时数 10 个周期切换 |
u_LED |
实例名 | 给这个实例起的名字 |
| 端口连接 | --- | 把 TB 信号连到模块端口 |
~sys_clk_p: 反相模拟差分负端。仿真里 ~ 可以作用于 reg。
verilog
initial begin
sys_clk_p = 0;
forever #2.5 sys_clk_p = ~sys_clk_p;
end
第 19~22 行: 产生 200MHz 时钟。
| 时刻 | 事件 |
|---|---|
| 0ns | sys_clk_p = 0 |
| 2.5ns | 翻转为 1 |
| 5ns | 翻转为 0 |
| ... | 每 2.5ns 翻转一次 |
周期 = 5ns → 200MHz。
verilog
initial begin
sys_rstn = 0;
#100;
sys_rstn = 1;
#1000;
$stop;
end
第 24~30 行: 产生复位激励。
| 时刻 | 事件 | 说明 |
|---|---|---|
| 0ns | sys_rstn = 0 |
复位有效 |
| 0~100ns | 保持 0 | 复位期间 led 固定 01 |
| 100ns | sys_rstn = 1 |
释放复位 |
| 100~1100ns | 逻辑工作 | LED 每 50ns 切换一次 |
| 1100ns | $stop |
停止仿真 |
verilog
initial begin
$monitor("Time = %0t ns, led = %b", $time, led);
end
第 32~34 行: 监视输出。
$monitor:只要led变化就打印%0t:时间,%b:二进制- 输出示例:
Time = 150 ns, led = 10
verilog
endmodule
第 36 行: 模块结束。
2.3 配套 XDC 逐行分析
tcl
set_property CFGBVS VCCO [current_design]
配置 Bank 电压跟随 VCCO。
tcl
set_property CONFIG_VOLTAGE 3.3 [current_design]
配置引脚电压 3.3V。
tcl
set_property BITSTREAM.GENERAL.COMPRESS true [current_design]
比特流压缩,文件更小。
tcl
set_property BITSTREAM.CONFIG.CONFIGRATE 50 [current_design]
配置速率 50MHz(从 Flash 加载比特流的速度,不是系统时钟)。
tcl
set_property BITSTREAM.CONFIG.SPI_BUSWIDTH 4 [current_design]
SPI 位宽 4(Quad-SPI,四线并行加载)。
tcl
set_property BITSTREAM.CONFIG.SPI_FULL_EDGE Yes [current_design]
SPI 全沿采样。
tcl
create_clock -period 5.000 -name sys_clk [get_ports sys_clk_p]
关键! 时钟约束:周期 5ns = 200MHz。差分时钟只约束 P 端,N 端自动关联。
tcl
set_property -dict {PACKAGE_PIN R4 IOSTANDARD DIFF_SSTL15} [get_ports sys_clk_p]
set_property -dict {PACKAGE_PIN R14 IOSTANDARD LVCMOS33} [get_ports sys_rstn]
set_property -dict {PACKAGE_PIN W22 IOSTANDARD LVCMOS33} [get_ports {led[0]}]
set_property -dict {PACKAGE_PIN Y22 IOSTANDARD LVCMOS33} [get_ports {led[1]}]
引脚约束:
| 端口 | 引脚 | 电平 |
|---|---|---|
sys_clk_p |
R4 | DIFF_SSTL15(差分) |
sys_rstn |
R14 | LVCMOS33 |
led[0] |
W22 | LVCMOS33 |
led[1] |
Y22 | LVCMOS33 |
{led[0]}用大括号包起来,因为 Tcl 里方括号有特殊含义。
2.4 补充:parameter 的两种写法
Verilog 里,parameter 有两种合法写法,功能完全等价。
写法 A:参数写在模块名后(推荐)
verilog
module Decode #(
parameter Width = 1,
parameter Polarity = 1
)(
input [Width-1:0] A,
output F
);
...
endmodule
写法 B:参数写在模块内部(老写法)
verilog
module Decode(A, F);
parameter Width = 1, Polarity = 1;
...
endmodule
对照表
| 对比项 | 写法 A | 写法 B |
|---|---|---|
| 位置 | 模块名后 #(...) |
模块名后 (...) 之内 |
| 可读性 | ✅ 更好 | ⚠️ 要往下翻 |
| 能否覆盖 | ✅ 可以 | ✅ 可以 |
| 年代 | Verilog-2001 之后 | Verilog-1995 |
| 推荐度 | ⭐⭐⭐ | ⭐ |
例化时的两种覆盖方式
按位置覆盖:
verilog
Decode #(4, 0) D1(A4, F16);
| 位置 | 对应参数 | 值 |
|---|---|---|
| 第 1 个 | Width |
4 |
| 第 2 个 | Polarity |
0 |
按名字覆盖(推荐):
verilog
Decode #(.Width(4), .Polarity(0)) D1(A4, F16);
| 写法 | 名称 | 优点 | 缺点 |
|---|---|---|---|
#(4, 0) |
按位置 | 简短 | 顺序不能错 |
#(.Width(4), .Polarity(0)) |
按名字 | 顺序无关,清晰 | 写起来长 |
经典教材里的例子
verilog
module Decode(A, F);
parameter Width = 1, Polarity = 1;
...
endmodule
module Top;
wire [3:0] A4;
wire [4:0] A5;
wire [15:0] F16;
wire [31:0] F32;
Decode #(4, 0) D1(A4, F16); // Width=4, Polarity=0
Decode #(5) D2(A5, F32); // Width=5, Polarity=默认 1
endmodule
结论: 你之前看到的 module LED #(parameter CNT_MAX = ...) 是写法 A,截图里的 module Decode(A, F); parameter Width = 1; 是写法 B,两者完全等价,新代码推荐写法 A。
三、重点问题回答
Q1:为什么仿真不能和实际一致?
仿真是"逻辑推演",不是"物理等待"。
| 维度 | 仿真 | 实际硬件 |
|---|---|---|
| 时间本质 | 逻辑推演 | 物理振荡 |
| 1 秒需要 | 2 亿次逻辑计算 | 2 亿次物理振荡 |
| 电脑耗时 | 可能几十分钟 | 真实 1 秒 |
| 能否加速 | ✅ 改参数即可 | ❌ 只能等 |
仿真器本质是软件,每模拟一个时钟周期都要消耗 CPU 时间。200MHz 时钟意味着每秒 2 亿次翻转,电脑扛不住。
Q2:forever #2.5 不是已经设好时钟了吗?为什么还要改 CNT_MAX?
时钟和 LED 切换是两码事。
| 项目 | 由谁决定 | 你的设置 |
|---|---|---|
| 时钟周期 | TB 里的 forever #2.5 |
5ns(200MHz) |
| LED 切换周期 | LED.v 里的 CNT_MAX |
默认 1 秒 |
时钟跑得快,不代表 LED 切换得快。 时钟设得再快,CNT_MAX = 2 亿 还是要数 2 亿下才切换。把 CNT_MAX 改成 10,几微秒就能看到切换。
Q3:u_LED 是什么?
是你给例化出来的 LED 模块起的名字(实例名)。
| 概念 | 例子 | 作用 |
|---|---|---|
| 模块名 | LED |
定义设计(图纸) |
| 实例名 | u_LED |
标识这次例化(造出来的设备) |
| 参数 | CNT_MAX |
可覆盖的配置值 |
| 端口 | sys_clk_p 等 |
模块对外的连接点 |
u_ 只是习惯前缀,不写也能跑,但推荐保留。实例名必须唯一,不能和别的信号重名。
Q4:#(.CNT_MAX(10)) 有必要改吗?
有必要,强烈建议保留。
| 方案 | CNT_MAX | 仿真切换周期 | 跑 1us 能看到什么 |
|---|---|---|---|
| 改 | 10 | 50ns | 20 次切换 ✅ |
| 不改 | 200,000,000 | 1 秒 | 0 次切换 ❌ |
不改的话,仿真要跑 2 亿个周期才切换一次,电脑可能卡几十分钟甚至几小时。改了之后,50ns 切换一次,几微秒就能验证逻辑。综合时 Testbench 不参与,板子上仍然是真实 1 秒。
Q5:仿真没波形怎么办?
先检查 sys_clk_p 有没有翻转。
波形里 sys_clk_p 一直为 0,说明:
- ```define SIMULATION`` 没写或位置不对
IBUFDS把clk卡死了sys_clk_p声明成了wire
解决: 在 LED_tb.v 最顶部加 define SIMULATION``,在 `LED.v` 里加 ifdef SIMULATION`` 分支。
Q6:set_property 怎么用?为什么有的要加 -dict?
基本语法:
tcl
set_property <属性名> <属性值> [<对象>]
不加 -dict(一次设一个属性):
tcl
set_property PACKAGE_PIN W22 [get_ports led[0]]
set_property IOSTANDARD LVCMOS33 [get_ports led[0]]
加 -dict(一次设多个属性):
tcl
set_property -dict {PACKAGE_PIN W22 IOSTANDARD LVCMOS33} [get_ports led[0]]
结论: -dict 只是批量设置的语法糖,功能等价于多条单属性命令。
Q7:current_design 是什么?
代表"当前正在处理的那个设计(整个 FPGA 工程)"。
| 对象 | 写法 | 作用范围 |
|---|---|---|
| 整个设计 | [current_design] |
全局配置(电压、比特流等) |
| 某个端口 | [get_ports led[0]] |
单个引脚/信号 |
结论: 全局配置用 current_design,引脚约束用 get_ports。
Q8:XDC 的时钟约束必须加吗?
必须加,尤其你的时钟是 200MHz。
tcl
create_clock -period 5.000 -name sys_clk [get_ports sys_clk_p]
不加的话 Vivado 不做时序分析,200MHz 下容易出问题。
Q9:XDC 的时钟约束加上后,cnt 还用改吗?
要改!两回事。
| 项目 | 作用 | 改不改 |
|---|---|---|
create_clock -period 5.000 |
告诉 Vivado:时钟周期是 5ns | ✅ 要加 |
cnt < 50000000 |
代码里数 50000000 个周期切换 LED | ✅ 也要改 |
结论: XDC 加约束是为了时序分析 ,代码改 cnt 是为了真正 1 秒切换,两者必须配套。
Q10:BITSTREAM.CONFIG.CONFIGRATE 50 是设置时钟为 50MHz 吗?
不是! 这是配置速率,指 FPGA 从 Flash 加载比特流时的 SPI 时钟频率。
| 概念 | 作用阶段 | 由谁决定 |
|---|---|---|
| 配置时钟(CONFIGRATE) | FPGA 上电加载时 | XDC 里的 CONFIGRATE |
| 系统时钟(sys_clk) | FPGA 正常运行时 | 板上晶振 + create_clock |
Q11:增量编译可以加快哪个地方的速度?
主要加速实现阶段(Implementation),特别是布局布线(Place & Route)。
| 阶段 | 是否加速 | 说明 |
|---|---|---|
| 综合(Synthesis) | ❌ 基本不加速 | 综合看的是逻辑,不是物理位置 |
| 实现(Implementation) | ✅ 主要加速点 | 布局布线是大头,能省 30%~70% 时间 |
| 生成比特流(Bitstream) | ❌ 不加速 | 只是打包数据,本来就快 |
Q12:改完代码没生效怎么办?
改了东西后,必须重新走完整流程:
Run Synthesis → Run Implementation → Generate Bitstream → Program Device
| 改了什么 | 需要重新综合? | 需要重新实现? | 需要重新生成 bit? |
|---|---|---|---|
| Verilog 代码 | ✅ | ✅ | ✅ |
| XDC 引脚/时钟 | ❌ | ✅ | ✅ |
| XDC 配置属性 | ❌ | ❌ | ✅ |
| 只改注释 | ❌ | ❌ | ❌ |
少任何一步,板子上跑的还是旧版本。
Q13:parameter 为什么有的写在模块名后,有的写在模块里?
两种写法都合法,功能等价。
| 写法 | 位置 | 年代 | 推荐度 |
|---|---|---|---|
| A | module 名字 #(parameter ...) (...) |
Verilog-2001 之后 | ⭐⭐⭐ |
| B | module 名字 (...); parameter ...; |
Verilog-1995 | ⭐ |
例化时都能用 #(...) 覆盖:
- 按位置:
Decode #(4, 0) D1(A4, F16); - 按名字:
Decode #(.Width(4), .Polarity(0)) D1(A4, F16);
推荐用按名字覆盖,顺序无关,可读性好。
四、总结:FPGA 新手最容易踩的坑
| # | 问题 | 原因 | 正确做法 |
|---|---|---|---|
| 1 | 闪快 4 倍 | 时钟频率搞错 | 确认 200MHz,改 cnt 阈值 |
| 2 | 误认 CONFIGRATE | 配置时钟 ≠ 系统时钟 | 用 create_clock 约束系统时钟 |
| 3 | 改完没生效 | 没走完整流程 | 综合→实现→bit→下载 |
| 4 | 编译太慢 | FPGA 编译成硬件 | 增量编译 + 仿真先行 |
| 5 | 仿真没波形 | IBUFDS 无仿真模型 | 加 ```ifdef SIMULATION`` 绕开 |
| 6 | 仿真跑不动 | CNT_MAX 太大 | 用参数覆盖改小 |
| 7 | parameter 写法混淆 | 两种写法都合法 | 推荐写在模块名后,按名字覆盖 |
五、最后的建议
- 先仿真,再上板。仿真几秒能看出的问题,不要花十分钟去综合实现。
- 改完代码走完整流程。综合 → 实现 → 生成 bit → 下载,一步不能少。
- 增量编译能省时间。改动小的时候效果明显。
- 确认板子参数。时钟频率、封装、引脚号,都要查手册确认。
- XDC 约束和代码阈值必须配套。时钟约束只管时序分析,计数器阈值才决定实际时间。
- 仿真时用参数覆盖缩短周期。仿真不是模拟真实时间,而是验证逻辑。
- parameter 推荐写在模块名后,用按名字覆盖。可读性好,顺序无关。
FPGA 开发的学习曲线确实陡,但踩过的坑都会变成经验。希望这篇博文能帮你少走一些弯路。
本文基于真实调试过程整理,代码已简化,仅保留关键逻辑。