4. 定时器与PWM输出:ESP Timer、LEDC与呼吸灯实战

定时器和PWM,是嵌入式开发里最常用的外设之一。ESP32的硬件定时器精度很高,LEDC控制器更是专门为PWM输出设计的。这一章,我带你把这些东西彻底搞明白。

4.1 ESP Timer:高精度定时器

ESP32内部有4组64位通用定时器,每组都可以独立配置。它们基于APB时钟,最高精度能达到1微秒级别。我个人习惯把ESP Timer叫做“硬件定时器”,因为它不依赖FreeRTOS的软件定时器,中断响应非常快。

4.1.1 基本配置流程

使用ESP Timer,一般分三步走:

  1. 分配定时器:调用 timer_init(),指定定时器组、索引和分频系数。
  2. 设置报警值:调用 timer_set_alarm_value(),告诉定时器什么时候触发中断。
  3. 注册中断回调:调用 timer_isr_callback_add(),把我们的处理函数挂上去。

看个例子:

#include "driver/timer.h"

#define TIMER_DIVIDER         16  // 分频系数
#define TIMER_SCALE           (TIMER_BASE_CLK / TIMER_DIVIDER)  // 转为微秒

static bool IRAM_ATTR timer_group0_isr_callback(void *args) {
    // 这里放你的处理逻辑
    // 注意:中断里不要做耗时操作
    return true;
}

void app_main(void) {
    timer_config_t config = {
        .divider = TIMER_DIVIDER,
        .counter_dir = TIMER_COUNT_UP,
        .counter_en = TIMER_PAUSE,
        .alarm_en = TIMER_ALARM_EN,
        .auto_reload = true,
    };
    timer_init(TIMER_GROUP_0, TIMER_0, &config);
    timer_set_alarm_value(TIMER_GROUP_0, TIMER_0, 1000000); // 1秒
    timer_isr_callback_add(TIMER_GROUP_0, TIMER_0, timer_group0_isr_callback, NULL, 0);
    timer_start(TIMER_GROUP_0, TIMER_0);
}
小技巧:分频系数选16,是因为APB时钟通常是80MHz,除以16后得到5MHz,每个计数周期就是0.2微秒。这样算时间很方便。

4.1.2 定时器回调函数注意事项

嗯,这里要注意。定时器回调是在中断上下文执行的。我曾经在项目里踩过一个坑——在回调里调用了 printf(),结果系统直接卡死。为什么呢?因为 printf() 可能触发任务切换,而中断里不允许这样做。

总结几条铁律:

  • 回调函数必须加 IRAM_ATTR:确保代码在IRAM中运行,避免flash访问延迟。
  • 不要调用阻塞函数:比如 vTaskDelay()、printf()、malloc()。
  • 处理逻辑要极简:只做标志位设置、数据拷贝等轻量操作。
  • 如果需要复杂处理:用信号量或消息队列通知任务线程去做。
避坑指南:我曾经在回调里直接操作I2C总线读取传感器,结果中断嵌套导致总线锁死。后来改成在回调里只置一个标志位,主循环里再处理I2C操作,问题就解决了。

4.2 LEDC控制器:灵活的PWM输出

LEDC是ESP32专门为LED控制设计的PWM模块。它支持8个通道,每个通道可以独立设置频率和占空比。说白了,它就是一套硬件PWM发生器,不占用CPU资源。

4.2.1 LEDC vs 软件PWM

特性 LEDC硬件PWM 软件PWM(GPIO翻转)
CPU占用 几乎为0 高,需要定时器中断
最高频率 可达40MHz 受限于中断频率
占空比精度 最高20位(约1百万级) 受定时器精度限制
多通道同步 硬件自动同步 需要软件协调

你想想看,如果只是控制一个LED亮度,软件PWM也能凑合。但要是做电机控制、呼吸灯、或者多个LED同时调光,LEDC的优势就非常明显了。

4.2.2 LEDC配置步骤

配置LEDC也很简单,主要分两步:

  1. 配置定时器:设置PWM频率和占空比分辨率。
  2. 配置通道:绑定GPIO引脚,关联定时器。
#include "driver/ledc.h"

#define LEDC_TIMER              LEDC_TIMER_0
#define LEDC_MODE               LEDC_LOW_SPEED_MODE
#define LEDC_OUTPUT_IO          (2)      // GPIO2
#define LEDC_CHANNEL            LEDC_CHANNEL_0
#define LEDC_DUTY_RES           LEDC_TIMER_13_BIT  // 13位分辨率
#define LEDC_FREQUENCY          (5000)   // 5kHz

void ledc_init(void) {
    // 配置定时器
    ledc_timer_config_t timer_conf = {
        .speed_mode       = LEDC_MODE,
        .timer_num        = LEDC_TIMER,
        .duty_resolution  = LEDC_DUTY_RES,
        .freq_hz          = LEDC_FREQUENCY,
        .clk_cfg          = LEDC_AUTO_CLK,
    };
    ledc_timer_config(&timer_conf);

    // 配置通道
    ledc_channel_config_t channel_conf = {
        .channel    = LEDC_CHANNEL,
        .duty       = 0,
        .gpio_num   = LEDC_OUTPUT_IO,
        .speed_mode = LEDC_MODE,
        .hpoint     = 0,
        .timer_sel  = LEDC_TIMER,
    };
    ledc_channel_config(&channel_conf);
}
关键点:占空比分辨率越高,可调节的亮度等级就越多。13位分辨率意味着占空比可以从0到8191,共8192级。但分辨率越高,能支持的最大频率就越低。这是一个权衡。

4.3 呼吸灯效果实现

呼吸灯,说白了就是让LED的亮度周期性变化,从暗到亮再到暗,模拟呼吸的节奏。实现这个效果,核心就是不断改变PWM的占空比。

4.3.1 软件实现方案

我一般用两种方式:

  • 线性变化:占空比从0到最大值线性增加,再线性减少。简单,但效果不够自然。
  • 指数/对数变化:人眼对亮度的感知是非线性的,用指数曲线会让呼吸效果更平滑。

看一个线性呼吸灯的代码:

void breath_led_task(void *arg) {
    int duty = 0;
    int step = 8;  // 步进值
    int direction = 1;

    while (1) {
        ledc_set_duty(LEDC_MODE, LEDC_CHANNEL, duty);
        ledc_update_duty(LEDC_MODE, LEDC_CHANNEL);
        vTaskDelay(pdMS_TO_TICKS(10));  // 每10ms更新一次

        duty += step * direction;
        if (duty >= 8191) {
            duty = 8191;
            direction = -1;
        } else if (duty <= 0) {
            duty = 0;
            direction = 1;
        }
    }
}

为什么步进值设成8?因为13位分辨率下,8192级变化如果每级都走一遍,周期太长。步进8可以让一个呼吸周期控制在2秒左右,效果比较舒服。

4.3.2 指数曲线优化

线性呼吸灯看起来有点“愣”,亮灭切换太生硬。我建议用指数曲线:

#include "math.h"

void breath_led_exponential(void *arg) {
    int max_duty = 8191;
    int steps = 200;  // 一个周期内的步数

    while (1) {
        for (int i = 0; i < steps; i++) {
            // 使用正弦波,更接近自然呼吸
            float phase = (float)i / steps * 2 * M_PI;
            float value = (sinf(phase - M_PI / 2) + 1) / 2;  // 0~1
            int duty = (int)(value * max_duty);
            
            ledc_set_duty(LEDC_MODE, LEDC_CHANNEL, duty);
            ledc_update_duty(LEDC_MODE, LEDC_CHANNEL);
            vTaskDelay(pdMS_TO_TICKS(10));
        }
    }
}
个人经验:用正弦波模拟呼吸效果最好。人呼吸时胸腔容积的变化本身就是正弦曲线。我做过对比测试,正弦波呼吸灯比线性呼吸灯看起来自然得多。

4.4 知识结构图

下面这张图展示了本章的核心知识点和它们之间的关系:

定时器与PWM知识结构图 定时器与PWM输出知识体系 ESP Timer 定时器初始化 报警值设置 中断回调注册 LEDC控制器 定时器配置 通道配置 占空比更新 呼吸灯效果 回调函数中不要做耗时操作

4.5 常见问题与避坑

最后,分享几个我在实际项目中遇到的坑:

  • 定时器回调里调用 ledc_set_duty():这个函数内部可能涉及I2C或SPI操作(某些芯片),在中断里调用会导致系统不稳定。我建议在回调里只置标志位,在主循环里更新PWM。
  • 占空比更新不及时:调用 ledc_set_duty() 后必须调用 ledc_update_duty(),否则新值不会生效。这个坑我踩过两次。
  • 频率选择:LED调光用1kHz以上就够了,但电机控制可能需要更高频率。频率太高会降低占空比分辨率,需要根据实际需求权衡。
重要提醒:ESP32的LEDC有高速模式和低速模式。高速模式使用APB时钟,精度更高但功耗也大。低功耗场景下建议用低速模式,配合省电模式使用。

好了,定时器和PWM这部分就讲到这里。这些内容在实际项目中几乎每天都会用到,掌握好了,后面做低功耗和OTA升级会顺手很多。