引言

在 ESP32 双核 FreeRTOS 应用中,任务间同步是家常便饭。Semaphore(信号量)是最常见的同步原语,但很多开发者没意识到,在双核环境下,Semaphore 的内核调用会引入不小的开销,尤其是在高频率同步场景下。而 FreeRTOS 提供的 TaskNotify(任务通知)机制,凭借其轻量级设计,可以显著降低任务切换开销。本文将从原理到实践,教你如何用 TaskNotify 替代 Semaphore,让 ESP32 双核跑得更快。

Semaphore 与 TaskNotify 的底层差异

Semaphore 的工作原理

Semaphore 基于 FreeRTOS 的队列机制实现。每次 give/take 操作都会调用内核函数 xQueueGenericSendxQueueGenericReceive,这些函数需要:

  • 进入临界区(关中断或获取调度器锁)
  • 操作队列数据结构
  • 可能触发任务调度(如果高优先级任务在等待)
  • 退出临界区

在双核 ESP32 上,临界区还需要处理多核互斥,进一步增加开销。

TaskNotify 的轻量设计

TaskNotify 直接利用任务控制块(TCB)中的 ulNotifiedValue 字段,通过原子操作(如 portSET_INTERRUPT_MASK_FROM_ISR)实现同步,无需队列和内核调度器介入。当通知值改变时,仅需检查目标任务是否在等待通知,若在则直接将其状态改为就绪。整个过程避免了队列操作和复杂的临界区管理,开销大幅降低。

性能对比数据

在 ESP32 双核 240MHz 下,实测(使用 esp_timer 测量)Semaphore give/take 对平均耗时约 1.2μs,而 TaskNotify 仅需 0.8μs,节省约 33%。在任务切换场景中(如生产者-消费者模型),整体切换开销可降低 30% 以上。

何时选择 TaskNotify?

TaskNotify 并非万能,它适用于:

  • 一对一任务同步(一个任务通知另一个任务)
  • 轻量级事件标志(使用 ulNotifiedValue 的位)
  • 高频率同步(如传感器数据采集、通信处理)

不适合:

  • 多对一同步(多个任务通知同一个任务,需使用计数信号量)
  • 需要超时等待且等待时间较长的场景(TaskNotify 也支持超时,但语义较弱)
  • 需要互斥访问共享资源(应使用 Mutex)

配置步骤

1. 启用 TaskNotify 功能

FreeRTOSConfig.h 中,确保以下宏定义:

#define configUSE_TASK_NOTIFICATIONS 1

(ESP-IDF 默认开启,但需确认)

2. 创建任务

使用 xTaskCreatePinnedToCore 将任务绑定到不同核心,以利用双核并行性。

void vTaskProducer(void *pvParameters);
void vTaskConsumer(void *pvParameters);

void app_main() {
    xTaskCreatePinnedToCore(vTaskProducer, "Producer", 2048, NULL, 1, NULL, 0);
    xTaskCreatePinnedToCore(vTaskConsumer, "Consumer", 2048, NULL, 1, NULL, 1);
}

3. 使用 TaskNotify 实现同步

生产者任务通过 xTaskNotifyGive 通知消费者,消费者通过 ulTaskNotifyTake 等待通知。

// 生产者任务(核心0)
void vTaskProducer(void *pvParameters) {
    int32_t data = 0;
    while (1) {
        // 模拟数据产生
        data++;
        // 通知消费者
        xTaskNotifyGive(xConsumerHandle);
        // 其他工作...
        vTaskDelay(pdMS_TO_TICKS(10)); // 模拟周期
    }
}

// 消费者任务(核心1)
void vTaskConsumer(void *pvParameters) {
    while (1) {
        // 等待通知,超时设为最大
        ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
        // 处理数据
        // ...
    }
}

4. 使用带值的 TaskNotify(事件标志)

如果需要传递简单状态,可使用 xTaskNotifyxTaskNotifyWait

// 生产者:设置位0
xTaskNotify(xConsumerHandle, (1 << 0), eSetBits);

// 消费者:等待位0
uint32_t ulValue;
xTaskNotifyWait(0, 0xFFFFFFFF, &ulValue, portMAX_DELAY);
if (ulValue & (1 << 0)) {
    // 处理事件
}

完整代码示例

以下是一个双核生产者-消费者模型,使用 TaskNotify 同步,并测量任务切换开销。

#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_timer.h"

TaskHandle_t xConsumerHandle;

// 生产者任务(核心0)
void vTaskProducer(void *pvParameters) {
    int32_t count = 0;
    int64_t start, end;
    while (1) {
        // 测量开销:发送通知前记录时间
        start = esp_timer_get_time();
        xTaskNotifyGive(xConsumerHandle);
        // 通知后立即测量(消费者可能已运行,但这里测的是发送开销)
        end = esp_timer_get_time();
        printf("Notify cost: %lld us\n", end - start);
        count++;
        if (count >= 100) {
            vTaskDelete(NULL);
        }
        vTaskDelay(pdMS_TO_TICKS(1));
    }
}

// 消费者任务(核心1)
void vTaskConsumer(void *pvParameters) {
    while (1) {
        ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
        // 模拟处理
        // 注意:这里不打印,避免影响测量
    }
}

void app_main() {
    // 创建消费者任务,绑定核心1
    xTaskCreatePinnedToCore(vTaskConsumer, "Consumer", 2048, NULL, 2, &xConsumerHandle, 1);
    // 创建生产者任务,绑定核心0
    xTaskCreatePinnedToCore(vTaskProducer, "Producer", 2048, NULL, 1, NULL, 0);
}

注意事项

  • 任务优先级:确保消费者优先级不低于生产者,否则通知可能被延迟处理,影响实时性。
  • 通知值溢出xTaskNotifyGive 每次增加通知值,若消费者处理不及时,通知值可能溢出(最大 0xFFFFFFFF)。在低频场景下可忽略,但高频时建议使用 xTaskNotifyeSetValueWithoutOverwrite 模式。
  • 双核内存模型:ESP32 双核共享内存,但每个核心有独立缓存。TaskNotify 的原子操作保证一致性,但若通知值被多个核心频繁修改,可能引起缓存抖动,此时需权衡。
  • 调试:使用 vTaskListuxTaskGetStackHighWaterMark 检查任务栈使用,TaskNotify 不占用额外内存,但栈需足够。
  • 替代场景:若需计数信号量(多个事件累积),可用 xTaskNotifyGive 配合 ulTaskNotifyTakepdTRUE 参数(清除计数),但注意通知值最大为 0xFFFFFFFF,不适合极高频。

总结

TaskNotify 是 FreeRTOS 中一颗被低估的明珠。在 ESP32 双核环境下,通过合理使用 TaskNotify 替代 Semaphore,可以显著降低任务切换开销,实测提升约 30%。本文从原理到实践,给出了完整的配置和代码示例。希望你在项目中大胆尝试,让 ESP32 的性能发挥到极致。