← 返回教程库

中断:让响应又快又不漏事件

最后更新 2026-06-22
L2 · 传感与交互 ⏱ 约 18 分钟 🟢 软件/低风险
你将学到
  • 搞懂中断比轮询好在哪:不漏快速事件、能边干别的边响应
  • 用 gpio_install_isr_service + gpio_isr_handler_add 给按键挂下降沿(NEGEDGE)中断
  • ISR 标 IRAM_ATTR、用 FreeRTOS 队列把事件抛给任务,去抖放到任务里做
  • 记住 ISR 三禁忌:要短、共享变量加 volatile(或用队列)、禁在 ISR 里用 ESP_LOGI/printf/vTaskDelay

上一节你做出了能去抖的按键计数器,跑得挺稳。但只要项目一变复杂,那套写法就开始露怯:你在 while(1) 里一边刷 OLED、一边读传感器、偶尔还 vTaskDelay 个几百毫秒,结果按键明明按了,count 却没动——因为 CPU 那会儿正卡在别的事上,根本没轮到去读引脚。或者你想做个旋钮编码器,转快一点就丢脉冲,数出来的圈数永远偏小。

这两个毛病,根子都在轮询:在 while(1) 里用 gpio_get_level 不停地"问"按键按了没。问得勤就费 CPU、还挤占别的活;问得稀就漏掉两次问之间发生的快速事件。这一节换个彻底不同的思路——中断:不用你反复问,事件一发生,硬件自己来"敲门",立刻打断主程序去处理。

读这篇前,你得先做过读按键与去抖,对 gpio_get_level、上拉、抖动这些有概念。还没把握就回头补一下。


一、轮询 vs 中断:先想清楚区别

打个比方。轮询像你在等快递时,每隔几秒就跑到门口看一眼有没有人——既累,又可能在你转身倒水的那几秒错过敲门。中断像你装了个门铃:平时该干嘛干嘛,快递一到、铃一响,你立刻去开门。

技术上,中断(interrupt)是这么回事:你预先告诉芯片"某根引脚一旦发生某种电平变化,就立刻停下手头的主程序,跳去执行我指定的一小段函数"。这段函数叫 ISR(Interrupt Service Routine,中断服务函数)。它执行完,CPU 再回到主程序刚才被打断的地方接着跑,跟没事人一样。

几个要先认识的词:

  • ISR:中断触发时被自动调用的函数。在 ESP-IDF 里它就是你写的 handler(习惯命名 gpio_isr_handler)。你写好它、用 gpio_isr_handler_add 登记上,剩下的由硬件在背后调度,你不用在主循环里手动调。
  • 触发类型 intr_type:你得在 gpio_config 里指定"什么样的电平变化"才触发。常用三种——
    • GPIO_INTR_NEGEDGE(下降沿):电平从高掉到低的那一刻触发。按键配成输入+上拉时,按下正好是高→低,所以读按键最常用这个。
    • GPIO_INTR_POSEDGE(上升沿):电平从低升到高触发。
    • GPIO_INTR_ANYEDGE(双沿):高变低、低变高都触发。编码器、需要同时知道按下和松开时用。
  • ISR 服务 + 挂载:ESP-IDF 分两步——先 gpio_install_isr_service(0) 装一次全局 ISR 服务(整个程序只装一次),再用 gpio_isr_handler_add 把"哪根引脚配哪个 handler"挂上去。这跟 Arduino 一句 attachInterrupt 包圆儿不同,IDF 把"装服务"和"挂引脚"拆开,是为了让你能给多根脚共用一套服务、按需增删。

中断的两大好处,正好治轮询的两个病:响应快(事件一来立刻处理,不用等主循环转回来),不漏事件(哪怕主程序正卡在 vTaskDelay 里,中断照样能打断它)。


二、用中断做一个不漏数的按键计数器(产品级:ISR + 队列 + 任务)

直接上完整代码,可以整段贴进工程的 main/main.c。按键还是接在 GPIO0 和 GND 之间(和上一节一样的接法),配成输入+上拉,挂一个下降沿中断。

这里用的是产品级套路:ISR 里只把"哪根脚响了"塞进一个 FreeRTOS 队列就走人,真正的去抖、计数、打印全交给一个专门的任务去慢慢做。ISR 飞快、不阻塞,任务那头爱干多重的活都不影响中断。

// 按键中断计数器:ISR 用队列把事件抛给任务,任务里去抖 + 打印
#include "driver/gpio.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
#include "esp_timer.h"
#include "esp_log.h"

static const char *TAG = "isr";

#define BTN GPIO_NUM_0                  // 按键接在 GPIO0 和 GND 之间
#define DEBOUNCE_US 50000               // 50ms 去抖窗口(单位微秒)

static QueueHandle_t gpio_evt_queue = NULL;   // ISR → 任务 的事件队列

// ISR:放进 IRAM,越短越好,只把"哪根脚响了"丢进队列
static void IRAM_ATTR gpio_isr_handler(void *arg)
{
    uint32_t gpio_num = (uint32_t)arg;
    BaseType_t high_task_wakeup = pdFALSE;
    xQueueSendFromISR(gpio_evt_queue, &gpio_num, &high_task_wakeup);
    if (high_task_wakeup) {
        portYIELD_FROM_ISR();           // 若有更高优先级任务被唤醒,立刻切过去
    }
}

// 处理任务:从队列收事件,做去抖,然后计数 + 打印(重活都在这)
static void button_task(void *arg)
{
    uint32_t io_num;
    uint32_t count = 0;
    int64_t last_us = 0;                // 上次有效触发的时刻(微秒)

    while (1) {
        if (xQueueReceive(gpio_evt_queue, &io_num, portMAX_DELAY)) {
            int64_t now = esp_timer_get_time();         // 自启动以来的微秒数
            if (now - last_us > DEBOUNCE_US) {          // 间隔够长才算一次真按
                last_us = now;
                count++;
                ESP_LOGI(TAG, "按下次数:%u", (unsigned)count);
            }
            // 否则:是触点弹跳,忽略掉
        }
    }
}

void app_main(void)
{
    // 1. 把按键脚配成输入 + 上拉 + 下降沿触发
    gpio_config_t io = {
        .pin_bit_mask = (1ULL << BTN),
        .mode         = GPIO_MODE_INPUT,
        .pull_up_en   = GPIO_PULLUP_ENABLE,
        .pull_down_en = GPIO_PULLDOWN_DISABLE,
        .intr_type    = GPIO_INTR_NEGEDGE,   // 下降沿:上拉接法下,按下是高→低
    };
    gpio_config(&io);

    // 2. 建队列、起任务
    gpio_evt_queue = xQueueCreate(10, sizeof(uint32_t));
    xTaskCreate(button_task, "button_task", 2048, NULL, 10, NULL);

    // 3. 装一次全局 ISR 服务,再把这根脚的 handler 挂上去
    gpio_install_isr_service(0);
    gpio_isr_handler_add(BTN, gpio_isr_handler, (void *)BTN);

    ESP_LOGI(TAG, "按键中断已就绪,按一下试试");
    // app_main 到这就返回了——后面全靠中断和 button_task 跑,不用自己写 while 轮询
}

逐段拆开看,这里头每一处都有讲究:

  • gpio_config 里的 .intr_type = GPIO_INTR_NEGEDGE:上拉接法下按下是高→低,所以选下降沿。这一句就是 Arduino 里 attachInterrupt(..., FALLING) 那个 FALLING 在 IDF 里的落点——不是单独一个参数,而是写进引脚配置里。
  • gpio_install_isr_service(0):装全局 ISR 派发服务,整个程序只调一次。参数 0 是中断分配标志(默认即可)。装早了装晚了都行,但只能装一回,重复装会返回错误。
  • gpio_isr_handler_add(BTN, gpio_isr_handler, (void *)BTN):把这根脚和 handler 绑定。第三个参数 (void *)BTN 会原样传进 ISR 的 arg——这样一个 handler 能伺候多根脚,进去 arg 一看就知道是谁响了。
  • IRAM_ATTR:让 gpio_isr_handler 被放进内部 RAM(IRAM),而不是默认的 Flash。中断随时可能在程序读 Flash(比如 SPI flash cache miss)的间隙触发,若 ISR 代码还躺在 Flash 里就可能取不到指令、直接崩。加上 IRAM_ATTR,ISR 常驻内存,又快又稳。ESP32 上写 ISR,养成习惯都加它
  • ISR 里只做一件事xQueueSendFromISRgpio_num 丢进队列,然后看一眼 high_task_wakeup、必要时 portYIELD_FROM_ISR()没有打印、没有延时、没有去抖逻辑。为什么这么克制?往下第三节专门讲。
  • xQueueSendFromISR 而不是 xQueueSend:在 ISR 里调 FreeRTOS 接口,必须用带 FromISR 后缀的版本——普通版可能会阻塞,而 ISR 里绝对不能阻塞high_task_wakeup 是个出参:如果这次入队唤醒了一个比当前被打断任务优先级更高的任务,它会被置真,你就该在 ISR 结尾 portYIELD_FROM_ISR(),让调度器立刻切过去,而不是傻等回到原任务再切——这能把响应延迟压到最低。
  • 去抖搬到 button_task:用 esp_timer_get_time()(自启动以来的微秒数)比较两次事件的时间间隔,太近的当抖动扔掉。放在任务里做比放 ISR 里更稳——ISR 越干净越好(细节看第四节)。
  • button_taskportMAX_DELAY 永久阻塞等队列:没事件时它就睡着、一点 CPU 不占;ISR 一塞事件,它立刻醒来处理。这套"ISR 抛事件、任务收事件"的分工,是 ESP-IDF 里中断的标准范式,比在主循环里轮询标志更省电、更利落。

你应该看到什么

idf.py build flash monitor 烧进去、进串口监视后:

  • 按一下按键,串口打印一行 I (xxxx) isr: 按下次数:1,再按打印 2、3……每按一次稳稳加一。
  • 快速连按几下(比上一节轮询版按得更急),数字一个不落地涨上去——这就是中断"不漏事件"的直观体现。
  • 注意 app_main 末尾直接返回了,程序里根本没有你写的轮询循环。按键能被记到,全靠中断把事件塞进队列、button_task 醒来处理。哪怕你在别处写个 vTaskDelay(pdMS_TO_TICKS(5000)) 睡 5 秒,这 5 秒里按下也照样进队列——主任务在睡觉,按键也不会漏。这是中断最值钱的地方。
💡 提示

不想一上来就用队列?看第五节有个"volatile 标志在 app_main 里轮询读"的简化版,逻辑更直白,适合先建立直觉。但做产品时优先用队列:ISR 更干净,去抖和重活都能安心放到任务里。


三、ISR 的铁律:要短、要快、有三不准

ISR 不是普通函数,它运行在一个特殊时刻——主程序被硬生生掐断、就等它赶紧跑完好放主程序回去。所以它有一套必须守的规矩,破了轻则数据错乱、重则直接死机重启(在 ESP-IDF 上常表现为 Guru Meditation Error 加一串栈回溯)。

铁律一:越短越好。 ISR 执行期间,主程序是停摆的,连别的中断可能都被压着。你在 ISR 里磨蹭,整个系统就跟着卡,重则触发看门狗(task watchdog / interrupt watchdog)复位。所以 ISR 里只干"非立刻做不可"的最小动作——丢个队列、置个标志、存个时间戳,然后立刻退出。所有耗时的活(打印、计算、刷屏、去抖)一律甩给任务。

铁律二:别在 ISR 里用 ESP_LOGI / printf 串口打印内部依赖锁、缓冲和别的中断机制,在 ISR 里调它,轻则不输出、重则把芯片搞挂。想知道中断到底进没进,正确做法是丢进队列或置个标志、让任务去打印(就像上面代码那样)。同理,ISR 里也不要调用 gpio_isr_handler_add、动态分配内存这类可能阻塞或带锁的接口。

铁律三:别在 ISR 里 vTaskDelay 或任何阻塞调用。 ISR 里 vTaskDelay 直接非法——它会尝试让"任务"睡觉,可 ISR 根本不是任务,结果就是崩。需要时间间隔,用 esp_timer_get_time() 读时间戳(去抖马上会用到),绝不延时。要跟 FreeRTOS 打交道,只能用 xQueueSendFromISR / xSemaphoreGiveFromISR 这类带 FromISR 后缀的非阻塞版本。

🚧 避坑

还有一条隐性铁律:ISR 和任务/主循环之间共享的普通变量,全部加 volatile——除非你像上面那样用队列彻底隔开。漏了 volatile,是中断程序里最隐蔽、最折磨人的 bug:代码看着没毛病,中断也确实触发了,可那头就是读不到新值(编译器把它缓存进寄存器了)。查半天查不出来时,先回头看共享变量加没加 volatile用队列传事件能从根上绕过这个坑,这也是本节首推队列方案的原因之一。

把 ISR 想成"火警按钮":它的职责是飞快拉响警报(丢个事件进队列),而不是"亲自去灭火"。灭火(去抖、计数、打印这些处理逻辑)是任务的事。


四、中断也要去抖:用 esp_timer_get_time 过滤抖动

机械按键会抖(上一节讲过),中断对抖动一样敏感——甚至更敏感。轮询版里 vTaskDelay 多少帮你滤掉了点抖动;中断响应太快,反而把每一次触点弹跳都当成一次独立的下降沿,按一下 count 跳好几。

ISR 里不能延时、也不该塞去抖逻辑,那怎么去抖?答案是把去抖放到接收队列的任务里:用 esp_timer_get_time() 记上次有效触发的时刻,两次事件间隔太近的就当抖动忽略掉。上面第二节的 button_task 已经这么做了,单独把那段拎出来看:

int64_t now = esp_timer_get_time();        // 自启动以来的微秒数
if (now - last_us > DEBOUNCE_US) {         // 距上次超过 50ms 才算数
    last_us = now;
    count++;
    ESP_LOGI(TAG, "按下次数:%u", (unsigned)count);
}
// 否则:是抖动,直接忽略

要点:抖动期内引脚高低乱跳、中断被连着触发好几次,事件挤进队列也好几条,但任务一条条取出来时,只有第一条(距上次超过 50ms 那条)会让 count++,紧接着的几条都因为间隔不到 50ms 被 if 挡掉。等过了去抖窗口、你真正再按一次,才又放行。esp_timer_get_time() 不依赖延时、返回 64 位微秒计数、读它不阻塞,正是它能替代延时做去抖的原因。

为什么去抖放任务里、不放 ISR 里?两个理由:一是 ISR 要尽量干净(铁律一);二是放任务里你能用全套工具(int64_t 运算、日志、甚至更复杂的状态机判断长按/双击),而 ISR 里这些都受限。

💡 提示

DEBOUNCE_US 取 50000(50ms)是个稳妥值。要是发现快速连按被误吞,往小调(30000);要是还偶尔跳两下,往大调(80000)。调的时候盯着串口计数器看,准不准它不会骗你。


五、简化版对照:volatile 标志 + app_main 轮询

队列方案最稳,但第一次读可能觉得"绕"。这里给一个不用队列的简化版做对照:ISR 只置一个 volatile 标志,app_main 自己起个循环轮询这个标志。逻辑更直白,适合先建立直觉。

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

static const char *TAG = "isr";
#define BTN GPIO_NUM_0

static volatile bool pressed = false;      // ISR 置位、app_main 读:必须 volatile

static void IRAM_ATTR gpio_isr_handler(void *arg)
{
    pressed = true;                        // ISR 只举个旗,重活留给 app_main
}

void app_main(void)
{
    gpio_config_t io = {
        .pin_bit_mask = (1ULL << BTN),
        .mode         = GPIO_MODE_INPUT,
        .pull_up_en   = GPIO_PULLUP_ENABLE,
        .intr_type    = GPIO_INTR_NEGEDGE,
    };
    gpio_config(&io);
    gpio_install_isr_service(0);
    gpio_isr_handler_add(BTN, gpio_isr_handler, (void *)BTN);

    uint32_t count = 0;
    while (1) {
        if (pressed) {                     // 主循环里检查标志
            pressed = false;               // 先清旗,免得重复处理
            count++;
            ESP_LOGI(TAG, "按下次数:%u", (unsigned)count);
        }
        vTaskDelay(pdMS_TO_TICKS(20));     // 让出 CPU,顺带粗去抖
    }
}

跟队列版比,差别一目了然:

  • pressed 必须加 volatile:它被 ISR 改、被 app_main 读,跨"看不见的代码"共享,不加 volatile 编译器会把它缓存进寄存器,主循环很可能永远读到旧值——表现就是中断明明在跑、循环却像瞎了一样没反应。这正是第三节那条隐性铁律的活例子。
  • 去抖偷懒靠那句 vTaskDelay(20):每轮睡 20ms 顺手滤掉了部分抖动,但不如队列版用时间戳精确。要更准,照样得在 if 里加 esp_timer_get_time() 时间戳判断。
  • app_main 不能返回:因为轮询逻辑就在它的 while(1) 里,返回了就没人读标志了。这跟队列版"app_main 直接返回、全靠任务"正好相反。

简化版够用在玩具和验证场景。但只要事件一多、去抖一复杂,或者你想低功耗(轮询那句 vTaskDelay 再短也是在空转),就回到第二节的队列方案。


六、故障排查:中断不对劲,按这个顺序查

现象 最可能的原因 怎么办
中断像没触发,count 一直是 0 触发类型选错 / 没装 ISR 服务 / 没挂 handler 上拉接法按下是高→低,.intr_typeGPIO_INTR_NEGEDGE;确认 gpio_install_isr_service 调过且只调一次、gpio_isr_handler_add 也调了
任务/主循环读到的值永远不变(简化版) 共享变量漏了 volatile 给 ISR 和外面共用的变量加 volatile,这是头号坑;或干脆改用队列方案彻底绕开
一按就崩、串口刷 Guru Meditation 或不停重启 ISR 里用了 ESP_LOGI/printf/vTaskDelay/阻塞调用,或没加 IRAM_ATTR ISR 里只 xQueueSendFromISR/置标志;打印挪到任务;给 ISR 加 IRAM_ATTR
gpio_install_isr_service 返回错误 重复装服务(整个程序只能装一次) 全程序只调一次;多根脚共用一套服务,各自 gpio_isr_handler_add 即可
按一下 count 跳好几 没做去抖 在任务里用第四节的 esp_timer_get_time() 时间戳去抖,窗口从 50ms 起调
偶发卡死或看门狗复位 ISR 太长、占着 CPU 太久 把 ISR 砍到只剩 xQueueSendFromISR,所有耗时逻辑搬去任务

七、变体:ANYEDGE 双沿,给编码器用

按键用 GPIO_INTR_NEGEDGE 够了,但有的输入需要双沿都抓——典型就是旋转编码器(你转一格,它的两个信号脚会按特定顺序高低翻转,得在每次跳变时都判一下转的方向)。这时把触发类型换成 GPIO_INTR_ANYEDGE

// 编码器的 A 相每次跳变都触发,进 ISR 后读 B 相判方向
gpio_config_t enc = {
    .pin_bit_mask = (1ULL << ENC_A),
    .mode         = GPIO_MODE_INPUT,
    .pull_up_en   = GPIO_PULLUP_ENABLE,
    .intr_type    = GPIO_INTR_ANYEDGE,     // 高→低、低→高都进 ISR
};
gpio_config(&enc);
gpio_install_isr_service(0);
gpio_isr_handler_add(ENC_A, enc_isr_handler, (void *)ENC_A);

GPIO_INTR_ANYEDGE 意味着两个方向的跳变都进 ISR,所以 ISR 里通常要再 gpio_get_level(ENC_B) 读一下另一根脚来判断"这次是往哪转",再把方向丢进队列。编码器的完整接法、判向逻辑和代码,单独讲在旋转编码器那一篇。霍尔测速也是中断的经典用途——磁铁每转过一次,霍尔元件给一个脉冲,用中断数脉冲就能算转速,细节看 A3144 霍尔传感器

📌 说明

ESP32-S3 几乎每个 GPIO 都能挂中断,靠的是 GPIO 矩阵,这点很灵活。但 ISR 总数、中断响应时间仍是稀缺资源,别滥用——能用一个 ANYEDGE 解决的别拆成两个,能交给任务的别塞进 ISR。需要超高频脉冲(编码器转得飞快)时,IDF 还有专门的 PCNT(脉冲计数器)外设硬件计数,比逐个中断更省 CPU,那是后话。


动手挑战

光看不算会,挑一个写出来:

  1. 不漏数 vs 漏数的对照实验:写两个版本的按键计数器——一个用本节的中断版,一个用纯轮询版(while(1)gpio_get_level,并故意加个 vTaskDelay(pdMS_TO_TICKS(500)))。两个都跑,然后用同样的节奏快速连按 10 下,对比两边数出来的数。亲眼看见轮询版漏了几下、中断版一个不少,你就再也忘不掉中断的价值了。
  2. 进阶:把去抖窗口 DEBOUNCE_US 从 10000 一路调到 100000,每个值都快按几下记下 count,找出"既不漏真实按下、又不把抖动算进去"的那个甜点值。
  3. 再进阶:在 button_task 里加状态机,用 esp_timer_get_time() 判断按下时长——短按打印"单击"、按住超过 1 秒打印"长按"。这一步你会真切体会到"重活放任务里"的好处:这些判断要是塞进 ISR,根本写不下去。

卡住了?把代码连同"我按了几下、期望数到几、实际数到几"一起发给 AI,让它帮你定位——描述越具体,它改得越准。具体怎么配合 AI 写 ESP-IDF 固件,看用 AI 写固件

📌 说明

上面这些是参考实现,思路与 ESP-IDF 5.x 的 GPIO/中断 API 对齐,但具体引脚、去抖窗口请在你自己的板子上跑一遍校准。


小结 · 你现在掌握了什么

  • 你想清楚了轮询和中断的区别:轮询费 CPU 还漏快速事件,中断让事件主动"敲门",响应快、不漏拍、主任务睡着也能记到。
  • 你会用 ESP-IDF 的两步法挂中断:gpio_config 里设 .intr_type(下降沿用 GPIO_INTR_NEGEDGE)→ gpio_install_isr_service(0) 装一次服务 → gpio_isr_handler_add 挂 handler。
  • 你掌握了产品级范式:ISR 标 IRAM_ATTR、只用 xQueueSendFromISR 把事件抛给任务(结尾按需 portYIELD_FROM_ISR),去抖和重活全放任务里。
  • 你记住了 ISR 三禁忌:要短、共享变量加 volatile(或用队列彻底隔开)、禁在 ISR 里用 ESP_LOGI/printf/vTaskDelay/任何阻塞调用;并能在任务里用 esp_timer_get_time() 时间戳做软件去抖。
  • 你知道了 GPIO_INTR_NEGEDGE/POSEDGE/ANYEDGE 各用在哪,以及编码器、霍尔测速这些靠中断吃饭的场景。

中断是从"傻轮询"迈向"会调度"的分水岭。把它吃透,后面做编码器、测速、传感器告警、低功耗唤醒,处理事件的思路全是这一套——而"ISR 抛事件、任务收事件"的队列范式,也正是你往 L3 固件工程 进阶时反复要用的并发骨架。

下一步:让硬件输出也"有层次"——学会 PWM 呼吸灯与调光,用代码控制灯的明暗,而不只是开和关。

内容有错、看不懂、或想看下一期?告诉我们 →

本文为公开资料的学习整理,非亲测。涉接线/花钱/合规的步骤请结合实物与官方最新资料验证,风险自负。见免责声明