FreeRTOS队列与任务通知详解
笔记 / 嵌入式
引言
信号量一篇讲了任务间如何同步,但同步不携带数据:采集任务采到一组 ADC 值要交给处理任务,只 give 一个信号量不够,数据怎么过去?用全局变量加锁当然可行,但 FreeRTOS 提供了更直接的机制:队列,它把数据本身拷贝进一个线程安全的缓冲区。数据传递与 任务调度 决定的任务运行顺序是两件事,前者由队列这类通信对象解决。
这页笔记讲三件进阶的事:队列的拷贝语义与阻塞行为、任务通知这条不经过中间对象的直达路线、以及互斥锁背后的优先级反转与优先级继承。最后一块在信号量一篇里只有一句话,这里把时间线展开。
一、队列:会拷贝数据的管道

先立住最核心的语义:队列传递的是值的拷贝。发送时数据被 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。定量做法是按”消费者最坏处理耗时 × 生产速率”估算积压上界,再留一倍余量。
二、任务通知:不经过中间对象的直达路线


队列有它的开销:创建要占用堆内存,每次收发要经过队列的锁和等待链表。任务通知把这部分开销省掉:每个任务本身带有一个通知状态和一个 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 位值 | 传结构体、不定长数据不行 | 队列 |
| 通知状态只有一位 | 未取走时连发两次会合并 | 计数语义或队列 |
三、优先级反转:低优先级阻塞了高优先级

调度器一篇的规则是高优先级任务永远先运行,那高优先级任务怎么会卡在低优先级任务手里?按时间线分四步:
- LP(低)拿到互斥锁,进入临界区
- HP(高)就绪,抢占 LP;接着请求同一把锁,锁被占用,HP 阻塞
- MP(中)就绪。MP 优先级高于 LP,直接抢走 CPU。此刻实际的执行优先级是 MP > LP > HP:HP 在等 LP 手里的锁,而 LP 连 CPU 都拿不到
- MP 运行完毕,LP 才得以跑完临界区放锁,HP 终于被唤醒
问题出在第 3 步:MP 与那把锁没有任何关系,却实际插到了全系统最高优先级任务前面。更严重的是,如果 MP 有一整串(定时器、日志、通信轮询轮流就绪),LP 可能长期得不到 CPU,HP 被无限期拖延,这叫无界优先级反转。1997 年火星探路者号在火星上反复重启,根因就是它。
把保护资源的二值信号量换成互斥锁并不能解决问题,反转照旧发生;两者的区别见 信号量家族。解决手段在下一节。
四、优先级继承:临时提权,用完归还

互斥锁与二值信号量的本质区别只有一条:互斥锁带优先级继承。规则一句话:持有互斥锁的任务,会被临时提升到”正在等这把锁的任务里最高的那个”的优先级。
对照上一节的时间线:第 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 转交 |
总结
队列用拷贝换来发送方与接收方的解耦,深度按积压上界确定;任务通知省去中间对象,点对点的同步优先使用;互斥锁的优先级继承把反转从无界压到有界,代价是必须由同一任务成对操作、且不能在中断中使用。