任务同步与通信:信号量、消息队列与事件标志组
任务之间怎么配合?这是 RTOS 里最核心的问题。你想想看,一个系统里十几个任务跑来跑去,如果它们之间没有规矩,数据乱抢、资源乱占,系统迟早要崩。我做了这么多年嵌入式,见过太多因为同步没做好导致的诡异 bug——有时候跑几个小时才出一次,查起来真要命。
这一章,咱们就把任务同步与通信的三大件讲透:信号量、消息队列、事件标志组。它们各有各的脾气,用对了地方,系统稳如老狗;用错了,调试到你怀疑人生。
信号量:二值、计数与互斥
信号量这东西,说白了就是一个“资源计数器”。任务想用资源,先拿信号量;用完了,还回去。就这么简单。
二值信号量
二值信号量只有两个状态:0 和 1。它最适合做“事件通知”。比如一个按键中断,通知任务去处理按键事件。
典型场景:中断服务程序(ISR)通知任务。ISR 里释放信号量,任务里等待信号量。这样中断处理时间极短,复杂逻辑全在任务里跑。
// 创建二值信号量
osSemaphoreId_t myBinarySem;
myBinarySem = osSemaphoreNew(1, 0, NULL); // 最大计数1,初始值0
// ISR 中释放
void EXTI0_IRQHandler(void) {
osSemaphoreRelease(myBinarySem); // 给信号量+1
// 清中断标志...
}
// 任务中等待
void KeyTask(void *arg) {
while(1) {
osSemaphoreAcquire(myBinarySem, osWaitForever); // 等信号量
// 处理按键事件
}
}
我刚开始用 RTOS 时,犯过一个低级错误:在 ISR 里调用了 osDelay()。结果系统直接挂掉。记住,ISR 里只能调用带“FromISR”后缀的 API,或者明确标注可在中断中使用的函数。
计数信号量
计数信号量可以管理多个同类资源。比如一个串口 DMA 缓冲池有 4 个缓冲区,任务每次取一个,用完归还。
| 特性 | 二值信号量 | 计数信号量 |
|---|---|---|
| 取值范围 | 0 或 1 | 0 ~ N(N 为最大计数) |
| 典型用途 | 事件通知、互斥 | 资源池管理 |
| 初始值 | 通常为 0 | 通常为 N(资源数量) |
| 释放操作 | 计数值从 0→1 | 计数值 +1(不超过最大值) |
我的习惯:管理资源池时,计数信号量的初始值设成资源总数。任务取资源时 acquire,用完 release。这样代码逻辑清晰,不容易漏掉资源归还。
互斥信号量(Mutex)
互斥信号量是二值信号量的“升级版”。它多了两个关键特性:优先级继承和递归访问。优先级继承能解决经典的“优先级反转”问题。
我曾经踩过的坑:在一个项目中,低优先级任务拿了互斥锁,中优先级任务抢占了 CPU,高优先级任务等锁等到死。这就是典型的优先级反转。后来换成带优先级继承的互斥量,问题解决。记住:保护共享资源时,永远用互斥信号量,别用二值信号量。
// 创建互斥信号量
osMutexId_t myMutex;
myMutex = osMutexNew(NULL);
// 任务中使用
void SharedResourceTask(void *arg) {
while(1) {
osMutexAcquire(myMutex, osWaitForever);
// 访问共享资源(比如全局变量、外设寄存器)
// 这段代码不会被其他任务打断
osMutexRelease(myMutex);
}
}
消息队列:阻塞与非阻塞发送
信号量只能传递“有没有”的信息,但很多时候我们需要传递具体数据。这时候消息队列就上场了。
消息队列本质上是一个 FIFO 缓冲区。发送方往里放数据,接收方从里面取数据。数据可以是整数、结构体、指针——随便你。
阻塞发送 vs 非阻塞发送
| 发送模式 | 队列满时的行为 | 适用场景 |
|---|---|---|
| 阻塞发送 | 任务挂起等待,直到队列有空位 | 数据必须发送成功,可以等 |
| 非阻塞发送 | 立即返回错误码,不等待 | ISR 中发送、数据可丢弃 |
// 创建消息队列(每个消息 4 字节,最多 10 条)
osMessageQueueId_t myQueue;
myQueue = osMessageQueueNew(10, 4, NULL);
// 任务中阻塞发送
void SenderTask(void *arg) {
uint32_t data = 0x12345678;
osMessageQueuePut(myQueue, &data, 0, osWaitForever); // 队列满就等
}
// ISR 中非阻塞发送
void UART_IRQHandler(void) {
uint8_t byte = UART->DR;
if (osMessageQueuePut(myQueue, &byte, 0, 0) != osOK) {
// 队列满了,数据丢了。可以加个计数统计丢包率
error_count++;
}
}
// 接收任务
void ReceiverTask(void *arg) {
uint32_t recv_data;
while(1) {
osMessageQueueGet(myQueue, &recv_data, NULL, osWaitForever);
// 处理收到的数据
}
}
我个人习惯:ISR 里一律用非阻塞发送。中断处理要快,不能等。如果队列满了,数据丢了就丢了,总比系统卡死强。任务之间通信,我一般用阻塞发送,保证数据不丢。
事件标志组
有时候一个任务要等多个条件同时满足,或者等其中任意一个条件满足。用信号量得等好几个,代码写起来又臭又长。事件标志组就是干这个的。
事件标志组是一个 32 位的变量,每一位代表一个事件。任务可以等待某一位或某几位同时置位,也可以等其中任意一位置位。
// 创建事件标志组
osEventFlagsId_t myEvents;
myEvents = osEventFlagsNew(NULL);
#define EVENT_BIT_0 (1 << 0) // 传感器数据就绪
#define EVENT_BIT_1 (1 << 1) // 网络数据到达
#define EVENT_BIT_2 (1 << 2) // 用户按键按下
// 任务等待多个事件(与模式:全部满足)
void MainTask(void *arg) {
uint32_t flags;
while(1) {
flags = osEventFlagsWait(myEvents,
EVENT_BIT_0 | EVENT_BIT_1 | EVENT_BIT_2,
osFlagsWaitAll, // 三个位都置位才返回
osWaitForever);
// 三个条件都满足了,开始处理
}
}
// 另一个任务等待任意事件(或模式)
void QuickTask(void *arg) {
uint32_t flags;
while(1) {
flags = osEventFlagsWait(myEvents,
EVENT_BIT_0 | EVENT_BIT_1,
osFlagsWaitAny, // 任意一个置位就返回
osWaitForever);
if (flags & EVENT_BIT_0) {
// 处理传感器数据
}
if (flags & EVENT_BIT_1) {
// 处理网络数据
}
}
}
// 其他任务或 ISR 设置事件位
void SensorTask(void *arg) {
while(1) {
// 采集传感器数据...
osEventFlagsSet(myEvents, EVENT_BIT_0);
}
}
关键区别:事件标志组是“多对一”的同步模式。多个任务或 ISR 可以设置不同的事件位,一个任务等待这些位的组合。信号量是“一对一”或“多对一”的计数同步,不能表达复杂的条件组合。
我曾经在一个数据采集项目里,用事件标志组同时等待“DMA 传输完成”、“定时器溢出”和“FIFO 半满”三个事件。主任务等这三个条件都满足后,才开始做数据融合。如果用信号量,得搞三个信号量再加一个任务去轮询,代码量翻倍还不说,实时性还差。
避坑指南:我曾经在一个项目里,用消息队列传递大结构体(几百字节)。结果队列深度设小了,任务发送频繁,导致内存碎片严重。后来改成传递指针,结构体放在静态池里管理,问题解决。记住:消息队列传递的数据越小越好,能传指针就别传值。
嗯,这一章的内容就到这儿。信号量、消息队列、事件标志组,这三样东西用好了,任务之间的配合就能行云流水。调试的时候,多看看任务状态、队列使用率、信号量计数值,很多问题一眼就能看出来。