4. FreeRTOS内核分析(下):队列、信号量、互斥量、事件组
好,咱们接着往下聊。上一章我们把任务管理和调度讲透了,这一章要解决的是任务之间怎么“说话”的问题。
你想想看,一个系统里十几个任务各跑各的,数据怎么传?资源怎么抢?同步怎么搞?FreeRTOS 给了我们四件套:队列、信号量、互斥量、事件组。我做了这么多年嵌入式,可以说这四样东西用好了,90% 的多任务通信问题都能搞定。
核心观点:这四种机制本质上都是“任务间通信”的不同表现形式。队列传数据,信号量传状态,互斥量管资源,事件组等条件。
4.1 队列(Queue)—— 任务间的数据管道
队列是 FreeRTOS 里最基础的通信机制。说白了,就是一个先进先出的数据缓冲区。一个任务往里写,另一个任务往外读。
我记得刚入行那会儿,有个同事把全局数组当共享缓冲区用,结果两个任务同时读写,数据全乱了。从那以后,我但凡看到任务间传数据,第一反应就是用队列。
4.1.1 队列的工作原理
队列内部维护了一个环形缓冲区。写数据叫“发送”,读数据叫“接收”。关键点在于:
- 阻塞机制:队列满时,发送任务可以等;队列空时,接收任务可以等
- 超时控制:等多久由你定,等不到就返回超时错误
- 数据拷贝:FreeRTOS 传的是数据副本,不是指针。这点很重要
我的建议:队列传数据是“值传递”,不是“引用传递”。如果你传的是大结构体,记得把队列项设小一点,或者传指针。但传指针要小心——指针指向的内存必须保证在接收方读取时仍然有效。
4.1.2 队列 API 实战
来看一个典型的使用场景:一个传感器采集任务把数据发给处理任务。
// 创建队列,每个元素 4 字节(一个 int),队列深度 10
QueueHandle_t xSensorQueue = xQueueCreate(10, sizeof(int));
// 发送任务
void vSensorTask(void *pvParameters) {
int sensorValue;
while(1) {
sensorValue = readSensor();
// 发送,最多等 100ms
if(xQueueSend(xSensorQueue, &sensorValue, pdMS_TO_TICKS(100)) != pdPASS) {
// 发送失败处理
}
vTaskDelay(pdMS_TO_TICKS(50));
}
}
// 接收任务
void vProcessTask(void *pvParameters) {
int receivedValue;
while(1) {
// 接收,一直等
if(xQueueReceive(xSensorQueue, &receivedValue, portMAX_DELAY) == pdPASS) {
processData(receivedValue);
}
}
}
这里有个细节:xQueueSend 和 xQueueSendToBack 是一样的,都是往队尾放。还有个 xQueueSendToFront,插队用的,紧急数据可以用它。
4.1.3 队列集(Queue Set)
一个任务要同时等多个队列怎么办?FreeRTOS 提供了队列集。我在做车载网关项目时用过这个——一个任务要同时监听 CAN 总线、以太网和诊断三个队列。
// 创建队列集
QueueSetHandle_t xQueueSet = xQueueCreateSet(3);
// 把三个队列加入集合
xQueueAddToSet(xCanQueue, xQueueSet);
xQueueAddToSet(xEthQueue, xQueueSet);
xQueueAddToSet(xDiagQueue, xQueueSet);
// 等待任意一个队列有数据
QueueSetMemberHandle_t xActivatedQueue;
xActivatedQueue = xQueueSelectFromSet(xQueueSet, portMAX_DELAY);
// 从激活的队列中读取
if(xActivatedQueue == xCanQueue) {
xQueueReceive(xCanQueue, &data, 0);
}
4.2 信号量(Semaphore)—— 轻量级的“有/没有”通知
信号量本质上是一个队列,只不过队列项大小为 0,只存计数。它分两种:
| 类型 | 最大计数 | 典型用途 |
|---|---|---|
| 二值信号量 | 1 | 任务同步、中断通知 |
| 计数信号量 | N | 资源池管理、事件计数 |
4.2.1 二值信号量——中断与任务的桥梁
这是我最常用的场景。中断里不能调用阻塞 API,但可以用信号量通知任务来处理。
// 创建二值信号量
SemaphoreHandle_t xSemaphore = xSemaphoreCreateBinary();
// 中断服务函数
void vTimerISR(void) {
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
// 给出信号量
xSemaphoreGiveFromISR(xSemaphore, &xHigherPriorityTaskWoken);
// 如果接收任务优先级更高,触发上下文切换
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
// 处理任务
void vProcessTask(void *pvParameters) {
while(1) {
// 等待信号量
if(xSemaphoreTake(xSemaphore, portMAX_DELAY) == pdPASS) {
// 处理中断事件
}
}
}
我曾经踩过的坑:二值信号量创建后默认是“已给”状态。如果你希望它一开始是“未给”状态,记得创建后先调用一次 xSemaphoreTake 把它吃掉。或者用 xSemaphoreCreateBinaryStatic 配合初始化。
4.2.2 计数信号量——管理有限资源
比如系统里有 5 个 DMA 通道,多个任务要用。用计数信号量来管理:
// 创建计数信号量,初始值 5,最大值 5
SemaphoreHandle_t xDmaSemaphore = xSemaphoreCreateCounting(5, 5);
// 任务申请 DMA 通道
void vTaskUseDMA(void *pvParameters) {
if(xSemaphoreTake(xDmaSemaphore, pdMS_TO_TICKS(1000)) == pdPASS) {
// 拿到一个 DMA 通道,开始传输
useDMAChannel();
// 用完归还
xSemaphoreGive(xDmaSemaphore);
} else {
// 超时,没拿到资源
}
}
4.3 互斥量(Mutex)—— 带“优先级继承”的锁
互斥量和二值信号量很像,但有个关键区别:互斥量有优先级继承机制。
为什么要这个机制?我给你讲个真实案例。有一次我在调试一个车载系统,低优先级任务拿了锁,高优先级任务在等锁,中间还有个中优先级任务在跑。结果中优先级任务一直占着 CPU,低优先级任务没法释放锁,高优先级任务就一直等——这就是经典的优先级反转。
互斥量的优先级继承能解决这个问题:当高优先级任务等锁时,持有锁的低优先级任务会临时“继承”高优先级,跑完了再降回去。
4.3.1 互斥量使用要点
- 不能用在中断里:互斥量的优先级继承需要任务上下文,中断里用会出问题
- 谁拿谁放:只有拿了锁的任务才能释放,不能跨任务操作
- 递归互斥量:同一个任务可以多次拿锁,适合递归函数
// 创建互斥量
SemaphoreHandle_t xMutex = xSemaphoreCreateMutex();
void vTaskA(void *pvParameters) {
while(1) {
// 拿锁
if(xSemaphoreTake(xMutex, portMAX_DELAY) == pdPASS) {
// 访问共享资源
writeSharedData();
// 放锁
xSemaphoreGive(xMutex);
}
}
}
关键对比:互斥量 vs 二值信号量
- 互斥量有优先级继承,二值信号量没有
- 互斥量只能用于任务间,二值信号量可用于中断
- 互斥量必须由同一任务释放,二值信号量可以跨任务
- 互斥量适合保护共享资源,二值信号量适合同步
4.4 事件组(Event Group)—— 多条件组合等待
事件组是 FreeRTOS 里一个比较“高级”的机制。它用 24 位(或更少)的位掩码来表示事件状态。一个任务可以等多个事件,而且可以指定“与”还是“或”的关系。
我做过一个车载诊断系统,需要等三个条件都满足才能开始诊断:CAN 总线就绪、诊断会话激活、ECU 唤醒。用事件组就特别合适。
4.4.1 事件组 API
// 定义事件位
#define EVENT_CAN_READY (1 << 0)
#define EVENT_DIAG_ACTIVE (1 << 1)
#define EVENT_ECU_AWAKE (1 << 2)
// 创建事件组
EventGroupHandle_t xEventGroup = xEventGroupCreate();
// 设置事件(可以在中断或任务中调用)
xEventGroupSetBits(xEventGroup, EVENT_CAN_READY);
// 等待多个事件(与逻辑)
EventBits_t uxBits = xEventGroupWaitBits(
xEventGroup, // 事件组句柄
EVENT_CAN_READY | EVENT_DIAG_ACTIVE | EVENT_ECU_AWAKE, // 要等的事件
pdTRUE, // 清除已等到的位
pdTRUE, // 与逻辑(pdFALSE 为或逻辑)
portMAX_DELAY // 超时
);
if((uxBits & (EVENT_CAN_READY | EVENT_DIAG_ACTIVE | EVENT_ECU_AWAKE)) ==
(EVENT_CAN_READY | EVENT_DIAG_ACTIVE | EVENT_ECU_AWAKE)) {
// 三个条件都满足了,开始诊断
startDiagnostic();
}
4.4.2 事件组的优势
| 特性 | 事件组 | 多个信号量 |
|---|---|---|
| 等待多个条件 | 原生支持,一行代码搞定 | 需要嵌套或轮询 |
| 与/或逻辑 | 通过参数切换 | 需要自己实现 |
| 清除事件 | 自动或手动 | 需要单独调用 |
| 性能 | 一次系统调用 | 多次系统调用 |
我的经验:事件组特别适合“状态机 + 多条件触发”的场景。比如汽车上的电源管理——需要等钥匙信号、CAN 唤醒、定时器超时等多个条件组合才能决定进入休眠还是唤醒。
4.5 避坑指南与性能考量
做了这么多年,我总结了几条关于 IPC 的“血泪教训”:
- 队列深度别设太大:每个队列项都要分配内存,设大了浪费 RAM。嵌入式环境 RAM 金贵
- 中断里只用 FromISR 版本:
xQueueSendFromISR、xSemaphoreGiveFromISR,别搞混了 - 互斥量不是万能的:优先级继承会增加调度开销。如果确定没有优先级反转风险,用二值信号量更轻量
- 事件组位不够用:标准版只有 24 位,如果事件超过 24 个,考虑用多个事件组或者自己封装
- 超时时间要合理:
portMAX_DELAY是死等,除非你确定事件一定会来,否则设个超时防止任务挂死
我曾经犯过的错:有一次在中断里用了 xSemaphoreGive 而不是 xSemaphoreGiveFromISR,结果系统随机死机。查了两天才发现是中断上下文调用了非 ISR 版本的 API。从那以后,我写中断代码第一件事就是检查 API 后缀。