Skip to content

第三章:中断与定时器

3.1 中断基础

什么是中断?

没有中断(轮询):
CPU ──→ 检查按键 → 没按 → 检查按键 → 没按 → ... → 按了!→ 处理
        浪费 CPU 时间,响应有延迟

有中断:
CPU ──→ 做其他事情 ──────────────────────────────→ 继续
                    ↑ 按键按下,硬件通知 CPU
                    └→ 暂停当前任务 → 执行 ISR → 返回
        CPU 利用率高,响应及时

ISR(中断服务函数)的限制

c
// ❌ ISR 中禁止的操作:
ESP_LOGI(...)           // 不能用,会阻塞
vTaskDelay(...)         // 不能用,会阻塞
malloc() / free()       // 不安全
任何可能阻塞的函数

// ✅ ISR 中允许的操作:
gpio_set_level(...)     // 快速 GPIO 操作
xQueueSendFromISR(...)  // 向队列发送(带 FromISR 后缀)
xSemaphoreGiveFromISR(...)  // 释放信号量
portYIELD_FROM_ISR(...)     // 请求任务切换

3.2 GPIO 中断

完整示例:按键中断 + 队列通知

c
#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
#include "driver/gpio.h"
#include "esp_log.h"

#define BTN_PIN     GPIO_NUM_0
#define LED_PIN     GPIO_NUM_2

static const char *TAG = "GPIO_INT";
static QueueHandle_t gpio_evt_queue = NULL;  // 事件队列

// ISR:中断服务函数(必须在 IRAM 中执行)
static void IRAM_ATTR gpio_isr_handler(void *arg)
{
    uint32_t gpio_num = (uint32_t)arg;
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;

    // 把 GPIO 编号发送到队列(不阻塞)
    xQueueSendFromISR(gpio_evt_queue, &gpio_num, &xHigherPriorityTaskWoken);

    // 如果唤醒了更高优先级的任务,请求立即切换
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

// 处理任务:在普通任务中处理中断事件
static void gpio_task(void *arg)
{
    uint32_t gpio_num;
    int led_state = 0;
    uint32_t press_count = 0;

    while (1) {
        // 阻塞等待队列消息(portMAX_DELAY = 永久等待)
        if (xQueueReceive(gpio_evt_queue, &gpio_num, portMAX_DELAY)) {
            press_count++;
            led_state = !led_state;
            gpio_set_level(LED_PIN, led_state);

            ESP_LOGI(TAG, "GPIO%"PRIu32" 中断触发,第 %"PRIu32" 次,LED=%s",
                     gpio_num, press_count, led_state ? "ON" : "OFF");
        }
    }
}

void app_main(void)
{
    // 1. 配置 LED
    gpio_reset_pin(LED_PIN);
    gpio_set_direction(LED_PIN, GPIO_MODE_OUTPUT);
    gpio_set_level(LED_PIN, 0);

    // 2. 配置按键(下降沿中断)
    gpio_config_t btn_cfg = {
        .pin_bit_mask = (1ULL << BTN_PIN),
        .mode         = GPIO_MODE_INPUT,
        .pull_up_en   = GPIO_PULLUP_ENABLE,
        .pull_down_en = GPIO_PULLDOWN_DISABLE,
        .intr_type    = GPIO_INTR_NEGEDGE,  // 下降沿(按下)触发
    };
    gpio_config(&btn_cfg);

    // 3. 创建事件队列(深度 10,每个元素 4 字节)
    gpio_evt_queue = xQueueCreate(10, sizeof(uint32_t));

    // 4. 安装 GPIO 中断服务
    gpio_install_isr_service(0);  // 0 = 默认标志
    gpio_isr_handler_add(BTN_PIN, gpio_isr_handler, (void*)BTN_PIN);

    // 5. 创建处理任务
    xTaskCreate(gpio_task, "gpio_task", 2048, NULL, 10, NULL);

    ESP_LOGI(TAG, "GPIO 中断初始化完成,按下 BOOT 键测试");
}

中断触发类型

c
GPIO_INTR_DISABLE       // 禁用中断
GPIO_INTR_POSEDGE       // 上升沿(低→高)
GPIO_INTR_NEGEDGE       // 下降沿(高→低)
GPIO_INTR_ANYEDGE       // 任意边沿
GPIO_INTR_LOW_LEVEL     // 低电平触发(持续触发,慎用)
GPIO_INTR_HIGH_LEVEL    // 高电平触发(持续触发,慎用)

3.3 硬件定时器(ESP Timer)

ESP Timer:高精度软件定时器

c
#include "esp_timer.h"

static const char *TAG = "TIMER";

// 定时器回调函数
static void periodic_timer_cb(void *arg)
{
    static uint32_t count = 0;
    count++;
    // 注意:回调运行在定时器任务中,可以用 ESP_LOGI
    ESP_LOGI(TAG, "定时器触发 #%"PRIu32",时间: %lld us",
             count, esp_timer_get_time());
}

void app_main(void)
{
    // 创建周期定时器
    esp_timer_handle_t periodic_timer;
    esp_timer_create_args_t timer_args = {
        .callback = periodic_timer_cb,
        .arg      = NULL,
        .name     = "periodic"
    };
    esp_timer_create(&timer_args, &periodic_timer);

    // 启动:每 1 秒触发一次(单位:微秒)
    esp_timer_start_periodic(periodic_timer, 1000000);

    ESP_LOGI(TAG, "定时器已启动");

    // 10 秒后停止
    vTaskDelay(pdMS_TO_TICKS(10000));
    esp_timer_stop(periodic_timer);
    esp_timer_delete(periodic_timer);
    ESP_LOGI(TAG, "定时器已停止");
}

单次定时器

c
// 单次触发(触发后自动停止)
esp_timer_start_once(timer_handle, 5000000);  // 5 秒后触发一次

获取系统运行时间

c
int64_t time_us = esp_timer_get_time();   // 微秒,从启动开始
uint32_t time_ms = xTaskGetTickCount() * portTICK_PERIOD_MS;  // 毫秒

3.4 LEDC(PWM 输出)

PWM 基础概念

PWM(脉冲宽度调制):
  频率:每秒多少个周期(Hz)
  占空比:高电平时间 / 总周期时间(0%~100%)

  占空比 0%:  ___________  → LED 全灭
  占空比 25%: ─┐___┌─┐___  → LED 较暗
  占空比 50%: ─┐_┌─┐_┌─┐  → LED 中等
  占空比 75%: ─┐┌─┐┌─┐┌─  → LED 较亮
  占空比 100%:───────────  → LED 全亮

LEDC 配置与使用

c
#include "driver/ledc.h"

#define LED_PIN         GPIO_NUM_2
#define LEDC_TIMER      LEDC_TIMER_0
#define LEDC_MODE       LEDC_LOW_SPEED_MODE
#define LEDC_CHANNEL    LEDC_CHANNEL_0
#define LEDC_DUTY_RES   LEDC_TIMER_13_BIT  // 13位分辨率:0~8191
#define LEDC_FREQUENCY  5000               // 5kHz

void ledc_init(void)
{
    // 1. 配置定时器
    ledc_timer_config_t timer_cfg = {
        .speed_mode      = LEDC_MODE,
        .timer_num       = LEDC_TIMER,
        .duty_resolution = LEDC_DUTY_RES,
        .freq_hz         = LEDC_FREQUENCY,
        .clk_cfg         = LEDC_AUTO_CLK,
    };
    ESP_ERROR_CHECK(ledc_timer_config(&timer_cfg));

    // 2. 配置通道
    ledc_channel_config_t channel_cfg = {
        .speed_mode = LEDC_MODE,
        .channel    = LEDC_CHANNEL,
        .timer_sel  = LEDC_TIMER,
        .intr_type  = LEDC_INTR_DISABLE,
        .gpio_num   = LED_PIN,
        .duty       = 0,    // 初始占空比 0
        .hpoint     = 0,
    };
    ESP_ERROR_CHECK(ledc_channel_config(&channel_cfg));
}

// 设置亮度(0~100 百分比)
void led_set_brightness(uint8_t percent)
{
    if (percent > 100) percent = 100;
    // 13位分辨率最大值 8191,计算对应 duty
    uint32_t duty = (8191 * percent) / 100;
    ledc_set_duty(LEDC_MODE, LEDC_CHANNEL, duty);
    ledc_update_duty(LEDC_MODE, LEDC_CHANNEL);
}

void app_main(void)
{
    ledc_init();

    // 呼吸灯效果
    while (1) {
        // 渐亮
        for (int i = 0; i <= 100; i++) {
            led_set_brightness(i);
            vTaskDelay(pdMS_TO_TICKS(20));
        }
        // 渐灭
        for (int i = 100; i >= 0; i--) {
            led_set_brightness(i);
            vTaskDelay(pdMS_TO_TICKS(20));
        }
    }
}

LEDC 渐变(硬件渐变,无需 CPU 干预)

c
// 硬件自动渐变到目标占空比
ledc_set_fade_with_time(
    LEDC_MODE,
    LEDC_CHANNEL,
    target_duty,    // 目标占空比
    2000            // 渐变时间 2000ms
);
ledc_fade_start(LEDC_MODE, LEDC_CHANNEL, LEDC_FADE_NO_WAIT);

3.5 综合示例:PWM + 中断

c
// 按键控制 LED 亮度(每次按下增加 20%,超过 100% 归零)

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
#include "driver/gpio.h"
#include "driver/ledc.h"
#include "esp_log.h"

#define BTN_PIN  GPIO_NUM_0
#define LED_PIN  GPIO_NUM_2

static QueueHandle_t btn_queue;
static const char *TAG = "PWM_BTN";

static void IRAM_ATTR btn_isr(void *arg)
{
    uint32_t val = 1;
    BaseType_t woken = pdFALSE;
    xQueueSendFromISR(btn_queue, &val, &woken);
    portYIELD_FROM_ISR(woken);
}

void app_main(void)
{
    // 初始化 LEDC
    ledc_timer_config_t t = {
        .speed_mode = LEDC_LOW_SPEED_MODE,
        .timer_num  = LEDC_TIMER_0,
        .duty_resolution = LEDC_TIMER_13_BIT,
        .freq_hz    = 5000,
        .clk_cfg    = LEDC_AUTO_CLK,
    };
    ledc_timer_config(&t);

    ledc_channel_config_t c = {
        .speed_mode = LEDC_LOW_SPEED_MODE,
        .channel    = LEDC_CHANNEL_0,
        .timer_sel  = LEDC_TIMER_0,
        .gpio_num   = LED_PIN,
        .duty       = 0,
    };
    ledc_channel_config(&c);

    // 初始化按键中断
    gpio_config_t btn_cfg = {
        .pin_bit_mask = (1ULL << BTN_PIN),
        .mode         = GPIO_MODE_INPUT,
        .pull_up_en   = GPIO_PULLUP_ENABLE,
        .intr_type    = GPIO_INTR_NEGEDGE,
    };
    gpio_config(&btn_cfg);

    btn_queue = xQueueCreate(5, sizeof(uint32_t));
    gpio_install_isr_service(0);
    gpio_isr_handler_add(BTN_PIN, btn_isr, NULL);

    int brightness = 0;
    uint32_t dummy;

    while (1) {
        if (xQueueReceive(btn_queue, &dummy, portMAX_DELAY)) {
            vTaskDelay(pdMS_TO_TICKS(20));  // 简单消抖

            brightness = (brightness + 20) % 120;  // 0,20,40,60,80,100,0...
            if (brightness > 100) brightness = 0;

            uint32_t duty = (8191 * brightness) / 100;
            ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, duty);
            ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0);

            ESP_LOGI(TAG, "亮度: %d%%", brightness);
        }
    }
}

📝 第三章练习题

练习 3-1:定时器计时(基础)

目标:掌握 esp_timer 使用

任务

  • 创建一个 100ms 周期定时器
  • 每次触发时计数 +1
  • 每 10 次(1 秒)打印一次 "运行时间: Xs"
  • 60 秒后自动停止并打印 "计时结束"

练习 3-2:按键中断计数(基础)

目标:掌握 GPIO 中断 + 队列模式

任务

  • 用中断方式检测按键
  • 统计按键次数
  • 每次按下打印次数
  • 按下 10 次后打印 "达到 10 次,重置计数"

要求:必须使用 ISR + Queue 模式,不能用轮询


练习 3-3:呼吸灯(进阶)

目标:掌握 LEDC PWM

任务:实现呼吸灯,要求:

  • 亮度变化曲线为非线性(模拟人眼感知,使用 gamma 校正)
  • 呼吸周期可通过宏定义调整(默认 2 秒)
  • 串口打印当前占空比百分比

Gamma 校正公式

c
// 人眼对亮度的感知是非线性的
// linear_val: 0~100 线性值
// 输出: 0~100 gamma 校正后的值
float gamma_correct(float linear_val) {
    return powf(linear_val / 100.0f, 2.2f) * 100.0f;
}

练习 3-4:秒表(进阶)

目标:综合中断 + 定时器

任务:用两个按键实现秒表:

  • 按键A:开始/暂停计时
  • 按键B:复位(归零)
  • 每 100ms 更新一次显示(串口打印)
  • 格式:[运行中] 00:01.234[已暂停] 00:01.234

状态机

STOPPED → 按A → RUNNING → 按A → PAUSED → 按A → RUNNING
任意状态 → 按B → STOPPED(归零)

练习 3-5:PWM 舵机控制(挑战)

目标:PWM 时序控制

背景:标准舵机控制信号:

周期:20ms(50Hz)
脉宽:0.5ms~2.5ms 对应 0°~180°
  0°   → 高电平 0.5ms
  90°  → 高电平 1.5ms
  180° → 高电平 2.5ms

任务

  • 配置 LEDC 输出 50Hz PWM
  • 实现 servo_set_angle(uint8_t angle) 函数(0~180°)
  • 按键A:角度 +10°
  • 按键B:角度 -10°
  • 串口打印当前角度

提示

c
// 50Hz,周期 20ms = 20000us
// 13位分辨率,总计数 8192
// 0.5ms 对应 duty = 8192 * 0.5 / 20 = 205
// 2.5ms 对应 duty = 8192 * 2.5 / 20 = 1024
uint32_t angle_to_duty(uint8_t angle) {
    return 205 + (uint32_t)angle * (1024 - 205) / 180;
}

练习 3-6:思考题

  1. ISR 中为什么不能调用 ESP_LOGI?如果强行调用会发生什么?

  2. portYIELD_FROM_ISR(xHigherPriorityTaskWoken) 的作用是什么?如果不加这行,程序还能工作吗?有什么区别?

  3. ESP Timer 和 FreeRTOS 软件定时器(xTimerCreate)有什么区别?各适合什么场景?

  4. LEDC 13位分辨率意味着什么?为什么不用 8 位(0~255)?分辨率越高越好吗?


本章小结

知识点掌握程度自评
中断概念与 ISR 限制⬜⬜⬜⬜⬜
GPIO 中断配置⬜⬜⬜⬜⬜
ISR + Queue 通信模式⬜⬜⬜⬜⬜
ESP Timer 定时器⬜⬜⬜⬜⬜
LEDC PWM 配置⬜⬜⬜⬜⬜
PWM 占空比计算⬜⬜⬜⬜⬜

上一章← GPIO 与 LED 控制下一章串口通信 UART →

个人知识库