FreeRTOS 里让两个任务通信,大家第一反应是队列或信号量——需要 xQueueCreate/xSemaphoreCreateBinary 新建内核对象,分配 TCB 和缓冲区。而任务通知是内置在每个任务的 TCB 里的,不需要额外创建任何东西。
这带来两个好处:快和省内存。在 STM32F4 上的实测数据:
1 2
| 二值信号量 Give → Take:~140 个 CPU 周期 任务通知 Notify → Take: ~45 个 CPU 周期(快了 3 倍)
|
内存方面——每省一个信号量,省约 80 字节 RAM。10 个信号量替代成 10 个任务通知,省 800 字节。
任务通知的机制。
每个任务的 TCB 里有两个字段:一个是 ulNotifiedValue(32 位通知值),一个是状态标志(pending 状态)。你不需要初始化任何东西——创建任务时就带着。
最基本的用法:二进制通知,替代二值信号量。
1 2 3 4 5 6
| xTaskNotifyGive(TaskHandle_t xTaskToNotify); vTaskNotifyGiveFromISR(TaskHandle_t xTaskToNotify, BaseType_t *pxHigherPriorityTaskWoken);
uint32_t ulTaskNotifyTake(BaseType_t xClearCountOnExit, TickType_t xTicksToWait);
|
xClearCountOnExit:pdTRUE 把计数清零再返回(始终返回 0 或 1),pdFALSE 累加计数。大多数场景用 pdTRUE 就够了——“有通知我才干活”。
替代信号量的例子。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| SemaphoreHandle_t g_data_ready = xSemaphoreCreateBinary();
void UART_Rx_IRQHandler(void) { BaseType_t woken = pdFALSE; xSemaphoreGiveFromISR(g_data_ready, &woken); portYIELD_FROM_ISR(woken); }
void vProcessorTask(void *pv) { while (1) { xSemaphoreTake(g_data_ready, portMAX_DELAY); ProcessData(); } }
|
1 2 3 4 5 6 7 8 9 10 11 12 13
| void UART_Rx_IRQHandler(void) { BaseType_t woken = pdFALSE; vTaskNotifyGiveFromISR(g_processor_task_handle, &woken); portYIELD_FROM_ISR(woken); }
void vProcessorTask(void *pv) { while (1) { ulTaskNotifyTake(pdTRUE, portMAX_DELAY); ProcessData(); } }
|
省了一个信号量对象,代码更短,速度更快。
通知不只是二进制。
xTaskNotify 可以用通知值传递数据(最多 32 位),不需要队列拷贝:
1 2 3 4 5 6 7 8 9
| xTaskNotify(TaskHandle_t xTask, uint32_t ulValue, eNotifyAction eAction); xTaskNotifyFromISR(TaskHandle_t xTask, uint32_t ulValue, eNotifyAction eAction, BaseType_t *pxHigherPriorityTaskWoken);
BaseType_t xTaskNotifyWait(uint32_t ulBitsToClearOnEntry, uint32_t ulBitsToClearOnExit, uint32_t *pulNotificationValue, TickType_t xTicksToWait);
|
eAction 有四种:
1 2 3 4 5
| eNoAction eSetBits eIncrement eSetValueWithOverwrite eSetValueWithoutOverwrite
|
接收端 xTaskNotifyWait :
ulBitsToClearOnEntry:进入等待前把通知值的某些位清零
ulBitsToClearOnExit:拿到通知后把通知值的某些位清零
pulNotificationValue:传出参数,拿到的实际通知值
实例:用任务通知替代队列传单个值。
ADC 完成 ISR,直接把 12 位采样值通过通知传给任务,不需要创建队列:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| TaskHandle_t g_adc_task;
void ADC_IRQHandler(void) { BaseType_t woken = pdFALSE; uint32_t raw_value = ADC1->DR; xTaskNotifyFromISR(g_adc_task, raw_value, eSetValueWithOverwrite, &woken); portYIELD_FROM_ISR(woken); }
void vADCTask(void *pv) { uint32_t sample; while (1) { if (xTaskNotifyWait(0, 0xFFFFFFFF, &sample, pdMS_TO_TICKS(100)) == pdTRUE) { float voltage = (float)sample * 3.3f / 4096.0f; } } }
|
没有队列、没有拷贝——12 位值直接通过 TCB 里的 ulNotifiedValue 传递。
实例:用任务通知实现”累积标志”。
一个任务等着多个不同来源的通知,每个来源设一个位:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| #define FLAG_UART_RX (1 << 0) #define FLAG_TIMER (1 << 1) #define FLAG_BUTTON (1 << 2)
void UART_Rx_ISR(void) { BaseType_t woken = pdFALSE; xTaskNotifyFromISR(g_main_task, FLAG_UART_RX, eSetBits, &woken); portYIELD_FROM_ISR(woken); }
void vTimerCallback(TimerHandle_t t) { BaseType_t woken = pdFALSE; xTaskNotifyFromISR(g_main_task, FLAG_TIMER, eSetBits, &woken); portYIELD_FROM_ISR(woken); }
void vMainTask(void *pv) { uint32_t flags; while (1) { if (xTaskNotifyWait(0, 0xFFFFFFFF, &flags, portMAX_DELAY) == pdTRUE) { if (flags & FLAG_UART_RX) HandleUART(); if (flags & FLAG_TIMER) HandleTimer(); if (flags & FLAG_BUTTON) HandleButton(); } } }
|
一个任务能同时等三个中断源,不需要三个信号量。
速度对比实测。
在 STM32F407(168MHz),开启 -O2 优化:
1 2 3 4 5
| 操作 周期数 时间(168MHz) 二值信号量 Give → Take ~140 ~0.83μs 队列 Send → Receive (1B payload) ~300 ~1.79μs 任务通知 Give → Take ~45 ~0.27μs 任务通知(带值) Send → Wait ~52 ~0.31μs
|
任务通知比信号量快 3 倍,比队列快 6 倍。关键原因是通知操作不分配内存、不维护链表、直接读写 TCB 里的一个 32 位字段。
什么时候不能用任务通知。
只有一个消费者 通知的目标是一个具体任务(需要 TaskHandle_t)。如果要广播给多个任务,还是用队列/信号量/事件组。
通知值是覆盖式的 用 eSetValueWithOverwrite 发,第二次通知会覆盖第一次,不管任务读了没。如果需要缓冲多条数据,用队列。
不能发结构体 通知值只有 32 位。大结构体必须走队列或内存池。
不能组合等待多个通知源 一个任务一次只能 xTaskNotifyWait 等一个通知。如果要”等队列 A 或信号量 B 任意一个”,用队列集(Queue Set)。
对比:
| 场景 |
任务通知 |
队列 |
信号量 |
| ISR → 任务(轻量) |
✅ 首选 |
可选 |
可选 |
| 多消费者 |
❌ |
✅ |
❌(计数信号量可以) |
| 需要缓冲数据 |
❌ 只有 32 位 |
✅ |
❌ |
| 速度要求最高 |
✅ |
❌ |
❌ |
| 不创建任何对象 |
✅ 零对象 |
❌ 需创建 |
❌ 需创建 |
项目里实际替代了多少。
一个之前的项目——USART、SPI、按键、定时器、ADC DMA 完成,总共 12 个二值信号量用于 ISR→任务通知。全部换成任务通知后:
|
替代前 |
替代后 |
| 内核对象数 |
12 个信号量 |
0(全用自带的) |
| RAM 占用 |
~960 字节 |
0 |
| Give→Take 延迟 |
~140 周期 |
~45 周期 |
| 代码行数 |
12 个 xSemaphoreCreateBinary |
0 |
另外队列集里有两处”只传一个 int 值”的队列也换成了通知——每个省 16 字节队列缓冲区+RX/TX 节点开销。
但有一个地方没换:UART 接收数据——ISR 逐字节接收,任务要缓冲,必须用队列。通知只有 32 位,装不了不定长的字节流。
总结和选择。
- ISR → 单任务通知:任务通知,首选
- 需要缓冲数据:队列。
- 一个生产者多个消费者:队列(不是任务通知)。
- 保护共享资源(互斥):互斥锁(任务通知不适合)。
- 二进制事件(”有数据了”、”按键按了”):以前用二值信号量,现在用任务通知。
从 FreeRTOS V8.2.0 开始任务通知就是正式特性了。如果你的 MCU 跑的是 FreeRTOS V9 或 V10,直接用。