ESP32双核下用xTaskNotifyFromISR替代队列传递GPIO中断事件的延迟实测
背景与问题
在ESP32上,GPIO中断服务程序(ISR)不能直接做耗时操作,通常把“事件”传给任务处理。常见做法是xQueueSendFromISR(),但队列涉及拷贝、链表操作和临界区,在双核高频率中断下延迟并不理想。FreeRTOS提供的任务通知(Task Notification)是轻量级替代方案:每个任务自带一个32位通知值和状态,ISR中调用xTaskNotifyFromISR()即可唤醒目标任务,无需额外内存分配。
本文目标:用同一GPIO中断源,分别用队列和任务通知传递事件,实测“中断触发→任务开始执行”的延迟,并给出可复现的代码与结论。
原理讲解
队列传递的路径
xQueueSendFromISR()会:
- 检查队列是否有空间;
- 拷贝数据到队列存储区;
- 必要时唤醒等待任务,触发调度;
- 涉及临界区与内存操作,路径较长。
任务通知的路径
xTaskNotifyFromISR()直接写目标任务的通知值并置位通知状态,若任务在等待通知则将其从阻塞列表移到就绪列表。它没有数据拷贝,只有一个32位值,路径更短。
双核关键点
- ESP32的GPIO中断默认可能落在任意核,需用
esp_ipc或中断分配API绑定; - 目标任务用
xTaskCreatePinnedToCore()绑定到指定核; - 若ISR与任务不同核,唤醒涉及跨核调度,延迟会略高,但仍优于队列。
配置步骤
- 创建两个任务:一个用队列接收,一个用通知接收,均绑定到Core 1;
- 配置GPIO为下降沿中断,ISR中分别调用
xQueueSendFromISR()和xTaskNotifyFromISR(); - 用高精度计时:在ISR入口翻转一个GPIO,在任务入口再翻转另一个GPIO,用逻辑分析仪测两沿间隔;
- 以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, ¬ify_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中断事件,可显著降低中断到任务的唤醒延迟,且不占额外内存。若事件语义简单(单事件、计数、位标志),优先选任务通知;若需携带复杂数据或多生产者,再考虑队列。
暂无评论,来抢沙发~