ESP32-S3 的 PWM(LEDC),我是怎么真正“用明白”的
LED 调光呼吸灯电机调速蜂鸣器编码器 / 反馈系统甚至是一些“伪 DAC”场景而且LEDC 天然就适合和 FreeRTOS 任务配合一个任务调占空比一个任务管业务逻辑硬件 PWM 自己跑,不占 CPU。
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 时,总是分成三步:
- 配定时器
- 配通道
- 改占空比
二、第一步:我真正理解 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: 定时器 3。
uint32_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:输出到哪个 GPIOduty:初始占空比
示例配置如下:
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: 定时器 3。
ledc_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
openvela 操作系统专为 AIoT 领域量身定制,以轻量化、标准兼容、安全性和高度可扩展性为核心特点。openvela 以其卓越的技术优势,已成为众多物联网设备和 AI 硬件的技术首选,涵盖了智能手表、运动手环、智能音箱、耳机、智能家居设备以及机器人等多个领域。
更多推荐


所有评论(0)