3、任务间同步与通信:信号量、互斥锁、消息队列、事件标志组、邮箱

各位同学,咱们今天聊点实在的。任务间同步与通信,说白了就是让多个任务能好好配合,别打架。我做了这么多年工控,见过太多因为同步没做好导致的系统崩溃。嗯,咱们一个一个来看。

3.1 信号量:最简单的同步工具

信号量,你可以把它想象成一个令牌。谁拿到令牌,谁就能干活。拿不到的,就乖乖等着。

我习惯把信号量分成两种:

  • 二值信号量:只有0和1两种状态。适合做互斥,或者事件通知。
  • 计数信号量:可以设置初始值,比如5。适合管理资源池,比如5个串口缓冲区。

核心操作就三个:创建、获取(take)、释放(give)。

举个例子,一个传感器采集任务,采集完数据后释放信号量。另一个处理任务,等待这个信号量。代码大概长这样:

// 创建二值信号量
SemaphoreHandle_t xSemaphore = xSemaphoreCreateBinary();

// 采集任务
void vSensorTask(void *pvParameters) {
    while(1) {
        // 采集数据
        sensor_data = read_sensor();
        // 释放信号量,通知处理任务
        xSemaphoreGive(xSemaphore);
        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

// 处理任务
void vProcessTask(void *pvParameters) {
    while(1) {
        // 等待信号量,超时设为 portMAX_DELAY
        if(xSemaphoreTake(xSemaphore, portMAX_DELAY) == pdTRUE) {
            // 处理数据
            process_data(sensor_data);
        }
    }
}

我在项目中遇到过一个问题:两个任务同时等待同一个信号量,结果优先级高的任务一直抢到,低优先级的任务饿死了。后来我加了优先级继承机制才解决。这个后面讲互斥锁时会细说。

小技巧:信号量的超时时间别设成 portMAX_DELAY,除非你确定对方一定会释放。否则一旦出问题,整个系统就卡死了。

3.2 互斥锁:解决资源争抢

互斥锁和信号量有点像,但有个关键区别:互斥锁有优先级继承机制。什么意思?

你想想看,一个低优先级任务拿着锁,高优先级任务在等。如果没有优先级继承,低优先级任务可能被其他中优先级任务抢走CPU,导致高优先级任务一直等。这就是经典的优先级反转问题。

互斥锁的优先级继承,会临时把低优先级任务的优先级提升到和高优先级一样,让它赶紧释放锁。嗯,这个机制在实时系统中太重要了。

注意:互斥锁只能在任务中使用,不能在中断服务程序中使用。因为中断里不能阻塞等待。我曾经犯过这个错,结果系统直接死机。

使用互斥锁的典型场景:

  • 保护共享数据结构,比如全局变量、链表
  • 保护硬件资源,比如ADC、DAC
  • 保护文件系统操作
// 创建互斥锁
SemaphoreHandle_t xMutex = xSemaphoreCreateMutex();

void vTask1(void *pvParameters) {
    while(1) {
        // 获取互斥锁
        if(xSemaphoreTake(xMutex, portMAX_DELAY) == pdTRUE) {
            // 访问共享资源
            write_to_shared_buffer(data);
            // 释放互斥锁
            xSemaphoreGive(xMutex);
        }
    }
}

避坑指南:我曾经在互斥锁保护区域内调用了可能导致任务切换的函数,结果死锁了。记住,锁的粒度要尽量小,持有时间要尽量短。

3.3 消息队列:任务间的数据快递

消息队列,说白了就是个先进先出的缓冲区。一个任务往里扔数据,另一个任务从里面取数据。我特别喜欢用消息队列,因为它天然解决了生产者和消费者的同步问题。

消息队列的几个关键参数:

参数 说明 我的建议
队列长度 最多能存多少条消息 根据峰值流量来定,留20%余量
消息大小 每条消息的字节数 尽量小,太大浪费内存
超时时间 发送/接收等待时间 别设太长,影响实时性
// 创建消息队列,每条消息4字节,队列长度10
QueueHandle_t xQueue = xQueueCreate(10, sizeof(uint32_t));

// 发送任务
void vSenderTask(void *pvParameters) {
    uint32_t data = 0;
    while(1) {
        data = read_sensor();
        // 发送到队列,超时100ms
        if(xQueueSend(xQueue, &data, pdMS_TO_TICKS(100)) != pdPASS) {
            // 发送失败处理
            handle_error();
        }
        vTaskDelay(pdMS_TO_TICKS(50));
    }
}

// 接收任务
void vReceiverTask(void *pvParameters) {
    uint32_t received_data;
    while(1) {
        // 接收数据,永久等待
        if(xQueueReceive(xQueue, &received_data, portMAX_DELAY) == pdPASS) {
            process_data(received_data);
        }
    }
}

我记得有一次做运动控制项目,用消息队列传递控制指令。结果队列长度设小了,指令丢失导致电机抖动。后来我把队列长度从5改成20,问题就解决了。所以队列长度一定要根据实际数据流量来算。

3.4 事件标志组:多条件同步

事件标志组,你可以理解为一组二进制位。每个位代表一个事件。任务可以等待多个事件同时发生,或者任意一个发生。

这个工具特别适合复杂的同步场景。比如一个任务需要等待传感器数据就绪、网络连接建立、用户按键按下,三个条件都满足才能执行。

关键点:事件标志组支持按位操作,可以设置、清除、等待特定位。

// 定义事件位
#define EVENT_SENSOR_READY  (1 << 0)
#define EVENT_NETWORK_UP    (1 << 1)
#define EVENT_BUTTON_PRESS  (1 << 2)

// 创建事件组
EventGroupHandle_t xEventGroup = xEventGroupCreate();

// 等待所有事件
void vMainTask(void *pvParameters) {
    EventBits_t uxBits;
    while(1) {
        // 等待三个事件都发生
        uxBits = xEventGroupWaitBits(
            xEventGroup,
            EVENT_SENSOR_READY | EVENT_NETWORK_UP | EVENT_BUTTON_PRESS,
            pdTRUE,  // 退出时清除位
            pdTRUE,  // 等待所有位
            portMAX_DELAY
        );
        
        if((uxBits & (EVENT_SENSOR_READY | EVENT_NETWORK_UP | EVENT_BUTTON_PRESS)) 
            == (EVENT_SENSOR_READY | EVENT_NETWORK_UP | EVENT_BUTTON_PRESS)) {
            // 执行主任务
            execute_main_task();
        }
    }
}

经验之谈:事件标志组适合事件数量不多(一般不超过24个)的场景。如果事件太多,建议用消息队列或者自己设计位图。

3.5 邮箱:轻量级消息传递

邮箱,其实就是简化版的消息队列。它只能存一条消息,但速度更快,内存占用更少。

我一般在以下场景用邮箱:

  • 传递简单的状态信息,比如「数据已更新」
  • 传递指针,指向更大的数据块
  • 中断服务程序和任务之间的快速通信
// 创建邮箱(使用队列模拟,长度为1)
QueueHandle_t xMailbox = xQueueCreate(1, sizeof(void*));

// 中断中发送指针
void vISR_Handler(void) {
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    void *pData = get_data_pointer();
    
    // 覆盖写入,不阻塞
    xQueueOverwriteFromISR(xMailbox, &pData, &xHigherPriorityTaskWoken);
    
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

// 任务中接收
void vTask(void *pvParameters) {
    void *pReceivedData;
    while(1) {
        if(xQueueReceive(xMailbox, &pReceivedData, portMAX_DELAY) == pdPASS) {
            // 处理数据
            process_data(pReceivedData);
        }
    }
}

注意:邮箱传递指针时,要确保指针指向的内存是有效的。我曾经在中断中传递了一个局部变量的指针,结果任务访问时数据已经没了。记住,要用全局或静态内存。

3.6 如何选择?我的经验总结

说了这么多,到底该用哪个?我给大家一个简单的选择指南:

场景 推荐工具 原因
简单事件通知 二值信号量 轻量,速度快
保护共享资源 互斥锁 有优先级继承,防反转
传递数据流 消息队列 支持多数据,FIFO顺序
多条件同步 事件标志组 支持按位组合等待
快速单数据传递 邮箱 内存占用小,速度快

最后说一句,这些工具没有绝对的好坏。我见过有人用信号量实现消息队列的功能,也见过用消息队列做同步。关键是理解每个工具的特性,根据实际需求选择。

嗯,今天就聊到这儿。下一章咱们讲中断管理,那可是实时系统的核心。到时候我会分享一些我在工业现场遇到的中断处理案例,保证精彩。