4. FreeRTOS 任务同步与通信:队列、信号量、互斥量、事件组、任务通知
多任务系统里,任务之间不是孤立的。它们需要交换数据,需要互相知会状态,也需要协调对共享资源的访问。这就是同步与通信要解决的问题。
FreeRTOS 提供了五种主要机制:队列、信号量、互斥量、事件组和任务通知。我刚开始用 RTOS 时,总觉得这些概念差不多,选哪个都行。后来踩过坑才明白——选错了,系统可能跑得磕磕绊绊,甚至死锁。
这一章,我把它们掰开揉碎讲清楚。
4.1 队列(Queue)——任务间传递数据的核心
队列是 FreeRTOS 里最基础的通信方式。说白了,就是一个先进先出的数据缓冲区。一个任务往里写,另一个任务往外读。
核心特性:
- 数据传递是拷贝式的——发送方把数据拷进队列,接收方从队列拷走数据
- 支持多个发送者和多个接收者
- 读空队列或写满队列时,任务可以阻塞等待
我在项目中遇到过一个问题:两个任务之间传递一个结构体,结构体里有个指针。发送方把指针地址发过去了,接收方拿着地址去读数据——结果发送方早就把数据释放了。这就是拷贝式传递的陷阱。记住,队列只拷贝指针本身,不拷贝指针指向的内容。
创建队列的 API 很简单:
// 创建一个能存放 5 个 uint32_t 类型数据的队列
QueueHandle_t xQueue;
xQueue = xQueueCreate(5, sizeof(uint32_t));
// 发送数据(在任务A中)
uint32_t data = 100;
xQueueSend(xQueue, &data, portMAX_DELAY);
// 接收数据(在任务B中)
uint32_t received;
xQueueReceive(xQueue, &received, portMAX_DELAY);
注意 portMAX_DELAY 这个参数。它表示如果队列满或空,任务会一直等下去。实际项目中我建议给个超时时间,别死等——万一出问题,整个系统就卡死了。
4.2 信号量(Semaphore)——轻量级的同步工具
信号量分两种:二值信号量和计数信号量。
| 类型 | 用途 | 典型场景 |
|---|---|---|
| 二值信号量 | 任务间或中断与任务间的同步 | 外设数据准备好,通知任务处理 |
| 计数信号量 | 资源计数或事件计数 | 管理有限数量的缓冲区 |
二值信号量其实就是一个只有 0 和 1 的队列。你想想看,如果只是为了通知任务“有数据来了”,用队列传一个空数据包是不是有点浪费?信号量就是干这个的。
我的习惯:中断服务程序里只用 xSemaphoreGiveFromISR(),不用 xSemaphoreGive()。后者会导致调度器在中断上下文中被调用,后果很严重。
计数信号量的用法:
// 创建计数信号量,最大计数值 10,初始值 0
SemaphoreHandle_t xSemaphore;
xSemaphore = xSemaphoreCreateCounting(10, 0);
// 任务A:生产资源
xSemaphoreGive(xSemaphore);
// 任务B:消费资源
if (xSemaphoreTake(xSemaphore, pdMS_TO_TICKS(100)) == pdTRUE) {
// 拿到了资源,开始处理
}
4.3 互斥量(Mutex)——解决资源竞争
互斥量和二值信号量很像,但有一个关键区别:互斥量支持优先级继承。
为什么会这样?假设低优先级任务拿了锁,高优先级任务在等锁。如果没有优先级继承,低优先级任务可能被中优先级任务抢占,导致高优先级任务一直等下去——这就是优先级反转。
互斥量的优先级继承机制会临时提升低优先级任务的优先级,让它尽快释放锁。
我曾经踩过的坑:在中断里调用 xSemaphoreGive() 给互斥量。互斥量不能在中断中使用!中断里只能用二值信号量或计数信号量。互斥量的优先级继承机制依赖任务调度,中断上下文里没有这个条件。
互斥量的使用示例:
// 创建互斥量
SemaphoreHandle_t xMutex;
xMutex = xSemaphoreCreateMutex();
// 任务A:访问共享资源
void vTaskA(void *pvParameters) {
for (;;) {
xSemaphoreTake(xMutex, portMAX_DELAY);
// 操作共享资源(比如写UART)
UART_Write("Hello");
xSemaphoreGive(xMutex);
vTaskDelay(pdMS_TO_TICKS(100));
}
}
4.4 事件组(Event Group)——多事件同步
有时候一个任务需要等待多个条件同时满足,或者等待任意一个条件满足。用信号量实现会很麻烦——你得创建多个信号量,然后轮询。事件组就是专门解决这个问题的。
事件组里的每个位代表一个事件。你可以设置位、清除位,也可以等待多个位同时置位或任意位置位。
// 创建事件组
EventGroupHandle_t xEventGroup;
xEventGroup = xEventGroupCreate();
// 任务A:设置事件位
xEventGroupSetBits(xEventGroup, BIT_0 | BIT_1);
// 任务B:等待 BIT_0 和 BIT_1 同时置位
EventBits_t uxBits;
uxBits = xEventGroupWaitBits(
xEventGroup, // 事件组句柄
BIT_0 | BIT_1, // 等待的位
pdTRUE, // 退出时清除这些位
pdTRUE, // 等待所有位都置位
portMAX_DELAY // 超时时间
);
第三个参数 pdTRUE 表示退出时自动清除这些位。我建议一般设为 pdTRUE,否则你得手动清除,容易漏掉。
事件组 vs 信号量:
- 信号量适合“单一事件”的同步
- 事件组适合“多个事件组合”的同步
- 事件组可以在中断中设置位,但不能在中断中等待
4.5 任务通知(Task Notification)——最高效的通信方式
任务通知是 FreeRTOS 里最轻量级的同步机制。每个任务内部都有一个 32 位的通知值和 8 位的通知状态。你可以直接给某个任务发通知,不需要创建队列或信号量。
它的优势很明显:
- 不需要额外的内存分配
- 速度比信号量快约 45%
- 支持多种操作:设置位、增加值、覆写值等
但有个限制:只能一对一通信。你不能用任务通知实现多个任务等待同一个事件。
// 任务A:给任务B发通知
TaskHandle_t xTaskBHandle;
void vTaskA(void *pvParameters) {
for (;;) {
// 做一些工作
xTaskNotifyGive(xTaskBHandle);
vTaskDelay(pdMS_TO_TICKS(100));
}
}
void vTaskB(void *pvParameters) {
uint32_t ulNotifiedValue;
for (;;) {
// 等待通知,超时时间 500ms
ulNotifiedValue = ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(500));
if (ulNotifiedValue > 0) {
// 收到了通知,开始处理
}
}
}
我个人习惯在中断里优先使用任务通知。它比信号量更快,而且不需要创建额外的对象。但要注意,任务通知的 32 位值如果用作计数,最大只能到 2^32 - 1,别溢出了。
4.6 如何选择?一张图说清楚
下面这张图总结了五种机制的适用场景:
4.7 避坑指南
最后,分享几个我实际项目中遇到的坑:
- 队列长度设太小:发送方写数据比接收方读数据快,队列满了,发送方阻塞——结果整个系统被拖慢。我建议根据数据峰值速率计算队列长度,留 20% 余量。
- 互斥量死锁:两个任务各自持有一个互斥量,又在等对方释放。解决办法是:统一锁的获取顺序,或者用
xSemaphoreTake()加超时。 - 中断里调用阻塞 API:中断里不能调用
portMAX_DELAY的等待函数。中断上下文没有任务调度,一调用就死机。 - 任务通知值溢出:如果频繁发通知但不消费,32 位值会溢出。我习惯在接收方用
ulTaskNotifyTake(pdTRUE, 0)轮询,确保通知值不会堆积。
嗯,这五种机制用好了,你的 FreeRTOS 系统会非常稳健。选对工具,事半功倍。