4. 定时器与PWM输出:ESP Timer API使用、硬件定时器配置、LEDC实现PWM调光、呼吸灯实验
定时器和PWM,这两个东西在嵌入式开发里太常用了。说白了,定时器就是芯片的“心跳”,PWM就是靠这个心跳模拟出来的“假模拟信号”。这一章,咱们就把它俩彻底搞明白。
4.1 硬件定时器:ESP32的“节拍器”
ESP32内部有4组64位通用定时器,每组都支持独立配置。我个人习惯把它们分成两类:一类是周期性中断,用来做固定频率的任务调度;另一类是单次定时,用来做超时检测。
| 定时器特性 | 说明 |
|---|---|
| 定时器数量 | 4组(TIMG0 ~ TIMG3) |
| 计数器位宽 | 64位(可配置为16/32/64位) |
| 时钟源 | APB_CLK(默认80MHz)或XTAL_CLK |
| 工作模式 | 递增/递减/自动重载 |
| 中断能力 | 支持边沿触发和电平触发 |
嗯,这里要注意:ESP32的定时器时钟源默认是APB_CLK,也就是80MHz。如果你改了CPU频率,APB_CLK也会跟着变,定时器的实际精度就会受影响。我在项目里踩过这个坑——调试时好好的,一上量产固件,定时周期全偏了。后来查了半天才发现是CPU频率配置不一样。
4.2 ESP Timer API:新框架,更顺手
ESP-IDF从v4.4开始推荐使用esp_timer这个高层API。它底层其实还是用硬件定时器,但帮你封装好了回调、优先级、任务上下文这些麻烦事。
核心API一览:
esp_timer_create()— 创建定时器句柄esp_timer_start_periodic()— 启动周期性定时esp_timer_start_once()— 启动单次定时esp_timer_stop()— 停止定时器esp_timer_delete()— 删除定时器
为什么推荐用这个?因为它的回调是在高优先级任务里执行的,不会被其他用户任务阻塞。你想想看,如果定时器回调被一个延时函数卡住,那整个时序就乱套了。
// 一个简单的周期性定时器示例
#include "esp_timer.h"
void my_timer_callback(void* arg) {
// 这里放你的定时任务
// 注意:不要做阻塞操作,不要调用 vTaskDelay()
static uint32_t count = 0;
count++;
// 比如翻转一个GPIO
gpio_set_level(LED_GPIO, count % 2);
}
void app_main(void) {
const esp_timer_create_args_t timer_args = {
.callback = &my_timer_callback,
.arg = NULL,
.name = "my_periodic_timer"
};
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, 1000000)); // 1秒 = 1,000,000微秒
}
我的经验:回调函数里千万别做malloc、printf、vTaskDelay这些操作。我曾经在回调里加了个打印调试信息,结果系统直接崩溃——因为printf本身会占用中断上下文,而esp_timer的回调是在任务上下文里跑的,但优先级很高,一旦阻塞就会导致看门狗复位。
4.3 LEDC:ESP32的PWM“专业户”
LEDC(LED Controller)是ESP32专门为PWM输出设计的硬件模块。它支持8个通道,每个通道可以独立配置频率和占空比。说白了,它就是专门干“调光”这活的。
LEDC的核心参数有三个:
- 频率(freq) — PWM波的频率,人眼能感知的闪烁大约是50Hz以下,所以调光一般用1kHz以上
- 分辨率(duty_resolution) — 占空比的精度,比如10位分辨率就是0~1023
- 定时器(timer) — LEDC内部有4个定时器,多个通道可以共用同一个定时器
| 参数 | 推荐值 | 说明 |
|---|---|---|
| PWM频率 | 1kHz ~ 5kHz | 调光用,太高了MOS管开关损耗大 |
| 占空比分辨率 | 10位 ~ 13位 | 位数越高,调光越细腻 |
| 定时器时钟源 | LEDC_USE_APB_CLK | 默认80MHz,够用 |
4.4 呼吸灯实验:从亮到暗,再从暗到亮
呼吸灯的原理很简单:让PWM的占空比按照正弦波或者三角波变化。我一般用三角波,因为计算简单,效果也够平滑。
具体步骤是这样的:
- 配置LEDC定时器和通道
- 设置PWM频率为1kHz,分辨率为10位
- 在主循环里,让占空比从0递增到1023,再从1023递减到0
- 每次变化后加一个短延时,控制呼吸速度
#include "driver/ledc.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.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_FREQ_HZ 1000
#define LEDC_RESOLUTION LEDC_TIMER_10_BIT
void app_main(void) {
// 配置定时器
ledc_timer_config_t timer_conf = {
.speed_mode = LEDC_MODE,
.timer_num = LEDC_TIMER,
.duty_resolution = LEDC_RESOLUTION,
.freq_hz = LEDC_FREQ_HZ,
.clk_cfg = LEDC_USE_APB_CLK
};
ESP_ERROR_CHECK(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
};
ESP_ERROR_CHECK(ledc_channel_config(&channel_conf));
// 呼吸灯主循环
int duty = 0;
int step = 5; // 步进值,控制呼吸速度
while (1) {
// 渐亮
for (duty = 0; duty <= 1023; duty += step) {
ledc_set_duty(LEDC_MODE, LEDC_CHANNEL, duty);
ledc_update_duty(LEDC_MODE, LEDC_CHANNEL);
vTaskDelay(pdMS_TO_TICKS(10));
}
// 渐暗
for (duty = 1023; duty >= 0; duty -= step) {
ledc_set_duty(LEDC_MODE, LEDC_CHANNEL, duty);
ledc_update_duty(LEDC_MODE, LEDC_CHANNEL);
vTaskDelay(pdMS_TO_TICKS(10));
}
}
}
避坑提醒:调用ledc_set_duty()之后,一定要调用ledc_update_duty(),否则占空比不会生效。我刚开始用LEDC时,调了半天灯都不亮,最后发现是忘了调用update函数。这个坑,我估计你也可能会遇到。
4.5 知识体系:定时器与PWM的关系
下面这张图,把这一章的核心逻辑串起来了。你看一遍,应该就能明白ESP32里定时器和PWM是怎么配合工作的。
从这张图可以看得很清楚:时钟源是基础,硬件定时器是中间层,ESP Timer API和LEDC是两种不同的上层应用方式。ESP Timer适合做周期性任务调度,LEDC专门做PWM输出。呼吸灯实验,就是把LEDC的占空比按照一定规律变化而已。
我的建议:如果你只是做简单的LED调光,用LEDC就够了。但如果你需要精确的时间控制,比如做电机控制或者传感器采样,那就用ESP Timer。两者各司其职,别混着用。