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时钟也会变。所以分频系数要跟着调整,否则定时就不准了。

小技巧: 如果你需要毫秒级的定时,建议用80分频得到1MHz,这样alarm值直接写毫秒数×1000就行,省得换算。

4.2 PWM原理:数字信号如何“骗”过眼睛

PWM,脉冲宽度调制。名字听着高大上,原理其实很简单:在一个固定周期里,让高电平的时间占比(占空比)变化,就能控制负载的平均功率。

为什么LED调光不用真正的模拟电压?因为ESP32没有DAC啊!而且就算有,模拟电路效率低、发热大。PWM就不一样了,要么全开、要么全关,MOS管工作在开关状态,效率极高。

ESP32的PWM是通过LEDC(LED Control)外设实现的。名字叫LEDC,但别被它骗了,它不光能控制LED,还能控制电机、舵机、蜂鸣器。LEDC有8个通道,每个通道都可以独立设置频率和占空比。

频率和占空比的关系,我画了张图帮你理解:

PWM波形原理图 一个周期 T 占空比 25% 占空比 50% 占空比 75% 时间 →

你看,周期固定,高电平宽度变了,平均电压就变了。人眼有视觉暂留效应,只要PWM频率高于100Hz左右,LED就不会闪烁,看起来就是稳定的亮度。

4.3 LED调光:从亮到暗的精准控制

用ESP32的LEDC做LED调光,代码其实很简洁。我一般分三步走:

  1. 配置定时器:设置PWM频率和分辨率
  2. 绑定通道:把通道和GPIO引脚关联
  3. 设置占空比:控制亮度

来看一个完整的调光示例:

#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个点的值存到数组里,中断里直接查表,又快又省电。

优化建议: 如果对功耗敏感,可以把正弦表放在RTC内存里,这样深度睡眠唤醒后不用重新计算。

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() 这些阻塞或非重入函数。否则系统会变得不稳定,甚至死机。

最后,我把这一章的核心知识点整理成了一张结构图,方便你回顾:

第4章 知识结构:定时器与PWM ESP32 定时器与PWM 硬件定时器配置 64位计数器 分频、自动重载 PWM输出 LEDC外设 频率、占空比、分辨率 定时器中断 ISR注册 按键消抖、超时检测 呼吸灯效果实现