本文是《FPGA入门到实战》专栏第22篇。上一篇完成了六位数码管秒表,本篇继续做一个更直观的板级项目:使用 PWM 控制 LED 亮度,实现三角波呼吸灯、按键调光,并把同一设计方法扩展到舵机控制。文中的 Verilog 均采用时钟使能方式,不在逻辑内部制造新的时钟。
1. PWM 为什么能控制 LED 亮度
PWM(Pulse Width Modulation,脉宽调制)的核心是:在固定周期内改变高电平所占的时间比例。
设一个 PWM 周期为 T,高电平持续时间为 Ton,占空比为:
text
D = Ton / T × 100%
LED 实际上仍在快速地亮、灭切换。只要 PWM 频率足够高,人眼就不会看到单次闪烁,而会把一个周期内的平均光功率感知为亮度:
- 占空比为 0%:LED 熄灭;
- 占空比为 25%:亮度较低;
- 占空比为 50%:中等亮度;
- 占空比为 100%:持续点亮。
PWM 不仅能控制 LED,还常用于直流电机调速、加热功率控制、蜂鸣器音量控制和舵机脉宽控制。
2. PWM 频率与占空比精度
2.1 用计数器生成 PWM
最直接的方法是使用一个自由运行计数器 pwm_cnt,再把它与占空比设定值 duty 比较:
text
pwm_cnt < duty -> 输出高电平
pwm_cnt >= duty -> 输出低电平
假设计数器宽度为 N,一个 PWM 周期包含 2^N 个系统时钟,占空比分辨率为:
text
1 / 2^N
PWM 频率为:
text
f_pwm = f_clk / 2^N
以 50 MHz 时钟为例:
| 计数位宽 | PWM 周期计数 | PWM 频率 | 占空比最小步进 |
|---|---|---|---|
| 8 bit | 256 | 195.3125 kHz | 0.390625% |
| 10 bit | 1024 | 48.828 kHz | 0.097656% |
| 12 bit | 4096 | 12.207 kHz | 0.024414% |
| 16 bit | 65536 | 762.94 Hz | 0.001526% |
位宽越大,占空比越细,但 PWM 频率越低。LED 调光通常不需要极端精度,12 bit 是一个很实用的折中:频率高于人眼闪烁感知范围,亮度步进也足够细腻。
2.2 为什么 duty 要多一位
如果 pwm_cnt 是 12 bit,它只能表示 0~4095。若 duty 也只有 12 bit,则无法表示完整的 4096 个高电平计数,也就无法得到真正的 100% 占空比。
因此本文把 duty 定义为 PWM_WIDTH+1 bit:
duty = 0:0%;duty = 2048:50%;duty = 4096:100%。
这是 PWM 设计中很容易忽略的边界问题。
3. 通用可调占空比 PWM 模块
下面先实现一个可复用的 PWM 基础模块。
verilog
module pwm_generator #(
parameter PWM_WIDTH = 12
) (
input wire clk , // 系统时钟
input wire rstn , // 低有效复位
input wire [PWM_WIDTH:0] duty , // 占空比计数值
output wire pwm_out // PWM 输出
);
localparam PWM_STEPS = 1 << PWM_WIDTH;
reg [PWM_WIDTH-1:0] pwm_cnt;
always @(posedge clk or negedge rstn)
begin
if (rstn == 1'b0)
begin
pwm_cnt <= 'd0;
end
else
begin
pwm_cnt <= pwm_cnt + 1'b1;
end
end
assign pwm_out = (duty >= PWM_STEPS) ? 1'b1 :
(pwm_cnt < duty[PWM_WIDTH-1:0]);
endmodule
这里对满量程单独判断,使 duty = PWM_STEPS 时输出保持高电平。其他情况下,比较器直接决定本周期的高电平宽度。
注意:
localparam PWM_STEPS = 1 << PWM_WIDTH在常用位宽下可以正常工作。若把PWM_WIDTH扩展到 31 以上,应改用更宽的常量类型,避免左移结果超出整数宽度。
4. 呼吸灯的亮度包络
PWM 只负责"把一个亮度值变成脉冲",呼吸效果还需要一个缓慢变化的 duty。
最常见的包络有两种:
- 三角波:占空比等步长上升,到顶后等步长下降;
- 正弦波:使用 ROM 查表,让亮度变化在两端更平滑。
三角波逻辑简单、资源少,非常适合入门实操。正弦查表视觉效果更柔和,适合后续优化。
4.1 三角波更新速度
系统时钟不能每拍都改变 duty,否则一次呼吸只需要几千个时钟,肉眼根本看不到渐变。因此先产生一个低频更新使能 update_en。
例如:
text
系统时钟 50 MHz
亮度更新频率 1000 Hz
亮度步进 8
PWM 满量程 4096
单向渐变时间 4096 / 8 / 1000 = 0.512 s
完整呼吸周期 约 1.024 s
4.2 三角波呼吸灯完整代码
verilog
module breathing_led #(
parameter CLK_FREQ = 50_000_000,
parameter UPDATE_HZ = 1_000,
parameter PWM_WIDTH = 12,
parameter STEP = 8
) (
input wire clk , // 系统时钟
input wire rstn, // 低有效复位
output wire led // LED PWM 输出
);
localparam UPDATE_DIV = CLK_FREQ / UPDATE_HZ;
localparam PWM_STEPS = 1 << PWM_WIDTH;
localparam UPDATE_CNT_WIDTH = $clog2(UPDATE_DIV);
reg [UPDATE_CNT_WIDTH-1:0] update_cnt;
reg update_en;
reg [PWM_WIDTH:0] duty;
reg direction;
always @(posedge clk or negedge rstn)
begin
if (rstn == 1'b0)
begin
update_cnt <= 'd0;
update_en <= 1'b0;
end
else if (update_cnt == UPDATE_DIV - 1)
begin
update_cnt <= 'd0;
update_en <= 1'b1;
end
else
begin
update_cnt <= update_cnt + 1'b1;
update_en <= 1'b0;
end
end
always @(posedge clk or negedge rstn)
begin
if (rstn == 1'b0)
begin
duty <= 'd0;
direction <= 1'b1;
end
else if (update_en == 1'b1)
begin
if (direction == 1'b1)
begin
if (duty >= PWM_STEPS - STEP)
begin
duty <= PWM_STEPS;
direction <= 1'b0;
end
else
begin
duty <= duty + STEP;
end
end
else
begin
if (duty <= STEP)
begin
duty <= 'd0;
direction <= 1'b1;
end
else
begin
duty <= duty - STEP;
end
end
end
end
pwm_generator #(
.PWM_WIDTH(PWM_WIDTH)
) u_pwm_generator (
.clk (clk ),
.rstn (rstn),
.duty (duty),
.pwm_out(led )
);
endmodule
update_en 只是一个单周期使能脉冲,所有寄存器仍由同一个 clk 驱动。与直接把分频结果当作新时钟相比,这种写法更容易约束,也避免在 FPGA 内部制造额外时钟域。
5. 正弦波查表法
人眼对亮度的感知不是线性的。三角波在低亮度区域可能显得变化较快,在最高点和最低点又会突然反向。若希望效果更柔和,可以用相位累加器寻址正弦 ROM。
下面给出 16 点半波亮度表,先从暗到亮,再从亮到暗循环:
verilog
module sine_breath_lut (
input wire clk , // 系统时钟
input wire rstn , // 低有效复位
input wire update_en, // 查表更新使能
output reg [11:0] duty // 12 bit 亮度值
);
reg [4:0] phase;
always @(posedge clk or negedge rstn)
begin
if (rstn == 1'b0)
begin
phase <= 5'd0;
end
else if (update_en == 1'b1)
begin
phase <= phase + 1'b1;
end
end
always @(*)
begin
case (phase)
5'd0, 5'd31: duty = 12'd0;
5'd1, 5'd30: duty = 12'd39;
5'd2, 5'd29: duty = 12'd156;
5'd3, 5'd28: duty = 12'd347;
5'd4, 5'd27: duty = 12'd605;
5'd5, 5'd26: duty = 12'd921;
5'd6, 5'd25: duty = 12'd1285;
5'd7, 5'd24: duty = 12'd1684;
5'd8, 5'd23: duty = 12'd2100;
5'd9, 5'd22: duty = 12'd2516;
5'd10, 5'd21: duty = 12'd2915;
5'd11, 5'd20: duty = 12'd3279;
5'd12, 5'd19: duty = 12'd3595;
5'd13, 5'd18: duty = 12'd3853;
5'd14, 5'd17: duty = 12'd4044;
5'd15, 5'd16: duty = 12'd4095;
default: duty = 12'd0;
endcase
end
endmodule
实际工程可以把表扩展到 64、128 或 256 点,并使用 Block RAM 存储。表越密,相邻亮度跳变越小;但呼吸效果主要由更新频率和亮度曲线决定,不必盲目追求大表。
6. 按键控制亮度
下面实现两个按键:
key_up_pulse:每按一次增加亮度;key_down_pulse:每按一次降低亮度。
为了让模块职责清晰,输入使用已经完成消抖和边沿检测的单周期脉冲。按键同步、消抖方法可复用前文的按键教程。
verilog
module pwm_key_ctrl #(
parameter PWM_WIDTH = 12,
parameter DUTY_STEP = 256
) (
input wire clk , // 系统时钟
input wire rstn , // 低有效复位
input wire key_up_pulse , // 增亮脉冲
input wire key_down_pulse, // 减亮脉冲
output wire led // LED PWM 输出
);
localparam PWM_STEPS = 1 << PWM_WIDTH;
reg [PWM_WIDTH:0] duty;
always @(posedge clk or negedge rstn)
begin
if (rstn == 1'b0)
begin
duty <= PWM_STEPS / 2;
end
else if (key_up_pulse == 1'b1)
begin
if (duty >= PWM_STEPS - DUTY_STEP)
begin
duty <= PWM_STEPS;
end
else
begin
duty <= duty + DUTY_STEP;
end
end
else if (key_down_pulse == 1'b1)
begin
if (duty <= DUTY_STEP)
begin
duty <= 'd0;
end
else
begin
duty <= duty - DUTY_STEP;
end
end
end
pwm_generator #(
.PWM_WIDTH(PWM_WIDTH)
) u_pwm_generator (
.clk (clk ),
.rstn (rstn),
.duty (duty),
.pwm_out(led )
);
endmodule
上、下边界都采用饱和处理,不允许无符号数越界回绕。例如亮度已经为 0 时继续减亮,结果仍保持 0,而不是回绕成最大值。
7. 板级顶层连接
如果开发板 LED 为低电平点亮,需要在顶层对 PWM 极性取反;如果高电平点亮则直接连接。
verilog
module pwm_led_top (
input wire clk , // 50 MHz 系统时钟
input wire rstn , // 低有效复位
output wire led_n // 低电平点亮 LED
);
wire led_pwm;
breathing_led #(
.CLK_FREQ (50_000_000),
.UPDATE_HZ(1_000 ),
.PWM_WIDTH(12 ),
.STEP (8 )
) u_breathing_led (
.clk (clk ),
.rstn(rstn ),
.led (led_pwm)
);
assign led_n = ~led_pwm;
endmodule
XDC 约束中的引脚名必须与顶层端口一致。上板后若 LED 一直反向显示,优先检查原理图中的 LED 有效电平,而不是立即修改 PWM 内核。
8. 自检 Testbench
仿真时把参数缩小,加快一个完整呼吸周期的验证:
CLK_FREQ = 2560;UPDATE_HZ = 10;PWM_WIDTH = 8;STEP = 64。
这样每 256 个时钟更新一次亮度,4 次更新到达满量程,再经过 4 次更新回到 0。
verilog
`timescale 1ns/1ps
module tb_breathing_led;
reg clk;
reg rstn;
wire led;
integer timeout_cnt;
reg seen_full;
reg seen_fall;
reg [8:0] last_duty;
breathing_led #(
.CLK_FREQ (2560),
.UPDATE_HZ(10 ),
.PWM_WIDTH(8 ),
.STEP (64 )
) dut (
.clk (clk ),
.rstn(rstn),
.led (led )
);
initial
begin
clk = 1'b0;
forever #10 clk = ~clk;
end
initial
begin
rstn = 1'b0;
timeout_cnt = 0;
seen_full = 1'b0;
seen_fall = 1'b0;
last_duty = 9'd0;
repeat (5) @(posedge clk);
rstn = 1'b1;
while ((seen_fall == 1'b0) && (timeout_cnt < 5000))
begin
@(posedge clk);
timeout_cnt = timeout_cnt + 1;
if (dut.duty == 9'd256)
begin
seen_full = 1'b1;
end
if ((seen_full == 1'b1) && (dut.duty < last_duty))
begin
seen_fall = 1'b1;
end
last_duty = dut.duty;
end
if (seen_full == 1'b0)
begin
$display("FAIL: duty never reaches full scale");
$finish;
end
if (seen_fall == 1'b0)
begin
$display("FAIL: duty never starts falling");
$finish;
end
$display("PASS: duty reaches full scale and reverses");
$finish;
end
endmodule
仿真波形中应同时观察:
update_en是否每 256 个时钟出现一次;duty是否按 0、64、128、192、256 递增;- 到达 256 后
direction是否翻转; duty是否按 192、128、64、0 递减;led在不同亮度阶段的高电平宽度是否变化。
9. 扩展:PWM 驱动舵机
常见模拟舵机也接收周期脉冲,但它与 LED PWM 的含义不同:
- 周期通常约为 20 ms,也就是 50 Hz;
- 高电平宽度通常在 0.5~2.5 ms 范围;
- 脉宽决定角度,而不是周期平均功率。
以 50 MHz 时钟为例:
text
20 ms 周期计数 = 50_000_000 × 0.020 = 1_000_000
0.5 ms 高电平 = 50_000_000 × 0.0005 = 25_000
1.5 ms 高电平 = 75_000
2.5 ms 高电平 = 125_000
因此舵机更适合使用"固定周期计数 + 可调高电平计数"的形式:
verilog
module servo_pwm #(
parameter CLK_FREQ = 50_000_000
) (
input wire clk , // 系统时钟
input wire rstn , // 低有效复位
input wire [17:0] pulse_count, // 高电平时钟数
output wire servo_out // 舵机控制输出
);
localparam PERIOD_COUNT = CLK_FREQ / 50;
reg [19:0] period_cnt;
always @(posedge clk or negedge rstn)
begin
if (rstn == 1'b0)
begin
period_cnt <= 20'd0;
end
else if (period_cnt == PERIOD_COUNT - 1)
begin
period_cnt <= 20'd0;
end
else
begin
period_cnt <= period_cnt + 1'b1;
end
end
assign servo_out = (period_cnt < pulse_count) ? 1'b1 : 1'b0;
endmodule
不同型号舵机允许的脉宽范围可能不同。上板前应查看数据手册,从中间位置开始测试,再逐步扩大范围,避免机械结构撞到限位。
10. 常见问题排查
10.1 LED 肉眼可见闪烁
原因通常是 PWM 频率过低。提高系统时钟或减小 PWM_WIDTH,让 PWM 频率至少达到几百赫兹,推荐大于 1 kHz。
10.2 呼吸速度太快或太慢
呼吸速度由 UPDATE_HZ、STEP 和 PWM_STEPS 共同决定:
text
完整周期约为 2 × PWM_STEPS / STEP / UPDATE_HZ
增大 STEP 或 UPDATE_HZ 会加快呼吸,减小它们会放慢呼吸。
10.3 最亮时仍有短暂熄灭
这通常是没有单独处理 100% 占空比。确认 duty 比计数器多一位,并在满量程时直接输出高电平。
10.4 亮度接近 0 时跳变明显
三角波占空比虽然线性,但人眼感知并不线性。可以使用正弦表或 Gamma 校正表,把更多采样点分配到低亮度区域。
10.5 按键按一次变化多级
说明输入仍含机械抖动,或者没有把按键电平转换为单周期脉冲。先做双触发同步、计数消抖和边沿检测,再连接 pwm_key_ctrl。
10.6 舵机抖动
检查 20 ms 周期是否稳定、脉宽是否每帧保持一致、电源是否足够。舵机应使用独立且电流充足的电源,并与 FPGA 共地,不能直接由 FPGA IO 供电。
11. 工程扩展方向
在当前工程上可以继续实现:
- 使用 UART 命令实时设置亮度;
- 使用多个 PWM 通道驱动 RGB LED,实现颜色渐变;
- 使用 Gamma 查表改善低亮度区域的视觉线性;
- 增加模式按键,在常亮、呼吸和手动调光之间切换;
- 把
duty接入 AXI-Lite 寄存器,由处理器控制; - 增加互补 PWM 和死区控制,为后续电机驱动做准备。
12. 本篇总结
本篇完成了 PWM 呼吸灯与按键调光的完整设计:
- 理解 PWM 频率、计数位宽和占空比精度的关系;
- 用多一位的
duty正确覆盖 0%~100% 边界; - 实现可复用的
pwm_generator; - 使用低频时钟使能生成三角波呼吸包络;
- 了解正弦 ROM 查表与视觉亮度优化方法;
- 实现带饱和边界的按键亮度调节;
- 用自检 Testbench 验证满量程和方向翻转;
- 将 PWM 思路扩展到 50 Hz 舵机脉宽控制。
下一篇将进入简易频率计项目,学习闸门计数、跨时钟域采样、测频误差分析,并把结果送到数码管显示。