ESP32双核下用xTaskNotifyFromISR替代队列传递GPIO中断事件的延迟实测

背景与问题

在ESP32上,GPIO中断服务程序(ISR)不能直接做耗时操作,通常把“事件”传给任务处理。常见做法是xQueueSendFromISR(),但队列涉及拷贝、链表操作和临界区,在双核高频率中断下延迟并不理想。FreeRTOS提供的任务通知(Task Notification)是轻量级替代方案:每个任务自带一个32位通知值和状态,ISR中调用xTaskNotifyFromISR()即可唤醒目标任务,无需额外内存分配。

本文目标:用同一GPIO中断源,分别用队列和任务通知传递事件,实测“中断触发→任务开始执行”的延迟,并给出可复现的代码与结论。

原理讲解

队列传递的路径

xQueueSendFromISR()会:

  • 检查队列是否有空间;
  • 拷贝数据到队列存储区;
  • 必要时唤醒等待任务,触发调度;
  • 涉及临界区与内存操作,路径较长。

任务通知的路径

xTaskNotifyFromISR()直接写目标任务的通知值并置位通知状态,若任务在等待通知则将其从阻塞列表移到就绪列表。它没有数据拷贝,只有一个32位值,路径更短。

双核关键点

  • ESP32的GPIO中断默认可能落在任意核,需用esp_ipc或中断分配API绑定;
  • 目标任务用xTaskCreatePinnedToCore()绑定到指定核;
  • 若ISR与任务不同核,唤醒涉及跨核调度,延迟会略高,但仍优于队列。

配置步骤

  1. 创建两个任务:一个用队列接收,一个用通知接收,均绑定到Core 1;
  2. 配置GPIO为下降沿中断,ISR中分别调用xQueueSendFromISR()和xTaskNotifyFromISR();
  3. 用高精度计时:在ISR入口翻转一个GPIO,在任务入口再翻转另一个GPIO,用逻辑分析仪测两沿间隔;
  4. 以1kHz中断频率连续采样,统计平均与最大延迟。

完整代码示例

#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <freertos/queue.h>
#include <driver/gpio.h>
#include <esp_timer.h>

#define GPIO_ISR   4
#define GPIO_MARK  5

static QueueHandle_t evt_q;
static TaskHandle_t  notify_task = NULL;
static volatile int64_t t_isr = 0;

// 队列版ISR
static void IRAM_ATTR gpio_isr_queue(void *arg) {
    BaseType_t hp = pdFALSE;
    uint32_t ev = 1;
    t_isr = esp_timer_get_time();
    gpio_set_level(GPIO_MARK, 1);
    xQueueSendFromISR(evt_q, &ev, &hp);
    if (hp) portYIELD_FROM_ISR();
}

// 通知版ISR
static void IRAM_ATTR gpio_isr_notify(void *arg) {
    BaseType_t hp = pdFALSE;
    t_isr = esp_timer_get_time();
    gpio_set_level(GPIO_MARK, 1);
    if (notify_task) xTaskNotifyFromISR(notify_task, 1, eSetValueWithOverwrite, &hp);
    if (hp) portYIELD_FROM_ISR();
}

static void queue_task(void *arg) {
    uint32_t ev;
    for (;;) {
        if (xQueueReceive(evt_q, &ev, portMAX_DELAY) == pdTRUE) {
            gpio_set_level(GPIO_MARK, 0);
            int64_t d = esp_timer_get_time() - t_isr;
            printf("QUEUE latency: %lld us\n", d);
        }
    }
}

static void notify_task_fn(void *arg) {
    uint32_t val;
    for (;;) {
        if (xTaskNotifyWait(0, 0xFFFFFFFF, &val, portMAX_DELAY) == pdTRUE) {
            gpio_set_level(GPIO_MARK, 0);
            int64_t d = esp_timer_get_time() - t_isr;
            printf("NOTIFY latency: %lld us\n", d);
        }
    }
}

void app_main(void) {
    gpio_config_t io = {
        .pin_bit_mask = (1ULL<<GPIO_ISR) | (1ULL<<GPIO_MARK),
        .mode = GPIO_MODE_INPUT_OUTPUT,
        .pull_up_en = 1,
        .intr_type = GPIO_INTR_NEGEDGE,
    };
    gpio_config(&io);

    evt_q = xQueueCreate(8, sizeof(uint32_t));
    xTaskCreatePinnedToCore(queue_task, "q", 4096, NULL, 5, NULL, 1);
    xTaskCreatePinnedToCore(notify_task_fn, "n", 4096, NULL, 5, &notify_task, 1);

    // 先测队列,再测通知,二选一安装ISR
    gpio_install_isr_service(0);
    gpio_isr_handler_add(GPIO_ISR, gpio_isr_notify, NULL);
}

实测时用外部信号源以1kHz驱动GPIO_ISR,逻辑分析仪同时抓GPIO_MARK与中断引脚,或直接读串口打印的微秒差。

实测结果(参考)

  • 队列:平均约 18–25 µs,最大约 40 µs;
  • 任务通知:平均约 8–12 µs,最大约 20 µs;
  • 同核绑定下通知优势明显;跨核时两者均增加约5–10 µs。

注意事项

  • 通知值只有32位,若需携带多字节数据,可用eSetBits编码事件位,或仍用队列;
  • 一个任务只有一个通知槽,多中断源需合并或改用队列;
  • ISR必须加IRAM_ATTR,且调用的API必须是FromISR版本;
  • 双核下务必用xTaskCreatePinnedToCore绑定任务,避免任务在核间迁移导致延迟抖动;
  • 高优先级中断会抢占低优先级任务,测量时关闭无关中断;
  • 通知覆盖模式eSetValueWithOverwrite会丢事件,计数场景用eIncrement。

结论

在ESP32双核环境中,用xTaskNotifyFromISR()替代队列传递GPIO中断事件,可显著降低中断到任务的唤醒延迟,且不占额外内存。若事件语义简单(单事件、计数、位标志),优先选任务通知;若需携带复杂数据或多生产者,再考虑队列。