4. ESP32 定时器:从硬件定时器到呼吸灯实战
定时器这东西,说白了就是单片机的“心跳”。没有它,你的程序就像没戴手表的打工人——啥时候该干啥全凭感觉。ESP32 的定时器资源挺丰富,配置起来也灵活。我个人习惯把定时器分成两类:一类是纯粹的计时中断,另一类是生成 PWM 波形。今天咱们就把这两块都捋清楚。
本章核心脉络:硬件定时器配置 → 定时中断回调 → PWM 输出原理 → 呼吸灯实验。一条线走下来,你就能用定时器干点“真活”了。
4.1 硬件定时器:ESP32 的“心跳”单元
ESP32 内部有 4 个 64 位通用定时器,每个定时器都可以独立配置。它们挂在 APB 总线上,时钟源通常是 80 MHz(APB 时钟)。
为什么是 64 位?因为 32 位定时器在 80 MHz 下,最大计时才 53 秒左右。64 位能撑到几万年,做长时间计时不用愁溢出。我在项目中遇到过需要连续运行 72 小时的设备状态记录,用 64 位定时器就很从容。
| 参数 | 说明 |
|---|---|
| 定时器数量 | 4 个(timer0 ~ timer3) |
| 位宽 | 64 位 |
| 时钟源 | APB 时钟(默认 80 MHz) |
| 工作模式 | 单次 / 自动重载 |
| 中断 | 可配置,支持回调函数 |
4.2 定时中断回调:让代码“按时”执行
配置定时中断其实就三步:初始化定时器、设置计数值和分频、注册中断回调。我刚开始用 ESP-IDF 时,总搞混 timer_config() 和 timer_set_counter_value() 的顺序。后来发现,先配置再设值,顺序错了定时器可能不工作。
来看一个典型的 1 秒定时中断示例:
#include "driver/timer.h"
#define TIMER_DIVIDER 80 // 80 MHz / 80 = 1 MHz, 即 1 us 一跳
#define TIMER_SCALE (TIMER_BASE_CLK / TIMER_DIVIDER) // 1 MHz
static void IRAM_ATTR timer_isr_handler(void *arg) {
// 清除中断标志
timer_group_clr_intr_status_in_isr(TIMER_GROUP_0, TIMER_0);
timer_counter_value_clear(TIMER_GROUP_0, TIMER_0);
// 你的定时任务放这里
// 比如翻转一个 GPIO
gpio_set_level(LED_GPIO, !gpio_get_level(LED_GPIO));
}
void timer_init_example(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);
// 设置计数值:1 秒 = 1,000,000 次
timer_set_alarm_value(TIMER_GROUP_0, TIMER_0, 1000000);
timer_enable_intr(TIMER_GROUP_0, TIMER_0);
timer_isr_register(TIMER_GROUP_0, TIMER_0, timer_isr_handler, NULL, ESP_INTR_FLAG_IRAM, NULL);
timer_start(TIMER_GROUP_0, TIMER_0);
}
注意:中断回调函数必须加 IRAM_ATTR 属性,否则在 Flash 加密或缓存未命中时会导致崩溃。我曾经因为这个 bug 排查了整整一个下午……
4.3 PWM 输出原理:用定时器“模拟”模拟信号
PWM 的全称是脉冲宽度调制。说白了,就是让 GPIO 以固定频率输出高低电平,通过改变高电平占一个周期的时间比例(占空比),来“假装”输出不同的电压。
ESP32 的 PWM 有两种实现方式:
- 软件 PWM:用定时器中断手动翻转 GPIO。精度有限,但灵活。
- 硬件 PWM(LEDC):ESP32 内置的 LED PWM 控制器,专门为 LED 调光和电机控制设计。精度高,不占 CPU。
我个人强烈推荐用硬件 PWM。原因很简单——不占 CPU。你想想看,如果呼吸灯用软件 PWM,CPU 得一直响应中断,别的活还干不干了?
| 特性 | 软件 PWM | 硬件 PWM(LEDC) |
|---|---|---|
| CPU 占用 | 高(每周期中断) | 低(硬件自动输出) |
| 分辨率 | 受中断频率限制 | 最高 20 位 |
| 通道数 | 受定时器数量限制 | 8 个独立通道 |
| 适用场景 | 简单指示、低频输出 | LED 调光、电机控制 |
4.4 呼吸灯实验:从原理到代码
呼吸灯的效果,就是让 LED 从暗到亮、再从亮到暗,循环往复。本质上就是 PWM 占空比从 0% 逐渐增加到 100%,再逐渐减小到 0%。
用 ESP32 的 LEDC 实现起来非常直接:
#include "driver/ledc.h"
#define LEDC_GPIO GPIO_NUM_2
#define LEDC_CHANNEL LEDC_CHANNEL_0
#define LEDC_TIMER LEDC_TIMER_0
#define LEDC_FREQ 5000 // 5 kHz PWM 频率
#define LEDC_RESOLUTION LEDC_TIMER_13_BIT // 13 位分辨率,即 0~8191
void ledc_init(void) {
ledc_timer_config_t timer_conf = {
.speed_mode = LEDC_HIGH_SPEED_MODE,
.timer_num = LEDC_TIMER,
.duty_resolution = LEDC_RESOLUTION,
.freq_hz = LEDC_FREQ,
.clk_cfg = LEDC_AUTO_CLK,
};
ledc_timer_config(&timer_conf);
ledc_channel_config_t channel_conf = {
.gpio_num = LEDC_GPIO,
.speed_mode = LEDC_HIGH_SPEED_MODE,
.channel = LEDC_CHANNEL,
.timer_sel = LEDC_TIMER,
.duty = 0,
.hpoint = 0,
};
ledc_channel_config(&channel_conf);
}
void breath_led_task(void *arg) {
int duty = 0;
int step = 8; // 步进值,控制呼吸速度
while (1) {
// 渐亮
for (duty = 0; duty <= 8191; duty += step) {
ledc_set_duty(LEDC_HIGH_SPEED_MODE, LEDC_CHANNEL, duty);
ledc_update_duty(LEDC_HIGH_SPEED_MODE, LEDC_CHANNEL);
vTaskDelay(pdMS_TO_TICKS(10));
}
// 渐暗
for (duty = 8191; duty >= 0; duty -= step) {
ledc_set_duty(LEDC_HIGH_SPEED_MODE, LEDC_CHANNEL, duty);
ledc_update_duty(LEDC_HIGH_SPEED_MODE, LEDC_CHANNEL);
vTaskDelay(pdMS_TO_TICKS(10));
}
}
}
调参技巧:步进值 step 控制呼吸速度,vTaskDelay 控制平滑度。step 越大呼吸越快,但会显得“跳变”。我个人喜欢 step=4、delay=5ms,效果最自然。
4.5 知识结构图:定时器与 PWM 的关系
下面这张图帮你理清硬件定时器、PWM 和呼吸灯之间的层级关系:
4.6 避坑指南与经验总结
做定时器和 PWM 开发,有几个坑我替你们踩过了:
- 中断服务函数不要太长。ISR 里只做标志位设置或数据拷贝,别做延时、打印、复杂计算。否则系统会变得“卡顿”,甚至触发看门狗复位。
- LEDC 的 duty 值不要超过分辨率上限。13 位分辨率对应 0~8191,你设个 10000 进去,结果可能不是你想要的。
- 定时器分频系数要算清楚。80 MHz 时钟,分频 80 得到 1 MHz,也就是 1 微秒一跳。分频 1000 得到 80 kHz,每跳 12.5 微秒。算错了,时间就不准了。
- 多定时器共用中断优先级。ESP32 的定时器中断默认优先级相同,如果两个定时器同时触发,后注册的可能被延迟。必要时可以调整中断优先级。
一句话总结:定时器是 ESP32 的“时间管理者”,PWM 是定时器的“打工仔”。用好 LEDC 硬件模块,呼吸灯这种效果就是几行代码的事。