夜雨聆风学习资料网

ARTICLE · 1082282

FreeRTOS 任务通知源码剖析:凭什么比二值信号量快 45%?

FreeRTOS 任务通知源码剖析:凭什么比二值信号量快 45%?
平台:STM32F103RET6(Cortex-M3,标准库)内核:FreeRTOS V11.1.0关联阅读:《FreeRTOS 信号量源码剖析:底层居然是条队列?》(本文的两个实验分别是"替代二值信号量/替代计数信号量",被替代的信号量机制在该文中拆过);ISR 侧行为(FromISR 红线、pxHigherPriorityTaskWoken、portYIELD_FROM_ISR)在《中断里写的第一行 FreeRTOS API 就死机:5 个版本,5 个坑,学透中断管理》拆过,本文 4.4 直接沿用

两个任务要同步,你的第一反应是信号量——但 FreeRTOS 说有个东西比二值信号量还快 45%、RAM 每任务固定 5 字节,不需要创建任何对象。它就是任务通知。这篇用两个实验拆它,看完你会发现它的代价藏在"点对点"三个字里。

一、概述

任务通知 = 把事件/数据直接发进目标任务的 TCB,不经过任何中间内核对象(队列、信号量、事件组都不用创建)。

本工程已开启通知功能(FreeRTOS/include/FreeRTOSConfig.h,configUSE_TASK_NOTIFICATIONS 宏 =1),每个任务的 TCB 自带通知状态与通知值(随 TCB 一起分配,不需要额外的堆分配):

/* FreeRTOS/tasks.c(TCB_t 内的 xNOTIFICATION 结构成员) */#if ( configUSE_TASK_NOTIFICATIONS == 1 )    volatile uint32_t ulNotifiedValue[ configTASK_NOTIFICATION_ARRAY_ENTRIES ];  /* 32 位通知值 */    volatile uint8_t ucNotifyState[ configTASK_NOTIFICATION_ARRAY_ENTRIES ];      /* 通知状态 */#endif

通知状态三态(FreeRTOS/tasks.c,TCB_t 内状态常量定义):taskNOT_WAITING_NOTIFICATION(0,初始值) / taskWAITING_NOTIFICATION(1,正在阻塞等通知) / taskNOTIFICATION_RECEIVED(2,有未读通知)。

两套 API:

- 轻量对:xTaskNotifyGive()(通知值 +1,等效 xSemaphoreGive)+ ulTaskNotifyTake()(清零或减 1,等效 xSemaphoreTake)——模拟二值/计数信号量;- 全能对:xTaskNotify(句柄, 值, eAction)(按 eAction 置位/写入/递增通知值)+ xTaskNotifyWait()(等"通知到达"状态,入口/出口按位清零)——可模拟事件组、长度 1 的队列甚至邮箱;但"模拟事件组"只能等"通知到达"这一种状态,不能等特定位组合(见 4.3)。

优势:比二值信号量快约 45%(官方数据);比计数信号量、队列、事件组也更快,官方未给具体数字。RAM 开销怎么算:

- 每个任务固定多占,用不用都占:通知成员由 #if ( configUSE_TASK_NOTIFICATIONS == 1 ) 在编译期编进 TCB_t,xTaskCreate 分配 TCB 时必然带上——哪怕这个任务从头到尾没收到过一条通知。总量 = 任务数 × configTASK_NOTIFICATION_ARRAY_ENTRIES × 5 字节(5 = 4 字节通知值 + 1 字节状态;本工程条目 =1,即每任务 5 字节,100 个任务就是 500 字节);- 通道数会线性放大:configTASK_NOTIFICATION_ARRAY_ENTRIES 决定每个任务带几组通知成员,一组就是一个独立"通道"(索引 0~N-1,配 xTaskNotifyIndexed 等 Indexed API 指定索引使用,让同一任务的多个事件源各占一个通道、互不干扰,见第五节坑清单第 4 条)。加大到 8 通道:100 任务 × 8 × 5 = 4000 字节。

对照队列/信号量/事件组的方式:它们按"创建了多少对象"占 RAM,不创建不占,但每个对象几十字节起步、还要动态分配(一个二值信号量对象约 80 字节:Queue_t 结构 + 存储区)。哪个划算取决于使用密度:

- 用得越密越划算:本实验 2 个任务都用通知,开销 2×5=10 字节,对照 2 个信号量对象的约 160 字节——省 15 倍不止;- 少数任务用通知时,白占可能反而更贵:100 个任务只有 2 个用得上时,98 份白占共约 490 字节,比 2 个信号量对象(约 160 字节)还多。临界点约在"每 16 个任务里有 1 个需要同步"(5 字节 × 16 = 80 字节 ≈ 一个对象)——同步需求比这稀疏,就该考虑用信号量并关掉通知宏(注意 configUSE_TASK_NOTIFICATIONS 是全局开关,只能整个工程关、没法按任务关)。

二、使用场景

场景
推荐用法
说明
ISR/任务 → 单个任务的事件同步(一对一)
xTaskNotifyGive
(ISR 侧改用 vTaskNotifyGiveFromISR)+ ulTaskNotifyTake
首选:零对象创建、最快
计数型事件(中断发生 N 次、处理 N 次)
ulTaskNotifyTake(pdFALSE, ...)
通知值累计不丢失,逐次递减消费
用一个 32 位值传事件标志/小数据
xTaskNotify(h, bits, eSetBits)
 + xTaskNotifyWait
通知值可当"单任务版事件组"
DMA/外设完成中断唤醒等待任务
vTaskNotifyGiveFromISR
官方外设驱动推荐模式(比信号量省一次队列操作;前提:中断优先级数值 ≥ configMAX_SYSCALL_INTERRUPT_PRIORITY(5),本工程串口中断为 2 不能用——见坑清单第 2 条)

局限(何时仍需信号量/事件组/队列):

- 只能点对点:通知存在目标 TCB 里,不能广播(多接收者)、不能"任何任务都能取";广播/多任务同步 → 事件组;- 发送方需要目标句柄:xTaskCreate 时必须保存句柄,对象句柄(如全局信号量)谁都能访问;- 不能发往 ISR:只有"ISR→任务"方向,"任务→ISR"不行;- 一次只缓冲 1 个 32 位值:不能替代多元素队列;- 发送方不能阻塞:目标通知已 pending 时再发也不会等待(eSetValueWithoutOverwrite 直接返回 pdFAIL)。

多通道 Indexed API(本工程条目 =1,备查)

概述说过通道数由 configTASK_NOTIFICATION_ARRAY_ENTRIES 决定(编译期定死,没有运行时查询的 API)。要多于 1 个通道,就把常用宏换成 Indexed 版——只多一个"通道下标"参数:

常用宏(固定通道 0)
Indexed 版(显式指定通道)
xTaskNotifyGive(h)xTaskNotifyGiveIndexed(h, 通道)
xTaskNotify(h, 值, eAction)xTaskNotifyIndexed(h, 通道, 值, eAction)
ulTaskNotifyTake(清零, 超时)ulTaskNotifyTakeIndexed(通道, 清零, 超时)
xTaskNotifyWait(入清位, 出清位, 值, 超时)xTaskNotifyWaitIndexed(通道, 入清位, 出清位, 值, 超时)
xTaskNotifyFromISR(h, 值, eAction, &xWoken)xTaskNotifyIndexedFromISR(h, 通道, 值, eAction, &xWoken)

( FreeRTOS/include/task.h,Indexed 宏把下标传给同一个 xTaskGenericNotify 系列函数)

通道号是收发双方的约定,工程惯例是定义成宏、两边引用同一个(不要在代码里写裸数字):

/* 示例:通道号宏约定 */#define CH_KEY        0    /* 约定:按键事件走通道 0 */#define CH_ADC_DONE   1    /* 约定:ADC 完成走通道 1 *//* 发送方(如 ADC 转换完成中断里) */xTaskNotifyIndexed(xTaskH, CH_ADC_DONE, 0, eNoAction);/* 接收方 */ulTaskNotifyTakeIndexed(CH_ADC_DONE, pdTRUE, portMAX_DELAY);

三条规则:

- 越界即断言:下标 ≥ 通道数会被 configASSERT( uxIndexToWaitOn < configTASK_NOTIFICATION_ARRAY_ENTRIES ) 拦下——开发期就暴露;- 错位不报错:读一个存在但没人写的通道,不报错、只是等到超时——约定错位很难查,所以通道号必须用宏; 一次只能等一个通道:一次调用只指定一个下标,没有"等任意通道来事件"的 API——阻塞在通道 1 时来了通道 0 的通知,任务不醒。要"同时等 N 个事件源、任何一个来了都醒",那是事件组 xEventGroupWaitBits 的能力。多通道解决的是"多个事件源的值互不覆盖",不是"多路同时等"。

三、测试场景用例(先跑起来)

两个实验分别演示通知替代二值信号量(清零模式)和计数信号量(递减模式)。

任务规划(2 个任务、0 个内核对象——注意整个实验没有任何 xQueueCreate/xSemaphoreCreate):

任务
优先级
栈
角色
vNotifySender
tskIDLE_PRIORITY+2
256 字
每秒 Give 一次(实验一)/ 每轮连发 3 次(实验二)
vNotifyReceiver
tskIDLE_PRIORITY+1
256 字
ulTaskNotifyTake 阻塞等待;句柄创建时保存

发送方优先级更高的用意:实验二"连发 3 次"期间接收方不会中途抢占取走——3 次通知先累计进 TCB 的通知值,接收方随后一次看到计数值 3,体现"累计不丢失"。

完整代码

 ① 声明与参数

#include"FreeRTOS.h"#include"task.h"/* xTaskNotifyGive / ulTaskNotifyTake 都在 task.h */#include<stdio.h>/* 实验参数。注意本工程 configTICK_RATE_HZ=100,1 tick=10ms:*//* pdMS_TO_TICKS(<10) 会被取整为 0——所有延时参数都必须 ≥10ms */#define NTf_BINARY_ROUNDS      5       /* 实验一:每秒发 1 次,共 5 次 */#define NTf_BINARY_PERIOD_MS   1000#define NTf_COUNT_ROUNDS       3       /* 实验二:每轮连发 3 次,共 3 轮 */#define NTf_COUNT_PERIOD_MS    2000/* printf 任务栈:标准库 printf 栈开销大,128 字容易触发栈溢出钩子,统一给 256 字 */#define NTf_PRINTF_STACK       ( configMINIMAL_STACK_SIZE * 2 )/* 接收任务句柄:xTaskNotifyGive 是"点对点"API,*/ /* 发送方必须先拿到目标句柄——创建任务时用 xTaskCreate 第 6 个参数保存 */static TaskHandle_t xNotifyReceiverHandle = NULL;

② 发送方任务A:先跑实验一(每秒 1 Give),再跑实验二(连发 3 次),循环往复

staticvoidvNotifySender(void *pvParameters){    (void)pvParameters;    uint32_t i, j;    for (;;)    {        /* ---- 实验一:每秒 xTaskNotifyGive 一次,*/         /*      相当于 xSemaphoreGive(二值信号量),但不需要创建任何内核对象 ---- */        for (i = 1; i <= NTf_BINARY_ROUNDS; i++)        {            vTaskDelay(pdMS_TO_TICKS(NTf_BINARY_PERIOD_MS));            printf("任务A:xTaskNotifyGive 发送通知(第 %u 次)\r\n", (unsigned int)i);            /* 宏展开为 xTaskGenericNotify(句柄, 0, 0, eIncrement, NULL):通知值 +1 */            (void)xTaskNotifyGive(xNotifyReceiverHandle);            /* Give 把 B 搬进就绪链表(唤醒),但 A(+2) 优先级更高继续执行,*/             /* 直到循环回到 vTaskDelay 让出 CPU,B 才上 CPU、Take 才返回消费 */        }        /* ---- 实验二:快速连发 3 次,通知值累计到 3 不丢失,*/         /*      相当于计数信号量的多次 Give ---- */        for (i = 1; i <= NTf_COUNT_ROUNDS; i++)        {            vTaskDelay(pdMS_TO_TICKS(NTf_COUNT_PERIOD_MS));            printf("任务A:快速连发 3 次通知(第 %u 轮)\r\n", (unsigned int)i);            for (j = 0; j < 3; j++)            {                (void)xTaskNotifyGive(xNotifyReceiverHandle);            }            /* 本任务优先级更高,连发期间接收方不会抢占——3 次通知先攒进它的 TCB */        }        /* 一轮共 5s + 6s = 11s,回到实验一重新开始 */    }}

 ③ 接收方任务B:两段 Take 与发送方的两段 Give 逐条对应

staticvoidvNotifyReceiver(void *pvParameters){    (void)pvParameters;    uint32_t i, j, ulValue;    for (;;)    {        /* ---- 实验一:二值语义——xClearCountOnExit=pdTRUE,取走后通知值清零 ---- */        printf("\r\n=== 实验一:任务通知替代二值信号量(ulTaskNotifyTake 清零模式)===\r\n");        for (i = 1; i <= NTf_BINARY_ROUNDS; i++)        {            /* 通知值==0 时阻塞;被唤醒(或等待前通知已到)后返回并清零通知值 */            (void)ulTaskNotifyTake(pdTRUE, portMAX_DELAY);            printf("任务B:收到通知,通知值已清零(二值语义,第 %u 次)\r\n", (unsigned int)i);        }        /* ---- 实验二:计数语义——xClearCountOnExit=pdFALSE,取走后通知值减 1 ---- */        printf("\r\n=== 实验二:任务通知替代计数信号量(ulTaskNotifyTake 递减模式)===\r\n");        for (i = 1; i <= NTf_COUNT_ROUNDS; i++)        {            for (j = 0; j < 3; j++)            {                /* 返回值是"清零/递减之前"的通知值:3 次连发后依次返回 3、2、1 */                ulValue = ulTaskNotifyTake(pdFALSE, portMAX_DELAY);                printf("任务B:取走 1 个通知,取走前计数值=%u,取走后剩余=%u\r\n",                       (unsigned int)ulValue, (unsigned int)(ulValue - 1u));            }        }        /* 回到实验一,循环往复 */    }}

 ④ 入口:创建两个任务,在 main() 里 vTaskStartScheduler() 之前调用

voidtask_notify_demo(void){    /* 先创建接收方并保存句柄(创建发生在调度器启动前,句柄无竞态) */    xTaskCreate(vNotifyReceiver, "NtfRcv", NTf_PRINTF_STACK, NULL,                tskIDLE_PRIORITY + 1, &xNotifyReceiverHandle);    /* 发送方优先级更高(+2):实验二连发 3 次期间接收方不会中途醒来消费 */    xTaskCreate(vNotifySender, "NtfSend", NTf_PRINTF_STACK, NULL,                tskIDLE_PRIORITY + 2, NULL);}

预期输出(一轮约 11 秒:实验一 5 秒 + 实验二 6 秒,然后循环):

=== 实验一:任务通知替代二值信号量(ulTaskNotifyTake 清零模式)===任务A:xTaskNotifyGive 发送通知(第 1 次)任务B:收到通知,通知值已清零(二值语义,第 1 次)任务A:xTaskNotifyGive 发送通知(第 2 次)任务B:收到通知,通知值已清零(二值语义,第 2 次)...(每秒 1 对,共 5 对)=== 实验二:任务通知替代计数信号量(ulTaskNotifyTake 递减模式)===任务A:快速连发 3 次通知(第 1 轮)任务B:取走 1 个通知,取走前计数值=3,取走后剩余=2任务B:取走 1 个通知,取走前计数值=2,取走后剩余=1任务B:取走 1 个通知,取走前计数值=1,取走后剩余=0任务A:快速连发 3 次通知(第 2 轮)...(共 3 轮,随后回到实验一重新开始)

两个实验跑完,几个现象值得带着进源码:为什么"连发 3 次"一次不丢?清零和递减怎么在同一个变量上实现?唤醒为什么不需要等待链表?答案都在下面的源码里——4.6 节会回头逐条对上。

四、源码剖析

以下源码选自本工程 FreeRTOS\tasks.c / task.h(V11.1.0),位置在小节标题注明。V11 的 API 实体是 xTaskGenericNotify 系列(带数组索引参数),常用的 xTaskNotifyGive 等都是映射到索引 0 的宏(tskDEFAULT_INDEX_TO_NOTIFY=0,FreeRTOS/include/task.h,通知默认索引宏):

/* FreeRTOS/include/task.h */#define xTaskNotifyGive( xTaskToNotify ) \    xTaskGenericNotify( ( xTaskToNotify ), ( tskDEFAULT_INDEX_TO_NOTIFY ), ( 0 ), eIncrement, NULL )#define xTaskNotify( xTaskToNotify, ulValue, eAction ) \    xTaskGenericNotify( ( xTaskToNotify ), ( tskDEFAULT_INDEX_TO_NOTIFY ), ( ulValue ), ( eAction ), NULL )#define ulTaskNotifyTake( xClearCountOnExit, xTicksToWait ) \    ulTaskGenericNotifyTake( ( tskDEFAULT_INDEX_TO_NOTIFY ), ( xClearCountOnExit ), ( xTicksToWait ) )

两个发送宏最终调的是同一个函数xTaskGenericNotify,区别只在一个参数:xTaskNotifyGive 把动作固定为 eIncrement("+1"专用的简化写法);xTaskNotify 把动作留给你填。也就是说,4.1 节 switch 的分支不是程序"跑进去"的,是调用者用参数指定的——想进哪个分支,就传对应的枚举:

想进的分支
调用写法
用途
eIncrement
xTaskNotifyGive(h)
(固定)或 xTaskNotify(h, 0, eIncrement)
计数 +1,信号量语义
eSetBits
xTaskNotify(h, BIT0, eSetBits)
按位或,单任务版事件组
eSetValueWithOverwrite
xTaskNotify(h, 值, eSetValueWithOverwrite)
直接覆盖写,只要最新值的邮箱
eSetValueWithoutOverwrite
xTaskNotify(h, 值, eSetValueWithoutOverwrite)
有未读则拒绝,返回 pdFAIL
eNoAction
xTaskNotify(h, 0, eNoAction)
只置"有通知"状态,不碰通知值

ISR 版同理:vTaskNotifyGiveFromISR 固定 eIncrement,xTaskNotifyFromISR(h, 值, 分支, &xWoken) 由你选分支。

4.1 xTaskGenericNotify:改值 + 唤醒(发送核心)(FreeRTOS/tasks.c,xTaskGenericNotify 函数体)

/* FreeRTOS/tasks.c(节选) */taskENTER_CRITICAL();                                       /* 临界区保护整个"改值+唤醒" */{    ucOriginalNotifyState = pxTCB->ucNotifyState[ uxIndexToNotify ];    pxTCB->ucNotifyState[ uxIndexToNotify ] = taskNOTIFICATION_RECEIVED;   /* 先置"有通知" */    switch( eAction )                                        /* 通知值按动作解读 */    {        case eSetBits:      /* 按位或:把 ulValue 中为 1 的位置起,原有位不动。 */                            /* "单任务版事件组"用它:多个事件源各占一位, */                            /* 接收端 xTaskNotifyWait 按位判断、按位清零 */            pxTCB->ulNotifiedValue[ uxIndexToNotify ] |= ulValue;            break;        case eIncrement:    /* 就是 ++,不看 ulValue 传了什么。 */                            /* xTaskNotifyGive 的底层动作:配 ulTaskNotifyTake */                            /* 就是二值/计数信号量语义(攒次数) */            ( pxTCB->ulNotifiedValue[ uxIndexToNotify ] )++;            break;        case eSetValueWithOverwrite:    /* 直接覆盖写:当"长度 1 的邮箱"用。 */                                        /* 风险:上一条没读走就被新值顶掉—— */                                        /* 适合"只要最新值"的场景(如最新采样) */            pxTCB->ulNotifiedValue[ uxIndexToNotify ] = ulValue;            break;        case eSetValueWithoutOverwrite:    /* 也是覆盖写,但先查状态: */                                           /* 没挂未读通知(状态!=RECEIVED—— */                                           /* 含"正在阻塞等待"的 WAITING)才写; */                                           /* 还挂着未读(RECEIVED)就拒绝,返回 pdFAIL。 */                                           /* 相当于"长度 1、满了不能放"的队列—— */                                           /* 要保证每条都被读到就用它 */            if( ucOriginalNotifyState != taskNOTIFICATION_RECEIVED )            {                pxTCB->ulNotifiedValue[ uxIndexToNotify ] = ulValue;            }            else            {                xReturn = pdFAIL;                            /* 已有未读通知,拒绝覆盖 */            }            break;        case eNoAction:      /* 值一个字节都不碰,只置"有通知"状态。 */                             /* 纯事件语义:接收方只关心"来过没有", */                             /* 不关心带没带数据——最省心的信号灯 */            break;        ...    }    /* 若目标正阻塞在通知上:从阻塞链表(延时/挂起链表)摘出、加入就绪链表(唤醒) */    if( ucOriginalNotifyState == taskWAITING_NOTIFICATION )    {        listREMOVE_ITEM( &( pxTCB->xStateListItem ) );        prvAddTaskToReadyList( pxTCB );        /* 被唤醒任务优先级更高则立即让出(任务上下文可直接切换) */        taskYIELD_ANY_CORE_IF_USING_PREEMPTION( pxTCB );    }}taskEXIT_CRITICAL();

对比信号量 xSemaphoreGive:走 queue.c 通用路径——要操作队列存储区、xTasksWaitingToReceive 事件链表、队列锁,路径长得多;这里只动 TCB 的两个成员。

4.2 ulTaskGenericNotifyTake:按计数值阻塞/清零/递减(接收核心,信号量替代)(FreeRTOS/tasks.c,ulTaskGenericNotifyTake 函数体)

/* FreeRTOS/tasks.c(节选) */taskENTER_CRITICAL();{    /* 只在通知值为 0 时才需要阻塞——     * 若之前的通知还没被取走(值非 0),本次 Take 立即成功,通知绝不丢失 */    if( pxCurrentTCB->ulNotifiedValue[ uxIndexToWaitOn ] == 0U )    {        pxCurrentTCB->ucNotifyState[ uxIndexToWaitOn ] = taskWAITING_NOTIFICATION;        if( xTicksToWait > ( TickType_t ) 0 ) { xShouldBlock = pdTRUE; }    }}taskEXIT_CRITICAL();...   /* 阻塞:有限超时挂延时链表,portMAX_DELAY 挂挂起链表(见下方节选说明) */taskENTER_CRITICAL();{    ulReturn = pxCurrentTCB->ulNotifiedValue[ uxIndexToWaitOn ];   /* 返回"清零/递减之前"的值 */    if( ulReturn != 0U )    {        if( xClearCountOnExit != pdFALSE )        {            pxCurrentTCB->ulNotifiedValue[ uxIndexToWaitOn ] = 0U;            /* 二值:清零 */        }        else        {            pxCurrentTCB->ulNotifiedValue[ uxIndexToWaitOn ] = ulReturn - 1U; /* 计数:减 1 */        }    }    pxCurrentTCB->ucNotifyState[ uxIndexToWaitOn ] = taskNOT_WAITING_NOTIFICATION;}taskEXIT_CRITICAL();

xClearCountOnExit=pdTRUE → 二值信号量语义(一次取走全部);pdFALSE → 计数信号量语义(每次取走 1,剩余累计)。"清零和递减在同一个变量上实现"的答案就在这一个参数的分支上——发送方对"二值还是计数"一无所知,实验对照见 4.6 第 2 条。

节选说明:两段临界区之间省略的是阻塞的包装——源码先 vTaskSuspendAll(),再把自己挂入阻塞链表(有限超时挂延时链表;本实验传 portMAX_DELAY,INCLUDE_vTaskSuspend=1 时挂进的是挂起链表——挂起链表没有超时属性,tick 不碰它,只能被通知唤醒),醒来后 xTaskResumeAll() 恢复调度、必要时补一次切换。全程没有事件链表——等待通知的任务只占状态项一个"座位",这是与队列/信号量等待的又一个差别。

4.3 xTaskGenericNotifyWait:按"状态"等待(更全能的接收方)(FreeRTOS/tasks.c,xTaskGenericNotifyWait 函数体)

xTaskNotifyWait 的完整函数:

/* FreeRTOS/include/task.h(参数省略索引版) */BaseType_t xTaskNotifyWait( uint32_t ulBitsToClearOnEntry,                            uint32_t ulBitsToClearOnExit,                            uint32_t *pulNotificationValue,                            TickType_t xTicksToWait );

与 4.2 结构相同,但判断条件是 ucNotifyState != taskNOTIFICATION_RECEIVED——只要状态是"已收到"就立即返回。进入阻塞前先把通知值 &= ~ulBitsToClearOnEntry(清掉不关心的位),返回值经 pulNotificationValue 带出,收到通知返回前再 &= ~ulBitsToClearOnExit(消费掉已处理的位)。

注意它没有"等特定位"的参数——这是与事件组的关键差别:xEventGroupWaitBits 能指定"等到 BIT0|BIT1 才醒",xTaskNotifyWait 只能等"通知到达"这一种状态,位到没到要你取回返回值后自己逐位判断。所以"单任务版事件组"省的是事件组的 RAM 和路径,判断逻辑还得自己写。

4.4 xTaskGenericNotifyFromISR:ISR 版的关键差异(FreeRTOS/tasks.c,xTaskGenericNotifyFromISR 函数体)

/* FreeRTOS/tasks.c(节选) */portASSERT_IF_INTERRUPT_PRIORITY_INVALID();                  /* 中断优先级合法性断言 */uxSavedInterruptStatus = ( UBaseType_t ) taskENTER_CRITICAL_FROM_ISR();  /* ISR 专用临界区 */{    ...                                                      /* 改值逻辑与 4.1 完全一致 */    if( ucOriginalNotifyState == taskWAITING_NOTIFICATION )    {        if( uxSchedulerSuspended == ( UBaseType_t ) 0U )        {            listREMOVE_ITEM( &( pxTCB->xStateListItem ) );            prvAddTaskToReadyList( pxTCB );        }        else        {            /* 调度器挂起期间:先挂到 xPendingReadyList,恢复后再入就绪 */            listINSERT_END( &( xPendingReadyList ), &( pxTCB->xEventListItem ) );        }        if( pxTCB->uxPriority > pxCurrentTCB->uxPriority )   /* 单核:唤醒了更高优先级任务 */        {            if( pxHigherPriorityTaskWoken != NULL )            {                *pxHigherPriorityTaskWoken = pdTRUE;         /* 只做标记,不切换! */            }            xYieldPendings[ 0 ] = pdTRUE;        }    }}taskEXIT_CRITICAL_FROM_ISR( uxSavedInterruptStatus );

谁挂起了调度器? 不是中断——uxSchedulerSuspended 只会被任务侧的 vTaskSuspendAll() 置位。代码里的 else 分支应对的是:任务挂起了调度器、还没恢复的窗口期,中断来了并唤醒了任务。挂起的语义是冻结就绪/延时链表的改动(挂起期间任务可以放心操作这些链表),此时中断里直接把任务插进就绪链表会踩坏任务侧正在进行的操作——所以先寄存到 xPendingReadyList,等 xTaskResumeAll() 恢复调度器时统一搬进就绪链表。

ISR 版绝不直接切换上下文:中断可能层层嵌套,在嵌套中途换栈会把调用链切断,所以 FromISR 版只把 *pxHigherPriorityTaskWoken 标成 pdTRUE,外加置 xYieldPendings 兜底。

那portYIELD_FROM_ISR(xHigherPriorityTaskWoken)顶什么用? 它并不"直接切到刚唤醒的那个任务",在 Cortex-M 上它只做一件事:x 为真时把 PendSV 异常挂起(往 ICSR 寄存器写 PENDSVSET 位)。接下来的执行链是:

ISR 退出 → 硬件发现 PendSV 挂着 → PendSV 优先级最低,等所有嵌套中断都退出后才执行 → PendSV 里调 vTaskSwitchContext,扫就绪链表数组,挑最高优先级的任务上 CPU

关键要读懂上面代码里那个 if( pxTCB->uxPriority > pxCurrentTCB->uxPriority )——严格大于。这个标记的语义是一句话:"这次重挑会不会换人?"(pxCurrentTCB 就是被打断的任务)。于是分两种情形:

唤醒的任务比被打断的优先级高:标记置 pdTRUE,xYieldPendings 同步置位兜底。写了 portYIELD_FROM_ISR(pdTRUE):中断退出后几微秒内 PendSV 完成切换,高优先级任务从阻塞点直接返回。不写:ISR 退出后 CPU 直接回到被打断的低优先级任务继续执行,刚唤醒的高优先级任务在就绪链表里等——最长到下次 tick(本工程 10ms),tick 处理看到 xYieldPendings 才补切。坑清单第 2 条说的"实时性受损",受损的正是且只是这一种情形;- 相等或更低:标记保持 pdFALSE,portYIELD_FROM_ISR(pdFALSE) 内部不做任何事——写与不写零差别。相等不切是刻意的:同优先级谁跑都公平,立刻切换只是白花一次压栈/出栈(本工程 configUSE_TIME_SLICING=0 关闭时间片,被唤醒的同优先级任务要等当前任务阻塞/让出才上);唤醒的优先级更低则本来就该排队,调度器重挑还是挑当前最高。

所以惯例写法可以无脑三行、不用自己判断——三种情形内核各给各的答案:

BaseType_t xWoken = pdFALSE;xTaskNotifyFromISR(h, 0, eIncrement, &xWoken);portYIELD_FROM_ISR(xWoken);   /* 更高:立刻切;相等/更低:空转零代价 */

这也是标记名叫 HigherPriorityTaskWoken(更高优先级任务被唤醒)而不是"有任务被唤醒"的原因——它只对"更高优先级"负责(对应第五节坑清单第 2 条)。

vTaskGenericNotifyGiveFromISR(FreeRTOS/tasks.c,Give FromISR 封装)是它"eIncrement + 无返回值"的封装。

4.5 收发全流程时序:对照信号量路径

把实验一里"任务B 睡、任务A 发"的完整时序画出来,和信号量路径并排对照——为什么比二值信号量快约 45%,一眼就能看出来:

4.6 实验现象总结:用上面的源码逐条对上

回到第三节的两个实验,四个现象现在都能指着源码解释了:

  1. "连发 3 次"一次不丢
    :4.2 节接收方只在"通知值 == 0"时才阻塞;3 次 Give 全部发生在 B 阻塞期间,每次都执行 4.1 的 eIncrement 分支把通知值 +1(0→1→2→3),B 第一次 Take 的返回值就是 3——等待前到达的通知落在了 TCB 的通知值里,不会因为"还没人等"就丢掉。
  2. 清零和递减在同一个变量上实现
    :两个实验的发送代码一字不差(同一行 Give、同一个 +1 分支执行 3 遍),发送端对"二值还是计数"一无所知——区分只在 4.2 尾部 xClearCountOnExit 的分支:pdTRUE 一把清零(二值语义),pdFALSE 只减 1(计数语义)。把实验一的 pdTRUE 改成 pdFALSE 重编译,行为不变(每次只攒 1 个,两种模式等价);连发多个时才分化。
  3. 发送方优先级更高的隐藏作用
    :4.1 唤醒分支里的让出(taskYIELD_ANY_CORE_IF_USING_PREEMPTION)只在"被唤醒任务优先级更高"时才触发切换——A(+2) 唤醒的是 B(+1),不切换,A 继续连发完 3 次才轮到 B 取。若把两者优先级对调,第一次 Give 后 B 立刻抢占取走,计数值永远到不了 3——可自行改优先级实验对比。
  4. 零对象创建
    :task_notify_demo() 里没有任何 xSemaphoreCreate/xQueueCreate——4.5 的对照图就是两条路径的差别:通知只动 TCB 的两个成员,信号量要先创建队列对象再走 queue.c 通用路径。对照《FreeRTOS 信号量源码剖析:底层居然是条队列?》里的等价实验,体会 RAM 与速度差异。最后补上桥接段的第三问——唤醒为什么不需要等待链表:等待通知的任务不占任何事件链表(4.2 节选说了),只把状态项挂进阻塞链表;通知目标唯一(TCB 里那组成员),Give 按句柄直达,不需要链表登记"谁在等"——对照 4.5 里信号量路径还要养着 xTasksWaitingToReceive 事件链表。

五、注意事项(踩坑清单)

坑
机制根源
通知在等待前到达也落进 TCB,不会丢
ulTaskGenericNotifyTake
 先查 ulNotifiedValue:非 0 直接返回不阻塞;xTaskGenericNotifyWait 查 ucNotifyState==taskNOTIFICATION_RECEIVED 也立即返回。发送时先把状态置 RECEIVED(xTaskGenericNotify 入口写通知状态),无论接收方等没等,通知都落在 TCB 里
ISR 唤醒高优先级任务后必须 portYIELD_FROM_ISR
xTaskGenericNotifyFromISR
 只置 *pxHigherPriorityTaskWoken=pdTRUE 和 xYieldPendings(按核一个的待切换标记数组),不切换上下文;不补 yield 则 CPU 回到被打断的任务继续执行,切换最长推迟到下次 tick,实时性受损。另注意本工程 USART1 中断优先级为 2,数值比 configMAX_SYSCALL_INTERRUPT_PRIORITY(5) 小、紧急程度更高(属于禁止调用 FromISR API 的 0~4 档),在其 ISR 里调任何 FromISR API 都会触发 configASSERT 卡死——串口 ISR 里不能用通知
通知值是 32 位整数,语义完全由 eAction/取走方式决定
同一个 ulNotifiedValue:eIncrement 当计数、eSetBits 当事件位、eSetValue* 当数据/邮箱;取走端 ulTaskNotifyTake(pdTRUE)=清零、pdFALSE=减1(通知值清零或递减分支),xTaskNotifyWait 按位清。收发两端必须约定同一种解读
configTASK_NOTIFICATION_ARRAY_ENTRIES=1
:只有索引 0 可用
本工程未加大该值(FreeRTOS/include/FreeRTOSConfig.h,通知数组条目数宏),所有非 Indexed 宏都固定操作 ulNotifiedValue[0];一个任务同时只能用一个通知"通道",多个独立事件源会挤在同一个值上互相干扰(需要多通道时加大该值并用 Indexed API,用法与"一次只能阻塞等一个通道"的规则见第二节末尾的备查小节)
点对点限制:不能广播、不能多接收者、不能发往 ISR
通知直接写进目标 TCB(xTaskGenericNotify 改值分支),没有公共对象可供多个任务等待;要广播/多任务同步用事件组,要缓冲多个数据项用队列
eIncrement
 无上限检查,计数可 32 位回绕到 0
xTaskGenericNotify
 的 ++(eIncrement 分支)不检查溢出;计数信号量 xSemaphoreCreateCounting(max, init) 有 max 上限、Give 超限返回失败。高频事件源用通知计数要防回绕
发送方拿不到"目标已收到"的反馈,也不能阻塞发送
通知是单向投递;若目标已有 pending 通知,eSetValueWithoutOverwrite 只返回 pdFAIL,发送方无法像满队列发送那样阻塞等待——需要握手时用队列或再加反向通知
pdMS_TO_TICKS(<10)
 取整为 0:超时参数传 0 变"只查询"
本工程 configTICK_RATE_HZ=100(1 tick=10ms),ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(5)) 等价于 0 tick——不阻塞、值非 0 才返回

— 写在最后 —

如果这篇文章对你有帮助,欢迎点赞、在看、转发三连支持,这是我持续更新的最大动力。

有问题欢迎在评论区指出,我都会认真看。

点个关注不迷路,更多硬核技术剖析持续更新中,下期再见。

相关学习资料