Chapter 03 · FreeRTOS 实时操作系统

任务通知 Task Notification

FreeRTOS 最轻量的任务间通信机制:无需额外内核对象,比二值信号量快 45%、省 RAM,是"一对一"通信的首选。

课程进度25%

关键概念

任务通知 (Task Notification)
FreeRTOS V8.2 引入的直达任务通信机制。每个任务的 TCB 内自带一个 32 位通知值和一个通知状态,发送方直接操作目标任务的 TCB,无需先创建队列/信号量对象。因为省去了中间对象,它比传统 IPC 更快、更省内存。
通知值 (Notification Value)
每个任务自带的一个 uint32_t 变量。它可以被当作事件位(按位或/清位)、计数值(自增)、或直接被覆盖成任意数据。这一个 32 位数字,就能替代二值信号量、计数信号量、事件组,甚至简单的邮箱。
通知状态 (Notification State)
每个任务的通知有三种状态:未等待通知等待通知(任务阻塞在 xTaskNotifyWait / ulTaskNotifyTake)、待处理通知(已收到但任务还没取走)。发送时若目标正在等待,会立即解除其阻塞。
eNotifyAction 四种操作
xTaskNotify 的动作参数:eNoAction(仅解阻塞,不改值,等价二值信号量)、eSetBits(按位或,等价事件组)、eIncrement(值加一,等价计数信号量)、eSetValueWithOverwrite(无条件覆盖,等价邮箱)、eSetValueWithoutOverwrite(有待处理通知时不覆盖并返回失败)。
ulTaskNotifyTake / vTaskNotifyGive
"轻量信号量"专用快捷 API。vTaskNotifyGive() 让通知值加一(相当于 Give 信号量);ulTaskNotifyTake() 取走通知,第一个参数决定取走后是清零(当二值用)还是减一(当计数用),并可带超时阻塞。这对组合是性能最高的用法。
xTaskNotifyWait
最通用的接收 API。可指定进入前要清除的位、退出时要清除的位、返回的通知值指针、以及阻塞超时。配合 eSetBits 发送,能实现事件组式的多位标志等待。
通知索引 (Notification Index)
FreeRTOS V10.4 起每个任务可有多个通知(数组),由 configTASK_NOTIFICATION_ARRAY_ENTRIES 配置数量。带 Indexed 后缀的 API(如 xTaskNotifyIndexed)指定操作第几个通知,让一个任务能并行接收多路不冲突的通知。
最大的限制:只能一对一
任务通知直达某个具体任务,因此只有一个接收者,且不能被多个任务同时等待、不能像队列那样缓冲多条数据、也不能用于 ISR 到多任务的广播。需要多对多、缓冲、或广播时,仍要用队列或事件组。

为什么任务通知更快

传统信号量/队列需要先在堆里创建一个内核对象,收发时要操作对象内的等待列表、加解锁。任务通知直接改目标 TCB 里那个现成的 32 位变量,省去了对象查找和多余的列表操作:

通信路径对比:队列/信号量 vs 任务通知 ═══════════════════════════════════════════════════════════ 传统信号量:发送方 → [内核信号量对象] → 唤醒等待列表 → 接收方 ↑ 额外 RAM(~80B) ↑ 操作对象锁/列表 任务通知: 发送方 ──直接写──► 接收方 TCB.notifyValue ↑ 零额外对象,就地唤醒 官方基准(Cortex-M): ┌────────────────────────┬──────────┬──────────┐ │ 操作 │ 二值信号量 │ 任务通知 │ ├────────────────────────┼──────────┼──────────┤ │ 解阻塞一个等待任务 │ 基准 100% │ ~55%(快 45%)│ │ 额外 RAM 占用 │ ~80 字节 │ 0(复用 TCB) │ └────────────────────────┴──────────┴──────────┘

用法一:当二值信号量(最常用)

中断里 Give,任务里 Take——这是"中断延迟处理"的经典范式,比 ISR 里直接干活好得多:

#include "FreeRTOS.h"
#include "task.h"

TaskHandle_t g_uartTask = NULL;

/* 任务:等待"数据到达"通知 */
void UartRxTask(void *arg) {
  for (;;) {
    /* 阻塞等通知;参数1=pdTRUE 表示取走后清零(当二值用) */
    uint32_t n = ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
    if (n > 0) {
      process_uart_data();   /* 在任务上下文里从容处理 */
    }
  }
}

/* 中断服务程序:收到一个字节就通知任务 */
void USART2_IRQHandler(void) {
  BaseType_t woken = pdFALSE;
  if (__HAL_UART_GET_FLAG(&huart2, UART_FLAG_RXNE)) {
    buffer_push(huart2.Instance->DR);
    /* ISR 版本:唤醒 g_uartTask */
    vTaskNotifyGiveFromISR(g_uartTask, &woken);
  }
  /* 若唤醒了更高优先级任务,退出中断时立即切换 */
  portYIELD_FROM_ISR(woken);
}
portYIELD_FROM_ISR 别忘了

所有 FromISR 的通知/信号量/队列 API 都带一个 pxHigherPriorityTaskWoken 出参。若它被置为 pdTRUE,说明这次操作唤醒了一个比当前被打断任务更高优先级的任务,必须在中断结束前调用 portYIELD_FROM_ISR(woken) 触发上下文切换,否则要等到下一个 Tick 才切换,白白损失实时性。这是第 9 章中断管理的核心细节。

用法二:当事件组(按位或)

#define EVT_WIFI_UP   (1UL << 0)
#define EVT_MQTT_UP   (1UL << 1)
#define EVT_TIME_SYNC (1UL << 2)

void NetTask(void *arg) {
  uint32_t bits;
  for (;;) {
    /* 进入前不清位;退出时清全部;等到任意位被置 */
    xTaskNotifyWait(0x00, 0xFFFFFFFF, &bits, portMAX_DELAY);
    if (bits & EVT_WIFI_UP)   printf("wifi up\r\n");
    if (bits & EVT_MQTT_UP)   printf("mqtt up\r\n");
    if (bits & EVT_TIME_SYNC) printf("time synced\r\n");
  }
}

/* 其他任务/中断置位通知 */
void on_wifi_connected(void) {
  xTaskNotify(g_netTask, EVT_WIFI_UP, eSetBits);
}

用法三:当邮箱(传一个 32 位数据)

/* 发送方:把最新的 ADC 值直接覆盖进接收任务 */
xTaskNotify(g_ctrlTask, adc_value, eSetValueWithOverwrite);

/* 接收方:取出最新值 */
void CtrlTask(void *arg) {
  uint32_t latest;
  for (;;) {
    if (xTaskNotifyWait(0, 0, &latest,
                        pdMS_TO_TICKS(100)) == pdTRUE) {
      pid_update(latest);
    } else {
      /* 100ms 没新值:可视为超时/维持上次输出 */
    }
  }
}

任务通知能替代什么?

要替代的对象发送侧动作接收侧 API是否推荐
二值信号量vTaskNotifyGive / eNoActionulTaskNotifyTake(pdTRUE, …)强烈推荐(最快)
计数信号量vTaskNotifyGive / eIncrementulTaskNotifyTake(pdFALSE, …)推荐
事件组(单接收者)eSetBitsxTaskNotifyWait(…)推荐
邮箱(1 个 32 位值)eSetValueWithOverwritexTaskNotifyWait(…)推荐
队列(多数据/多接收者)不能替代,用队列
什么时候不能用任务通知

任务通知只能直达一个已知的接收任务。当出现以下需求时必须回退到队列/信号量/事件组:① 多个任务要等待同一事件(广播);② 需要缓冲多条消息(队列的深度);③ 接收者身份不确定(如生产者-消费者池);④ 需要传递大于 4 字节的结构体(应用队列拷贝)。用错会导致通知丢失或只有一个任务被唤醒。

本章小结

任务通知是 FreeRTOS 最轻量的 IPC:每个任务 TCB 内自带一个 32 位通知值,发送方直接操作目标 TCB,无需创建额外内核对象,因此比二值信号量快约 45%、零额外 RAM。核心 API:vTaskNotifyGive/ulTaskNotifyTake 组合最快(当二值/计数信号量用),xTaskNotify+eNotifyAction(eNoAction/eSetBits/eIncrement/eSetValueWithOverwrite)配 xTaskNotifyWait 能模拟事件组和邮箱。ISR 里用 vTaskNotifyGiveFromISR/xTaskNotifyFromISR,并务必 portYIELD_FROM_ISR(woken)。V10.4 起支持带索引的多通道通知。最大限制是只能一对一——需要广播、多缓冲、多接收者或大数据时,必须用队列/信号量/事件组。中断→任务的"延迟处理"是它最经典、最推荐的用法。