第二十二章:基于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.1.1 呼吸灯PWM占空比示意图

22.2 硬件设计

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

图22.2.2 核心板上的PL 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 秒翻转一次。

图22.4.4 模块内部框图

占空比是怎么"动"起来的?

关键洞察: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——占空比自动递增。

图22.4.6 呼吸灯由暗变亮波形图

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

图22.4.7 呼吸灯由亮到暗变化的波形图

完整代码

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

注意两点技巧:

  1. 级联计数:cnt_2ms 不是从 0 数到 10 万,而是在 cnt_2us 计满时才加 1——多级计数器比一个大计数器省寄存器位宽(如果直接数 2 秒需要 27 位,三个级联只要 7+10+10 位且逻辑清晰)。
  2. PWM 输出就是一个比较cnt_2ms <= cnt_2s 一行代码就是占空比控制的全部,这就是 PWM 的优雅之处。

仿真验证

仿真时把三个计数参数都调小(比如 CNT_2US_MAX=2、CNT_2MS_MAX=10、CNT_2S_MAX=10),几微秒就能跑完一个完整"呼吸":

图22.4.8 呼吸灯逐渐变亮的波形图

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

图22.4.10 呼吸灯完整仿真波形

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 显示。

图22.6.1 在线逻辑分析仪的应用原理框图

代价是 ILA 会占用片内资源(块 RAM、查找表、触发器),所以采样深度不是越大越好。

Vivado 提供三种插入 ILA 的方法:

方法一:HDL 实例化调试探针流程(集成层次最高)

  1. 在 IP Catalog 中搜索并双击 "ILA (Integrated Logic Analyzer)";
  2. 配置参数:探针数(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);
  3. 点击 OK 后 Vivado 会对 IP 做 OOC(Out-of-Context,独立综合)——IP 与顶层设计分开单独综合,顶层迭代时不必重复综合 IP,加快设计周期;
  4. 打开 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]
);
  1. 综合实现后下载比特流即可调试。缺点:调试完成后要从代码中删除 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,点击开始触发——波形会在计数器归零(呼吸方向切换)时刻对齐显示:

图22.6.95 更新的波形

小技巧:计数器信号默认十六进制显示,右键信号 → Radix → Unsigned Decimal 可切换为十进制,方便读数。

调试完成后记得清理调试代码:删除 ILA IP 核文件(勾选同时从磁盘删除)、注释掉代码中的 ILA 例化(或删除 mark_debug 属性 / XDC 调试命令),再重新编译生成最终比特流。

ILA 时钟频率的坑

实测经验:ILA 采样时钟的可用频率区间大致是 JTAG 下载器时钟频率的 2.5 倍工程时序违例的频率上限。采样时钟过低会导致 ILA 无法启动、状态异常;过高会导致时序违例、抓不到波形。如果待测工程时钟频率很低又不能改,可以反过来降低 JTAG 下载器频率去迁就 ILA(Hardware Manager 中可设置)。

上板验证

删除调试内容、重新编译下载后:核心板 PL LED 由暗慢慢变亮,再由亮慢慢变暗,循环往复——呼吸灯成功。

图22.6.105 呼吸灯实验现象

初学者容易踩的坑

  • 级联计数的判断条件写漏: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 核输出多路不同频率的时钟。