PWM 调光:让 LED 会呼吸
- 用 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_resolution 和 freq_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_hz:PWM 一秒钟重复多少个周期。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_MODE 和 LEDC_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));
}
}
逐行拆开看:
- 第一个
for:v从 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Ω)。
动手挑战
别只跑通,动手改两个,你对占空比的手感才算真正建立:
- gamma 呼吸对比:把第四步的线性呼吸和 gamma 版本各跑一次,盯着暗部看,确认你能用肉眼分出"线性版暗部一晃而过、gamma 版暗部从容"的差别。
- 双灯反相呼吸(共用一个 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_resolution与freq_hz互相制约(时钟 ≈ freq × 2^bits),知道配错会返回错误码、该怎么取值。 - 你会用
ledc_fade硬件渐变把呼吸交给芯片、不占 CPU,体会到"能交给硬件就别让 CPU 守着"的产品级思路。 - 你看清了"调光、调速、控角度底层都是 LEDC 调占空比"这件事,后面学电机和舵机会顺很多。
下一步:把这套"调占空比"的能力用到会动的东西上——学舵机控制,让你的项目第一次真正"动起来"。整条进阶路线见学习路线图。