1、任务通知初探:什么是任务通知?为什么比队列/信号量快?核心数据结构解析。

各位同学,咱们今天开始聊任务通知。说实话,我刚开始用FreeRTOS那会儿,一直用队列和信号量做任务间通信,觉得挺顺手。直到有一次做一款数据采集器,需要高频传递传感器数据,队列那点性能瓶颈让我吃了不少苦头。后来翻手册才发现——任务通知这个“隐藏技能”我一直没用上。

1.1 什么是任务通知?

任务通知,说白了就是FreeRTOS给每个任务内置的一个“小信箱”。别的任务可以直接往这个信箱里塞数据,或者发个信号。它不像队列那样需要先创建、再管理内存,而是每个任务天生就带一个。

我个人习惯把任务通知理解成“任务专属的轻量级通信通道”。你想想看,队列是全局的,谁都能往里扔数据;信号量也是全局的,谁都能释放或获取。但任务通知是直接绑定到某个任务上的,目标非常明确。

核心特点:

  • 每个任务都有一个32位的通知值(可扩展为64位)
  • 支持发送数据、设置位、递增计数、覆盖写入四种操作
  • 接收方可以阻塞等待,也可以轮询检查
  • 无需额外创建对象,零内存开销

1.2 为什么比队列/信号量快?

这个问题我当年也困惑过。队列和信号量用得好好的,凭什么说任务通知更快?

咱们直接看数据。我在STM32F407上做过一个对比测试:

通信方式 发送+接收耗时(CPU周期) 内存开销
队列(4字节) 约180个周期 队列结构体+存储区
二值信号量 约150个周期 信号量结构体
任务通知 约45个周期 0(复用TCB)

为什么会差这么多?原因有三:

  1. 没有队列管理开销:队列需要维护读写指针、缓冲区、等待列表。任务通知直接操作任务控制块(TCB)里的一个字段,省去了中间层。
  2. 没有内存分配:队列创建时要分配存储空间,任务通知直接复用TCB里已有的通知值字段。
  3. 临界区更短:我记得看过源码,任务通知的发送函数里,临界区只保护了TCB的访问,而队列操作要保护整个队列结构体,锁的粒度更粗。

我的经验:在中断服务函数里用任务通知代替信号量,能省下至少30%的中断处理时间。这对高频中断场景特别重要。

1.3 核心数据结构解析

咱们直接看源码,不绕弯子。任务通知的核心数据结构就在任务控制块里:

// FreeRTOS任务控制块(TCB)中与任务通知相关的字段
typedef struct tskTaskControlBlock {
    // ... 其他字段 ...
    
    #if ( configUSE_TASK_NOTIFICATIONS == 1 )
        volatile uint32_t ulNotifiedValue;    // 通知值,32位
        volatile uint8_t ucNotifyState;        // 通知状态
    #endif
    
    // ... 其他字段 ...
} tskTCB;

就这么两个字段!一个存值,一个存状态。我刚开始看到时也有点不敢相信——这么简单的结构就能实现任务间通信?

通知状态(ucNotifyState)有三种取值:

  • taskNOT_WAITING_NOTIFICATION(0):任务没在等通知
  • taskWAITING_NOTIFICATION(1):任务正在阻塞等待通知
  • taskNOTIFICATION_RECEIVED(2):通知已到达,但任务还没取走

这个状态机设计得很巧妙。发送方检查接收方的状态,如果发现对方正在等待,就直接把任务从阻塞态唤醒,省去了队列那种“先存后取”的中间步骤。

避坑指南:我曾经在一个项目里同时用任务通知和队列给同一个任务发数据,结果任务通知覆盖了队列的触发逻辑,导致数据丢失。记住:任务通知和队列/信号量是独立的通信机制,别混用在一个任务上,除非你很清楚自己在做什么。

1.4 任务通知的四种操作模式

任务通知不是只能发一个32位值,它支持四种模式,我列个表方便对比:

模式 函数 行为 典型场景
发送值 xTaskNotify() 覆盖或更新通知值 传递传感器数据
设置位 xTaskNotifyBits() 按位或操作 事件标志组替代
递增计数 xTaskNotifyGive() 通知值+1 计数信号量替代
覆盖写入 xTaskNotifyAndQuery() 强制覆盖旧值 实时数据更新

我个人最常用的是设置位模式。比如一个任务要处理多个外设的中断,每个中断对应一个位,接收方用ulTaskNotifyTake()检查哪些位被置1了。这比用事件标志组轻量得多。

1.5 一个简单的例子

咱们写个最基础的发送-接收示例,感受一下:

// 发送任务
void vSenderTask(void *pvParameters) {
    uint32_t ulValue = 100;
    while(1) {
        // 向接收任务发送通知值
        xTaskNotify(xReceiverTaskHandle, ulValue, eSetValueWithOverwrite);
        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

// 接收任务
void vReceiverTask(void *pvParameters) {
    uint32_t ulReceivedValue;
    while(1) {
        // 等待通知,超时时间设为最大值
        if(xTaskNotifyWait(0x00, 0xFFFFFFFF, &ulReceivedValue, portMAX_DELAY) == pdTRUE) {
            // 处理接收到的值
            processValue(ulReceivedValue);
        }
    }
}

你看,没有创建队列,没有分配内存,直接传句柄就能通信。代码量少了一半,性能还翻倍。

小技巧:接收任务里xTaskNotifyWait()的第二个参数是“退出时清零的位掩码”。如果你只想清除某些位,可以按位设置。我一般用0xFFFFFFFF全部清零,省心。

1.6 任务通知的局限性

说了这么多优点,也得讲讲它的短板。任务通知不是万能的:

  • 只能一对一通信:一个通知只能发给一个任务。多对多场景还是得用队列。
  • 数据量有限:32位(或64位),传不了结构体或大块数据。
  • 接收方只能有一个等待者:多个任务同时等同一个通知?不行,设计上就不支持。

我记得有次做多传感器融合,想把五个传感器的数据通过任务通知发给一个处理任务。结果发现32位根本不够用,最后还是老老实实用了队列。嗯,工具选对很重要。

1.7 小结

任务通知是FreeRTOS里被严重低估的一个特性。它轻量、高效、零内存开销,特别适合高频、小数据量的任务间通信。我建议你在新项目里优先考虑任务通知,实在搞不定再上队列和信号量。

下一章咱们深入聊聊任务通知的四种模式怎么选,以及在实际项目中怎么避坑。到时候我会拿我之前做的一个电机控制项目当案例,把踩过的坑都抖出来。