裸机编程切到RTOS之后任务之间的通信和同步永远是躲不开的坎。前面聊过队列、信号量、互斥锁、事件组这次把FreeRTOS任务通知Task Notification单独拎出来讲透。原因很简单任务通知是FreeRTOS里最容易用错、也最容易被低估的一套机制。用好了它能替掉一大部分信号量和事件组的活儿还省内存、省CPU时间用不好数据覆盖、通知丢失、任务卡死排查起来能让你怀疑人生。这篇是系列第7篇专注任务通知。我会从内核实现、五个发送动作、四种实战模式讲到中断里的用法最后附上我在实际项目中踩过的坑和排查清单。适合正在学FreeRTOS的嵌入式开发者也适合那些已经把任务通知用起来但偶尔觉得“哪里不对劲”的人。1. 任务通知到底是什么1.1 内核里存的不是消息是一个32位数字任务通知在FreeRTOS里并不是一个独立的内核对象不需要像队列那样先创建队列控制块、再分配存储区。它的本质非常简单每个任务的TCB任务控制块里都内置了两个字段一个是32位的通知值 ulNotifiedValue一个是通知状态 ucNotifyState。这两个字段在任务创建时就存在了不管你用不用它它都在那里不额外占堆内存。当你调用 xTaskNotify 往某个任务发通知时内核做的事情其实非常直接找到目标任务TCB改一改它的通知值再改一下通知状态然后看这个任务是不是阻塞在等待通知上如果是就把它的状态从Blocked切到Ready等待调度器调度它。这就引出了任务通知最大的优势没有队列复制数据的开销没有创建内核对象的步骤操作的是每个任务自己TCB里的东西。我在Cortex-M4上做的简单对比测试里任务通知发送一个唤醒信号的耗时大约是二进制信号量的60%左右内存节省更是直观——一个信号量至少需要一个Queue_t结构加上存储区而任务通知什么都不用建。官方文档给过一个数据任务通知比二进制信号量快约45%RAM占用更低。我实测下来没有45%那么夸张但在频繁触发的中断里这个差距确实能感受到。1.2 任务通知、信号量、队列、事件组的本质差异很多新手会问已经有了队列和信号量为什么还要任务通知我的回答是任务通知解决的是一个非常特定的场景——一个任务向另一个明确的任务发送状态变化这个信息。它适合一到一的通知只要数据量不超过一个整数并且你不需要让多个任务同时收到这个消息。特性任务通知二进制信号量队列事件组数据长度32位整数无数据任意字节最多24/8位标志接收者数量1个必须指定目标任务多个任务可等待多个任务可读写多个任务可等待是否需要创建对象不需要需要需要需要ISR中发送支持支持支持支持阻塞接收支持支持支持支持数据是否排队否覆盖或保留最新值是多次give会累积是FIFO仅状态位典型RAM开销0使用TCB内字段约80~120字节队列头存储区事件组对象这个表里最值得注意的两行是接收者数量和数据是否排队。任务通知发给谁由参数里的任务句柄直接决定无法像信号量那样让多个任务竞争同一个资源任务通知保存的永远是最新的一个整数值不会像队列那样把多次发送的值按顺序存起来。理解不了这两点后面用起来必踩坑。2. 任务通知API全解析与选型思路2.1 八个核心API一次性说清楚任务通知的API看着多但其实可以分成四组发送组、接收组、ISR组、状态清理组。我平时最常用的就下面这几个全部列成表方便对照。API作用关键参数说明xTaskNotify(任务句柄, 通知值, eAction)向指定任务发送通知eAction决定如何修改通知值xTaskNotifyGive(任务句柄)等价于xTaskNotify(句柄, 0, eIncrement)通知值加1最常用的给信号xTaskNotifyWait(清除位入口, 清除位出口, 输出值指针, 超时)阻塞等待通知可同时读取/清除通知值可同时等待多个标志位ulTaskNotifyTake(是否清零, 超时)阻塞等待通知取走计数值pdTRUE清零取出pdFALSE递减xTaskNotifyFromISR(句柄, 值, eAction, 高优先级任务标记)中断版发送返回是否需要上下文切换vTaskNotifyGiveFromISR(句柄, 高优先级任务标记)中断版Give用法与FromISR版本一致xTaskNotifyStateClear(句柄)清除任务的通知状态让ucNotifyState回到未通知状态xTaskNotifyIndexed系列使用通知数组新版功能需要开启configTASK_NOTIFICATION_ARRAY_ENTRIES这里特别说明一下xTaskNotifyGive和ulTaskNotifyTake这对组合。xTaskNotifyGive每次调用让目标任务的通知值加1ulTaskNotifyTake则把通知值取走——如果第二个参数传pdTRUE取走时通知值清零并把原值作为返回值如果传pdFALSE通知值减1。这就相当于一个不需要创建对象、计数上限为2^32的计数信号量只是接收方变成了固定的那个任务。2.2 eAction五种动作每种都是不同语义xTaskNotify函数里的eAction参数是整个任务通知的精华它决定了这次通知到底要怎么改变目标任务的通知值。新手最容易在这里犯迷糊我把这五种动作拆开讲。eNoAction只唤醒任务不修改通知值。适合那种我就想叫他一下的场景比如通知另一个任务去做一次轮询具体做什么由别的条件决定。eSetBits按位或。通知值 旧值 | 新值。这是把任务通知当事件组用的关键动作。eIncrement通知值加1。xTaskNotifyGive的内部实现就是这个做计数信号量时用。eSetValueWithOverwrite直接覆盖写。通知值 新值。旧数据直接丢掉无论目标任务是否已读走上次的值。适合传递最新温度读数这种只要最新的数据。eSetValueWithoutOverwrite如果通知值当前为非零有未读通知本次写入失败返回pdFAIL如果通知值为0则写入新值。这个有点像长度为1的队列的非阻塞尝试写。我做过一个传感器数据采集项目需要把ADC采样结果传给处理任务用的就是eSetValueWithOverwrite。因为传感器采样周期比任务处理周期快中间丢掉几帧完全没关系只要拿到最新值就行。而有个同事在通信协议解析里用了这个动作结果因为中间某帧还没被处理就被新帧覆盖直接导致协议解析错位——选哪个动作取决于你的业务能不能接受旧数据被丢。2.3 阻塞、超时与立即返回的三种等待状态任务通知的接收函数都带超时参数这个参数的单位是Tick。你可以传0表示不等待立刻检查一次传portMAX_DELAY表示无限期等下去直到有通知或者用pdMS_TO_TICKS(毫秒)把毫秒转成Tick。具体到调用行为任务进入xTaskNotifyWait或ulTaskNotifyTake后如果通知值条件不满足任务会被挂到Blocked状态不占CPU。这时候有两种方式能唤醒它一是别的任务或中断往它发通知二是超时时间到了。被通知唤醒后函数返回pdPASS超时唤醒返回pdFAIL取函数返回值是pdFAIL。这里有个容易忽略的细节任务通知的等待是只要通知值满足条件就立刻返回但它不会像队列那样自动把消息弹出。你用xTaskNotifyWait接收时拿到的是通知值的完整快照清除哪些位、怎么处理值全由你自己在代码里控制。所以任务通知的灵活性高但自动管理程度低出错时也更难看出来。3. 四种实战模式把任务通知用出花来3.1 模式一轻量级信号量替代二进制/计数信号量这是任务通知最基础的用法。生产者任务或中断调用xTaskNotifyGive消费者任务调用ulTaskNotifyTake来等待。直接上代码。/* 生产者任务采集完成通知处理任务 */ void vProducerTask(void *pvParameters) { for (;;) { /* 模拟采集耗时 */ vTaskDelay(pdMS_TO_TICKS(100)); /* 通知处理任务通知值 1 */ xTaskNotifyGive(xHandlerTaskHandle); } } /* 消费者任务等待采集完成信号 */ void vHandlerTask(void *pvParameters) { uint32_t ulSignalCount; for (;;) { /* 阻塞等待通知值清零并返回原值 */ ulSignalCount ulTaskNotifyTake(pdTRUE, portMAX_DELAY); /* 处理一次采集结果 */ vProcessData(); /* 如果 ulSignalCount 1说明生产者已经积累了多个信号 */ if (ulSignalCount 1) { /* 需要把积压的处理完防止信号堆积 */ for (uint32_t i 1; i ulSignalCount; i) { vProcessData(); } } } }注意顺手看了一眼ulTaskNotifyTake的返回值这是良好习惯。虽然大多数场景下你只关心被唤醒了但返回值能告诉你这次唤醒积累了多少个信号帮助判断生产者是不是已经跑得比消费者快了。3.2 模式二轻量级事件组用eSetBits置位当你想让一个任务等待多个事件中的某些事件发生了但又不希望为此创建一个完整的事件组对象时可以用eSetBits配合xTaskNotifyWait。/* 任务A准备数据完成 */ xTaskNotify(xEventTaskHandle, EVENT_DATA_READY, eSetBits); /* 任务B外设初始化完成 */ xTaskNotify(xEventTaskHandle, EVENT_PERIPH_OK, eSetBits); /* 等待事件的任务两种事件都到位才开始工作 */ uint32_t ulNotifiedValue; if (xTaskNotifyWait(0x00, 0x00, ulNotifiedValue, pdMS_TO_TICKS(500)) pdPASS) { if ((ulNotifiedValue (EVENT_DATA_READY | EVENT_PERIPH_OK)) (EVENT_DATA_READY | EVENT_PERIPH_OK)) { /* 所有条件满足 */ vStartWork(); } } else { /* 超时至少有一个条件未满足 */ vLogError(); }这里有两个隐藏重点。第一xTaskNotifyWait的第一个参数是进入时清除位第二个参数是退出时清除位它们在事件的一次性消费语义里非常关键。如果你希望事件标志被读取后立即清零就把对应位放在第二个参数里比如xTaskNotifyWait(0, EVENT_DATA_READY | EVENT_PERIPH_OK, value, timeout)这样退出函数后这两个位自动清零。第二任务通知做事件组时依然只能有一个任务等待多个任务同时等同一组事件时老老实实用事件组。3.3 模式三4字节消息传递WithOverwrite实现最新值传递任务通知虽然只有一个32位值但用来传递最新状态是完全够用的。比如温控系统里传感器任务每隔50ms读一次温度控制任务希望拿到最新的温度值但处理一帧控制计算可能要100ms。用队列的话要么存很多过时数据要么用队列的覆盖写特性xQueueOverwrite——任务通知的eSetValueWithOverwrite干的就是这件事。/* 传感器任务每50ms读一次发送最新温度 */ int16_t sTemperature vReadSensor(); xTaskNotify(xControllerTaskHandle, (uint32_t)sTemperature, eSetValueWithOverwrite); /* 控制任务循环读取最新温度并计算控制量 */ uint32_t ulTemperature; if (xTaskNotifyWait(0xFFFFFFFF, 0xFFFFFFFF, ulTemperature, pdMS_TO_TICKS(200)) pdPASS) { int16_t sTemp (int16_t)ulTemperature; vCalcControlOutput(sTemp); }我特意把xTaskNotifyWait的两个清位参数都写成0xFFFFFFFF表示进入函数时把通知值清零这样每次读走的值必然是从上次读走到现在写入的最新值。如果你不这么做通知值会一直保留旧数据你会陷入为什么值一直没变的困惑。3.4 模式四中断到任务最简单的ISR通知版本中断里发任务通知是最常见的用法按键、定时器、DMA传输完成、UART接收空闲统统可以把事件发生了通过任务通知扔给任务去处理减少中断里做慢速逻辑的时间。/* 中断服务函数外部按键按下 */ void EXTI_IRQHandler(void) { BaseType_t xHigherPriorityTaskWoken pdFALSE; /* 清除中断标志 */ CLEAR_EXTI_FLAG(); /* 发送任务通知计数模式 */ vTaskNotifyGiveFromISR(xKeyTaskHandle, xHigherPriorityTaskWoken); /* 如果唤醒了高优先级任务需要进行上下文切换 */ portYIELD_FROM_ISR(xHigherPriorityTaskWoken); }这个xHigherPriorityTaskWoken是ISR版API的经典参数等会第4节专门展开。简单理解它告诉内核有没有唤醒了比当前被中断任务更高优先级的任务如果有中断退出时不能回到原任务继续跑而应该直接切到那个高优先级任务。portYIELD_FROM_ISR实际执行的行为在Cortex-M上就是触发一次PendSV调度器会重新选一次任务。4. 中断环境下的任务通知细节4.1 为什么ISR版本必须有xHigherPriorityTaskWoken很多刚从裸机转RTOS的人会忽略ISR版本API的返回值处理直接不检查就往下跑。这在大多时候看起来正常但一旦发生中断唤醒了高优先级任务系统不会立刻切换到那个任务而是回到被中断的任务继续执行。表现就是响应变慢严重的会破坏实时性尤其在高优先级任务是控制类、保护类任务时这个延迟可能是致命的。正确姿势是上面那段代码的写法先定义一个局部变量xHigherPriorityTaskWoken并初始化为pdFALSE传入ISR版API调用后立即判断该变量如果为pdTRUE调用portYIELD_FROM_ISR触发一次调度。有一个细节很多人不知道xHigherPriorityTaskWoken定义为局部变量是必须的不能用一个全局变量初始化为pdFALSE就反复传进去。因为每次中断调用的上下文都是独立的全局变量可能在并发场景下出现竞态导致漏掉调度或者重复调度。这是我在审查代码时经常发现的问题。4.2 什么时候用xTaskNotifyFromISR什么时候用vTaskNotifyGiveFromISR这两个函数的区别其实就是eAction的区别。vTaskNotifyGiveFromISR内部就是xTaskNotifyFromISR写成eIncrement动作所以只需要通知值加1用vTaskNotifyGiveFromISR代码简短。需要置位、覆盖写、无覆盖写时用xTaskNotifyFromISR配合对应的eAction。/* 中断里置事件位 */ xTaskNotifyFromISR(xTaskHandle, BIT_TIMER_EXPIRED, eSetBits, xHigherPriorityTaskWoken); /* 中断里传最新值 */ xTaskNotifyFromISR(xTaskHandle, (uint32_t)ulStatus, eSetValueWithOverwrite, xHigherPriorityTaskWoken);另外注意ISR版本的API不能在临界区里反复调用太多次虽然FreeRTOS的ISR版本API内部会自己处理临界区但如果你在中断里循环通知几百个任务中断时间会变长。比如你要通知所有任务系统要休眠了用一个for循环调用几百次每次都有进出临界区的开销这种情况下不如用一个事件组广播或者用一个全局标志软定时器回调的方式。4.3 中断里用任务通知替代二值信号量的注意点传统做法里ISR给任务发信号是用xSemaphoreGiveFromISR任务用xSemaphoreTake等待。用任务通知替代后代码确实精简了但有一个语义差异要心里有数二值信号量在任务持有信号量时再give一次信号量保持满状态不会变2而任务通知加1模式多次give会累积成大于1的计数值。这说明什么如果你的ISR和任务是一一对应的事件通知计数累积反而不是坏事任务可以通过返回值的数值判断ISR发生了多少次但如果你的本意是只发一次信号ISR里频繁触发导致计数不断累加任务处理时就要用ulTaskNotifyTake(pdFALSE, ...)配合循环把计数值消化完否则通知计数会越积越多。我见过一个项目按键中断用任务通知给任务发信号用户快速连按三下通知值变成3任务只处理了一次剩下两次信号残留导致后面“无故唤醒”就是这个原因。5. 配置宏、调试技巧和常见坑5.1 这些配置宏不打开API都不能用任务通知的大部分功能默认就是可用的因为configUSE_TASK_NOTIFICATIONS默认是1。但有几个相关的宏你需要确认configUSE_TASK_NOTIFICATIONS总开关设为1启用任务通知。某些厂商提供的库可能默认关闭用之前查一下。INCLUDE_xTaskNotifyWait如果要使用xTaskNotifyWait这个宏必须为1否则API不会编译。INCLUDE_xTaskNotifyStateClear使用状态清理时需要。configTASK_NOTIFICATION_ARRAY_ENTRIES如果使用较新的xTaskNotifyIndexed系列API通知数组需要把它设为大于0最常见的就是设为1代表每个任务有1个通知槽位。设为N时每个任务TCB里就有N个通知值你可以给一个任务的不同用途发不同类型的通知彼此不干扰。新版FreeRTOS V10.4之后任务通知数组已经是标准功能了我用它做过一个很舒服的场景同一个任务挂两个通知槽槽0接收外设中断的数据就绪信号槽1接收管理命令的参数更新信号两个互不干扰。不过老项目的代码如果用的旧APIxTaskNotify系列是操作槽0的行为不受影响兼容性没问题。5.2 调试任务通知状态的办法任务通知是藏在TCB里的状态不像队列能用队列统计函数看到队列中有多少消息。调试起来更费劲我一般用三个办法。第一个办法是看返回值。xTaskNotifyWait、ulTaskNotifyTake的返回值直接告诉你成功还是超时配合通知值输出参数基本能判断出发送和接收是否匹配。第二个办法是查看任务状态。如果任务卡在ulTaskNotifyTake用vTaskList / vTaskGetRunTimeStats之类的手段能看到这个任务处于Blocked状态且阻塞原因是等待通知。在串口调试终端里定期打印所有任务状态你会很清楚地看到哪个任务在等什么。第三个办法是借助FreeRTOS内核的断言和Trace工具。比如可以临时打开configUSE_TRACE_FACILITY或者挂接vApplicationStackOverflowHook、vApplicationMallocFailedHook之类的钩子看是不是任务栈不够或者消息处理不及时导致的溢出。注意不推荐一上来就怀疑内核问题任务通知这种机制本身在FreeRTOS里非常成熟99%的情况问题出在应用层的动作选型或标志清除逻辑上。5.3 经典踩坑清单这些问题我全遇到过下面这个表是我自己和身边同事在任务通知上踩过的典型坑每条都有实际项目背景。错误现象根因正确解法通知值一直在涨任务处理不过来使用了eIncrement且接收端没有及时把计数消化合理设置超时或用ulTaskNotifyTake(pdTRUE, ...)一次性清零通知值明明是0发通知却失败eSetValueWithoutOverwrite语义非0值存在时会拒绝写入先调用xTaskNotifyStateClear清状态或改用eSetValueWithOverwrite任务没有被唤醒但通知值已经变了任务没有进入阻塞态就收到了通知通知状态停留在已通知用xTaskNotifyWait的入口清位参数把旧值清掉或用xTaskNotifyStateClear悬空任务句柄系统崩溃目标任务被删除后仍往它的句柄发通知删除任务前置空句柄或使用任务通知数组后注意索引越界ISR里调用了带阻塞的API误以为任务通知的ISR版能像普通版一样等待ISR环境只能发不能等需要等就把数据丢给任务去处理多个任务同时调用ulTaskNotifyTake只有一个能收到任务通知是单对单的定向机制多方等待请使用信号量、队列或事件组这里再展开说一个最容易阴沟翻船的xTaskNotifyWait的入口清除位和出口清除位。很多人看到API原型时以为这两个参数是收什么通知和清什么通知其实它们的作用是进入等待时把通知值中对应位先清掉退出时再把通知值中对应位清掉。如果你写的代码是xTaskNotifyWait(0x01, 0x00, ...)那进入时bit0直接被清零了你这个bit0的事件标志就永远等不到了。我在用任务通知模拟事件组时被这个坑过一次排查了大半天最后打印通知值才发现是这个原因。6. 什么时候不该用任务通知6.1 一对多广播场景事件组才是正解任务通知的接收方由任务句柄唯一确定无法实现当某个事件发生时让所有等待它的任务都醒来。如果需要多个任务同时响应一个事件比如多个任务都要在系统休眠命令发出后各自保存状态请用事件组xEventGroupSetBits配合xEventGroupWaitBits让每个任务独立等待同一个事件位。6.2 超过4字节的数据传输用队列或流缓冲区任务通知能装下的数据量就一个机器字长多数平台32位。你要传一个结构体、一包字节流、一组浮点数任务通知直接出局。哪怕你觉得我把它拆成两个通知也别这么干拆开传会有中间状态接收方可能在收到一半时被调度走导致数据错乱。这种场景用队列、流缓冲区Stream Buffer或者消息缓冲区Message Buffer更合适它们有完整的复制和排队机制。6.3 FIFO场景任务通知没有排队能力队列的典型价值是FIFO先发进去的先被取出来消息按顺序处理。任务通知保存的是最新值后来的覆盖先来的完全不具备顺序性。如果业务逻辑要求发10次就要处理10次按顺序来别用任务通知的覆盖写用计数模式计数还可以如果还要携带不同的数据内容就必须上队列。6.4 共享计数型信号量场景多个任务竞争同一信号量二值信号量或者计数信号量的经典用途是多个任务竞争一个资源使用权比如三个任务抢一个串口外设谁先take到谁发送。任务通知做不到这种竞争关系因为它发通知时已经明确指定了目标任务的句柄不可能让多个任务去抢同一个通知值。这种场景用真正的信号量配合互斥锁做资源保护别用任务通知硬凑。我在一个数据采集网关项目里做过一次整体替换把原来用的3个二值信号量、1个事件组全部改成任务通知同时删除对应的队列对象最后内存占用下降了将近1KB所有功能测试通过。但另一个项目里我试图把5个等待同一启动命令的任务全部改成任务通知结果代码写得极其绕最后换回事件组两行搞定。任务通知适合的是明确的一对一通知任何企图共享和广播的用法都是在跟自己过不去。写在最后做嵌入式这些年我见过太多把任务通知用成玄学的代码通知值莫名其妙变大、任务偶尔卡死、中断发通知后任务不响应。每次排查到最后几乎都是同一个原因——没有把通知当成一种有明确语义的同步原语来用而是把它当成了一个神秘的黑盒子。它是FreeRTOS里最高效的通信方式之一但这种高效是有前提的你要清楚地知道自己在用哪种动作、清除哪些标志、是否允许覆盖、接收方是谁。最后分享一个我一直在用的小技巧在每个接收任务里把xTaskNotifyWait返回后的通知值打印到调试串口形成一个历史记录。任务通知最大的调试难点就是看不见中间状态这个习惯能帮你把无形变成有形。等你在实际项目里跑过一轮信号量替换成任务通知的优化之后你会回来认同那句话任务通知不是万能的但用对了地方它是整个FreeRTOS里最顺手的一把刀。
阅读完成 · 觉得有帮助?