笔记 / 嵌入式FreeRTOS队列任务通知

引言

信号量一篇讲了任务间如何同步,但同步不携带数据:采集任务采到一组 ADC 值要交给处理任务,只 give 一个信号量不够,数据怎么过去?用全局变量加锁当然可行,但 FreeRTOS 提供了更直接的机制:队列,它把数据本身拷贝进一个线程安全的缓冲区。数据传递与 任务调度 决定的任务运行顺序是两件事,前者由队列这类通信对象解决。

这页笔记讲三件进阶的事:队列的拷贝语义与阻塞行为、任务通知这条不经过中间对象的直达路线、以及互斥锁背后的优先级反转与优先级继承。最后一块在信号量一篇里只有一句话,这里把时间线展开。

一、队列:会拷贝数据的管道

队列读写时序(Mastering FreeRTOS Figure 31):Task A 两次发送把 10、20 拷贝进队列,Task B 稍后按先进先出依次取出——数据经队列以副本传递,发送方发完即与接收方解耦

先立住最核心的语义:队列传递的是值的拷贝。发送时数据被 memcpy 进队列内部存储,接收时再拷出来。发送方发完立刻可以复用甚至销毁那个变量,两边的生命周期被队列解耦。传指针也允许,把指针当作值拷进去即可,但指向的内存由谁释放、会不会被发送方改掉,都成了靠约定维持的隐患;默认的拷贝语义换的是确定性,结构体小的时候没有必要改成传指针。

时序图里 Task A 把 10、20 两次送进队列,Task B 按先送先进的顺序取走,Task A 发完继续执行,不需要知道对方何时取。围绕这个 FIFO,阻塞行为有四条规则:

场景行为
队列满时发送带阻塞时间的发送方进入阻塞态等待空位;超时返回 errQUEUE_FULL
队列空时接收接收方阻塞等待数据,数据到达时被唤醒
多个任务等同一队列按优先级唤醒,同级先来先服务
中断里收发不允许阻塞,FromISR 版本失败直接返回,靠返回值处理

最小骨架,采集任务到处理任务的单向数据流:

QueueHandle_t xQueue = xQueueCreate(5, sizeof(int16_t)); // 5 格,每格 2 字节

// ---- 生产者任务 ----
int16_t sample = ADC_Read();
xQueueSendToBack(xQueue, &sample, pdMS_TO_TICKS(10));    // 满了最多等 10ms

// ---- 消费者任务 ----
int16_t got;
if (xQueueReceive(xQueue, &got, portMAX_DELAY) == pdPASS) {
    Process(got);    // 拷出来的是副本,可独立使用
}

// ---- 中断里:绝不阻塞 ----
BaseType_t woken = pdFALSE;
xQueueSendToBackFromISR(xQueue, &sample, &woken);
portYIELD_FROM_ISR(woken);   // 与信号量一篇里的中断交接流程相同

中断到任务的交接也在这里完成:ISR 里只拷走数据、唤醒任务,业务逻辑留给任务执行。串口、CAN 这类外设的中断到任务交接用的是同一套做法,见 DMA直接存储器访问详解、CAN总线详解。

三个常用的变体:xQueueSendToFront 从队头插入(LIFO,用于紧急数据);xQueuePeek 只读不取(读出的数据仍留在队列中);uxQueueMessagesWaiting 查询当前消息数(用于健康监测、判断消费者是否积压)。

队列深度在创建时固定,没有自动扩容:深度过小,高负载时发送方开始等待甚至失败;深度过大,则占用额外的 RAM。定量做法是按”消费者最坏处理耗时 × 生产速率”估算积压上界,再留一倍余量。

二、任务通知:不经过中间对象的直达路线

四种通信对象对比(Mastering FreeRTOS Figure 76):队列、信号量、事件组都要先创建中间对象、经它转交;任务通知由发送方直接写目标任务的内核标志,不经过任何中间对象

任务通知的内核存储(Mastering FreeRTOS Figure 77):每个任务自带 Notification State(Pending / Not-pending)与 32 位 Notification Value,零额外创建

队列有它的开销:创建要占用堆内存,每次收发要经过队列的锁和等待链表。任务通知把这部分开销省掉:每个任务本身带有一个通知状态和一个 32 位通知值,发送方直接写目标任务的内核存储,不需要中间对象。代价是方向单一,只能点对点发给一个任务。

两套 API 对应两种用法:

// ---- 用法一:轻量信号量(模拟二值/计数) ----
xTaskNotifyGive(xConsumerHandle);                    // 通知值 +1
vTaskNotifyGiveFromISR(xConsumerHandle, &woken);     // 中断同样直达

ulTaskNotifyTake(pdTRUE,  portMAX_DELAY);  // 取走即清零 → 二值语义
ulTaskNotifyTake(pdFALSE, portMAX_DELAY);  // 取走减 1   → 计数语义

// ---- 用法二:带 32 位值的灵活通知 ----
xTaskNotify(xConsumerHandle, 0x03, eSetBits);        // 按位置 1,像事件组
xTaskNotify(xConsumerHandle, seq, eSetValueWithOverwrite); // 直接覆写
uint32_t value;
xTaskNotifyWait(0, 0, &value, portMAX_DELAY);        // 等 Pending 并取回值

官方手册给出的实测参考:任务通知比信号量快约 45%,且不占用额外 RAM。因此”中断唤醒一个任务”这种最高频的模式,官方推荐优先使用任务通知而不是二值信号量,语义等价而开销更低。

直达路线的边界需要记牢:

限制后果该换什么
只能发给一个指定任务广播、多消费者做不到事件组(xEventGroupSetBits)
发送方需要目标任务句柄句柄没建好就没法发队列(接收方自行取用,发送方不必知道接收方)
数据只有一个 32 位值传结构体、不定长数据不行队列
通知状态只有一位未取走时连发两次会合并计数语义或队列

三、优先级反转:低优先级阻塞了高优先级

优先级反转时间线(Mastering FreeRTOS Figure 66):LP 持锁进临界区,HP 请求同一把锁被阻塞;MP 与锁毫无关系,却因抢占 LP 而实际插到了 HP 前面执行

调度器一篇的规则是高优先级任务永远先运行,那高优先级任务怎么会卡在低优先级任务手里?按时间线分四步:

  1. LP(低)拿到互斥锁,进入临界区
  2. HP(高)就绪,抢占 LP;接着请求同一把锁,锁被占用,HP 阻塞
  3. MP(中)就绪。MP 优先级高于 LP,直接抢走 CPU。此刻实际的执行优先级是 MP > LP > HP:HP 在等 LP 手里的锁,而 LP 连 CPU 都拿不到
  4. MP 运行完毕,LP 才得以跑完临界区放锁,HP 终于被唤醒

问题出在第 3 步:MP 与那把锁没有任何关系,却实际插到了全系统最高优先级任务前面。更严重的是,如果 MP 有一整串(定时器、日志、通信轮询轮流就绪),LP 可能长期得不到 CPU,HP 被无限期拖延,这叫无界优先级反转。1997 年火星探路者号在火星上反复重启,根因就是它。

把保护资源的二值信号量换成互斥锁并不能解决问题,反转照旧发生;两者的区别见 信号量家族。解决手段在下一节。

四、优先级继承:临时提权,用完归还

优先级继承时间线(Mastering FreeRTOS Figure 67):HP 阻塞在 LP 持有的互斥锁上的瞬间,调度器把 LP 提到 HP 的优先级;MP 再也插不进队,LP 跑完临界区放锁、跌回原优先级,HP 立即接管

互斥锁与二值信号量的本质区别只有一条:互斥锁带优先级继承。规则一句话:持有互斥锁的任务,会被临时提升到”正在等这把锁的任务里最高的那个”的优先级。

对照上一节的时间线:第 2 步 HP 阻塞的瞬间,LP 被提升到 HP 的优先级;第 3 步 MP 再就绪也抢不走 CPU(同级不抢占,何况 LP 正带着 HP 的优先级运行临界区);LP 放锁,优先级立刻还原,HP 接管。反转从无界变为有界:HP 最坏也只等一个临界区的长度,而这个长度由写代码的人控制。

三条使用纪律:

  • Take 和 Give 必须由同一个任务成对完成,互斥锁的所有权属于获取它的任务
  • 互斥锁禁止在中断里使用:继承机制要修改任务优先级,中断不是任务,没有优先级可提。中断里要同步就改用信号量或任务通知
  • 临界区尽量短:优先级继承只是事后补救,持锁时间本身才是开销。锁里不要等 I/O、不要 printf、不要 vTaskDelay

锁嵌套会触发递归继承(A 等 B 的锁、B 等 C 的锁,优先级层层上提),FreeRTOS 能够处理,但可读性差,设计上应当避免嵌套。

五、选型速查

机制带数据保护共享资源中断可用优先级继承相对开销
队列✅ 拷贝任意定长❌✅ FromISR❌中
二值信号量❌⚠️ 别这么用✅❌中
计数信号量❌(计数即数据)❌✅❌中
互斥锁❌✅ 专用❌✅中
任务通知32 位值❌✅❌最低
事件组❌(位标志)❌✅❌中

选型顺序一句话:先问要不要带数据,带结构体或数据流就用队列;只需要同步且接收方唯一,用任务通知;保护共享资源,用互斥锁;要广播给一群任务,用事件组;管理资源池计数,用计数信号量。

六、常见问题与注意事项

问题原因解决
接收到的数据与发送时不符传的是指针,源缓冲已被复用传值;确要传指针就用缓冲池并约定所有权转移
高优先级任务偶发卡顿几十毫秒无界优先级反转资源保护换互斥锁,检查锁内是否执行了慢操作
互斥锁 Give 无效甚至断言Take/Give 不在同一任务,或在中断里使用了同任务成对;中断改用信号量或任务通知交接
任务通知偶发漏计一次状态位唯一,未取走时连发会合并改计数语义 ulTaskNotifyTake(pdFALSE),或换队列
中断里队列满、发送总失败FromISR 不能阻塞加大深度;或中断只置标志,任务侧搬运
消费任务收不到广播任务通知点对点,其它任务不在收件范围事件组广播
想在定时器回调里发队列回调上下文不允许阻塞用超时 0 的发送,或 xTimerPendFunctionCall 转交

总结

队列用拷贝换来发送方与接收方的解耦,深度按积压上界确定;任务通知省去中间对象,点对点的同步优先使用;互斥锁的优先级继承把反转从无界压到有界,代价是必须由同一任务成对操作、且不能在中断中使用。