4. 任务同步与通信:信号量、消息队列、事件标志组、邮箱与管道
任务调度只是RTOS的骨架,任务之间的同步与通信才是真正的血肉。我见过不少工程师,任务划分得挺合理,但一到数据交换就出问题——要么死锁,要么数据覆盖。说白了,就是没选对同步机制。
这一章,咱们把RTOS里最常用的几种通信方式掰开揉碎。信号量、消息队列、事件标志组、邮箱、管道,还有经典的生产者-消费者模型。我会结合项目里踩过的坑,告诉你什么时候该用哪个。
4.1 信号量:二值、计数与互斥
信号量是RTOS里最基础的同步工具。很多人觉得它简单,但用起来却容易出问题。我把它分成三类来讲。
4.1.1 二值信号量
二值信号量只有两个状态:0和1。它就像一把锁,要么锁着,要么开着。常用于任务与中断之间的同步。
举个例子:一个按键中断,按下后通知任务处理。中断里释放信号量,任务里等待信号量。这样任务就不用轮询按键状态了。
// 二值信号量典型用法
SemaphoreHandle_t xKeySemaphore;
// 中断服务函数
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) {
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
// 释放信号量,通知任务
xSemaphoreGiveFromISR(xKeySemaphore, &xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
// 任务函数
void KeyTask(void *pvParameters) {
while(1) {
// 等待信号量,超时设为最大值
if(xSemaphoreTake(xKeySemaphore, portMAX_DELAY) == pdTRUE) {
// 处理按键事件
ProcessKeyEvent();
}
}
}
4.1.2 计数信号量
计数信号量可以记录多个资源。比如一个缓冲区有10个空位,生产者放一个,计数减1;消费者取一个,计数加1。计数信号量就是用来管理这种多资源场景的。
我记得有一次做网络协议栈,接收缓冲区用了计数信号量。每收到一个包就释放一次,处理任务每次取一个。这样既不会丢包,也不会溢出。
| 特性 | 二值信号量 | 计数信号量 |
|---|---|---|
| 取值范围 | 0 或 1 | 0 到 N(N为最大值) |
| 典型场景 | 中断与任务同步 | 多资源管理 |
| 释放操作 | 只能释放一次 | 可多次释放 |
| 适用资源数 | 单个资源 | 多个同类资源 |
4.1.3 互斥信号量
互斥信号量是二值信号量的升级版。它多了优先级继承机制,能解决优先级反转问题。
什么是优先级反转?低优先级任务拿着锁,高优先级任务等着用,中间优先级的任务却抢占了CPU。结果高优先级任务反而被低优先级任务拖死。
// 互斥信号量创建
SemaphoreHandle_t xMutex = xSemaphoreCreateMutex();
// 任务A(高优先级)
void HighPriorityTask(void *pvParameters) {
while(1) {
xSemaphoreTake(xMutex, portMAX_DELAY);
// 访问共享资源
xSemaphoreGive(xMutex);
}
}
// 任务B(低优先级)
void LowPriorityTask(void *pvParameters) {
while(1) {
xSemaphoreTake(xMutex, portMAX_DELAY);
// 长时间占用资源
vTaskDelay(pdMS_TO_TICKS(100));
xSemaphoreGive(xMutex);
}
}
4.2 消息队列
消息队列是任务间传递数据的标准方式。它像一个管道,一个任务往里放,另一个任务往外取。数据是拷贝进去的,所以不用担心共享内存的问题。
我个人习惯用消息队列来解耦任务。发送方只管发,接收方只管收,双方不需要知道对方的存在。
// 消息队列创建
QueueHandle_t xMsgQueue = xQueueCreate(10, sizeof(MsgPacket_t));
// 发送任务
void SendTask(void *pvParameters) {
MsgPacket_t msg;
while(1) {
msg.id = GetSensorId();
msg.value = ReadSensor();
// 发送消息,超时100ms
if(xQueueSend(xMsgQueue, &msg, pdMS_TO_TICKS(100)) != pdTRUE) {
// 队列满了,处理错误
HandleQueueFull();
}
vTaskDelay(pdMS_TO_TICKS(50));
}
}
// 接收任务
void RecvTask(void *pvParameters) {
MsgPacket_t msg;
while(1) {
// 等待消息
if(xQueueReceive(xMsgQueue, &msg, portMAX_DELAY) == pdTRUE) {
ProcessMessage(&msg);
}
}
}
4.3 事件标志组
事件标志组适合一个任务等待多个条件的场景。每个位代表一个事件,任务可以等待任意一个事件,也可以等待所有事件。
你想想看,一个数据采集任务,要等温度、湿度、气压三个传感器都就绪了才能开始处理。用事件标志组就特别合适。
// 事件标志组创建
EventGroupHandle_t xEventGroup = xEventGroupCreate();
#define TEMP_READY (1 << 0)
#define HUMI_READY (1 << 1)
#define PRESS_READY (1 << 2)
// 传感器中断
void TempSensor_ISR(void) {
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
xEventGroupSetBitsFromISR(xEventGroup, TEMP_READY, &xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
// 采集任务
void CollectTask(void *pvParameters) {
EventBits_t uxBits;
while(1) {
// 等待所有三个事件
uxBits = xEventGroupWaitBits(
xEventGroup,
TEMP_READY | HUMI_READY | PRESS_READY,
pdTRUE, // 退出时清除标志
pdTRUE, // 等待所有位
portMAX_DELAY
);
if((uxBits & (TEMP_READY | HUMI_READY | PRESS_READY)) ==
(TEMP_READY | HUMI_READY | PRESS_READY)) {
ProcessAllData();
}
}
}
4.4 邮箱与管道
邮箱和管道是消息队列的变体。邮箱通常只存一条消息,管道则是无格式的字节流。
我记得在做一个音频处理项目时,用管道来传递PCM数据流。因为数据量大,用消息队列拷贝太慢,管道直接操作缓冲区就快多了。
| 通信方式 | 数据格式 | 容量 | 典型用途 |
|---|---|---|---|
| 消息队列 | 结构化消息 | 多条 | 任务间数据传递 |
| 邮箱 | 指针或小数据 | 1条 | 快速通知 |
| 管道 | 字节流 | 可配置 | 大数据流传输 |
4.5 生产者-消费者模型实战
生产者-消费者模型是嵌入式系统里最常见的架构。一个任务生产数据,另一个任务消费数据。中间用缓冲区解耦。
我做过一个数据采集系统,ADC采样是生产者,数据处理是消费者。中间用环形缓冲区加计数信号量来实现。
// 环形缓冲区 + 信号量实现生产者-消费者
#define BUFFER_SIZE 256
typedef struct {
uint16_t data[BUFFER_SIZE];
volatile int head;
volatile int tail;
SemaphoreHandle_t semEmpty; // 空位计数
SemaphoreHandle_t semFull; // 数据计数
} RingBuffer_t;
RingBuffer_t rb;
// 生产者(中断中调用)
void Producer_ISR(uint16_t sample) {
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
// 检查是否有空位
if(xSemaphoreTakeFromISR(rb.semEmpty, &xHigherPriorityTaskWoken) == pdTRUE) {
rb.data[rb.head] = sample;
rb.head = (rb.head + 1) % BUFFER_SIZE;
// 通知消费者
xSemaphoreGiveFromISR(rb.semFull, &xHigherPriorityTaskWoken);
}
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
// 消费者(任务中调用)
void ConsumerTask(void *pvParameters) {
uint16_t sample;
while(1) {
// 等待数据
if(xSemaphoreTake(rb.semFull, portMAX_DELAY) == pdTRUE) {
sample = rb.data[rb.tail];
rb.tail = (rb.tail + 1) % BUFFER_SIZE;
// 释放空位
xSemaphoreGive(rb.semEmpty);
ProcessSample(sample);
}
}
}
- 生产者-消费者之间不要加锁,用信号量控制即可
- 缓冲区大小要能覆盖生产峰值
- 消费者处理速度不能长期低于生产速度
嗯,到这里,任务同步与通信的核心内容就讲完了。信号量、消息队列、事件标志组、邮箱、管道,还有生产者-消费者模型,每个都有它的用武之地。选对了,系统跑得稳;选错了,调试起来真要命。