3. PWM调光原理:模拟信号与数字信号、PWM占空比、LED呼吸灯实现
说到调光,很多人第一反应是「拧旋钮,灯变亮」。但在嵌入式世界里,事情没那么简单。ESP32 的 GPIO 脚只能输出 0 或 1——要么 3.3V,要么 0V。那怎么让 LED 看起来像在「慢慢变亮」呢?
答案就是 PWM。今天我们就把它彻底讲透。
3.1 模拟信号 vs 数字信号
先理清一个基础概念。模拟信号是连续的,比如老式调光台灯的旋钮,拧到哪,电压就到哪。数字信号只有两个状态:高电平和低电平,也就是 1 和 0。
| 特性 | 模拟信号 | 数字信号 |
|---|---|---|
| 取值 | 连续(如 0V ~ 3.3V 任意值) | 离散(只有 0 和 1) |
| 抗干扰 | 差,噪声会直接叠加 | 强,只要阈值判断正确 |
| 处理方式 | 运放、ADC、DAC | 逻辑门、MCU 直接处理 |
| 典型器件 | 电位器、模拟温度传感器 | 按键、LED、数字传感器 |
你可能会问:「那 ESP32 怎么输出模拟效果?」
嗯,这就是 PWM 的用武之地了。它用数字信号「模拟」出模拟效果,而且效率极高。
3.2 PWM 占空比:核心概念
PWM 的全称是 Pulse Width Modulation,脉冲宽度调制。说白了,就是让一个方波信号里「高电平」和「低电平」的时间比例发生变化。
占空比(Duty Cycle) = 高电平时间 / 整个周期时间 × 100%
占空比 0% → 一直低电平(灯灭)
占空比 50% → 一半时间亮,一半时间灭
占空比 100% → 一直高电平(灯最亮)
举个例子。假设 PWM 频率是 1kHz,也就是周期 1ms。如果高电平持续 0.5ms,占空比就是 50%。LED 会以半功率发光。人眼有视觉暂留效应,只要频率够高(通常 > 60Hz),我们就感觉不到闪烁,只看到亮度变化。
我的经验: 我在项目里常用 1kHz ~ 5kHz 的 PWM 频率驱动 LED。低于 200Hz 会明显闪烁,高于 10kHz 对普通 LED 来说意义不大,反而增加开关损耗。ESP32 的 LEDC 外设默认就能覆盖这个范围。
3.3 LED 呼吸灯实现
呼吸灯的效果就是 LED 从暗到亮,再从亮到暗,循环往复。实现思路很简单:让占空比从 0% 逐渐增加到 100%,再逐渐降回 0%。
在 ESP32 上,我习惯用 LEDC(LED Control)外设来做 PWM。它独立于 CPU 运行,不占用主核资源。
硬件连接
- LED 正极 → GPIO 2(通过 220Ω 限流电阻)
- LED 负极 → GND
代码实现
#include <driver/ledc.h>
#define LED_GPIO 2
#define LEDC_CHANNEL LEDC_CHANNEL_0
#define LEDC_TIMER LEDC_TIMER_0
#define LEDC_FREQ 5000 // 5kHz
#define LEDC_RESOLUTION LEDC_TIMER_13_BIT // 13位分辨率,0~8191
void ledc_init() {
ledc_timer_config_t timer_conf = {
.speed_mode = LEDC_HIGH_SPEED_MODE,
.timer_num = LEDC_TIMER,
.duty_resolution = LEDC_RESOLUTION,
.freq_hz = LEDC_FREQ,
.clk_cfg = LEDC_AUTO_CLK
};
ledc_timer_config(&timer_conf);
ledc_channel_config_t channel_conf = {
.gpio_num = LED_GPIO,
.speed_mode = LEDC_HIGH_SPEED_MODE,
.channel = LEDC_CHANNEL,
.timer_sel = LEDC_TIMER,
.duty = 0,
.hpoint = 0
};
ledc_channel_config(&channel_conf);
}
void breath_led() {
// 从暗到亮
for (int duty = 0; duty <= 8191; duty += 8) {
ledc_set_duty(LEDC_HIGH_SPEED_MODE, LEDC_CHANNEL, duty);
ledc_update_duty(LEDC_HIGH_SPEED_MODE, LEDC_CHANNEL);
vTaskDelay(pdMS_TO_TICKS(2));
}
// 从亮到暗
for (int duty = 8191; duty >= 0; duty -= 8) {
ledc_set_duty(LEDC_HIGH_SPEED_MODE, LEDC_CHANNEL, duty);
ledc_update_duty(LEDC_HIGH_SPEED_MODE, LEDC_CHANNEL);
vTaskDelay(pdMS_TO_TICKS(2));
}
}
void app_main() {
ledc_init();
while (1) {
breath_led();
}
}
我曾经踩过的坑: 第一次写呼吸灯时,我直接把 duty 从 0 循环到 8191,步长设为 1。结果呼吸一次要 8 秒多,太慢了。后来把步长调到 8,配合 2ms 延时,呼吸周期大约 4 秒,效果刚好。你调的时候可以根据自己的喜好调整步长和延时。
3.4 PWM 调光知识体系
下面这张图把 PWM 调光的核心逻辑串起来了。从数字信号到模拟效果,再到 ESP32 的实现路径,一目了然。
3.5 调光参数速查表
实际项目中,你经常需要根据 LED 类型和场景调整参数。我整理了一份速查表,方便你快速参考。
| 场景 | 推荐频率 | 推荐分辨率 | 说明 |
|---|---|---|---|
| 普通 LED 指示灯 | 1kHz ~ 5kHz | 8 ~ 10 bit | 够用,省资源 |
| 呼吸灯效果 | 1kHz ~ 2kHz | 10 ~ 13 bit | 步长调细,过渡更平滑 |
| 高精度调光(如摄影灯) | 20kHz+ | 13 ~ 16 bit | 避免频闪,但注意开关损耗 |
| 电机调速 | 100Hz ~ 1kHz | 8 ~ 10 bit | 频率太低会抖动,太高扭矩下降 |
一个小技巧: 如果你发现 LED 在低占空比时亮度跳变明显,可以试试「伽马校正」。人眼对亮度的感知不是线性的。把占空比曲线从线性改成指数型,呼吸效果会更自然。代码里把 duty 改成 pow(duty / 8191.0, 2.2) * 8191 就行。