第二十二章:基于PWM波的呼吸灯实验
本章对应原书第 446~513 页,属于「第五篇 实战篇」。
本章在讲什么?(先看这个)
呼吸灯——亮度像呼吸一样由暗渐亮、再由亮渐暗——最早是苹果用在笔记本睡眠指示灯上的设计。数字电路只能输出 0 和 1,怎么"调"出中间的亮度?答案是 PWM(脉冲宽度调制):让 LED 高速开关,通过改变"开"的时间比例(占空比)让人眼感觉到亮度变化。本章前半部分用三个级联的计数器实现呼吸灯;后半部分是全书的重点技能课——在线逻辑分析仪(ILA),它让你不用外接昂贵的仪器,就能在 Vivado 里实时观察 FPGA 内部信号,这一章会把三种 ILA 插入流程一次讲透。
核心概念拆解
22.1 呼吸灯简介:PWM 原理
PWM(Pulse Width Modulation,脉冲宽度调制):在固定频率的方波信号里,通过调整占空比(高电平时间占整个周期的比例)来控制输出"平均效果"。
- 占空比 = 0 → LED 永远不亮;
- 占空比 = 100% → LED 最亮;
- 占空比从 0% 渐增到 100%,再渐减回 0%,LED 就呈现"呼吸"效果。
人眼的视觉暂留会"平均"高速开关的光,只要 PWM 频率足够高(比如几百 Hz 以上),看到的就是连续的亮度而非闪烁。

22.2 硬件设计
本实验用的是核心板上的 PL LED(前面几章用的是底板 LED,注意引脚不同)。LED 阳极接 ZYNQ IO,阴极经 330 欧姆限流电阻接地,因此 IO 输出高电平点亮,输出低电平熄灭——和底板 LED 正好相反。

22.3 实验任务
用核心板 PL LED 实现呼吸灯:LED 由暗渐亮,再由亮渐暗,循环往复。
22.4 程序设计:三级计数器拆解呼吸过程
设计的核心思想是把 2 秒的呼吸周期拆成层层嵌套的时间片:
- 一个呼吸过程(由暗到亮)定义为 2 秒,分成 1000 份 → 每份 2ms(1000 个亮度等级);
- 每个 2ms 再分成 1000 份 → 每份 2us(每个 2us 决定一小段高电平)。
于是需要三个级联计数器:
| 计数器 | 计时目标 | 计数值 | 说明 |
|---|---|---|---|
| cnt_2us | 2us | 0~99 | 2us/20ns = 100 个时钟周期 |
| cnt_2ms | 2ms | 0~999 | 1000 个 2us |
| cnt_2s | 2s | 0~999 | 1000 个 2ms |
再加上一个 inc_dec_flag 标志:0 表示亮度递增(由暗变亮),1 表示递减(由亮变暗),每 2 秒翻转一次。

占空比是怎么"动"起来的?
关键洞察:cnt_2s 的计数值就是"当前亮度等级"。每个 2ms 周期内:
- cnt_2s = 0 时(第 1 个 2ms),给 LED 一个 2us 的高电平(占空比 0.1%);
- cnt_2s = 1 时,给两个 2us 的高电平(0.2%);
- ……
- cnt_2s = 999 时,整个 2ms 全是高电平(100%,最亮)。
判断条件非常简洁:cnt_2ms ≤ cnt_2s 时输出高电平。当 cnt_2s 从 0 数到 999,满足条件的时间从 1 个 2us 涨到 1000 个 2us——占空比自动递增。

由亮变暗则相反:cnt_2ms ≥ cnt_2s 时输出高电平,满足条件的时间从 1000 个 2us 缩到 1 个,占空比递减。

完整代码
module breath_led(
input sys_clk , //系统时钟 50MHz
input sys_rst_n, //系统复位,低电平有效
output reg led //LED灯
);
parameter CNT_2US_MAX = 7'd100;
parameter CNT_2MS_MAX = 10'd1000;
parameter CNT_2S_MAX = 10'd1000;
reg [6:0] cnt_2us;
reg [9:0] cnt_2ms;
reg [9:0] cnt_2s;
reg inc_dec_flag; //0:亮度递增 1:亮度递减
//cnt_2us:计数2us
always @(posedge sys_clk or negedge sys_rst_n) begin
if(!sys_rst_n)
cnt_2us <= 7'b0;
else if(cnt_2us == (CNT_2US_MAX - 7'b1))
cnt_2us <= 7'b0;
else
cnt_2us <= cnt_2us + 7'b1;
end
//cnt_2ms:在2us基础上计数2ms
always @(posedge sys_clk or negedge sys_rst_n) begin
if(!sys_rst_n)
cnt_2ms <= 10'b0;
else if(cnt_2ms == (CNT_2MS_MAX - 10'b1) && cnt_2us == (CNT_2US_MAX - 7'b1))
cnt_2ms <= 10'b0;
else if(cnt_2us == CNT_2US_MAX - 7'b1)
cnt_2ms <= cnt_2ms + 10'b1;
else
cnt_2ms <= cnt_2ms;
end
//cnt_2s:在2ms基础上计数2s
always @(posedge sys_clk or negedge sys_rst_n) begin
if(!sys_rst_n)
cnt_2s <= 10'b0;
else if(cnt_2s == (CNT_2S_MAX - 10'b1) && cnt_2ms == (CNT_2MS_MAX - 10'b1)
&& cnt_2us == (CNT_2US_MAX - 7'b1))
cnt_2s <= 10'b0;
else if(cnt_2ms == (CNT_2MS_MAX - 10'b1) && cnt_2us == (CNT_2US_MAX - 7'b1))
cnt_2s <= cnt_2s + 10'b1;
else
cnt_2s <= cnt_2s;
end
//每2s翻转一次呼吸方向
always @(posedge sys_clk or negedge sys_rst_n) begin
if(!sys_rst_n)
inc_dec_flag <= 1'b0;
else if(cnt_2s == (CNT_2S_MAX - 10'b1) && cnt_2ms == (CNT_2MS_MAX - 10'b1)
&& cnt_2us == (CNT_2US_MAX - 7'b1))
inc_dec_flag <= ~inc_dec_flag;
end
//PWM输出:决定LED亮暗
always @(posedge sys_clk or negedge sys_rst_n) begin
if(!sys_rst_n)
led <= 1'b0;
else if((inc_dec_flag == 1'b1 && cnt_2ms >= cnt_2s)
|| (inc_dec_flag == 1'b0 && cnt_2ms <= cnt_2s))
led <= 1'b1;
else
led <= 1'b0;
end
endmodule
注意两点技巧:
- 级联计数:cnt_2ms 不是从 0 数到 10 万,而是在 cnt_2us 计满时才加 1——多级计数器比一个大计数器省寄存器位宽(如果直接数 2 秒需要 27 位,三个级联只要 7+10+10 位且逻辑清晰)。
- PWM 输出就是一个比较:
cnt_2ms <= cnt_2s一行代码就是占空比控制的全部,这就是 PWM 的优雅之处。
仿真验证
仿真时把三个计数参数都调小(比如 CNT_2US_MAX=2、CNT_2MS_MAX=10、CNT_2S_MAX=10),几微秒就能跑完一个完整"呼吸":

inc_dec_flag=0 时,cnt_2ms ≤ cnt_2s 的区间随 cnt_2s 增大而变宽,led 高电平占空比递增:

22.5 创建工程(引脚约束)
本实验只用核心板上的 LED,引脚很少:
| 信号名 | 方向 | 管脚 | 说明 |
|---|---|---|---|
| sys_clk | input | U18 | 系统时钟 50MHz |
| sys_rst_n | input | N16 | PL复位按键,低有效 |
| led | output | J16 | 核心板 PL LED(高电平点亮) |
create_clock -period 20.000 -name sys_clk [get_ports sys_clk]
set_property -dict {PACKAGE_PIN U18 IOSTANDARD LVCMOS33} [get_ports sys_clk]
set_property -dict {PACKAGE_PIN N16 IOSTANDARD LVCMOS33} [get_ports sys_rst_n]
set_property -dict {PACKAGE_PIN J16 IOSTANDARD LVCMOS33} [get_ports led]
22.6 在线逻辑分析仪(ILA)的使用
ILA 是什么?
传统板级调试要把内部信号引到 IO 脚上,再用昂贵的台式逻辑分析仪抓波形。ILA(Integrated Logic Analyzer,集成逻辑分析仪) 把逻辑分析仪"塞进"了 FPGA 芯片里:探针(Probe)采集内部信号,数据存进片内 RAM,通过 JTAG 下载器上传到 Vivado 显示。

代价是 ILA 会占用片内资源(块 RAM、查找表、触发器),所以采样深度不是越大越好。
Vivado 提供三种插入 ILA 的方法:
方法一:HDL 实例化调试探针流程(集成层次最高)
- 在 IP Catalog 中搜索并双击 "ILA (Integrated Logic Analyzer)";
- 配置参数:探针数(Number of Probes)设为 6(对应 sys_rst_n、cnt_2us、cnt_2ms、cnt_2s、inc_dec_flag、led);采样深度(Sample Data Depth)选 4096;探针类型选 Native;各探针位宽按信号实际位宽设置(1/7/10/10/1/1);
- 点击 OK 后 Vivado 会对 IP 做 OOC(Out-of-Context,独立综合)——IP 与顶层设计分开单独综合,顶层迭代时不必重复综合 IP,加快设计周期;
- 打开 ILA 的例化模板,复制到顶层代码中:
ila_0 u_ila_0 (
.clk (sys_clk ), //采样时钟
.probe0 (sys_rst_n ), // [0:0]
.probe1 (cnt_2us ), // [6:0]
.probe2 (cnt_2ms ), // [9:0]
.probe3 (cnt_2s ), // [9:0]
.probe4 (inc_dec_flag ), // [0:0]
.probe5 (led ) // [0:0]
);
- 综合实现后下载比特流即可调试。缺点:调试完成后要从代码中删除 ILA 例化并重新综合。
方法二:使用 Debug 标记创建 ILA 调试环境
在综合前,给要观察的信号加综合属性:
(* mark_debug = "true" *) reg [6:0] cnt_2us;
这个属性还有个副作用:被标记的信号不会被综合器优化掉。然后综合 → Open Synthesized Design → 切换到 "Debug" 布局 → 点击 "Set Up Debug" 向导,工具自动识别时钟域、生成 ILA IP;调试信息以 Tcl 命令形式自动写入 XDC 约束文件(记得 Ctrl+S 保存),实现阶段 Vivado 读取约束自动插入 ILA。调试完后需删除代码中的 mark_debug 属性和 XDC 中的调试命令。
方法三:网表插入调试探针流程
综合后在网表(Netlist 窗口)里找到信号(注意:综合后信号名会变,比如 led 变成 led_OBUF),右键 "Mark Debug",然后同样走 Set Up Debug 向导。优点是不用改源代码;缺点是综合后信号名与代码不一致、且部分信号可能已被优化,初学者不容易找到目标信号,不推荐新手优先使用。
Hardware Manager 中观察信号
下载比特流时 Vivado 会自动识别 .ltx 文件(存储探针信息,与比特流同目录),点 Program 后自动打开 ILA 波形窗口。四个触发按钮:
- 循环触发:持续检测触发条件,每满足一次就上传一次波形并刷新;
- 开始触发:执行一次触发检测;
- 立即触发:不管触发条件,立即把当前 RAM 数据上传显示;
- 停止触发:停止触发活动。
实践:在 Trigger Setup 窗口添加触发条件 cnt_2s == 0,点击开始触发——波形会在计数器归零(呼吸方向切换)时刻对齐显示:

小技巧:计数器信号默认十六进制显示,右键信号 → Radix → Unsigned Decimal 可切换为十进制,方便读数。
调试完成后记得清理调试代码:删除 ILA IP 核文件(勾选同时从磁盘删除)、注释掉代码中的 ILA 例化(或删除 mark_debug 属性 / XDC 调试命令),再重新编译生成最终比特流。
ILA 时钟频率的坑
实测经验:ILA 采样时钟的可用频率区间大致是 JTAG 下载器时钟频率的 2.5 倍 至 工程时序违例的频率上限。采样时钟过低会导致 ILA 无法启动、状态异常;过高会导致时序违例、抓不到波形。如果待测工程时钟频率很低又不能改,可以反过来降低 JTAG 下载器频率去迁就 ILA(Hardware Manager 中可设置)。
上板验证
删除调试内容、重新编译下载后:核心板 PL LED 由暗慢慢变亮,再由亮慢慢变暗,循环往复——呼吸灯成功。

初学者容易踩的坑
- 级联计数的判断条件写漏:cnt_2ms 加 1 的条件是
cnt_2us == CNT_2US_MAX-1,cnt_2s 加 1 需要同时满足cnt_2ms == MAX-1 && cnt_2us == MAX-1。漏掉任何一个条件,时间基准就全错了。 - 亮暗条件搞反:由暗到亮是
cnt_2ms <= cnt_2s输出高电平,由亮到暗是cnt_2ms >= cnt_2s。两处比较符写反,现象就变成"渐亮后突然熄灭"之类的怪样子。 - LED 极性想当然:核心板 LED 是高电平点亮,底板 LED 是低电平点亮。拿错引脚约束或想错极性,呼吸就变成"反相呼吸"。
- ILA 采样深度贪大:采样深度越大占用的 BRAM 越多,设太大可能导致布局布线失败或资源不足,按需选择即可。
- 调试完忘删 ILA:ILA 例化留在代码里会持续占用资源,正式版本务必删除 IP、注释例化代码并重新编译。
- ILA 时钟频率不匹配:采样时钟低于 JTAG 频率的 2.5 倍或过高违例,都会出现 ILA 启动失败、抓不到波形等疑难杂症,优先检查频率关系。
小结 & 下一步
本章用"2us / 2ms / 2s"三级级联计数器实现了 PWM 呼吸灯,核心只有一行比较逻辑——把复杂问题拆成时间片是数字设计的通用思路;同时完整学习了 ILA 在线调试的三种流程(HDL 实例化 / Mark Debug / 网表插入),这个技能将伴随你之后所有的 FPGA 调试工作。建议动手做拓展训练:用底板两个 PL LED 实现以不同频率呼吸的双呼吸灯。下一章我们将进入 IP 核的世界,学习用 PLL IP 核输出多路不同频率的时钟。