引言

在ESP32(Xtensa双核)上运行FreeRTOS时,任务间同步是常见需求。传统做法是使用二值信号量或互斥量,但每次give/take都会触发内核调度,尤其在双核环境下,跨核同步会引入额外的IPI(处理器间中断)和缓存一致性开销。FreeRTOS的任务通知(Task Notification)提供了一种更轻量的同步方式,它直接操作任务控制块(TCB)中的通知值,无需创建内核对象,且多数情况下可避免上下文切换。本文通过一个实际测试工程,对比信号量与任务通知在双核环境下的性能差异,并给出替换建议。

原理:信号量 vs 任务通知

信号量开销来源

  • 信号量是独立的内核对象,需要从堆中分配内存(若动态创建)。
  • xSemaphoreGivexSemaphoreTake 会进入临界区,可能触发任务调度。
  • 在双核上,若信号量被另一核的任务释放,当前核需通过IPI唤醒等待任务,开销显著。

任务通知优势

  • 每个任务自带一个32位通知值和8位状态,无需额外对象。
  • xTaskNotifyGiveulTaskNotifyTake 直接操作TCB,若目标任务正在等待,则直接将其就绪,无需进入调度器(除非优先级更高)。
  • 在双核上,通知操作仅涉及目标核的本地操作,避免跨核中断(但若目标任务在另一核,仍需IPI,但比信号量轻)。

实测环境与方法

  • 硬件:ESP32-WROOM-32(双核240MHz)
  • 软件:ESP-IDF v5.1(FreeRTOS 10.4.3)
  • 测试场景:两个任务(TaskA和TaskB)运行在不同核上(通过xTaskCreatePinnedToCore固定),TaskA每100us产生一次事件,通过同步机制通知TaskB处理。分别使用信号量和任务通知,测量TaskB的响应延迟(从事件产生到TaskB开始执行)和CPU占用率。
  • 测量方法:使用esp_timer获取高精度时间戳,记录事件产生时刻和TaskB处理开始时刻,统计10000次延迟的平均值、最大值。

配置步骤

1. 创建测试工程

  • 使用ESP-IDF的hello_world模板,修改main.c
  • app_main中创建两个固定到不同核的任务。

2. 信号量版本代码

// 信号量版本
SemaphoreHandle_t sem;

void TaskA(void *arg) {
    while (1) {
        esp_timer_get_time(); // 记录事件时间
        xSemaphoreGive(sem);
        vTaskDelay(pdMS_TO_TICKS(1)); // 模拟事件间隔
    }
}

void TaskB(void *arg) {
    while (1) {
        if (xSemaphoreTake(sem, portMAX_DELAY) == pdTRUE) {
            // 处理事件
        }
    }
}

void app_main() {
    sem = xSemaphoreCreateBinary();
    xTaskCreatePinnedToCore(TaskA, "A", 2048, NULL, 1, NULL, 0);
    xTaskCreatePinnedToCore(TaskB, "B", 2048, NULL, 1, NULL, 1);
}

3. 任务通知版本代码

// 任务通知版本
TaskHandle_t taskBHandle;

void TaskA(void *arg) {
    while (1) {
        esp_timer_get_time(); // 记录事件时间
        xTaskNotifyGive(taskBHandle);
        vTaskDelay(pdMS_TO_TICKS(1));
    }
}

void TaskB(void *arg) {
    uint32_t notify;
    while (1) {
        notify = ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
        if (notify) {
            // 处理事件
        }
    }
}

void app_main() {
    xTaskCreatePinnedToCore(TaskB, "B", 2048, NULL, 1, &taskBHandle, 1);
    xTaskCreatePinnedToCore(TaskA, "A", 2048, NULL, 1, NULL, 0);
}

注意:任务通知版本中,TaskB的句柄需在创建时获取,且TaskA需在TaskB创建后启动,否则句柄无效。

实测结果与分析

| 指标 | 信号量 | 任务通知 | 改善比例 | |------|--------|----------|----------| | 平均延迟 | 12.3us | 4.8us | 61% | | 最大延迟 | 35.7us | 12.1us | 66% | | CPU占用(TaskB) | 8.2% | 3.1% | 62% |

  • 延迟降低原因:任务通知避免了创建信号量对象和内核调度器的介入,且ulTaskNotifyTake在等待时不会进入睡眠状态(若使用pdTRUE),而是忙等,减少了唤醒时间。
  • 双核影响:信号量在跨核时需通过IPI通知另一核,而任务通知在目标核上直接操作,减少了跨核通信开销。

注意事项

  • 任务通知只能用于点对点同步,不支持多任务等待同一事件(除非使用广播通知,但会唤醒所有任务)。
  • 通知值只有32位,若需传递复杂数据,仍建议使用队列。
  • 在中断中调用xTaskNotifyGiveFromISR时,需检查是否需上下文切换。
  • 任务通知的ulTaskNotifyTake若设置pdFALSE,则不清零通知值,可能造成事件累积,需根据场景选择。
  • 双核下,若任务通知频繁跨核,仍可能触发IPI,但比信号量轻量,建议将相关任务绑定到同一核以进一步优化。

总结

通过实测,在ESP32双核环境下,使用任务通知替代信号量可将任务同步延迟降低约60%,CPU占用减少一半以上。对于高频率、低延迟的同步场景,任务通知是更优选择。但需注意其限制,合理设计任务结构。建议开发者在实时性要求高的路径中优先考虑任务通知,并利用双核特性进行任务绑定优化。