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,一般分三步走:
- 分配定时器:调用
timer_init(),指定定时器组、索引和分频系数。 - 设置报警值:调用
timer_set_alarm_value(),告诉定时器什么时候触发中断。 - 注册中断回调:调用
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);
}
4.1.2 定时器回调函数注意事项
嗯,这里要注意。定时器回调是在中断上下文执行的。我曾经在项目里踩过一个坑——在回调里调用了 printf(),结果系统直接卡死。为什么呢?因为 printf() 可能触发任务切换,而中断里不允许这样做。
总结几条铁律:
- 回调函数必须加
IRAM_ATTR:确保代码在IRAM中运行,避免flash访问延迟。 - 不要调用阻塞函数:比如
vTaskDelay()、printf()、malloc()。 - 处理逻辑要极简:只做标志位设置、数据拷贝等轻量操作。
- 如果需要复杂处理:用信号量或消息队列通知任务线程去做。
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也很简单,主要分两步:
- 配置定时器:设置PWM频率和占空比分辨率。
- 配置通道:绑定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);
}
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 知识结构图
下面这张图展示了本章的核心知识点和它们之间的关系:
4.5 常见问题与避坑
最后,分享几个我在实际项目中遇到的坑:
- 定时器回调里调用
ledc_set_duty():这个函数内部可能涉及I2C或SPI操作(某些芯片),在中断里调用会导致系统不稳定。我建议在回调里只置标志位,在主循环里更新PWM。 - 占空比更新不及时:调用
ledc_set_duty()后必须调用ledc_update_duty(),否则新值不会生效。这个坑我踩过两次。 - 频率选择:LED调光用1kHz以上就够了,但电机控制可能需要更高频率。频率太高会降低占空比分辨率,需要根据实际需求权衡。
好了,定时器和PWM这部分就讲到这里。这些内容在实际项目中几乎每天都会用到,掌握好了,后面做低功耗和OTA升级会顺手很多。