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 的实现路径,一目了然。

PWM 调光知识体系 数字信号(0 / 1) PWM 脉冲宽度调制 频率 + 占空比 = 等效模拟电压 频率(Hz) 占空比(%) 分辨率(bit) 人眼视觉暂留 等效亮度变化 呼吸灯效果 ESP32 LEDC 外设:独立定时器 + 多通道

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 就行。