这一篇在干嘛?
前面的实验里 LED 只有「亮」和「灭」两种状态,这一篇让它学会「渐变」——像呼吸一样由灭渐亮、再由亮渐灭。核心武器是 PWM(脉宽调制):通过高速切换亮灭并改变亮的时间比例,人眼就会看到平滑的亮度变化。你会学到周期计数器、占空比控制和 PWM 输出比较这三个经典模块的写法。
实验目标与 PWM 原理
实验任务:使用康芯开发板上的 LED,实现呼吸灯的效果,即由灭渐亮,然后再由亮渐灭。
直接给 LED 输出一个「中间电压」并不容易(FPGA 引脚只有 0 和 1),但可以利用人眼的视觉暂留:让 LED 以足够高的频率快速亮灭切换,只要**亮的时间占比(占空比)**连续变化,看起来亮度就是连续变化的。这就是 PWM 调光的基本原理。
本实验的设计思路如下,模块端口及结构框图如下图所示:

整个结构分三层:
- 周期信号计数器:产生 1KHz 的周期脉冲信号(周期 1ms),这是 PWM 的载波节拍;
- 占空比调节逻辑:每过完一个周期,就把占空比计数值递增或递减一步;
- 比较输出:把当前周期计数值和占空比计数值做比较,输出占空比可调的脉冲信号,驱动 LED。
几个关键数字先算清楚:系统时钟 50MHz,周期 20ns;要产生 1KHz(周期 1ms)的信号,一个周期内需要计 1ms / 20ns = 50000 个时钟周期,所以周期计数器从 0 计到 50000。占空比每次变化 25 个计数值,从 0 涨到 50000 需要 2000 个周期,即 2 秒——这就是呼吸灯「吸气」或「呼气」各约 2 秒的来源。
Verilog 实现
呼吸灯代码如下:
module breath_led(
input sys_clk, //时钟信号50Mhz
input sys_rst_n, //复位信号
output led //LED
);
//reg define
reg [15:0] period_cnt; //周期计数器频率:1khz 周期:1ms 计数值:1ms/20ns=50000
reg [15:0] duty_cycle; //占空比数值
reg inc_dec_flag; //0 递增 1 递减
//******************************************************************
//main code
//******************************************************************
//根据占空比和计数值之间的大小关系来输出 LED
assign led = (period_cnt >= duty_cycle) ? 1'b1 : 1'b0;
//周期计数器
always @(posedge sys_clk or negedge sys_rst_n) begin
if(!sys_rst_n)
period_cnt <= 16'd0;
else if(period_cnt == 16'd50000)
period_cnt <= 16'd0;
else
period_cnt <= period_cnt + 1'b1;
end
//在周期计数器的节拍下递增或递减占空比
always @(posedge sys_clk or negedge sys_rst_n) begin
if(!sys_rst_n) begin
duty_cycle <= 16'd0;
inc_dec_flag <= 1'b0;
end
else begin
if(period_cnt == 16'd50000) begin //计满 1ms
if(inc_dec_flag == 1'b0) begin //占空比递增状态
if(duty_cycle == 16'd50000) //如果占空比已递增至最大
inc_dec_flag <= 1'b1; //则占空比开始递减
else //否则占空比以 25 为单位递增
duty_cycle <= duty_cycle + 16'd25;
end
else begin //占空比递减状态
if(duty_cycle == 16'd0) //如果占空比已递减至 0
inc_dec_flag <= 1'b0; //则占空比开始递增
else //否则占空比以 25 为单位递减
duty_cycle <= duty_cycle - 16'd25;
end
end
end
end
endmodule(说明:原文 OCR 版本在此处缺了两个 end 和注释符,上表代码已按上下文补全,所有信号定义、参数与原文完全一致。)
逐块拆解:
① PWM 比较输出(组合逻辑)
assign led = (period_cnt >= duty_cycle) ? 1'b1 : 1'b0;在一个 1ms 周期内,period_cnt 从 0 涨到 50000。当 period_cnt >= duty_cycle 时输出高电平,否则输出低电平。这样每个周期内高电平的持续长度正好由 duty_cycle 决定——duty_cycle 越大,高电平时间越长,占空比越高。
注意有效电平与亮度方向的对应关系
代码里
period_cnt >= duty_cycle输出 1。如果板子上的 LED 是低电平点亮(共阳接法),占空比增大时灯反而变暗,呼吸方向会「反」过来。遇到这种现象不用慌,把比较方向反过来(或输出取反)即可。这也是 PWM 调试时最常见的第一个「为什么和我想的不一样」。
② 周期计数器
period_cnt 每个时钟加 1,计到 50000 归零,周而复始,产生 1KHz 的节拍。注意它是 16 位寄存器([15:0]),最大能表示 65535,装得下 50000。
③ 占空比递增/递减控制
这块是呼吸效果的灵魂。它只在 period_cnt == 16'd50000(即每个 1ms 周期结束的节拍)时动作:
inc_dec_flag == 0(递增状态):duty_cycle每次 +25;涨到 50000(最亮)后把标志翻成 1,开始递减;inc_dec_flag == 1(递减状态):duty_cycle每次 −25;减到 0(熄灭)后把标志翻回 0,开始递增。
如此循环往复,占空比就在 0 到 50000 之间走锯齿波,LED 的亮度随之由灭渐亮、再由亮渐灭。
呼吸快慢由「步长」决定
占空比每次递增/递减的数值 25 就是呼吸频率旋钮:步长越大,走完 0→50000 的周期数越少,呼吸越快;步长越小,呼吸越舒缓。想让呼吸慢下来,把 25 改成 10 试试;想明显加快就改成 100。注意步长要能整除关系合理,否则最亮/最暗点附近的判断(
== 50000、== 0)可能错过,导致呼吸「卡死」在某一状态。
另外提醒一点:duty_cycle 只在周期结束时变化一次,而比较输出每个时钟都在进行,所以 PWM 波形在 1ms 之内是稳定的,不会出现一个周期内占空比突跳的毛刺。
引脚约束
本实验用到系统时钟、复位和 4 个 LED(代码只输出一路 led 信号,约束表按实验板通用配置给出 LED0~LED3):
set_pin_assignment { led[0] } { LOCATION = A4; IOSTANDARD = LVCMOS33;
DRIVESTRENGTH = 8; PULLTYPE = NONE; }
set_pin_assignment { led[1] } { LOCATION = A3; IOSTANDARD = LVCMOS33;
DRIVESTRENGTH = 8; PULLTYPE = NONE; }
set_pin_assignment { led[2] } { LOCATION = C10; IOSTANDARD = LVCMOS33;
DRIVESTRENGTH = 8; PULLTYPE = NONE; }
set_pin_assignment { led[3] } { LOCATION = B12; IOSTANDARD = LVCMOS33;
DRIVESTRENGTH = 8; PULLTYPE = NONE; }
set_pin_assignment { sys_clk } { LOCATION = R7; IOSTANDARD = LVCMOS33;
PULLTYPE = PULLUP; }
set_pin_assignment { sys_rst_n } { LOCATION = A2; IOSTANDARD = LVCMOS33;
PULLTYPE = PULLUP; }整理成表格:
| 信号 | FPGA 引脚 | 说明 |
|---|---|---|
| sys_clk | R7 | 50MHz 系统时钟 |
| sys_rst_n | A2 | 复位按键,低有效 |
| led[0] | A4 | LED1 |
| led[1] | A3 | LED2 |
| led[2] | C10 | LED3 |
| led[3] | B12 | LED4 |
引脚分配的操作方法(IO Constraint 手动分配或 .adc 文件)与软件流程篇完全相同,不再赘述。
下载验证
按照标准流程编译布线后,插好下载线,下载程序至核心板。此时可以看到核心板 LED1 发光管由亮到暗,又由暗到亮,往复循环——呼吸灯跑起来了。

如果看到的现象是「呼吸」方向反了(暗→亮变成亮→暗),回忆前面 warning 里说的有效电平问题;如果灯只是匀速闪烁而不是渐变,检查占空比步长是否写错、period_cnt == 16'd50000 的判断条件是否生效。
现象与总结
本实验的关键收获:
- PWM 的本质:数字引脚只有高低电平,但用 1KHz 以上的快速亮灭切换 + 可变占空比,就能骗过人眼得到「模拟」的亮度;
- 三层结构:周期计数器(载波)→ 占空比控制(慢速锯齿)→ 比较输出(PWM 波形),这个结构是电机调速、蜂鸣器变调等一切 PWM 应用的通用骨架;
- 两个时间尺度的嵌套:20ns 的时钟节拍撑起 1ms 的 PWM 周期,1ms 的节拍又撑起约 4 秒的完整呼吸周期(渐亮 2 秒 + 渐灭 2 秒)。
改参数做扩展练习:把步长 25 改成 50,观察呼吸变快;把 duty_cycle 位宽和最大值改成 25000,观察亮度上限变化。
自测
自测
- FPGA 引脚只能输出 0 和 1,为什么能让 LED 呈现出「中间亮度」?靠的是什么现象和什么参数?
- 50MHz 时钟产生 1KHz 的 PWM 载波,周期计数器为什么要计到 50000?
period_cnt为什么用 16 位就够了?- 占空比步长 25 是如何决定呼吸快慢的?算一算从全灭到全亮大约需要多少秒。
inc_dec_flag的作用是什么?如果把它去掉、只让duty_cycle一直递增,会出现什么现象?- 如果上板后发现占空比越大 LED 反而越暗,可能的原因是什么?如何修改?