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。结果高优先级任务反而被低优先级任务拖死。

我曾经踩过的坑:在一个电机控制项目里,三个任务共享一个I2C总线。用了普通二值信号量,结果高优先级的急停任务被中间优先级的日志任务阻塞了整整200ms。换成互斥信号量后,问题立刻解决。
// 互斥信号量创建
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);
        }
    }
}
避坑指南:消息队列的大小要合理设置。太小了容易丢消息,太大了浪费内存。我一般按峰值速率的1.5倍来估算。

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);
        }
    }
}
实战要点:
  • 生产者-消费者之间不要加锁,用信号量控制即可
  • 缓冲区大小要能覆盖生产峰值
  • 消费者处理速度不能长期低于生产速度
生产者-消费者模型架构图 生产者任务 (ADC采样/数据生成) 消费者任务 (数据处理/存储) 环形缓冲区 BUFFER_SIZE = 256 head → 写入位置 tail → 读取位置 semEmpty 空位计数 semFull 数据计数 写入数据 读取数据 Take Give Take Give 说明: 生产者写入数据后Give semFull,消费者Take semFull后读取数据。 消费者读取后Give semEmpty,生产者Take semEmpty后继续写入。形成闭环。

嗯,到这里,任务同步与通信的核心内容就讲完了。信号量、消息队列、事件标志组、邮箱、管道,还有生产者-消费者模型,每个都有它的用武之地。选对了,系统跑得稳;选错了,调试起来真要命。