← 返回教程库

PWM 调光:让 LED 会呼吸

最后更新 2026-06-22
L2 · 传感与交互 ⏱ 约 16 分钟 🟢 软件/低风险
你将学到
  • 用 LEDC 配 timer + channel,控制 LED 亮度
  • 搞懂 ledc_set_duty 与 ledc_update_duty 为什么必须成对、占空比怎么变成亮度
  • 想清 duty_resolution(位数)与 freq_hz 互相制约的关系,知道该怎么取值
  • 做出呼吸灯,并用 ledc_fade 硬件渐变这个进阶变体把渐变交给芯片、不占 CPU

你在点亮第一个 LED 里能做的,只有两件事:亮、灭。gpio_set_level(LED, 1) 给满,gpio_set_level(LED, 0) 给空,中间没有挡位。可现实里你想要的常常是"半亮""调到三成""慢慢暗下去"——台灯有旋钮,屏幕有亮度条,那个"中间状态"才是真正有用的部分。

l1-blink 末尾那个呼吸灯变体,已经让你照着把 LEDC 跑通了。这一节是 PWM 专章,往深里讲一层:占空比到底怎么算成亮度、duty_resolutionfreq_hz 为什么不能各自随便填、以及怎么把渐变直接甩给芯片硬件去做、连 vTaskDelay 都省掉。学会这一招你拿到的不只是一个会呼吸的灯,而是一把通用钥匙:同样的 LEDC,往下能调电机转速、控舵机角度。

读这篇前你要会用 idf.py build flash monitor 烧录、跑通过 l1-blink。我们全程用 ESP-IDF 的 LEDC 外设——这是乐鑫官方的 PWM 控制器,做能卖的产品用的就是它。


第一步:用 LEDC 把灯调到"半亮"

把下面这段放进工程的 main/main.c,接好一颗外接 LED(GPIO2 → 220Ω 电阻 → LED 长脚,短脚回 GND,和点灯那节一样),idf.py build flash monitor

// 让 GPIO2 上的 LED 在"四分之一亮"和"全亮"之间切换
#include "driver/ledc.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"

static const char *TAG = "pwm";

#define LED GPIO_NUM_2

void app_main(void)
{
    // 1) 配一个 PWM 定时器:决定频率和分辨率
    ledc_timer_config_t timer = {
        .speed_mode      = LEDC_LOW_SPEED_MODE,  // 低速模式,普通调光足够
        .duty_resolution = LEDC_TIMER_8_BIT,     // 8 位分辨率:占空比可取 0~255
        .timer_num       = LEDC_TIMER_0,
        .freq_hz         = 5000,                 // 5kHz,肉眼绝对看不出闪
        .clk_cfg         = LEDC_AUTO_CLK,        // 时钟源交给驱动自动选
    };
    ledc_timer_config(&timer);

    // 2) 配一个通道,把 LED 这根 GPIO 绑到上面那个定时器
    ledc_channel_config_t ch = {
        .gpio_num   = LED,
        .speed_mode = LEDC_LOW_SPEED_MODE,
        .channel    = LEDC_CHANNEL_0,
        .timer_sel  = LEDC_TIMER_0,            // 用 0 号定时器的频率/分辨率
        .duty       = 0,                       // 初始占空比 0,先灭着
        .hpoint     = 0,
    };
    ledc_channel_config(&ch);

    ESP_LOGI(TAG, "LEDC 就绪,开始在 1/4 亮和全亮之间切换");

    while (1) {
        ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, 64);   // 写占空比 64/255 ≈ 25%
        ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0);    // 让刚写的值真正生效
        vTaskDelay(pdMS_TO_TICKS(1000));

        ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, 255);  // 满占空比 = 全亮
        ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0);
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}
📌 说明

这是参考实现,需自行验证。引脚号按你板子实际接线改。

你应该看到什么

  • 串口先打出 LEDC 就绪… 一行。
  • 灯不再是"亮/灭"两个极端,而是在偏暗全亮之间来回跳。盯着看一秒就能分出两个明显不同的亮度档——这说明 GPIO 已经能输出"中间状态"了。

l1-blink 里你用 gpio_set_level 只能给 0 或 1;现在换成 LEDC,ledc_set_duty 的第三个参数能填 0~255 之间任何数字,从"开关"升级成了"旋钮"。下面把这套 LEDC 的每一块讲透。


第二步:LEDC 的两块积木——timer 和 channel

ESP-IDF 把 PWM 拆成两个对象,比 Arduino 那种 analogWrite 一行打包要啰嗦,但这点啰嗦换来的是产品级的清晰——你能精确控制每一路 PWM 的频率、分辨率、用哪个定时器。

timer:定义"怎么数"

ledc_timer_config_t 配的是一个定时器,它决定两件事:

  • freq_hzPWM 一秒钟重复多少个周期。5000 就是每秒 5000 个开关循环。
  • duty_resolution:把一个周期切成多少格。LEDC_TIMER_8_BIT = 256 格(0~255),LEDC_TIMER_10_BIT = 1024 格,LEDC_TIMER_13_BIT = 8192 格。格子越多,亮度能分得越细。

配好后 ledc_timer_config(&timer) 把它注册进去。

channel:定义"输出给谁"

ledc_channel_config_t 配的是一个通道,它把一根具体 GPIO 绑到某个定时器上:

  • gpio_num:信号从哪根脚出去。
  • timer_sel = LEDC_TIMER_0:这路输出跟着 0 号定时器走,频率和分辨率都用它的。
  • channel = LEDC_CHANNEL_0:通道编号,后面 set_duty 就靠它点名。
  • duty / hpoint:初始占空比和相位起点,一般 duty=0(先灭)、hpoint=0 即可。

一个 timer 可以喂多个 channel。比如三路 RGB 灯,可以共用一个 5kHz 的 timer,配三个 channel 各管一根脚——频率统一、各调各的亮度。这正是产品里常见的接法。

💡 提示

ESP32-S3 的 LEDC 有 LEDC_LOW_SPEED_MODELEDC_HIGH_SPEED_MODE 两套说法,但 S3 实际只有低速这一组通道(高速模式是早期 ESP32 才有的)。所以这里统一用 LEDC_LOW_SPEED_MODE,timer 和 channel 的 speed_mode 必须一致,别一个低一个高。


第三步:占空比怎么变成亮度——把原理讲透

ledc_set_duty(..., 64) 为什么是"四分之一亮"?因为 PWM 的本质是:把时间切成一段段极短的周期,每个周期里一部分时间给高电平、剩下给低电平,然后用极快的频率不停重复。

"高电平占整个周期的比例"就叫占空比(duty cycle)。在 8 位分辨率下,占空比的数值范围正好是 0~255:

  • duty = 0 → 占空比 0%,全程低电平,灯灭;
  • duty = 64 → 64/255 ≈ 25%,每个周期里灯只亮四分之一的时间;
  • duty = 128 → ≈ 50%,亮一半时间;
  • duty = 255 → 100%,全程高电平,全亮。

因为频率高到 5000Hz,眼睛根本跟不上单次的亮灭,只会感知到一个"平均亮度"。占空比 25%,平均下来就是约四分之一的亮度。想看清这套"快速开关骗过眼睛"的机制到底怎么运作,读 PWM 是怎么调出来的

一个新手都会栽的坑:为什么必须 update_duty

注意上面每次 ledc_set_duty 后面都跟了一句 ledc_update_duty。这不是多余的

  • ledc_set_duty(...) 只是把你要的新占空比写进一个影子寄存器,灯不会立刻变。
  • ledc_update_duty(...) 才把影子寄存器的值真正搬进硬件、下个周期开始生效

LEDC 故意这么设计:你可以先把多个通道的新值都 set 好,再统一 update,让它们同一时刻一起切换(比如 RGB 三路同步换色,不会有先后错位的闪烁)。代价就是——只 set 不 update,灯纹丝不动,而且不报错。这是用 LEDC 时第一个会卡住人的地方,记牢:set 和 update 必须成对

duty_resolution 和 freq_hz 互相卡着——这是本节最该懂的一层

很多人以为分辨率和频率可以各填各的,其实它们被同一个时钟源死死绑在一起,关系是:

时钟频率 ≈ freq_hz × 2^(duty_resolution)

LEDC 的时钟源(S3 上常用 80MHz 的 APB 时钟)是固定的。频率越高、或者分辨率位数越多,所需的时钟就越快——一旦超过时钟源能给的上限,配置就失败。换句话说:分辨率越高,能用的最高频率越低;频率越高,能用的最高分辨率越低。 二者此消彼长。

举两个能跑的组合感受一下(以 80MHz 时钟粗算):

freq_hz 最高可用分辨率 80M / (freq × 2^bits)
5000Hz 13 位(8192 档) 80M /(5000×8192) ≈ 2,富余
40000Hz 约 11 位(2048 档) 80M /(40000×2048) ≈ 1,临界
80000Hz 约 10 位 再高分辨率就配不出来了

实践里给 LED 调光,5kHz + 8 位5kHz + 13 位都绰绰有余:5kHz 远高于人眼闪烁阈值(约 200Hz 以上就看不出闪),8~13 位的亮度档位也够细。真要追求顺滑的暗部过渡再上 13 位。配错了 ledc_timer_config 会返回错误码(用 ESP_ERROR_CHECK 包起来就能在串口看到它喊),不是玄学。

💡 提示

拿不准某个频率最高能配几位?别硬算,直接把 duty_resolution 往高了填,ESP_ERROR_CHECK(ledc_timer_config(&timer)) 跑一次,配不出来它会报 LEDC_FREQ_OUT_OF_RANGE 之类的错,按报错往下降一位就行。


第四步:做一个会呼吸的灯

知道了亮度由占空比决定,呼吸灯的思路就很直白了:让占空比从 0 慢慢爬到 255,再慢慢退回 0,循环往复。亮度连续平滑地变化,看起来就像灯在呼吸。

定时器和通道沿用第一步的配置,只把 while 换成两个循环:

#define MAX_DUTY 255   // 8 位分辨率下满占空比

while (1) {
    for (int v = 0; v <= MAX_DUTY; v++) {   // 渐亮:占空比 0 → 255
        ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, v);
        ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0);
        vTaskDelay(pdMS_TO_TICKS(6));        // 每加一档停 6ms,太快眼睛看不出过程
    }
    for (int v = MAX_DUTY; v >= 0; v--) {   // 渐暗:占空比 255 → 0
        ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, v);
        ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0);
        vTaskDelay(pdMS_TO_TICKS(6));
    }
}

逐行拆开看:

  • 第一个 forv 从 0 加到 255,每轮 set+update 一次,灯一档一档变亮。
  • vTaskDelay(pdMS_TO_TICKS(6)):每加一档让任务睡 6 毫秒。256 档 × 6ms ≈ 1.5 秒,一次"吸气"约 1.5 秒。把这句去掉,整个渐变在一瞬间跑完,你只会看到灯"啪"地亮——慢,才看得见呼吸。
  • 第二个 for:反过来从 255 减到 0,灯一档一档变暗。

你应该看到什么

灯不再是闪,而是像深呼吸一样匀速地由暗转亮、再由亮转暗,周而复始。改 vTaskDelay 的毫秒数就能调呼吸快慢:改成 3 更急促,改成 12 更舒缓。

一个你一定会发现的别扭:暗部"咻"地一下就过去了

跑通后盯着看,你多半会觉得:灯在暗的那头变化特别快、亮的那头变化特别慢,并不像真的"匀速呼吸"。这不是代码写错了——是人眼对亮度的感知不是线性的。占空比从 10 涨到 20,亮度翻倍,你看着变化巨大;从 200 涨到 210,物理上同样涨了 10,眼睛却几乎无感。

要让呼吸"看起来"匀速,得做 gamma 校正:先把线性的进度做一道幂次映射(经验上 γ≈2.2),再写成占空比。思路是这样:

// 进度 i 走 0~255,但映射成"人眼觉得匀速"的占空比
// duty = MAX_DUTY * (i/255)^2.2
float ratio = (float)i / MAX_DUTY;
int duty = (int)(MAX_DUTY * powf(ratio, 2.2f));
ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, duty);
ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0);

先把上面线性版本跑通建立手感,再换成 gamma 版本对比——你会立刻看出后者的呼吸顺眼得多,暗部不再一晃而过。powf 每档都算一次有点费,产品里通常预先算好一张 256 项的查找表,运行时直接查。这就是"为什么显示器都要做 gamma"的同一件事。


第五步(进阶变体):把渐变甩给硬件——ledc_fade

上面的呼吸灯有个隐患:CPU 得守在 for 循环里,每 6ms 醒一次写一次占空比。灯就这么点事还好,可一旦你的产品还要同时联网、读传感器,让 CPU 花时间盯着一颗灯渐变就太浪费了。

LEDC 自带硬件渐变:你只要告诉它"从当前亮度,用 1000 毫秒平滑过渡到目标亮度",剩下的逐档递增由 LEDC 硬件自己完成,全程不占 CPU

// 在 app_main 配好 timer + channel 之后,先装一次渐变服务
ledc_fade_func_install(0);   // 参数 0 表示不需要额外中断标志

while (1) {
    // 用 1000ms 硬件渐变到全亮
    ledc_set_fade_with_time(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0,
                            255, 1000);
    ledc_fade_start(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0,
                    LEDC_FADE_NO_WAIT);   // 不阻塞,发起就返回
    vTaskDelay(pdMS_TO_TICKS(1000));      // 等这次渐变跑完

    // 再用 1000ms 渐变回灭
    ledc_set_fade_with_time(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0,
                            0, 1000);
    ledc_fade_start(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0,
                    LEDC_FADE_NO_WAIT);
    vTaskDelay(pdMS_TO_TICKS(1000));
}

要点:

  • ledc_fade_func_install(0) 全程只装一次,放在配置之后、循环之前。
  • ledc_set_fade_with_time(目标占空比, 毫秒) 设定这次渐变的终点和时长。
  • ledc_fade_start(..., LEDC_FADE_NO_WAIT) 发起渐变就立刻返回,渐变在后台由硬件跑;填 LEDC_FADE_WAIT_DONE 则会卡到渐变结束才返回。
  • 这里 NO_WAIT 配合 vTaskDelay(1000) 让节奏对齐——CPU 睡的这 1 秒里硬件正自己渐变,醒来正好该发下一段。

对比一下:第四步的软件渐变 CPU 一直忙;这一版 CPU 99% 时间在睡,灯却照样平滑呼吸。产品级固件要同时干很多事,这种"能交给硬件就别让 CPU 守着"的思路,比呼吸灯本身更值钱。

📌 说明

ledc_set_fade_with_time 同样需要 ledc_fade_start 才生效,逻辑和 set_duty/update_duty 一脉相承:先设定、再触发。


同一招还能干什么

呼吸灯只是起点。"用占空比控制一个连续量"这个思路,换个被控对象就是另一个技能:

  • 直流电机调速:占空比越大,电机平均得到的功率越大,转得越快。LEDC 配置几乎一样,只是把 LED 那根 GPIO 换成电机驱动的输入脚——见电机驱动
  • 控制舵机角度:舵机吃的是约 50Hz 下、特定脉宽的 PWM,不同脉宽对应不同角度。频率改成 50、分辨率给高一点好分脉宽,底层还是 LEDC——见舵机控制

调光、调速、控位置——表面三件事,底层都是"用 LEDC 调占空比"这一件事。把这一个吃透,上面三个你都已经会了一半。这正是 L2 阶段反复用到的核心手感。


故障排查:对不上预期就按这张表查

现象 最可能的原因 怎么办
灯完全不亮 只 set_duty 没 update_duty / GPIO 号不对 每个 ledc_set_duty 后补一句 ledc_update_duty;确认 #define LED 是你接的那根
占空比改了灯却不变 漏了 ledc_update_duty 或渐变漏了 ledc_fade_start 这两类"设定+触发"必须成对,补上触发那句
ledc_timer_config 报错/返回非 ESP_OK freq_hz 与 duty_resolution 冲突,超出时钟上限 把分辨率降一位,或频率调低;用 ESP_ERROR_CHECK 看具体错误码
亮度没区别,看着像全亮/全灭 duty 值太接近 0 或 255,或外接 LED 装反 先用 64、128、200 三个值对比;长脚(+)朝电阻、短脚(−)朝 GND
不渐变,灯只是"啪"地亮一下 for 里漏了 vTaskDelay,或延时太小 加上 vTaskDelay(pdMS_TO_TICKS(6)),全程太快眼睛跟不上
灯在明显闪烁、不平滑 freq_hz 设得太低(如几十 Hz) 调到 1000Hz 以上,常用 5000Hz
调用 fade 直接崩 / 无效 没先 ledc_fade_func_install 配置后、循环前装一次渐变服务
外接灯很暗 限流电阻偏大,或供电不足 正常现象,想更亮换小一点的电阻,但别低于安全值
🚧 避坑

LED 直接插在 GPIO 和 GND 之间会烧。PWM 模式下灯看着没全亮、像是"更安全",其实在高电平的那段时间里电流照样是满的,一样会烧 LED、伤引脚。限流电阻该串还得串,一颗都不能省(红/黄串 220Ω、蓝/白串 330Ω)。


动手挑战

别只跑通,动手改两个,你对占空比的手感才算真正建立:

  1. gamma 呼吸对比:把第四步的线性呼吸和 gamma 版本各跑一次,盯着暗部看,确认你能用肉眼分出"线性版暗部一晃而过、gamma 版暗部从容"的差别。
  2. 双灯反相呼吸(共用一个 timer):再配一个 LEDC_CHANNEL_1 绑到另一根 GPIO、复用 LEDC_TIMER_0,让两颗灯交替呼吸——一颗渐亮的同时另一颗写 255 - v,记得每路都 set+update。

卡住了就把代码和想要的效果一起发给 AI,让它帮你补完——具体怎么配合 AI 写 ESP-IDF 固件,看用 AI 写固件


小结 · 你现在掌握了什么

  • 你会用 LEDC 的 timer + channel 两块积木配出一路 PWM,并用 ledc_set_duty + ledc_update_duty(必须成对)控制 LED 亮度。
  • 你理解了占空比这个核心概念:它线性对应高电平占周期的比例,平均下来就是你看到的亮度;而人眼非线性,要顺滑还得做 gamma 校正
  • 你想清了 duty_resolutionfreq_hz 互相制约(时钟 ≈ freq × 2^bits),知道配错会返回错误码、该怎么取值。
  • 你会用 ledc_fade 硬件渐变把呼吸交给芯片、不占 CPU,体会到"能交给硬件就别让 CPU 守着"的产品级思路。
  • 你看清了"调光、调速、控角度底层都是 LEDC 调占空比"这件事,后面学电机和舵机会顺很多。

下一步:把这套"调占空比"的能力用到会动的东西上——学舵机控制,让你的项目第一次真正"动起来"。整条进阶路线见学习路线图

📄 来源 / 自校链接

本文为公开资料整理,非亲测。关键参数与代码请结合实物与下列官方来源验证。

内容有错、看不懂、或想看下一期?告诉我们 →

本文为公开资料的学习整理,非亲测。涉接线/花钱/合规的步骤请结合实物与官方最新资料验证,风险自负。见免责声明