☰
FPGA呼吸灯实战:PWM占空比调制与Verilog实现详解
2026/10/13 1:04:13 网站建设 项目流程

1. 从一个简单的呼吸灯说起:为什么它是FPGA入门的必修课

呼吸灯这个效果,做过嵌入式或者单片机的朋友应该都不陌生。手机上的通知灯、键盘背光、路由器指示灯,那种由暗到亮、再由亮到暗的平滑渐变,就是呼吸灯最典型的应用。它看起来简单,但如果你真拿一块FPGA开发板,想用纯硬件描述语言把它做出来,会发现里面涉及的知识点其实相当密集:时钟分频、计数器设计、PWM调制、位宽计算、仿真验证,一个都跑不掉。

我之所以说呼吸灯是FPGA入门的必修课,是因为它几乎把时序逻辑设计中最核心的几个概念都串起来了。你写一个流水灯,本质上就是移位寄存器加计数器;你写一个按键消抖,核心是状态机加采样。但呼吸灯不一样,它要求你同时理解“时间尺度”和“占空比”这两个维度的关系,还要在Verilog里把数学关系转化成硬件逻辑。说白了,这是一个从“点灯”到“调光”的跨越,是从数字逻辑到模拟效果的桥梁。

这篇文章面向的读者,是已经掌握了Verilog基本语法、能在开发板上跑通流水灯或者按键控制的朋友。如果你连always块和assign语句的区别还没搞明白,建议先补一下基础。但如果你已经能写出一个分频器,那呼吸灯就是你下一步最值得动手的项目。我会从设计思路、参数计算、代码实现、仿真验证到上板调试,把整个流程拆开揉碎讲清楚,包括我在实际调试中踩过的坑和总结出来的经验。

2. 呼吸灯的核心原理拆解与方案选型

2.1 呼吸灯的本质:PWM占空比的周期性调制

呼吸灯的核心原理其实一句话就能概括:用PWM波的占空比变化来模拟亮度的变化。LED的亮度取决于流过它的平均电流,而平均电流又取决于PWM信号的占空比。占空比越大,LED越亮;占空比越小,LED越暗。呼吸灯要做的,就是让占空比按照一定的规律从0%慢慢增加到100%,再从100%慢慢减小到0%,循环往复。

这里有一个关键点需要理解:PWM的频率必须足够高,高到人眼察觉不到闪烁。一般来说,PWM频率在1kHz以上人眼就基本感觉不到闪烁了,但为了效果更平滑,实际项目中通常会用到几kHz甚至几十kHz。这个频率决定了你“亮度更新”的最小时间单位。

而呼吸的“节奏”——也就是从最暗到最亮再回到最暗的时间——通常在1秒到3秒之间比较舒适。太快了像在闪烁,太慢了又感觉不到呼吸的效果。我一般会选2秒左右作为一个完整的呼吸周期,这个后面会详细算。

2.2 为什么不用DAC或者模拟电路

有人可能会问:直接用一个DAC输出变化的电压来控制LED亮度不行吗?当然可以,但那就不是FPGA的玩法了。FPGA擅长的是数字逻辑,用PWM做调光不需要任何外部模拟器件,只需要一个普通的GPIO引脚就能实现。而且PWM调光的线性度好、效率高,LED始终工作在开关状态,不会因为半导通状态导致发热或者色偏。

另一个方案是用FPGA内部的IP核生成PWM,但我觉得对于呼吸灯这种级别的项目,自己手写计数器更直观,也更能锻炼对时序逻辑的理解。IP核适合复杂场景,比如多通道同步PWM或者需要动态调整频率的场合,但呼吸灯用不上。

2.3 两种常见实现思路的对比

实现呼吸灯有两种主流思路,我在不同项目里都用过,各有优劣。

第一种是“占空比递增递减法”。用一个计数器控制PWM的占空比,另一个计数器控制这个占空比的变化方向。当占空比计到最大值时,方向翻转,开始递减;减到0时再翻转,开始递增。这种方法的优点是逻辑简单、资源占用少,缺点是呼吸的亮度曲线是线性的,人眼看起来可能会觉得“两端变化快、中间变化慢”,不够自然。

第二种是“正弦波调制法”。用查找表或者CORDIC算法生成一个正弦波序列,用正弦波的幅值去调制PWM的占空比。这样亮度变化符合人眼的感知曲线,呼吸效果更柔和。缺点是资源占用大,需要存储查找表或者实现复杂的运算。

对于入门项目,我建议先用第一种方法把整个流程跑通,等有余力了再尝试第二种。下面这张表可以帮你快速做决策:

对比维度占空比递增递减法正弦波调制法
逻辑复杂度低,两个计数器即可高,需要查找表或DSP
资源占用极少,几十个LUT较多,取决于查找表精度
呼吸效果线性变化,两端略突兀柔和自然,接近真实呼吸
适合场景入门学习、快速验证产品级效果、多灯同步
调试难度低,波形直观中,需要观察正弦调制效果

3. 参数计算:从时钟频率到呼吸周期的完整推导

3.1 系统时钟与PWM频率的关系

假设你的开发板晶振是50MHz,这是最常见的配置。PWM频率我选1kHz,因为这个频率下LED不会闪烁,同时计数器的位宽也不会太大。那么PWM周期就是1ms,对应的计数值是:

50,000,000 / 1,000 = 50,000

也就是说,需要一个能计到49,999的计数器,位宽至少是16位(2^16 = 65,536)。这个计数器我称之为pwm_cnt,它从0计到49,999,然后归零,循环往复。

3.2 占空比控制计数器的位宽选择

接下来需要一个控制占空比的计数器,我称之为duty_cnt。它的值决定了PWM输出在多少个时钟周期内为高电平。如果duty_cnt是0,占空比就是0%,LED全暗;如果duty_cnt是50,000,占空比就是100%,LED全亮。

那么duty_cnt的位宽怎么定?它需要能表示0到50,000的范围,所以至少16位。但为了留一点余量,我一般会用17位或者直接复用16位但注意边界条件。实际代码里我会把duty_cnt定义为reg [15:0],最大值设为50,000,这样刚好够用。

3.3 呼吸周期的计算与亮度更新频率

呼吸周期我定为2秒。在这2秒内,占空比要从0变到50,000再变回0,总共变化100,000个步进。那么每一步需要持续多长时间?

2秒 / 100,000 = 20微秒

也就是说,每20微秒更新一次占空比。对应的时钟周期数是:

20微秒 × 50MHz = 1,000

所以需要一个每1,000个时钟周期产生一个脉冲的信号,用来触发占空比的更新。这个信号我称之为duty_update,它本质上是一个分频器,把50MHz分频到50kHz。

这里有个细节需要注意:duty_update的脉冲宽度只有一个时钟周期,它只是告诉系统“该更新占空比了”,而不是说占空比本身持续1,000个周期。这个区分很重要,写代码的时候容易搞混。

3.4 完整参数表

把上面的计算整理成一张表,方便你对照自己的开发板调整:

参数名称符号数值说明
系统时钟频率f_clk50MHz常见开发板晶振
PWM频率f_pwm1kHz人眼无闪烁
PWM周期计数值N_pwm50,00016位计数器
呼吸周期T_breath2s完整呼吸时间
占空比步进总数N_step100,0000→50,000→0
亮度更新频率f_update50kHz每20us更新一次
更新分频计数值N_update1,00050MHz/50kHz

如果你用的是其他频率的晶振,比如100MHz或者25MHz,只需要按比例调整N_pwm和N_update就行了。公式很简单:N_pwm = f_clk / f_pwm,N_update = f_clk / f_update。

4. Verilog代码实现:从模块定义到功能验证

4.1 模块端口定义与参数化设计

我习惯把关键参数写成parameter,这样换开发板的时候只需要改参数,不用动逻辑代码。模块端口也很简单:时钟、复位、PWM输出,就这三个。

module breathing_led #( parameter CLK_FREQ = 50_000_000, // 系统时钟频率 parameter PWM_FREQ = 1_000, // PWM频率 parameter BREATH_MS = 2_000 // 呼吸周期,单位毫秒 )( input wire clk, input wire rst_n, output reg pwm_out );

这里BREATH_MS用毫秒做单位,因为呼吸周期用秒做单位太粗了,用毫秒更灵活。2,000毫秒就是2秒。

4.2 时钟分频与PWM生成逻辑

先算两个关键计数值:

localparam PWM_MAX = CLK_FREQ / PWM_FREQ; // 50,000 localparam STEP_MAX = PWM_MAX * 2; // 100,000 localparam UPD_MAX = CLK_FREQ / (STEP_MAX / (BREATH_MS / 1000.0));

最后这个UPD_MAX的计算稍微绕一点。BREATH_MS / 1000.0得到呼吸周期的秒数,STEP_MAX除以这个秒数得到每秒需要更新的步数,也就是更新频率。然后用CLK_FREQ除以更新频率,得到分频计数值。代入数值:

STEP_MAX = 100,000 BREATH_MS / 1000.0 = 2.0 更新频率 = 100,000 / 2.0 = 50,000 Hz UPD_MAX = 50,000,000 / 50,000 = 1,000

和之前手算的结果一致。不过在实际代码里,我建议直接用整数运算,避免浮点数综合出问题。可以写成:

localparam UPD_MAX = CLK_FREQ * BREATH_MS / (STEP_MAX * 1000);

这样全程整数运算,综合器不会报错。

4.3 占空比递增递减的状态控制

核心逻辑用一个duty_cnt和一个方向标志位dir来实现。dir为0时递增,为1时递减。当duty_cnt达到PWM_MAX时,dir翻转为1;当duty_cnt达到0时,dir翻转为0。

reg [16:0] duty_cnt; // 17位,因为STEP_MAX=100,000需要17位 reg dir; // 0:递增 1:递减 reg [15:0] pwm_cnt; reg [15:0] upd_cnt; reg upd_pulse; // 更新脉冲生成 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin upd_cnt <= 16'd0; upd_pulse <= 1'b0; end else if (upd_cnt == UPD_MAX - 1) begin upd_cnt <= 16'd0; upd_pulse <= 1'b1; end else begin upd_cnt <= upd_cnt + 1'b1; upd_pulse <= 1'b0; end end // 占空比控制 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin duty_cnt <= 17'd0; dir <= 1'b0; end else if (upd_pulse) begin if (dir == 1'b0) begin if (duty_cnt >= PWM_MAX) dir <= 1'b1; else duty_cnt <= duty_cnt + 1'b1; end else begin if (duty_cnt == 17'd0) dir <= 1'b0; else duty_cnt <= duty_cnt - 1'b1; end end end

这里有个细节:duty_cnt我用了17位,因为STEP_MAX是100,000,16位最大只能到65,535,不够用。但PWM_MAX是50,000,16位够用。所以duty_cnt的位宽要按STEP_MAX来定,不能按PWM_MAX来定。这是很多新手容易忽略的地方,位宽不够会导致计数器溢出,呼吸效果直接乱掉。

4.4 PWM输出与占空比比较

PWM输出的逻辑很简单:pwm_cnt从0计到PWM_MAX-1,当pwm_cnt < duty_cnt时输出高电平,否则输出低电平。

always @(posedge clk or negedge rst_n) begin if (!rst_n) begin pwm_cnt <= 16'd0; pwm_out <= 1'b0; end else begin if (pwm_cnt == PWM_MAX - 1) pwm_cnt <= 16'd0; else pwm_cnt <= pwm_cnt + 1'b1; if (pwm_cnt < duty_cnt) pwm_out <= 1'b1; else pwm_out <= 1'b0; end end

注意这里duty_cnt是17位,pwm_cnt是16位,比较的时候Verilog会自动扩展pwm_cnt的位宽,不会出问题。但如果你用的是其他语言或者工具链,最好显式地做位宽匹配。

4.5 仿真测试与波形观察要点

写完代码一定要仿真。我一般用ModelSim或者Vivado自带的仿真器,写一个简单的testbench,给时钟和复位,跑个几毫秒,观察pwm_out的波形。

仿真的时候重点看三个东西:第一,pwm_out的频率是不是1kHz;第二,duty_cnt是不是在0到50,000之间来回变化;第三,pwm_out的占空比是不是跟着duty_cnt在变。如果这三个都对,基本就没问题了。

`timescale 1ns / 1ps module tb_breathing_led; reg clk, rst_n; wire pwm_out; breathing_led #( .CLK_FREQ(50_000_000), .PWM_FREQ(1_000), .BREATH_MS(2_000) ) uut ( .clk(clk), .rst_n(rst_n), .pwm_out(pwm_out) ); initial begin clk = 0; forever #10 clk = ~clk; // 50MHz end initial begin rst_n = 0; #100; rst_n = 1; #10_000_000; // 跑10ms看波形 $stop; end endmodule

仿真跑10ms就能看到占空比从0%慢慢增加到20%左右(因为2秒才走完一个完整周期,10ms只走了0.5%)。如果想看完整周期,得跑2秒以上的仿真时间,那计算量会比较大。我一般会先把BREATH_MS改小,比如改成20ms,快速验证逻辑,然后再改回2,000ms上板测试。

5. 上板调试与效果优化:从能跑到好看

5.1 引脚分配与LED极性确认

代码仿真通过了,接下来就是上板。第一步是分配引脚,把clk、rst_n和pwm_out分配到开发板对应的引脚上。这里有个坑:不同开发板的LED极性可能不一样。有的板子高电平点亮,有的低电平点亮。如果你发现LED的呼吸方向反了——该亮的时候暗、该暗的时候亮——那就是极性搞反了,把pwm_out取反就行。

我一般会在代码里加一个参数来控制极性:

parameter LED_ACTIVE_HIGH = 1; // 1:高电平点亮 0:低电平点亮 // 输出时: assign led = LED_ACTIVE_HIGH ? pwm_out : ~pwm_out;

这样换板子的时候只需要改一个参数,不用动逻辑。

5.2 呼吸曲线的线性度调整

前面说过,线性递增递减的占空比在人眼看来可能不够自然。如果你想让呼吸效果更柔和,可以尝试对duty_cnt做一个简单的非线性变换。比如用平方律:duty_actual = duty_cnt * duty_cnt / PWM_MAX。这样低亮度区域变化慢,高亮度区域变化快,更符合人眼的感知特性。

不过平方运算会消耗乘法器资源,对于呼吸灯这种小项目可能有点浪费。另一个更省资源的办法是分段线性:把0到50,000分成几段,每段的步进速度不同。比如前1/4段步进慢一点,中间快一点,后1/4段再慢一点。这样也能改善视觉效果,而且只需要几个比较器和多路选择器。

5.3 多路呼吸灯的同步与独立控制

如果你想让多个LED同时呼吸,有两种做法。一种是所有LED共用同一个duty_cnt,这样它们完全同步,呼吸节奏一致。另一种是每个LED有独立的duty_cnt和相位偏移,这样它们此起彼伏,效果更丰富。

独立控制的做法是给每个LED一个相位偏移量,比如LED0从0开始,LED1从25,000开始,LED2从50,000开始。这样它们虽然呼吸周期相同,但相位不同,看起来像波浪一样。实现起来也不复杂,就是在比较duty_cnt的时候加上偏移量再取模。

5.4 实测效果与常见问题速查

下面这张表是我在实际调试中遇到过的典型问题和解决方法,你可以直接对照排查:

现象可能原因排查方法解决方法
LED完全不亮引脚分配错误用万用表测引脚电压检查约束文件
LED常亮不呼吸duty_cnt没变化仿真看upd_pulse检查分频计数器
呼吸速度太快UPD_MAX太小计算更新频率增大BREATH_MS
呼吸速度太慢UPD_MAX太大计算更新频率减小BREATH_MS
亮度跳变不平滑duty_cnt位宽不够仿真看计数器溢出增加位宽
LED闪烁而非呼吸PWM频率太低示波器测频率提高PWM_FREQ
呼吸方向反了LED极性搞反查开发板手册取反输出或改参数

注意:如果你用的是Xilinx或者Intel的FPGA,综合工具可能会把duty_cnt的递增递减逻辑优化成DSP或者进位链,这会影响时序。如果时序不满足,可以在综合选项里关掉自动优化,或者手动加流水线寄存器。

6. 从呼吸灯延伸出去:还能玩出什么花样

呼吸灯跑通之后,其实可以在此基础上做很多扩展。比如把PWM输出接到蜂鸣器上,用同样的占空比控制逻辑做音量渐变;或者把duty_cnt的更新逻辑改成由外部按键控制,做一个手动调光器;再或者把呼吸周期和外部传感器联动,做一个根据环境光自动调节亮度的智能灯。

我个人最喜欢的一个扩展是“心跳灯”:用两个不同频率的呼吸周期叠加,模拟心跳的“咚-咚”节奏。实现方法是用两个duty_cnt,一个周期1.2秒,一个周期0.8秒,把它们的输出做某种组合(比如取最大值或者加权平均),就能得到类似心跳的效果。这个在可穿戴设备或者健康类产品上很有用。

另一个方向是提高PWM分辨率。现在用的是16位计数器,占空比分辨率是1/50,000,已经够用了。但如果要做高精度调光,比如医疗设备或者专业摄影补光灯,可能需要20位甚至24位的分辨率。这时候pwm_cnt和duty_cnt的位宽都要相应增加,同时PWM频率要降低,否则计数器跑不过来。这是一个典型的“分辨率与频率的权衡”,需要根据具体应用来定。

最后再分享一个小技巧:如果你发现呼吸灯在低亮度区域有闪烁,可能是PWM频率不够高。可以试着把PWM_FREQ从1kHz提高到5kHz,同时把PWM_MAX从50,000降到10,000,这样计数器位宽不变,但PWM频率提高了5倍。代价是占空比分辨率降低了,不过对于呼吸灯来说,10,000级的分辨率完全够用。这个调整我在好几个项目里都用过,效果立竿见影。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询