4. 定时器与PWM输出:硬件定时器配置、PWM原理与LED调光、呼吸灯效果实现、定时器中断应用
定时器和PWM,这两个东西在嵌入式开发里太常用了。说白了,定时器就是单片机的“心跳”,PWM就是靠这个心跳模拟出来的“假模拟信号”。我刚开始学ESP32的时候,觉得定时器不就是个计数器嘛,有啥好研究的?后来做低功耗项目才发现,定时器用得好不好,直接决定了你的设备能睡多久、响应快不快。
这一章,咱们就把ESP32的硬件定时器、PWM输出、呼吸灯效果,还有定时器中断这些事儿,一次性讲透。
4.1 硬件定时器配置:ESP32的“精准节拍器”
ESP32有4个硬件定时器,每个都是64位的。嗯,64位意味着什么?你想想看,就算你让它以最高频率计数,也得跑几百年才溢出一次。所以基本不用担心溢出问题。
每个定时器都可以独立配置:
- 时钟源:APB时钟(默认80MHz)或REF_TICK(1MHz)
- 分频系数:2到65536之间的整数
- 计数模式:递增或递减
- 自动重载:到达目标值后自动重置
我个人习惯用 timer_config() 结构体来初始化,清晰明了。来看一段实际代码:
#include "driver/timer.h"
#define TIMER_DIVIDER 80 // 80分频,得到1MHz计数频率
#define TIMER_SCALE (TIMER_BASE_CLK / TIMER_DIVIDER) // 1MHz
void timer_init_example() {
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_counter_value(TIMER_GROUP_0, TIMER_0, 0);
timer_set_alarm_value(TIMER_GROUP_0, TIMER_0, 1000000); // 1秒
timer_start(TIMER_GROUP_0, TIMER_0);
}
这里有个坑,我曾经踩过:TIMER_BASE_CLK 默认是APB时钟,但如果你修改了CPU频率,APB时钟也会变。所以分频系数要跟着调整,否则定时就不准了。
4.2 PWM原理:数字信号如何“骗”过眼睛
PWM,脉冲宽度调制。名字听着高大上,原理其实很简单:在一个固定周期里,让高电平的时间占比(占空比)变化,就能控制负载的平均功率。
为什么LED调光不用真正的模拟电压?因为ESP32没有DAC啊!而且就算有,模拟电路效率低、发热大。PWM就不一样了,要么全开、要么全关,MOS管工作在开关状态,效率极高。
ESP32的PWM是通过LEDC(LED Control)外设实现的。名字叫LEDC,但别被它骗了,它不光能控制LED,还能控制电机、舵机、蜂鸣器。LEDC有8个通道,每个通道都可以独立设置频率和占空比。
频率和占空比的关系,我画了张图帮你理解:
你看,周期固定,高电平宽度变了,平均电压就变了。人眼有视觉暂留效应,只要PWM频率高于100Hz左右,LED就不会闪烁,看起来就是稳定的亮度。
4.3 LED调光:从亮到暗的精准控制
用ESP32的LEDC做LED调光,代码其实很简洁。我一般分三步走:
- 配置定时器:设置PWM频率和分辨率
- 绑定通道:把通道和GPIO引脚关联
- 设置占空比:控制亮度
来看一个完整的调光示例:
#include "driver/ledc.h"
#define LED_GPIO GPIO_NUM_2
#define LEDC_TIMER LEDC_TIMER_0
#define LEDC_CHANNEL LEDC_CHANNEL_0
#define LEDC_FREQ 5000 // 5kHz,足够高,不会闪烁
#define LEDC_RESOLUTION LEDC_TIMER_10_BIT // 10位分辨率,0-1023
void ledc_init() {
ledc_timer_config_t timer_conf = {
.speed_mode = LEDC_LOW_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 = LED_GPIO,
.speed_mode = LEDC_LOW_SPEED_MODE,
.channel = LEDC_CHANNEL,
.timer_sel = LEDC_TIMER,
.duty = 0,
.hpoint = 0,
};
ledc_channel_config(&channel_conf);
}
void set_brightness(int percent) {
uint32_t duty = (percent * 1023) / 100;
ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL, duty);
ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL);
}
这里有个细节:ledc_set_duty() 只是把值写到寄存器,必须调用 ledc_update_duty() 才会真正生效。我刚开始用的时候忘了调update,调了半天亮度不变,还以为硬件坏了。
- 频率:LED调光建议1kHz-10kHz。太低会闪烁,太高会增加开关损耗。
- 分辨率:10位(0-1023)够用,12位(0-4095)更细腻。但分辨率越高,能支持的最高频率就越低。
4.4 呼吸灯效果:让LED“活”起来
呼吸灯,说白了就是让LED的亮度按照正弦波或者指数曲线变化。从暗到亮,再从亮到暗,循环往复。
为什么不用线性变化?因为人眼对亮度的感知不是线性的。你想想看,从0%到10%的变化,比从50%到60%的变化明显得多。所以用指数曲线或者查表法,效果更自然。
我常用的方法是用定时器中断,每隔10ms更新一次占空比:
#include "math.h"
#define BREATH_PERIOD 2000 // 一个呼吸周期2秒
#define STEP_MS 10 // 每10ms更新一次
#define STEPS (BREATH_PERIOD / STEP_MS)
int breath_step = 0;
void breath_timer_isr(void *arg) {
breath_step++;
if (breath_step >= STEPS) breath_step = 0;
// 使用正弦波,让变化更平滑
float phase = (float)breath_step / STEPS * 2 * 3.14159;
float brightness = (sinf(phase - 3.14159/2) + 1.0) / 2.0;
uint32_t duty = (uint32_t)(brightness * 1023);
ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL, duty);
ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL);
}
这里用 sinf() 计算,效果确实好。但在低功耗场景下,每次中断都算浮点,功耗有点高。我后来改成了查表法,提前算好256个点的值存到数组里,中断里直接查表,又快又省电。
4.5 定时器中断应用:不只是“定时”那么简单
定时器中断的应用场景太多了。除了呼吸灯,我还用它做过:
- 按键消抖:检测到按键后,启动10ms定时器,到期再读一次引脚状态
- 超时检测:比如等待传感器响应,超过100ms没反应就报错
- 周期性采样:每1ms读一次ADC,做数字滤波
- 看门狗喂狗:在主循环里定期重置看门狗定时器
配置定时器中断的流程,我总结了一个表格:
| 步骤 | 函数/操作 | 说明 |
|---|---|---|
| 1 | timer_init() |
初始化定时器参数 |
| 2 | timer_set_counter_value() |
设置计数器初始值 |
| 3 | timer_set_alarm_value() |
设置报警值(触发中断的计数值) |
| 4 | timer_enable_intr() |
使能定时器中断 |
| 5 | timer_isr_register() |
注册中断服务函数 |
| 6 | timer_start() |
启动定时器 |
中断服务函数里,记得要做两件事:一是清除中断标志位,二是尽量少做事。我曾经在中断里调用了 printf() 打印日志,结果系统直接卡死。中断里只做最核心的事,比如置一个标志位,具体的处理放到主循环里。
delay()、printf()、malloc() 这些阻塞或非重入函数。否则系统会变得不稳定,甚至死机。
最后,我把这一章的核心知识点整理成了一张结构图,方便你回顾: