3. ESP32 中断编程:从入门到实战

中断,说白了就是让CPU停下手里的事,先去处理一个紧急任务。我刚开始学单片机时,总觉得轮询也能干活,干嘛非要用中断?直到有一次做多传感器采集,轮询导致数据丢包严重……嗯,从那以后我才真正理解了中断的价值。

ESP32的中断系统比传统单片机灵活得多。它有两个核心处理器,每个核都有自己的中断控制器。你想想看,这相当于你有两个秘书,可以同时处理不同的紧急事务。

核心概念:中断是嵌入式系统的"紧急通道"。没有中断,CPU就得不停地"查岗";有了中断,CPU可以安心做正事,等"紧急电话"打进来。

3.1 中断的基本概念

中断的本质是什么?我个人的理解是:事件驱动的优先级抢占。CPU平时跑主循环,当外设或内部事件触发时,硬件自动保存当前现场,跳转到中断服务函数执行,执行完再回来继续干活。

ESP32的中断系统有几个关键参数:

参数 说明 取值范围
中断源 触发中断的外设或事件 GPIO、定时器、UART、I2C等
中断优先级 数字越小优先级越高 0~31(ESP32)
中断类型 电平触发或边沿触发 上升沿、下降沿、高电平、低电平
中断服务函数 处理中断的代码块 ISR(Interrupt Service Routine)

这里有个容易踩的坑:中断服务函数里不能做耗时操作。我曾经见过有人直接在ISR里调用printf,结果系统直接崩溃。为什么?因为printf涉及系统调用和锁,中断上下文里调用这些函数会死锁。

避坑指南:ISR里只做三件事——读寄存器、清标志位、设置事件标志。所有复杂逻辑放到主循环或任务里处理。

3.2 GPIO中断配置

GPIO中断是嵌入式开发中最常用的中断类型。ESP32的每个GPIO都可以配置为中断源,这一点比很多传统MCU都灵活。

配置GPIO中断的步骤,我习惯这样记:

  1. 初始化GPIO:设置引脚模式为输入
  2. 配置中断触发方式:上升沿、下降沿、或双边沿
  3. 注册中断服务函数:告诉CPU中断来了该干什么
  4. 使能中断:打开中断开关

来看一个实际代码示例:

#include "driver/gpio.h"

#define BUTTON_GPIO 0  // 开发板上的BOOT按钮

// 中断服务函数
static void IRAM_ATTR gpio_isr_handler(void* arg) {
    uint32_t gpio_num = (uint32_t) arg;
    // 这里只做最轻量级操作
    // 实际处理放到主循环
}

void app_main(void) {
    // 1. 配置GPIO
    gpio_config_t io_conf = {};
    io_conf.intr_type = GPIO_INTR_NEGEDGE;  // 下降沿触发
    io_conf.mode = GPIO_MODE_INPUT;
    io_conf.pin_bit_mask = (1ULL << BUTTON_GPIO);
    io_conf.pull_up_en = 1;
    gpio_config(&io_conf);

    // 2. 安装GPIO中断服务
    gpio_install_isr_service(0);

    // 3. 注册中断处理函数
    gpio_isr_handler_add(BUTTON_GPIO, gpio_isr_handler, (void*) BUTTON_GPIO);
}

注意看那个 IRAM_ATTR 宏。这个很重要!它告诉编译器把函数放到IRAM(指令RAM)里。为什么?因为中断发生时,Flash可能正在被其他操作占用,如果ISR在Flash里,就会导致取指失败。我调试时遇到过这个问题,折腾了半天才发现是ISR没放到IRAM里。

个人经验:所有中断服务函数都加上IRAM_ATTR,这是ESP32开发的"潜规则"。不加的话,99%的情况下也能跑,但偶尔会出诡异问题,而且极难复现。

3.3 中断服务函数编写规范

写ISR和写普通函数完全不同。我总结了几个铁律:

  • 快进快出:ISR执行时间越短越好,最好在微秒级别
  • 禁止阻塞:不能用delay、vTaskDelay等阻塞函数
  • 禁止调用FreeRTOS API:大部分API不能在ISR里用,除非带FromISR后缀
  • 注意临界区:如果ISR和主循环共享变量,要用volatile和临界区保护

来看一个规范的ISR写法:

// 共享变量,用volatile防止编译器优化
static volatile uint32_t g_button_press_count = 0;

// 事件标志组,用于通知主循环
static EventGroupHandle_t g_event_group;
#define BUTTON_PRESS_BIT (1 << 0)

static void IRAM_ATTR gpio_isr_handler(void* arg) {
    // 1. 清除中断标志(硬件自动完成,但有些外设需要手动清)
    // 2. 更新共享变量
    g_button_press_count++;
    
    // 3. 通知主循环(使用FromISR版本)
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    xEventGroupSetBitsFromISR(g_event_group, BUTTON_PRESS_BIT, 
                              &xHigherPriorityTaskWoken);
    
    // 4. 如果需要上下文切换
    if (xHigherPriorityTaskWoken) {
        portYIELD_FROM_ISR();
    }
}

你可能会问:为什么不用全局变量直接传数据?嗯,这里有个关键点——中断和主循环是异步的。如果不用事件标志或队列,主循环可能错过中断,或者读到不完整的数据。

3.4 中断优先级与嵌套

ESP32的中断优先级系统比传统单片机复杂。它有32个优先级(0~31),数字越小优先级越高。但实际可用的优先级范围取决于你用的FreeRTOS配置。

我画了一张图,帮你理解中断优先级和嵌套的关系:

ESP32中断优先级与嵌套示意图 主循环(优先级最低) 低优先级中断(优先级15) 例如:按键中断、GPIO边沿中断 中优先级中断(优先级8) 例如:定时器中断、UART接收中断 高优先级中断(优先级1) 例如:紧急故障、实时性要求极高的任务 高优先级中断可以打断低优先级中断,形成中断嵌套

中断嵌套是怎么回事?说白了就是:高优先级中断可以打断正在执行的低优先级中断。比如你正在处理按键中断(优先级15),突然来了一个定时器中断(优先级8),CPU会暂停按键中断,先去处理定时器中断,处理完再回来继续处理按键中断。

配置中断优先级的方法:

// 在安装中断服务时设置优先级
gpio_install_isr_service(ESP_INTR_FLAG_LEVEL1);  // 设置优先级为1

// 或者使用更底层的API
// intr_alloc(0, ESP_INTR_FLAG_LEVEL3, &handle);
重要提醒:中断嵌套不是越多越好。嵌套层数太多会导致栈溢出,而且低优先级中断可能被饿死。我建议最多嵌套2~3层,超过这个数就要重新设计架构了。

3.5 实战中的避坑指南

做中断编程这些年,我踩过的坑能写一本书。这里挑几个最常见的:

问题 现象 解决方案
ISR里调用printf 系统死机或重启 用事件标志通知主循环打印
忘记加IRAM_ATTR 偶发性的崩溃 所有ISR都加上IRAM_ATTR
共享变量没加volatile 编译器优化导致变量值不对 所有中断共享变量加volatile
中断触发抖动 一次按键触发多次中断 硬件加RC滤波,软件做去抖
优先级设置不当 实时性要求高的任务被延迟 按实时性需求分配优先级

关于去抖,我多说两句。机械按键按下时,触点会弹跳几十毫秒。如果不做处理,一次按下可能触发几十次中断。我的做法是:硬件去抖 + 软件去抖。硬件加一个RC低通滤波,软件在ISR里启动一个50ms的定时器,定时器到期后才确认按键有效。

个人习惯:我一般把中断相关的代码单独放在一个文件里,比如interrupts.c。这样方便管理,也方便以后移植。每个中断服务函数前面都写清楚:触发条件、优先级、注意事项。这是血的教训换来的习惯。

最后说一个很多人忽略的问题:中断的延迟。从硬件触发中断到ISR开始执行,中间有几十个时钟周期的延迟。这包括:CPU完成当前指令、保存上下文、跳转到中断向量表、查找ISR地址等。如果你的应用对实时性要求极高(比如微秒级),就要考虑这个延迟。

ESP32的中断延迟一般在50~100个时钟周期左右。对于240MHz的主频来说,大约是0.2~0.4微秒。大多数应用场景下完全够用。但如果你的系统要求纳秒级的响应,那就要考虑用硬件外设或者专用芯片了。