切换主题
任务通信与同步机制
用一句话说明这篇笔记最终要解决的问题。
1. 问题与背景
| 机制 | 主要解决什么问题 | 典型场景 |
|---|---|---|
| Queue | 传递数据 | UART数据帧、传感器数据、控制命令 |
| Semaphore | 事件通知 / 资源计数 | DMA完成、中断通知Task、资源数量管理 |
| Mutex | 保护共享资源 | I2C总线、SPI总线、Flash、printf |
| Task Notification | 轻量级任务通知 | ISR快速唤醒某个固定Task |
| Event Group | 多事件组合等待 | 等待多个状态位同时满足 |
2. Queue(队列)
Queue 可以理解为: 一个由 FreeRTOS 管理的任务间数据缓冲区。 Producer Task生产数据 ↓ Queue ↓ Consumer Task消费数据
发送者不直接调用接收者,而是把数据放入Queue 接收者没有数据时Blocked,有数据时被唤醒
Queue 让系统变成: Sensor_Task ↓ xQueueSend() ↓ Queue ↓ xQueueReceive() ↓ Display_Task
Display_Task 可以这样:xQueueReceive(queue, &data, portMAX_DELAY);
意思是 没有数据就阻塞,不浪费 CPU;有数据就被唤醒处理。 这和我之前的消息队列就合到一起了,这就是RTOS的味道。
3. Queue 的底层本质
Queue 内部不只是一个数组,它大致包含: Queue Control Block │ ├── 数据缓冲区 ├── 每个元素大小 ├── 当前已有元素数量 ├── 写指针 ├── 读指针 ├── 等待发送的Task列表 └── 等待接收的Task列表
先形成这个模型:Queue不是一个简单数组 它还管理:谁在等数据,谁在等空间 这就是Queue和普通数组最大的区别。
4. Queue 会改变 Task 状态
xQueueReceive(queue, &data, portMAX_DELAY); 如果 Queue 为空: 当前Task Running ↓ Blocked
它会进入:等待这个Queue的事件列表
发送数据后:
等待接收的Task Blocked ↓ Ready 然后 Scheduler 判断:被唤醒的 Task 优先级是否更高?
和链表开始联系起来: 为什么 TCB 里要有两个 ListItem? 因为一个 Task 可能同时表达两件事:我现在处于Blocked状态 以及 我正在等待某个Queue
xStateListItem用于挂: Ready List Delayed List Suspended List
xEventListItem → 挂到Queue等待列表,表示等待哪个事件
例如xQueueReceive(queue, &data, 100); 意思是:等 Queue 数据,最多等 100 Tick。
此时这个 Task 可能同时: xStateListItem 挂 Delayed List
xEventListItem 挂 Queue 的等待接收列表
如果数据来了: 从Queue事件列表移除 从Delayed List移除 加入Ready List
如果超时了: 从Delayed List移除 从Queue事件列表移除 加入Ready List
5. 源码剖析
BaseType_t xQueueReceive( QueueHandle_t xQueue, void * const pvBuffer, TickType_t xTicksToWait )的主线 for( ;; ) { taskENTER_CRITICAL(); { 检查 Queue 有没有数据 } taskEXIT_CRITICAL();
vTaskSuspendAll();
prvLockQueue( pxQueue );
再次检查 Queue 是否仍然为空
如果仍然为空,就把当前任务挂到等待列表
} xQueueReceive() 的一条主线:D3Task 想收数据,但是 Queue 为空,于是它要安全地睡下去。 所谓“安全”,核心就是:不能出现“数据来了,但 D3Task 反而睡死了”的情况。 核心: 第一次检查:Queue 现在有没有数据? 第二次检查:我准备睡觉之前,Queue 还是不是空? 真正阻塞:如果还是空,才把当前任务挂到 Queue 等待列表
为什么第一次检查 Queue 空了,不直接睡? 因为第一次检查负责快速判断当前 Queue 有没有数据,没有进行真正阻塞,因为这里存在一个危险窗口 D3Task 检查 Queue,发现 Queue 为空 ↓ 就在这个瞬间 D2Task 或 UART中断 往 Queue 里发了数据 ↓ D3Task 继续执行,把自己挂起睡觉
结果变成: Queue 里面明明有数据 D3Task 却睡着了 这类问题就叫 并发问题:多个执行流交替访问同一个资源,而且顺序不完全由你代码表面顺序决定。 在这里,共享资源就是:Queue 可能访问 Queue 的执行流有: D3Task:xQueueReceive() D2Task:xQueueSend() ISR:xQueueSendFromISR() 所以 FreeRTOS 必须防止: 我刚判断 Queue 空 别人马上改 Queue 我还按旧判断去睡觉
所以我们来看FreeRTOS是怎么解决掉这种并发的: taskEXIT_CRITICAL(); //第一,退出临界区。因为不能一直关中断,所以第一段临界区只做短操作,然后马上退出。 vTaskSuspendAll();//暂时不允许任务切换,中断仍然可以进来。因为接下来 FreeRTOS 要修改一些调度相关结构: 把当前任务放到 Queue 等待列表 把当前任务从 Ready 状态移走 让当前任务进入 Blocked / Suspended 这些动作必须连贯完成。 prvLockQueue( pxQueue );//我现在要调整 Queue 的等待列表,其他中断里对 Queue 的操作先不要立刻乱改等待列表,先记录下来,等我解锁后统一处理。
简单理解: vTaskSuspendAll() 保护调度过程 prvLockQueue() 保护 Queue 事件列表
最关键:为什么后面还要再次检查 Queue 是否为空? 第一次检查 Queue 空了之后,到第二次检查之前,可能发生: D2Task 或 ISR 已经发了数据,所以 FreeRTOS 必须再问一次:prvIsQueueEmpty( pxQueue ) 我现在真的要睡了,Queue 还是空的吗?
核心保护逻辑:D3Task 不是发现 Queue 空就立刻睡,而是在准备睡之前再次确认 Queue 仍然为空;如果这期间数据来了,就不睡,重新读取;如果数据没来,才把自己登记到 Queue 等待列表里。 xQueueReceive() 不是简单“读数组”,它会在 Queue 为空时,把当前 Task 挂到 Queue 的等待接收列表,并让任务进入 Blocked。
taskENTER_CRITICAL():短时间保护 Queue 状态读取。别让中断在我读关键变量时插进来 vTaskInternalSetTimeOutState():记录开始等待的时间。以后判断有没有等超时 vTaskSuspendAll():暂停任务调度。接下来我要改任务状态和链表,先别任务切换 prvLockQueue():锁住 Queue 的事件处理。我正在调整 Queue 等待列表,相关事件先别乱处理 xTaskCheckForTimeOut():检查等待是否超时。我还能不能继续等 prvIsQueueEmpty():第二次确认 Queue 是否还是空。睡觉前最后看一眼,有没有数据来了 vTaskPlaceOnEventList():真正把当前任务挂到 Queue 等待列表。D3Task正式登记:Queue有数据后叫醒我
FreeRTOS 中的 Blocked 是一种逻辑状态,并不一定对应一个统一的 Blocked List。对于带有有限超时时间的阻塞,例如 vTaskDelay() 或 xQueueReceive(..., 100),任务会通过 xStateListItem 挂入 Delayed List,由 Tick 计数负责超时唤醒。
但当任务使用 portMAX_DELAY 无限期等待某个事件对象时,任务没有确定的超时唤醒时间,因此 FreeRTOS 会将它的 xStateListItem 挂入 Suspended List,表示它不参与普通 Tick 超时调度。与此同时,它的 xEventListItem 会挂入对应事件对象的等待列表,例如 Queue 的 xTasksWaitingToReceive。当 Queue 有数据到来时,FreeRTOS 会通过事件等待列表找到该任务,将其从等待列表和 Suspended List 中移出,并重新加入 Ready List。
因此,D3Task 虽然出现在 xSuspendedTaskList 中,但它并不是由用户调用 vTaskSuspend() 人为挂起,而是处于“无限期等待 Queue 事件”的 Blocked 状态。
6. vTaskSuspendAll()
不是:把所有 Task 都放进 Suspended List 它的真正含义是:暂停调度器切换任务。
也就是: 暂时不允许任务切换 但中断仍然可以响应
| 函数 | 含义 |
|---|---|
vTaskSuspend(task) | 挂起某个具体任务 |
vTaskSuspendAll() | 暂停调度器,不进行任务切换 |
prvLockQueue( pxQueue ) ---- Queue 内部自己的锁机制。
作用是: 在当前任务准备阻塞期间,防止 Queue 的事件列表被并发修改得太乱。
7. 重点考虑
FreeRTOS必须保证:“检查Queue状态”和“进入等待状态”必须是一个不可被破坏的整体。 这就是为什么有:vTaskSuspendAll()
因为举例: D3:xQueueReceive() 发现:Queue为空,准备睡眠。
检查为空
|
|
↓
准备进入Blocked
但是就在这个间隙: D2或者ISR:xQueueSend()发送数据, 如果没有保护结果: D3睡了 但是数据已经在Queue里面,但是却没人接收
这就是灾难。
8. 并发
嵌入式里面: 并发更多表示:多个执行流在访问同一个资源,并且执行顺序不可预测。
最常见的问题:并发访问共享资源
| 问题 | 解决 |
|---|---|
| 多个任务访问变量 | Mutex |
| 任务传数据 | Queue |
| 任务等待事件 | Semaphore/Event |
| 中断通知任务 | Task Notification |
以后你听工程师说:“这个地方有并发问题” 翻译成人话:“这里多个执行流可能同时碰一个东西,需要保证顺序和一致性。”
9. 我的结论
D3Task:xQueueReceive(g_test_queue, &recv_value, portMAX_DELAY); 当 Queue 空时: D3Task 正在运行
↓
进入 xQueueReceive()
↓
第一次检查 Queue uxMessagesWaiting == 0
↓
不是不等,而是 portMAX_DELAY 所以准备阻塞
↓
退出短临界区
↓
暂停调度器 vTaskSuspendAll()
↓
锁 Queue prvLockQueue()
↓
检查是否超时 没有超时
↓
第二次检查 Queue 是否仍然为空
├── 不空:
│ 不睡
│ 回到循环开头重新接收
│
└── 仍然为空:
把 D3Task 挂到 Queue 等待接收列表
把 D3Task 移出 Ready
进入 Blocked / Suspended
恢复调度器
触发任务切换
10. 更深层理解:
taskENTER_CRITICAL():保护的不只是“读取”这个动作,而是保护这一小段对 Queue 状态的原子检查和处理: 也就是说,它保护的是:
读 Queue 当前数据数量 + 如果有数据就取走并更新数量 + 如果没数据就确定下一步策略
这整套短操作不能被打断。
在 Cortex-M FreeRTOS 里,临界区通常不是“关闭所有中断”,而是通过 BASEPRI 屏蔽一部分会影响 RTOS 内核数据结构的中断。极高优先级中断理论上仍然可以进来,但它们不允许调用 FreeRTOS API。你现在可以先理解为:临时屏蔽会影响 Queue/调度器安全的中断。
taskENTER_CRITICAL(); ... taskEXIT_CRITICAL();
这段临界区内部,相关中断和任务切换通常是被保护住的。
真正危险的窗口是: taskEXIT_CRITICAL();
/* 这里开始,其他任务和中断又可以操作 Queue */
vTaskSuspendAll(); prvLockQueue(pxQueue);
也就是说:
第一次检查 Queue 为空 ↓ 退出临界区 ↓ 在进入 vTaskSuspendAll / prvLockQueue 之前 ↓ 可能有 ISR 或其他任务发送数据
所以第二次检查不是因为“临界区内没有保护”,而是因为: 第一次检查之后,到真正准备阻塞之前,中间有一段时间 Queue 状态可能已经变化了。
prvLockQueue() 不是让别人完全不能操作 Queue,而是让 Queue 的某些“事件列表修改/唤醒任务动作”暂时延迟处理,等解锁时统一处理。
D3Task 调用 xQueueReceive() 后,首先进入临界区,短暂保护 Queue 的核心状态检查。它读取 uxMessagesWaiting 判断 Queue 中是否有数据。如果有数据,就直接取出并更新 Queue 状态;如果 Queue 为空,并且 xTicksToWait 不为 0,就记录等待起始时间,准备进入阻塞等待。随后退出临界区,避免长时间屏蔽中断。
退出临界区后,到真正把任务挂入等待列表之前,Queue 的状态可能已经被其他任务或中断改变,因此 FreeRTOS 会暂停调度器并锁住 Queue,然后再次检查 Queue 是否仍然为空。如果 Queue 已经有数据,就不阻塞,解锁后回到循环开头重新接收;如果 Queue 仍然为空,才调用 vTaskPlaceOnEventList(),把当前任务挂到 Queue 的等待接收列表,并将任务移出 Ready 状态,使其进入阻塞等待。
这样做的目的,是保证任务不会在“Queue 已经有数据”的情况下错误地睡眠,从而避免并发访问导致的事件丢失问题。
11. vTaskPlaceOnEventList(&( g_test_queue->xTasksWaitingToReceive ),portMAX_DELAY);
把当前运行的 D3Task 放到 g_test_queue 的“等待接收任务列表”里,并让它进入阻塞等待
第一件事:挂 xEventListItem 把当前任务登记到事件对象的等待列表里
Queue里面pxQueue->xTasksWaitingToReceive 当前有哪些任务正在等这个 Queue 变得可运行。 D3Task 要被挂进去:以后这个 Queue 有数据了,请叫醒我。 D3Task->xEventListItem ↓ g_test_queue->xTasksWaitingToReceive
第二件事:挂 xStateListItem 把当前任务从 Ready 状态移走,进入阻塞状态
D3Task 当前 Queue 没数据,所以它不应该继续占 CPU,因此 FreeRTOS 还要把它从 Ready List 移走。
D3Task 的状态不是简单一句“Blocked”能解释完的。 更准确是:
逻辑状态: Blocked
等待原因: Queue为空,等待接收数据
时间属性: portMAX_DELAY,无限期等待
内部链表: xEventListItem → Queue等待接收列表 xStateListItem → Suspended List
vTaskPlaceOnEventList() 用于将当前任务放入某个事件对象的等待列表,并使当前任务进入阻塞状态。以 xQueueReceive() 为例,当 Queue 为空且任务允许等待时,FreeRTOS 会把当前任务的 xEventListItem 插入 Queue 的 xTasksWaitingToReceive 列表,表示该任务正在等待 Queue 数据。同时,FreeRTOS 会通过 xStateListItem 将当前任务从 Ready List 移出,并根据等待时间放入 Delayed List 或 Suspended List。若等待时间为 portMAX_DELAY,任务没有明确的超时唤醒点,因此其 xStateListItem 可能被放入 Suspended List。
因此,一个等待 Queue 的任务并不是只简单地“挂起”,而是同时记录了“等待哪个事件”和“当前不可运行”两个信息。
12. 唤醒D3Task
D2Task 发送数据时,Queue 内部要做三件事 第一步: 检查 Queue 有没有空间
第二步: 把 value 拷贝进 Queue 内部缓冲区
第三步: 检查有没有任务正在等待接收
如果有:把等待接收的任务唤醒
也就是: D2Task ↓ xQueueSend() ↓ Queue 存入数据 ↓ 发现 D3Task 正在等数据 ↓ 把 D3Task 从等待链表移出来 ↓ 放回 Ready List
当前Task3链表状态: D3Task->xEventListItem:g_test_queue->xTasksWaitingToReceive D3Task->xStateListItem:xSuspendedTaskList
D2Task 发送时怎么找到 D3Task?检查有没有任务正在等我这个 Queue 的数据。 发现Task3在等待,于是执行: xTaskRemoveFromEventList( &( pxQueue->xTasksWaitingToReceive ) );从 Queue 的等待接收列表里,取出一个正在等待的任务,并把它重新变成 Ready。
xTaskRemoveFromEventList() 第一件事:从事件等待列表移除 第二件事:从阻塞状态链表移除,并加入 Ready List 但注意Ready 不等于立刻 Running,还要看优先级
D3Task 运行
↓
xQueueReceive()
↓
Queue 为空
↓
D3Task->xEventListItem 挂到 Queue->xTasksWaitingToReceive
↓
D3Task->xStateListItem 挂到 Suspended List / Delayed List
↓
D3Task 阻塞
=========================
D2Task 运行
↓
xQueueSend()
↓
把数据写入 Queue
↓
发现 Queue->xTasksWaitingToReceive 不为空
↓
xTaskRemoveFromEventList()
↓
D3Task->xEventListItem 从 Queue 等待列表移除
↓
D3Task->xStateListItem 从 Suspended / Delayed List 移除
↓
D3Task 加入 Ready List
↓
Scheduler 根据优先级决定是否立刻切换
↓
D3Task 最终运行
↓
从 xQueueReceive() 返回 pdPASS
↓
拿到 recv_value
函数API: prvCopyDataToQueue() 把数据拷贝进 Queue 缓冲区 listLIST_IS_EMPTY() 检查有没有任务在等接收 xTaskRemoveFromEventList() 从事件等待列表移除任务,并放回 Ready queueYIELD_IF_USING_PREEMPTION() 必要时请求任务切换
当某个任务调用 xQueueSend() 向 Queue 发送数据时,FreeRTOS 会先检查 Queue 是否有可用空间。若空间足够,则将数据拷贝到 Queue 的内部缓冲区,并更新 Queue 中的消息数量。随后,FreeRTOS 会检查该 Queue 的 xTasksWaitingToReceive 列表是否为空。若列表不为空,说明存在任务因为等待该 Queue 数据而阻塞。此时内核会调用 xTaskRemoveFromEventList(),将等待任务的 xEventListItem 从 Queue 等待列表中移除,并将该任务的 xStateListItem 从阻塞相关链表中移除后加入 Ready List。
因此,xQueueSend() 不仅完成数据发送,还可能唤醒因等待 Queue 数据而阻塞的任务。被唤醒的任务是否立即运行,取决于它与当前任务的优先级关系以及调度策略。
D2Task 运行
↓
调用 xQueueSend(g_test_queue, &value, 0)
↓
进入 xQueueGenericSend()
↓
taskENTER_CRITICAL() 保护 Queue 核心状态
↓
判断 Queue 未满
↓
prvCopyDataToQueue() 把 value 拷贝进 Queue 缓冲区
↓
检查 Queue->xTasksWaitingToReceive 是否为空
↓
发现 D3Task 正在等待接收
↓
xTaskRemoveFromEventList() 把 D3Task 从 Queue等待列表移除 并加入 Ready List
↓
如果 D3Task 优先级更高 请求任务切换
↓
taskEXIT_CRITICAL()
↓
return pdPASS
xQueueGenericSend() 不只是把数据写入 Queue,它还会检查是否有任务正阻塞在这个 Queue 的接收等待列表中;如果有,就调用 xTaskRemoveFromEventList() 将等待任务唤醒,使其重新进入 Ready List。
xTaskRemoveFromEventList() 是事件对象唤醒任务的核心函数。它先通过事件等待列表找到阻塞任务,再移除任务的 xEventListItem,随后移除任务的 xStateListItem,最后将任务加入 Ready List。如果被唤醒任务优先级高于当前任务,则返回 pdTRUE,提示调用者请求一次任务切换。