4. 定时器与PWM:ESP定时器API、软件定时器、硬件定时器、LEDC(PWM)驱动、呼吸灯实验

定时器和PWM,这两个东西在嵌入式开发里太常用了。说白了,定时器就是帮我们计时的,PWM就是模拟电压的。我刚开始用ESP32的时候,觉得定时器API挺绕的,后来摸清楚了,其实就那么回事。

4.1 ESP32的定时器体系

ESP32的定时器分两类:软件定时器和硬件定时器。很多人搞混,我简单说说区别。

类型 精度 资源消耗 适用场景
软件定时器 毫秒级(受FreeRTOS调度影响) 低(基于任务) 按键消抖、周期性状态查询
硬件定时器 微秒级(硬件计数器) 高(占用外设) PWM生成、精确延时、电机控制

嗯,这里要注意:软件定时器本质上是FreeRTOS的软件定时器,它依赖系统tick。如果你的系统负载高,定时器回调可能会被延迟。硬件定时器就不一样了,它自己跑自己的,不受任务调度影响。

4.2 软件定时器实战

我个人习惯用软件定时器做周期性的非实时任务。比如每隔100ms读一次传感器,这种场景用软件定时器就够了。

#include "freertos/FreeRTOS.h"
#include "freertos/timers.h"

// 定时器回调函数
void my_timer_callback(TimerHandle_t xTimer) {
    // 这里放你的周期性任务
    ESP_LOGI("TIMER", "定时器触发了");
}

void app_main(void) {
    // 创建软件定时器
    TimerHandle_t my_timer = xTimerCreate(
        "my_timer",          // 定时器名字
        pdMS_TO_TICKS(1000), // 周期:1000ms
        pdTRUE,              // 自动重载
        (void *)0,           // 定时器ID
        my_timer_callback    // 回调函数
    );

    if (my_timer != NULL) {
        xTimerStart(my_timer, 0); // 启动定时器
    }
}
我曾经踩过的坑:软件定时器的回调函数是在定时器服务任务中执行的,不要在回调里调用阻塞函数(比如vTaskDelay),否则会影响其他定时器。我有一回在回调里加了延时,结果整个定时器组都卡住了。

4.3 硬件定时器:ESP_TIMER API

需要高精度的时候,就得用硬件定时器了。ESP-IDF提供了esp_timer API,它基于硬件定时器实现,精度能达到微秒级。

#include "esp_timer.h"

void periodic_timer_callback(void* arg) {
    int64_t time_since_boot = esp_timer_get_time();
    ESP_LOGI("TIMER", "当前系统时间: %lld us", time_since_boot);
}

void app_main(void) {
    const esp_timer_create_args_t timer_args = {
        .callback = &periodic_timer_callback,
        .arg = NULL,
        .name = "periodic"
    };

    esp_timer_handle_t timer_handle;
    ESP_ERROR_CHECK(esp_timer_create(&timer_args, &timer_handle));
    ESP_ERROR_CHECK(esp_timer_start_periodic(timer_handle, 500000)); // 500ms
}

为什么用esp_timer而不是直接用硬件定时器寄存器?说白了,esp_timer帮你封装好了,你不用自己去配置定时器的分频系数、计数值这些底层东西。而且它还能获取系统时间,这个在调试时特别有用。

4.4 LEDC:ESP32的PWM驱动

LEDC是ESP32专门用来生成PWM的外设。它支持8个通道,每个通道可以独立配置频率和占空比。我一般用它来控制LED亮度、舵机角度、电机速度。

参数 说明 典型值
频率 PWM波的频率 50Hz(舵机)、5kHz(LED)
占空比 高电平占整个周期的比例 0% ~ 100%
分辨率 占空比的调节精度 8位(0-255)、13位(0-8191)
#include "driver/ledc.h"

#define LEDC_TIMER      LEDC_TIMER_0
#define LEDC_MODE       LEDC_LOW_SPEED_MODE
#define LEDC_CHANNEL    LEDC_CHANNEL_0
#define LEDC_GPIO       GPIO_NUM_2

void ledc_init(void) {
    // 配置定时器
    ledc_timer_config_t timer_conf = {
        .speed_mode = LEDC_MODE,
        .timer_num = LEDC_TIMER,
        .duty_resolution = LEDC_TIMER_13_BIT, // 13位分辨率
        .freq_hz = 5000,                      // 5kHz
        .clk_cfg = LEDC_AUTO_CLK
    };
    ledc_timer_config(&timer_conf);

    // 配置通道
    ledc_channel_config_t channel_conf = {
        .gpio_num = LEDC_GPIO,
        .speed_mode = LEDC_MODE,
        .channel = LEDC_CHANNEL,
        .timer_sel = LEDC_TIMER,
        .duty = 0,
        .hpoint = 0
    };
    ledc_channel_config(&channel_conf);
}

void set_duty(uint32_t duty) {
    ledc_set_duty(LEDC_MODE, LEDC_CHANNEL, duty);
    ledc_update_duty(LEDC_MODE, LEDC_CHANNEL);
}
我的经验:LEDC的占空比设置后,必须调用ledc_update_duty才能生效。这个我刚开始忘了,调了半天发现PWM没输出,后来才想起来要更新一下。

4.5 呼吸灯实验:把知识串起来

呼吸灯这个实验,说白了就是用PWM控制LED亮度,让亮度从暗到亮、再从亮到暗,循环变化。我拿这个实验来教大家怎么把定时器和PWM结合起来用。

实现思路很简单:用软件定时器每10ms触发一次,在回调里改变PWM的占空比。占空比从0逐渐增加到最大值,然后再逐渐减小到0,如此循环。

#include "freertos/FreeRTOS.h"
#include "freertos/timers.h"
#include "driver/ledc.h"

#define LEDC_TIMER      LEDC_TIMER_0
#define LEDC_MODE       LEDC_LOW_SPEED_MODE
#define LEDC_CHANNEL    LEDC_CHANNEL_0
#define LEDC_GPIO       GPIO_NUM_2
#define LEDC_DUTY_MAX   (8191)  // 13位分辨率最大值

static int duty_direction = 1;  // 1:增加 -1:减小
static uint32_t current_duty = 0;

void breath_timer_callback(TimerHandle_t xTimer) {
    // 更新占空比
    current_duty += duty_direction * 50;
    
    // 边界判断
    if (current_duty >= LEDC_DUTY_MAX) {
        current_duty = LEDC_DUTY_MAX;
        duty_direction = -1;
    } else if (current_duty <= 0) {
        current_duty = 0;
        duty_direction = 1;
    }
    
    // 设置PWM占空比
    ledc_set_duty(LEDC_MODE, LEDC_CHANNEL, current_duty);
    ledc_update_duty(LEDC_MODE, LEDC_CHANNEL);
}

void app_main(void) {
    // 初始化LEDC
    ledc_init();
    
    // 创建软件定时器,10ms触发一次
    TimerHandle_t breath_timer = xTimerCreate(
        "breath",
        pdMS_TO_TICKS(10),
        pdTRUE,
        NULL,
        breath_timer_callback
    );
    
    if (breath_timer != NULL) {
        xTimerStart(breath_timer, 0);
    }
}

关键点总结:

  • 定时器周期决定了呼吸速度:周期越短,呼吸越快
  • 占空比步长决定了平滑度:步长越小,过渡越平滑
  • LEDC分辨率越高,呼吸效果越细腻

4.6 定时器与PWM的知识体系

我把这章的知识点画了个图,方便大家理解它们之间的关系。

ESP32定时器与PWM知识体系 定时器 软件定时器 硬件定时器 毫秒级精度 依赖FreeRTOS tick 适合周期性任务 微秒级精度 独立硬件计数器 适合实时控制 PWM (LEDC) 频率配置 占空比设置 分辨率选择 呼吸灯实验 软件定时器 LEDC PWM 占空比渐变 组合使用 核心API • xTimerCreate / xTimerStart (软件定时器) • esp_timer_create / esp_timer_start_periodic (硬件定时器) • ledc_timer_config / ledc_channel_config (PWM)

这张图把定时器和PWM的关系理清楚了。左边是定时器体系,中间是PWM配置参数,右边是呼吸灯实验怎么把两者结合起来。你想想看,呼吸灯其实就是定时器驱动PWM占空比变化的过程。

调试小技巧:用逻辑分析仪看PWM波形,比用示波器方便。ESP32的PWM频率和占空比都能在逻辑分析仪上直接读出来。我调试呼吸灯的时候,就是用逻辑分析仪确认占空比变化曲线是不是平滑的。

嗯,这章的内容就这些。定时器和PWM是嵌入式开发的基础,呼吸灯实验虽然简单,但把两个知识点串起来了。你把这个实验跑通了,后面做更复杂的控制就心里有底了。