【Zephyr|ESP32-S3】基础学习:用LEDC外设实现PWM呼吸灯效果

哈喽,我是余火,一个普通的牛马打工人,目前正在学如何使用Zephyr RTOS。

上篇用定时器做了消抖和灯效节奏控制,定时器的本质是"算时间",到点就回调。末尾说"下一篇用定时器的亲戚——PWM 来做呼吸灯",这次来填这个坑。

PWM 和定时器的区别:定时器是"输入端"(算时间、到点通知),PWM 是"输出端"(硬件持续输出特定波形信号)。通过调整高低电平的时间比例控制外接 LED 亮度,就是呼吸灯的原理。

上篇我们用三个线程分别处理按键、WS2812 灯效和定时器,但 WS2812 的"呼吸"效果是软件模拟的——线程周期性地计算亮度、推送像素数据到 WS2812。这次引入真正的硬件 PWM 输出,让外设自己维持波形,CPU 只需要在切换亮度时更新一次参数就行。

💡 本篇学习目标
• PWM 原理与占空比
pwm_set_dt:设置 PWM 占空比控制亮度
• ESP32-S3 LEDC 外设与 Zephyr PWM 驱动 API

改了哪些东西

在上一篇定时器工程基础上扩展,新增 PWM 呼吸灯功能:

文件 改了什么
prj.conf 新增 CONFIG_PWM=y
overlay 新增 LEDC 外设节点、pinctrl 引脚映射、PWM LED 节点和别名
src/main.c 新增 PWM 头文件、设备树规格、呼吸灯线程和设备检查
CMakeLists.txt 无改动

另外这次需要简单外接个硬件。ESP32-S3 的引脚本身有一点驱动能力,直接在 GPIO5 外接一颗 LED:长脚(正极)接 GPIO5,短脚(负极)接 GND。

PWM 与占空比

PWM(Pulse Width Modulation,脉宽调制)通过快速开关信号控制输出功率。LED 亮灭切换足够快时,人眼看到的是平均亮度——高电平占比越大越亮,这个占比就叫占空比

举个具体例子:PWM 周期 1000ns(即 1kHz 频率),如果高电平持续 500ns,占空比就是 50%,LED 表现为半亮度。高电平 1000ns 就是 100%,全亮;高电平 0 就是全灭。

实际开发中,PWM 的应用远不止 LED。电机调速通过改变占空比控制电机平均电压;蜂鸣器音调通过改变 PWM 频率控制声音高低;屏幕背光通过 PWM 调节亮度以节省功耗。它们本质上都是同一个 API——pwm_set_dt

💡 ESP32-S3 的 LEDC 外设
ESP32-S3 有专门的 LEDC(LED PWM Controller)外设,可独立输出多路 PWM 信号。LEDC 底层也是定时器模块驱动的,所以和上篇学的 k_timer 紧密关联。Zephyr 通过标准 PWM 驱动 API 操作 LEDC,不用直接写寄存器。

PWM 输出方式 原理 精度 CPU 占用
软件翻转 GPIO 线程 k_msleep + gpio_pin_set 低,受线程调度影响 高,线程周期唤醒
硬件 PWM(LEDC) 外设自动输出波形 高,硬件计数器驱动 零,硬件自主运行

启用 PWM 子系统

prj.conf 新增一行:

CONFIG_PWM=y    # 启用PWM子系统

这一行会拉起 Zephyr 的 PWM 子系统框架,包括 pwm_dt_specpwm_set_dt 等 API 的实现。不需要额外启用 LEDC 相关的 Kconfig——CONFIG_PWM=y 会自动根据设备树中 LEDC 节点的存在,拉起 ESP32 的 LEDC 驱动。

overlay 配置

overlay 需要新增三块内容:PWM LED 节点、LEDC 引脚映射和 LEDC 外设声明。

PWM LED 节点

#include <zephyr/dt-bindings/pwm/pwm.h>

/ {
    pwmleds {
        compatible = "pwm-leds";

        pwm_led_blue: pwm_led_gpio5 {
            label = "PWM LED0";
            pwms = <&ledc0 0 1000 PWM_POLARITY_NORMAL>;
        };
    };
};

pwms = <&ledc0 0 1000 PWM_POLARITY_NORMAL> 四个字段含义:

字段 含义
&ledc0 LEDC 控制器引用 使用 ESP32-S3 的 LEDC 外设
0 通道号 LEDC 通道 0
1000 周期(纳秒) 1000ns = 1kHz 频率
PWM_POLARITY_NORMAL 极性 高电平有效(占空比越高越亮)

LEDC 引脚映射

&pinctrl {
    ledc0_default: ledc0_default {
        group1 {
            pinmux = <LEDC_CH0_GPIO5>;
            output-enable;
        };
    };
};

LEDC 通道 0 通过 pinctrl 映射到 GPIO5output-enable 声明该引脚为输出模式。

LEDC 外设声明

&ledc0 {
    pinctrl-0 = <&ledc0_default>;
    pinctrl-names = "default";
    status = "okay";
    #address-cells = <1>;
    #size-cells = <0>;

    channel0@0 {
        reg = <0x0>;
        timer = <0>;
    };
};

channel0@0 声明 LEDC 通道 0,timer = <0> 表示使用 LEDC 内部定时器 0(LEDC 有独立的定时器模块,每个通道绑定一个定时器)。ESP32-S3 的 LEDC 支持 8 路通道,每路可绑定到不同的 GPIO 引脚和不同的定时器,但本篇只需要一路通道 0 驱动 GPIO5 上的一颗 LED。

aliases 中补上 pwm-led0 = &pwm_led_blue,代码通过 DT_ALIAS(pwm_led0) 引用这个节点。

PWM 设备树规格

新增的头文件和设备树规格:

#include <zephyr/drivers/pwm.h>  /* PWM API: pwm_dt_spec, pwm_set_dt */

/* 从 pwm-led0 别名获取 LEDC 参数(控制器、通道、周期) */
static const struct pwm_dt_spec pwm_led0 = PWM_DT_SPEC_GET(DT_ALIAS(pwm_led0));

PWM_DT_SPEC_GET 从设备树中一次性提取控制器引用、通道号和周期,封装为 pwm_dt_spec 结构体,后续调用 pwm_set_dt 时直接传这个结构体即可,不需要分别获取控制器、通道和周期三个参数。

PWM 呼吸灯线程

新增了第三个线程专门驱动 PWM 呼吸灯,与 WS2812 灯效线程、按键线程独立运行:

/*
 * PWM 呼吸灯参数
 *
 * BREATH_STEPS — 占空比渐变步数,100 步从 0% 渐变到 100%
 * STEP_DELAY_MS — 每步间隔,100 步 × 20ms = 2 秒完成一个呼吸周期
 */
#define BREATH_STEPS    100
#define STEP_DELAY_MS   20

/*
 * pwm_breath_thread — PWM 呼吸灯线程
 *
 * 独立于 WS2812 灯效线程运行,通过 pwm_set_dt() 控制 GPIO5 外接 LED
 * 的占空比,实现呼吸效果。优先级 6 介于灯效线程(5)和按键线程(7)之间。
 */
void pwm_breath_thread(void *p1, void *p2, void *p3)
{
    ARG_UNUSED(p1);
    ARG_UNUSED(p2);
    ARG_UNUSED(p3);

    uint8_t step = 0;
    int dir = 1;

    while (1) {
        /* pulse = 周期 × 步数 / 总步数,控制当前亮度 */
        uint32_t pulse = (pwm_led0.period * step) / BREATH_STEPS;
        pwm_set_dt(&pwm_led0, pwm_led0.period, pulse);

        step += dir;
        if (step >= BREATH_STEPS) {
            dir = -1;
        }
        if (step <= 0) {
            dir = 1;
        }

        k_msleep(STEP_DELAY_MS);
    }
}

K_THREAD_DEFINE(pwm_breath_tid, 512, pwm_breath_thread,
                NULL, NULL, NULL, 6, 0, 0);

pwm_set_dt 三个参数:

参数 含义
&pwm_led0 pwm_dt_spec 指针,包含控制器、通道、周期信息
pwm_led0.period 周期(与设备树中的 1000ns 一致)
pulse 脉冲宽度,0 = 全灭,等于周期 = 全亮

💡 PWM 与 WS2812 呼吸灯的区别
上篇 WS2812 的呼吸灯是在线程里用 k_msleep(20) 轮询改亮度,每步需要软件计算颜色值再通过 I2S+DMA 推送到 WS2812。PWM 呼吸灯则是调用 pwm_set_dt 后硬件自动维持波形输出,线程只管算占空比就行,不需要周期性地重新推送数据——这就是硬件 PWM 的优势。

三线程架构

本篇工程运行着三个独立线程,各自负责不同的外设,互不干扰:

线程 优先级 功能 依赖
effect_thread 5(最高) WS2812 灯效渲染(常亮/呼吸) 信号量 + 互斥锁
pwm_breath_thread 6 GPIO5 外接 LED 呼吸灯 无同步原语
button_thread 7(最低) 按键检测 + 模式切换 信号量 + 互斥锁

💡 优先级设计逻辑
灯效渲染对时序敏感(呼吸效果需要稳定 20ms 步进),所以优先级最高(5);按键处理是事件响应型,优先级最低(7)也不会有明显延迟;PWM 呼吸灯介于两者之间(6)。Zephyr 采用优先级抢占式调度,高优先级线程可以打断低优先级线程——但如果 effect_thread 不主动 k_msleep 让出 CPU,低优先级线程会一直得不到执行。

main 函数新增检查

main 函数里新增了 PWM 设备就绪检查,其余初始化逻辑(WS2812、GPIO 按键、定时器)与上篇完全一致:

    /* 检查 PWM 设备是否就绪 */
    if (!pwm_is_ready_dt(&pwm_led0)) {
        LOG_ERR("PWM device not ready");
        return 0;
    }
    LOG_INF("PWM breathing LED on GPIO5 ready");

编译烧录后,WS2812 灯效和 GPIO5 外接 LED 同时运行:WS2812 按定时器节奏自动换色,按 BOOT 切换常亮/呼吸模式;GPIO5 的 LED 独立做 2 秒周期的呼吸效果,三个线程各跑各的互不干扰。LOG 打印了 WS2812 像素数、PWM 就绪信息和按键提示。
在这里插入图片描述

串口监视器(波特率 115200)中可以看到如下输出:
在这里插入图片描述

常见问题

Q1:外接 LED 正负极接反了会怎样?

  • LED 不亮但不会损坏。检查长脚(正极)接 GPIO5,短脚(负极)接 GND。

Q2:LED 一直亮着没有呼吸效果?

  • 可能是共阳极接法(正极接 VCC、负极接 GPIO),占空比越高反而越暗。改接线或将极性改为 PWM_POLARITY_INVERTED

Q3:编译报 undefined reference to pwm_set_dt

  • 原因prj.conf 没加 CONFIG_PWM=y
  • 解决:加上配置项后重新编译

Q4:编译报 LEDC_CH0_GPIO5 undeclared?

  • 原因:overlay 缺少 #include <zephyr/dt-bindings/pwm/pwm.h>,该头文件提供 LEDC 引脚复用宏
  • 解决:在 overlay 文件顶部加上 include

PWM 是嵌入式三大输出方式之一,电机调速、蜂鸣器音调、屏幕背光调节都靠它控制功率输出。学会了呼吸灯,这些场景本质都是同一个 API。

本篇在定时器工程基础上新增了 PWM 呼吸灯线程,通过 ESP32-S3 的 LEDC 外设实现硬件 PWM 输出。同样这套 pwm_set_dt API 可以直接套用到电机调速、蜂鸣器音调、LCD 背光等场景,核心就三步:设备树声明 → 代码获取 pwm_dt_spec → 线程调用 pwm_set_dt 更新占空比。

希望我的笔记能对你有一点点点的帮助!欢迎关注一起学习👇

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐