ESP32-S3 的 PWM,我是怎么真正用明白的

在刚接触 ESP32-S3 的时候,我对 PWM 这件事其实并不陌生。
LED 调光、电机调速、蜂鸣器发声——在单片机世界里,PWM 是绕不开的基础能力。理论上也不复杂:
通过调节一个周期内高电平所占的比例,就可以“模拟”出不同强度的模拟信号。

但真正让我卡住的,是 ESP32-S3 里 PWM 的实现方式
它不叫 PWM,而是叫 LEDC(LED PWM Controller)

一开始我还以为这是个“专门给 LED 用的外设”,后来才意识到:
这只是命名问题,本质上它就是 ESP32-S3 的通用 PWM 控制器。


一、先搞清楚:ESP32-S3 的 PWM 架构到底是什么样的?

ESP32-S3 内部提供了一个 LED PWM 控制器(LEDC),核心结构是三层:

  • 定时器(Timer)
  • 通道(Channel)
  • GPIO 输出

它的设计思路非常典型,也非常“硬件味”:

定时器负责“节拍”,通道负责“波形”,GPIO 只是出口。

在 ESP32-S3 上:

  • 一共有 4 个 LEDC 定时器
  • 一共有 8 个 PWM 通道
  • 每个通道 必须绑定某一个定时器
  • 多个通道可以共用同一个定时器(频率一致,占空比不同)

这也解释了为什么后面配置 PWM 时,总是分成三步:

  1. 配定时器
  2. 配通道
  3. 改占空比

二、第一步:我真正理解 LEDC 定时器,是从“频率 + 分辨率”开始的

一开始看 ledc_timer_config_t,字段非常多,很容易被吓住。
但后来我发现,真正决定你 PWM 行为的,其实就两个东西

  • 频率(freq_hz)
  • 占空比分辨率(duty_resolution)

为什么这两个是核心?

  • 频率决定了:
    • LED 会不会闪
    • 电机会不会叫
  • 分辨率决定了:
    • 你能把亮度 / 速度调得多细

在我自己的项目里,LED 调光基本遵循一个经验值:

  • 频率:几 kHz(肉眼不可见闪烁)
  • 分辨率:13 bit(8192 个台阶,足够细)

于是定时器配置通常长这样:

ledc_timer_config_t ledc_timer = {
    .speed_mode      = LEDC_LOW_SPEED_MODE,
    .duty_resolution = LEDC_TIMER_13_BIT,
    .timer_num       = LEDC_TIMER_0,
    .freq_hz         = 4000,
    .clk_cfg         = LEDC_AUTO_CLK,
};
ESP_ERROR_CHECK(ledc_timer_config(&ledc_timer));

这里穿插一个小知识点 ESP_ERROR_CHECK

为了让我们编写的代码更健壮,我们可以给所有带返回状态的函数添加错误处理代码,一旦某些资源初始化失败,可以马上终止程序,避免运行异常,触发不确定的问题。这个错误处理代码就是使用IDF提供的ESP_ERROR_CHECK宏定义。ESP_ERROR_CHECK 宏说明如下:

ESP_ERROR_CHECK

ESP_ERROR_CHECK 宏用于检查 ESP-IDF API 调用的返回值。如果返回值不是 ESP_OK,宏会记录错误日志并终止程序。这有助于在开发过程中快速定位和处理错误。
#define ESP_ERROR_CHECK(x) do { \
    esp_err_t err_rc = (x); \
    if (err_rc != ESP_OK) { \
        ESP_LOGE(TAG, "error %s: %d", esp_err_to_name(err_rc), err_rc); \
        abort(); \
    } \
} while(0)

宏实现说明:
do { ... } while(0): 使用 do-while 循环确保宏的行为类似于一个语句块。
esp_err_t err_rc = (x): 将 API 调用的结果存储在 err_rc 变量中。
if (err_rc != ESP_OK): 检查返回值是否为 ESP_OK。
ESP_LOGE(TAG, "error %s: %d", esp_err_to_name(err_rc), err_rc): 如果返回值不是 ESP_OK,记录错误日志。esp_err_to_name(err_rc) 将错误码转换为可读的错误名称。
abort(): 终止程序执行。


ledc_timer_config

esp_err_t ledc_timer_config(const ledc_timer_config_t *timer_conf);
功能: ledc_timer_config 函数用于配置 LED 控制器(LEDC)的定时器。通过传递一个 ledc_timer_config_t 结构体,可以设置定时器的速度模式、占空比分辨率、定时器编号、频率和时钟源。
参数:
timer_conf: 指向 ledc_timer_config_t 结构体的指针,包含定时器的配置参数。
返回值:
ESP_OK: 定时器配置成功。
ESP_ERR_INVALID_ARG: 参数无效。
ESP_ERR_INVALID_STATE: 定时器未创建或已启用。

ledc_timer_config_t 结构体用于定义和配置 LED 控制器(LEDC)的定时器。通过这个结构体,你可以指定定时器的速度模式、占空比分辨率、定时器编号、频率和时钟源。
typedef struct {
    ledc_mode_t speed_mode;       
    ledc_timer_bit_t duty_resolution; //!< LEDC duty resolution
    ledc_timer_t timer_num;       //!< LEDC timer number
    uint32_t freq_hz;             //!< LEDC timer frequency (Hz)
    ledc_clk_cfg_t clk_cfg;       //!< LEDC timer clock source
} ledc_timer_config_t;

ledc_mode_t speed_mode:
描述: 指定 LEDC 的速度模式。可以是以下值之一:
LEDC_LOW_SPEED_MODE: 低速模式(默认),适用于大多数应用。
LEDC_HIGH_SPEED_MODE: 高速模式,适用于需要更高频率的应用。

ledc_timer_bit_t duty_resolution:
描述: 设置占空比的分辨率,即占空比的位数。分辨率越高,占空比的精度越高。
可选值包括 LEDC_TIMER_1_BIT 到 LEDC_TIMER_16_BIT,通常使用较高的分辨率如 LEDC_TIMER_13_BIT。

ledc_timer_t timer_num:
描述: 指定使用的定时器编号。LEDC 支持多个定时器,每个定时器可以独立配置。
LEDC_TIMER_0: 定时器 0。
LEDC_TIMER_1: 定时器 1。
LEDC_TIMER_2: 定时器 2。
LEDC_TIMER_3: 定时器 3uint32_t freq_hz:
描述: 设置输出 PWM 信号的频率,单位为赫兹 (Hz)。频率决定了 PWM 波形每秒的周期数。

ledc_clk_cfg_t clk_cfg:
描述: 指定定时器的时钟源配置。可以选择自动选择时钟源或手动指定。
LEDC_AUTO_CLK: 自动选择时钟源。
LEDC_USE_APB_CLK: 使用 APB 时钟源。
LEDC_USE_RTC8M_CLK: 使用 RTC 8 MHz 时钟源。
LEDC_USE_XTAL_CLK: 使用晶振时钟源。
LEDC_USE_PLL_F80M_CLK: 使用 PLL 80 MHz 时钟源。
LEDC_USE_PLL_D2_CLK: 使用 PLL 分频后的时钟源。    

这里我个人有一个非常重要的经验

除非你真的知道自己在干什么,否则用 LEDC_LOW_SPEED_MODE 就对了。

高速模式不是“更牛”,而是“限制更多、坑也更多”。
LED PWM文档参考:https://docs.espressif.com/projects/esp-idf/zh_CN/v5.4/esp32s3/api-reference/peripherals/ledc.html


三、第二步:通道配置,其实就是“把 PWM 接到哪根脚上”

如果说定时器决定了 PWM 的“节奏”,
通道配置就是在说:这路 PWM 最终从哪个 GPIO 出来
通道配置我一般只关心四个字段:

  • channel:用哪个通道
  • timer_sel:绑定哪个定时器
  • gpio_num:输出到哪个 GPIO
  • duty:初始占空比

示例配置如下:

ledc_channel_config_t ledc_channel = {
    .speed_mode = LEDC_LOW_SPEED_MODE,
    .channel    = LEDC_CHANNEL_0,
    .timer_sel  = LEDC_TIMER_0,
    .gpio_num   = 9,   // LED 接在 GPIO9
    .duty       = 0,
    .hpoint     = 0,
};
ledc_channel_config(&ledc_channel);

ledc_channel_config

esp_err_t ledc_channel_config(const ledc_channel_config_t *ledc_conf);
功能: ledc_channel_config 函数用于配置 LED 控制器(LEDC)的通道。通过传递一个 ledc_channel_config_t 结构体,可以设置通道的速度模式、通道编号、关联的定时器、中断类型、GPIO 引脚、初始占空比和高电平起点。
参数:
ledc_conf: 指向 ledc_channel_config_t 结构体的指针,包含通道的配置参数。
返回值:
ESP_OK: 通道配置成功。
ESP_ERR_INVALID_ARG: 参数无效。
ESP_ERR_INVALID_STATE: 通道未创建或已启用。


ledc_channel_config_t 结构体用于定义和配置 LED 控制器(LEDC)的通道。通过这个结构体,你可以指定通道的速度模式、通道编号、关联的定时器、中断类型、GPIO 引脚、初始占空比和高电平起点。
typedef struct {
    ledc_mode_t speed_mode;       
    ledc_channel_t channel;       //!< LEDC channel number
    ledc_timer_t timer_sel;       //!< LEDC timer number to be used
    ledc_intr_type_t intr_type;   //!< LEDC interrupt type
    gpio_num_t gpio_num;          //!< GPIO number to be used
    uint32_t duty;                //!< Initial duty cycle
    uint32_t hpoint;              //!< Initial high point of duty cycle
} ledc_channel_config_t;
功能: 
ledc_mode_t speed_mode:
描述: 指定 LEDC 的速度模式。可以是以下值之一:
LEDC_LOW_SPEED_MODE: 低速模式。
LEDC_HIGH_SPEED_MODE: 高速模式。

ledc_channel_t channel:
描述: 指定使用的 LEDC 通道编号。可以是以下值之一:
LEDC_CHANNEL_0 到 LEDC_CHANNEL_15可选。

ledc_timer_t timer_sel:
描述: 指定关联的定时器编号。定时器决定了该通道的频率和占空比分辨率。可以是以下值之一:
LEDC_TIMER_0: 定时器 0。
LEDC_TIMER_1: 定时器 1。
LEDC_TIMER_2: 定时器 2。
LEDC_TIMER_3: 定时器 3ledc_intr_type_t intr_type:
描述: 指定是否启用中断以及中断触发条件。
#define GPIO_INTR_DISABLE     0     /*!< 禁用 GPIO 中断                             */
#define GPIO_INTR_POSEDGE     1     /*!< GPIO 中断类型 : 上升沿触发                  */
#define GPIO_INTR_NEGEDGE     2     /*!< GPIO 中断类型 : 下降沿触发                 */
#define GPIO_INTR_ANYEDGE     3     /*!< GPIO 中断类型 : 上升沿和下降沿触发          */
#define GPIO_INTR_LOW_LEVEL   4     /*!< GPIO 中断类型 : 低电平触发                  */
#define GPIO_INTR_HIGH_LEVEL  5     /*!< GPIO 中断类型 : 高电平触发                  */

gpio_num_t gpio_num:
描述: 指定使用的 GPIO 引脚编号。PWM 信号将通过这个引脚输出

uint32_t duty:
描述: 设置初始占空比。占空比决定了高电平时间与周期时间的比例。

uint32_t hpoint:
描述: 高电平起点,用于某些特殊模式(如相位控制)。对于普通 PWM 应用,通常设置为 0

做到这一步,其实 PWM 已经在跑了,只是占空比是 0,看不到效果而已。


四、真正“有感觉”的地方:修改占空比

LEDC 的占空比更新,被拆成了两个 API:

  • ledc_set_duty()
  • ledc_update_duty()

一开始我也觉得奇怪:
为什么不一个函数搞定?

后来才理解,这是典型的寄存器写入 + 生效分离设计,好处是:

  • 可以批量改
  • 可以控制生效时机

以 13 bit 分辨率为例:

  • 最大占空比:8191
  • 50% 占空比:4096
    // 设置占空比为 50%
    //(4096) // 设置占空比为 50%。 (2 ** 13) * 50% = 4096
    ESP_ERROR_CHECK(ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, 4096)); 
    
    // 更新占空比以应用新值
    ESP_ERROR_CHECK(ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0));

ledc_set_duty

esp_err_t ledc_set_duty(ledc_mode_t speed_mode, ledc_channel_t channel, uint32_t duty);
参数
speed_mode::LEDC 的速度模式,可以是低速模式或高速模式。
channel:指定使用的 LEDC 通道编号。
duty:要设置的占空比值。这个值是一个无符号整数,范围取决于定时器的占空比分辨率。
示例中设置为 4096,表示 50% 的占空比(因为占空比分辨率为 13 位,即 (2^{13} = 8192),所以 50% 占空比对应 4096)。

esp_err_t ledc_update_duty(ledc_mode_t speed_mode, ledc_channel_t channel);
参数
speed_mode:LEDC 的速度模式,可以是低速模式或高速模式。
channel:指定使用的 LEDC 通道编号。


当我第一次真正看到 LED 随着 duty 值变化平滑变亮、变暗时,这套机制才算真正“落地”到我脑子里。


五、完整示例:用 PWM 控制 LED 亮度渐变

下面这个例子,是我实际调试时最常用的一种:

  • GPIO9 接 LED
  • 占空比不断递增
  • 形成呼吸灯效果
    在这里插入图片描述

在这里插入图片描述
从仿真图中可以看到led灯再由暗到亮的变化。

#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/ledc.h"
#include "esp_err.h"

 static void ledc_init(void)
{
    // 准备并应用 LEDC PWM 定时器配置
    ledc_timer_config_t ledc_timer = {
        .speed_mode       = LEDC_LOW_SPEED_MODE,   // 设置为低速模式
        .duty_resolution  = LEDC_TIMER_13_BIT,     // 设置占空比分辨率为 13 位
        .timer_num        = LEDC_TIMER_0,          // 使用定时器 0
        .freq_hz          = 4000,                  // 设置输出频率为 4 kHz
        .clk_cfg          = LEDC_AUTO_CLK          // 自动选择时钟源
    };
    ESP_ERROR_CHECK(ledc_timer_config(&ledc_timer));

    // 准备并应用 LEDC PWM 通道配置
    ledc_channel_config_t ledc_channel = {
        .speed_mode     = LEDC_LOW_SPEED_MODE, // 设置为低速模式
        .channel        = LEDC_CHANNEL_0,      // 使用通道 0
        .timer_sel      = LEDC_TIMER_0,        // 关联定时器 0
        .intr_type      = LEDC_INTR_DISABLE,   // 禁用中断
        .gpio_num       = 9,                   // 定义输出 GPIO 引脚为 GPIO 9,对应LED引脚
        .duty           = 0,                   // 设置初始占空比为 0%
        .hpoint         = 0                    // 设置高电平起点为 0
    };
    ESP_ERROR_CHECK(ledc_channel_config(&ledc_channel));
}

void update_duty(uint32_t duty) {
    ESP_ERROR_CHECK(ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, duty));
    ESP_ERROR_CHECK(ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0));
}

void app_main(void)
{
    // 设置 LEDC 外设配置
    ledc_init();
    // 设置占空比为 50%
    ESP_ERROR_CHECK(ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, 8000)); //设置占空比为50%。(2^13)*50%=4096
    // 更新占空比以应用新值
    ESP_ERROR_CHECK(ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0));

    int duty = 0;
    while (1)
    {
        duty = duty + 100;
        if(duty >= 8192)
            duty = 0;
        //硬件电路上,LED1灯是低电平点亮的,所以我们可以看到占空比越低灯越亮
        update_duty(duty);
        vTaskDelay(100 / portTICK_PERIOD_MS);
    }
    
}

根据上几节的led灯点亮来修改代码,就可以看到呼吸灯效果了;
在这里插入图片描述
在这里插入图片描述
这里还有一个非常工程化的细节

硬件上 LED 是“低电平点亮”的

所以你会看到一个现象:

  • duty 越小 → LED 越亮
  • duty 越大 → LED 越暗

这不是代码问题,而是电路决定的行为
如果你意识不到这一点,调 PWM 时非常容易怀疑人生。


六、写在最后:LEDC 是 PWM,但不只是“点灯”

当你真正理解 LEDC 之后,你会发现它的应用远不止:

  • LED 调光
  • 呼吸灯

它更常被用在:

  • 电机调速
  • 蜂鸣器
  • 编码器 / 反馈系统
  • 甚至是一些“伪 DAC”场景

而且 LEDC 天然就适合和 FreeRTOS 任务配合

  • 一个任务调占空比
  • 一个任务管业务逻辑
  • 硬件 PWM 自己跑,不占 CPU
Logo

openvela 操作系统专为 AIoT 领域量身定制,以轻量化、标准兼容、安全性和高度可扩展性为核心特点。openvela 以其卓越的技术优势,已成为众多物联网设备和 AI 硬件的技术首选,涵盖了智能手表、运动手环、智能音箱、耳机、智能家居设备以及机器人等多个领域。

更多推荐