LEDC 硬件 PWM:制作平滑呼吸灯

2026-09-10 00:00    #ESP32-S3   #ESP-IDF   #LEDC   #PWM   #呼吸灯   #BSP  

这一课跟学教材第 18 章,用 LEDC 产生 PWM,并由硬件渐变单元自动改变占空比。红色 LED 约 1 秒渐亮、1 秒渐暗;BOOT 可以暂停,再按一次从暗端重新开始。

配套工程:examples/11-ledc-hardware-fade。当前代码已在默认配置的隔离副本中通过 ESP-IDF v5.5.5 编译,本次未烧录;本课的实板渐变、按键和波形尚未验收。

一图看懂关键调用

实线表示同一执行路径上的顺序,虚线表示任务调度、回调触发或消息传递;它们不表示跨任务的直接函数调用。

%%{init: {"flowchart": {"nodeSpacing": 20, "rankSpacing": 24, "padding": 10, "subGraphTitleMargin": {"top": 8, "bottom": 16}}}}%%
flowchart TB
    A["app_main():pwm_led_init()、key_init()<br/>fade_init(xTaskGetCurrentTaskHandle())<br/>fade_to(1023, 1000):启动 NO_WAIT 渐变"]
    A --> L["每轮 key_poll(esp_timer_get_time())<br/>当前任务同时处理按键与完成通知"]
    A -. "LEDC 硬件完成一段渐变" .-> I
    I["fade_done():ISR<br/>vTaskNotifyGiveFromISR(handle, &amp;woken)<br/>return woken == pdTRUE"]
    L -- "有按键:优先处理" --> P["fade_stop() → 清除旧通知<br/>running 取反<br/>恢复时:pwm_led_set(0)<br/>再 fade_to(1023, 1000)"]
    L -- "无按键" --> N["ulTaskNotifyTake(pdTRUE, 0)<br/>有通知且 running:切换目标 0 / 1023<br/>fade_to(target, 1000)"]
    I -. "完成通知" .-> N
    P --> D["vTaskDelayUntil(&amp;wake,<br/>pdMS_TO_TICKS(10))"]
    N --> D
    D --> L

本课直接使用运行 app_main() 的任务,不另外创建 LED 任务。fade_init() 注册完成回调;fade_to() 内部依次调用 ledc_set_fade_with_time()、ledc_fade_start(..., LEDC_FADE_NO_WAIT)。按键分支与完成通知分支是 if / else if,暂停时保留亮度,恢复时从暗端重启。

1. 两种频率分别控制什么

GPIO1 红色 LED 电路

LEDC 载波、渐变与任务的关系

本例的 PWM 载波是 5 kHz,每个 PWM 周期为 200 us;亮度从暗到亮则需要约 1 秒。载波周期和渐变时长要分开理解。

第 10 课需要每秒 2000 次中断决定 GPIO 电平。这里持续波形交给 LEDC,任务只在一段渐变完成后安排下一段,并按短节拍处理输入。

2. 定时器决定频率,通道决定输出引脚

 1void pwm_led_init(void)
 2{
 3    ledc_timer_config(&(ledc_timer_config_t){
 4        .speed_mode = LEDC_LOW_SPEED_MODE, // S3 的 LEDC 仅支持低速模式。
 5        .duty_resolution = LEDC_TIMER_10_BIT,
 6        .timer_num = LEDC_TIMER_0, .freq_hz = 5000, .clk_cfg = LEDC_AUTO_CLK,
 7    });
 8    // 硬件反相:内部 duty 越大,GPIO1 低电平时间越长,LED 越亮。
 9    ledc_channel_config(&(ledc_channel_config_t){
10        .gpio_num = 1, .speed_mode = LEDC_LOW_SPEED_MODE, .channel = LEDC_CHANNEL_0,
11        .timer_sel = LEDC_TIMER_0, .flags.output_invert = 1,
12    });
13}

ESP32-S3 的 LEDC 只支持低速模式。“低速”是外设模式名称,不代表只能生成低频信号。10 位占空比分辨率,本例使用 0..1023 的安全范围;不要把 10 位当成 10 档,也不要无条件把 1 << bits 当最大可用值。

初始化先调用 ledc_timer_config(),再调用 ledc_channel_config()。通道配置中的 duty、hpoint 未显式填写,复合字面量将它们初始化为 0;output_invert=1 使低电平点亮的 LED 保持“目标值越大越亮”的方向。

这样,应用传给 BSP 的亮度值越大,LED 就越亮,无需在应用中逐个取反。0 对应熄灭,1023 接近全亮;在这个取值约定下最大值并非严格 100% 点亮,肉眼通常难以分辨,但测量时要知道差异。

同一个 GPIO1 已经被 LEDC 接管,不能同时再运行第 10 课的 GPIO 软件 PWM,或让别的任务直接调用 gpio_set_level() 来竞争输出。

3. 非阻塞地发起一段硬件渐变

PWM_LED 封装硬件初始化和亮度输出,fade.c 封装渐变功能:

 1void fade_init(TaskHandle_t receiver)
 2{
 3    ledc_fade_func_install(0);
 4    ledc_cb_register(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0,
 5                     &(ledc_cbs_t){.fade_cb = fade_done}, receiver);
 6}
 7
 8void fade_to(uint32_t brightness, int duration_ms)
 9{
10    // 越界亮度或零时长直接忽略,否则暂停/继续的逻辑状态会与实际 PWM 脱节。
11    if (brightness > PWM_LED_MAX || duration_ms <= 0) return;
12    ledc_set_fade_with_time(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, brightness, duration_ms);
13    ledc_fade_start(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, LEDC_FADE_NO_WAIT);
14}

LEDC_FADE_NO_WAIT 允许发起任务继续处理按键;LEDC_FADE_WAIT_DONE 则等待渐变完成。硬件完成时间受步进量化影响,所以参数中的 1000 ms 是目标时间,不宜写成实测精确时长。

4. 完成回调只通知任务

1static bool IRAM_ATTR fade_done(const ledc_cb_param_t *event, void *arg)
2{
3    (void)event;
4    BaseType_t woken = pdFALSE;
5    // 渐变完成回调在 ISR;只通知任务,由任务启动下一段渐变。
6    vTaskNotifyGiveFromISR((TaskHandle_t)arg, &woken);
7    return woken == pdTRUE;
8}

应用每轮检查通知,如果渐亮完成,就把下一目标改成 0;渐暗完成,再改成 1023。只有应用任务启动、停止渐变并更新目标,避免多个任务同时操作一个通道。

回调放在 IRAM,通知对象使用默认配置下位于内部 RAM 的任务句柄;不在回调中访问 Flash 字符串或外部 RAM。若以后改变内存配置,还需要检查整条 ISR 调用链和上下文数据。

1// 零等待读取通知,因此检查本身不会阻塞按键。
2if (ulTaskNotifyTake(pdTRUE, 0) && running) {
3    target = target == PWM_LED_MAX ? 0 : PWM_LED_MAX;
4    fade_to(target, 1000);
5}

使用 NO_WAIT 后,启动调用无需等待渐变完成,但仍不能随意重写正在渐变的同一个通道。修改前要按芯片和驱动支持的方式停止旧操作。

5. BOOT 如何及时暂停渐变

本例复用第 09 课基于时间戳的 20 ms 消抖,每 10 ms 扫描一次。按键事件优先于当前轮的完成通知:

1fade_stop(); // ESP32-S3 支持中止 NO_WAIT 渐变。
2ulTaskNotifyTake(pdTRUE, 0);  // 丢弃已经排队的旧完成通知。
3running = !running;

暂停时保留当前亮度;再次按下时,先设置亮度为 0,再启动到 1023 的新渐变。从暗端重新开始,便于观察重新启动的过程,也能避免在终点暂停后启动零距离渐变,难以判断完成事件。

ledc_fade_stop() 依赖芯片能力。ESP32-S3 支持,不能假定其他 ESP32 型号都支持。停止返回后输出的稳定也有硬件周期边界,不能把它当成零延迟电平开关。

BOOT 电路仍是 GPIO0 按下接地。程序正常启动后再测试,不要按住 BOOT 复位。按键与 GPIO1 无需额外接线。

6. 源码分层与依赖

1components/BSP/
2├── KEY/key.c、key.h       # 输出一次稳定按下事件
3└── PWM_LED/
4    ├── pwm_led.c、pwm_led.h # 初始化、逻辑亮度映射
5    └── fade.c、fade.h       # 渐变服务、完成通知和停止
6main/main.c                 # 暂停状态、渐变方向、输入调度

下面是实际 app_main():它复用当前任务,通过 if / else if 让按键分支优先于本轮的完成通知。

 1void app_main(void)
 2{
 3    pwm_led_init();
 4    key_init();
 5    fade_init(xTaskGetCurrentTaskHandle()); // 完成通知投递给本任务
 6    bool running = true;
 7    uint32_t target = PWM_LED_MAX;
 8    fade_to(target, 1000);
 9    TickType_t wake = xTaskGetTickCount();
10    while (1) {
11        bool pressed = key_poll(esp_timer_get_time());
12        if (pressed) {
13            // 先终止旧渐变,再清除已排队的完成通知,防止误启动下一段。
14            fade_stop();
15            ulTaskNotifyTake(pdTRUE, 0);
16            running = !running;
17            if (running) {
18                // 从暗端重新开始,避免暂停恰好落在终点时发起零距离渐变。
19                pwm_led_set(0);
20                target = PWM_LED_MAX;
21                fade_to(target, 1000);
22            }
23            ESP_LOGI("fade", "%s", running ? "restart" : "paused");
24        } else if (ulTaskNotifyTake(pdTRUE, 0) && running) {
25            target = target == PWM_LED_MAX ? 0 : PWM_LED_MAX;
26            fade_to(target, 1000);
27        }
28        // PWM 和渐变由硬件继续执行;任务每 10 ms 检查按键和完成事件。
29        vTaskDelayUntil(&wake, pdMS_TO_TICKS(10));
30    }
31}

main 依赖 BSP esp_timer freertos;BSP 实现依赖 esp_driver_ledc esp_driver_gpio freertos。自建模块的头文件不要也命名为 ledc.h,以免需要依靠包含顺序区分。本例使用 pwm_led.h,便于与官方驱动区分。

用到的 LEDC 与任务通知 函数查询表

名称链接到 ESP-IDF v5.5.5 官方文档或该版本源码。表中的调用环境按本课用法标注;带 FromISR 的接口用于中断,普通任务接口不能直接搬进 ISR。

函数或宏本课用途调用环境
ledc_timer_config()配置 5 kHz、10 位 PWM 定时器普通任务,不能在 ISR 中调用
ledc_channel_config()将通道、定时器和 GPIO1 关联普通任务,不能在 ISR 中调用
ledc_set_duty()设置目标占空比本课由应用任务调用,不与渐变并发写同一通道
ledc_update_duty()使设置的占空比更新到输出本课由应用任务调用
ledc_fade_func_install()安装渐变服务普通任务,不能在 ISR 中调用
ledc_cb_register()为通道注册渐变完成回调普通任务,不能在 ISR 中调用
ledc_set_fade_with_time()设置下一段渐变目标与时长普通任务,不能在 ISR 中调用
ledc_fade_start()启动设置好的渐变普通任务,不能在 ISR 中调用
ledc_fade_stop()中止 NO_WAIT 渐变普通任务,不能在 ISR 中调用
xTaskGetCurrentTaskHandle()取得接收完成通知的当前任务句柄普通任务,不能在 ISR 中调用
vTaskNotifyGiveFromISR()从中断通知指定任务ISR 中,不能阻塞
ulTaskNotifyTake()读取并处理当前任务的通知计数普通任务,不能在 ISR 中调用
esp_timer_get_time()读取单调时间,单位微秒本课在任务中读取;也可在 ISR 中读取
xTaskGetTickCount()读取系统 tick 计数普通任务,不能在 ISR 中调用
vTaskDelayUntil()相对计划唤醒时间维持扫描节拍普通任务,不能在 ISR 中调用
pdMS_TO_TICKS()毫秒换算为 tick 数计算宏,不会延时

定时器、通道与直接亮度更新

ledc_timer_config(&timer) 接收模式、定时器编号、频率和分辨率配置;本课是低速模式、定时器 0、5000 Hz、10 位。ledc_channel_config(&channel) 再指定 GPIO1、通道 0、定时器 0、初始 duty 和输出反相。

ledc_set_duty(mode, channel, duty) 设置目标,ledc_update_duty(mode, channel) 提交更新。这里 mode 为 LEDC_LOW_SPEED_MODE,channel 为 LEDC_CHANNEL_0,逻辑亮度使用 0..1023。配置和更新接口成功返回 ESP_OK;参数错误、资源未初始化等失败情况按接口返回错误码,应逐项检查。

pwm_led_set() 是 BSP 封装,内部依次调用设置和更新接口;本课重新开始呼吸前用它把亮度置为 0。正在运行的渐变要先停止,再修改同一通道。

渐变服务、回调与目标时间

ledc_fade_func_install(0) 的参数是中断分配标志;本例传 0。成功返回 ESP_OK,内存不足可返回 ESP_ERR_NO_MEM。ledc_cb_register(mode, channel, &callbacks, receiver) 的最后一个参数会传给完成回调,本例用它保存接收任务句柄。注册成功返回 ESP_OK,通道未初始化可返回 ESP_ERR_INVALID_STATE。

ledc_set_fade_with_time(mode, channel, target_duty, duration_ms) 设置目标占空比和目标渐变毫秒数;ledc_fade_start(mode, channel, LEDC_FADE_NO_WAIT) 启动后立即返回,由硬件继续渐变。它们成功返回 ESP_OK,参数或初始化有误则返回错误码。NO_WAIT、WAIT_DONE 是等待方式枚举,不是函数。

ledc_fade_stop(mode, channel) 用于本课 ESP32-S3 的 NO_WAIT 渐变,成功返回 ESP_OK。函数返回后,输出最迟还需一个 PWM 周期才固定,不能按零延迟理解。fade_init()、fade_to() 和 fade_stop() 是 BSP 对这些接口的封装。

完成通知与按键扫描

xTaskGetCurrentTaskHandle() 无参数,返回调用者的 TaskHandle_t,本例即运行 app_main() 的任务。ISR 中的 vTaskNotifyGiveFromISR(handle, &woken) 无返回值,通过 woken 报告是否唤醒更高优先级任务;回调返回相应布尔值,由 LEDC 驱动处理切换请求。

ulTaskNotifyTake(pdTRUE, 0) 在当前任务中读取并清零通知计数,不等待,返回 0 表示没有完成通知。停止旧渐变后也用它清除已经到达的旧通知。

esp_timer_get_time() 提供微秒时间供按键消抖;xTaskGetTickCount() 初始化扫描用的 tick 基准。这两个时间单位不同。

固定节拍的两个参数

vTaskDelayUntil(&wake, pdMS_TO_TICKS(10)) 的第一个参数指向保存计划唤醒时间的变量,第二个参数是每轮间隔 tick 数。先用 xTaskGetTickCount() 初始化 wake,后续调用会更新它,不要在每轮重新置为当前时间。

vTaskDelayUntil() 没有返回值。它在本版本中调用 xTaskDelayUntil() 并忽略返回值;后者返回 pdTRUE 表示本轮发生了延时,返回 pdFALSE 表示计划时刻已到、无需再阻塞。两者都不能保证超负载循环按时完成。

当前代码的接口与错误处理

本工程接口用途与条件返回类型
pwm_led_init() / key_init()配置 LEDC 输出和按键输入void
key_poll(now_us)稳定按下时返回 true;内部不延时bool
fade_init(receiver)安装渐变服务,注册向 receiver 发通知的完成 ISRvoid
fade_to(brightness, duration_ms)启动目标渐变;亮度大于 1023 或时长非正时直接忽略void
fade_stop()请求停止 NO_WAIT 渐变void
pwm_led_set(brightness)直接设置并更新 duty;调用者保证 0..1023,BSP 不检查越界void

这些接口属于配套工程 BSP 或业务代码,不是同名的 ESP-IDF 底层 API。返回 void 的接口没有可检查的返回值,不能直接放进 ESP_ERROR_CHECK()。

当前教学版省略了多项初始化、资源创建和驱动调用的返回值检查。查询表保留底层 API 的成功与失败返回值,表示接口本身的约定;不代表当前源码已经检查。若要补齐,应在 BSP 内处理底层错误、按需返回 esp_err_t,并单独判断 FreeRTOS 创建结果。

7. 构建、验收与常见问题

1# 使用与前面课程一致的 IDF 版本。
2source ~/.espressif/tools/activate_idf_v5.5.5.sh
3cd ~/mycode/esp32-blog/examples/11-ledc-hardware-fade
4idf.py build
5# 换成实际端口;Ctrl+] 退出监视。
6idf.py -p <实际串口> flash monitor

检查 LED 连续渐亮渐暗;在中间亮度按 BOOT,应该很快暂停;再按一次从暗端重新呼吸。持续按住不反复切换。需要量化响应时间时,应测量按键稳定到输出变化的间隔;肉眼观察无法给出精确延迟。

灯越来越暗却显示目标在增加,先查 output_invert;完全不动,先查 GPIO、LEDC 定时器和通道是否一致;只能渐亮一次,检查是否注册完成回调、是否处理通知、是否又启动了下一段。

教材第 18 章使用硬件渐变,本课保持这个核心,再增加“完成 ISR → 普通任务”的通知和可中止操作。频率、分辨率与渐变幅度采用本例自己的参数,BSP 对外统一逻辑亮度方向。

依据:ESP-IDF v5.5.5 LEDC 文档及本机 v5.5.5 的 driver/ledc.h、ESP32-S3 soc_caps.h。

下一课:阶段项目:按键切换呼吸灯模式。