第四章:任务间通信机制

好,咱们进入第四章。这一章我特别想跟你聊聊,因为任务间通信这块,是RTOS的灵魂所在。你想想看,一个万用表里跑着好几个任务——ADC采样、数据处理、按键扫描、显示刷新——它们之间怎么配合?数据怎么传?谁先谁后?这就是通信机制要解决的问题。

我个人习惯把通信机制分成三类:消息队列传数据、信号量做同步、事件标志组管状态。咱们一个一个来拆解。

4.1 消息队列:测量结果的“快递通道”

先说消息队列。说白了,它就是任务之间的一个缓冲区。ADC任务把测量结果扔进去,数据处理任务从里面取出来。两个任务不用直接打交道,通过队列这个“中间人”来传递。

我在项目中遇到过一个问题:ADC采样率是1kHz,但数据处理任务处理一次需要2ms。如果直接让ADC任务调用数据处理函数,那ADC就被阻塞了,采样会丢数据。用消息队列就完美解决——ADC只管往队列里塞,数据处理任务有空了再慢慢取。

核心要点:消息队列是“生产者-消费者”模型的标准实现。生产者(ADC任务)不关心消费者(数据处理任务)什么时候处理,消费者也不关心生产者什么时候生产。队列本身负责缓冲和同步。

来看看代码怎么写。我用的是FreeRTOS的API,其他RTOS大同小异:

// 定义消息队列句柄
QueueHandle_t xMeasQueue;

// 测量结果结构体
typedef struct {
    float voltage;
    float current;
    uint32_t timestamp;
} MeasResult_t;

// 创建队列,深度10,每个元素大小sizeof(MeasResult_t)
xMeasQueue = xQueueCreate(10, sizeof(MeasResult_t));

// ADC任务:发送测量结果
void vADCTask(void *pvParameters) {
    MeasResult_t meas;
    while(1) {
        // 采集数据...
        meas.voltage = readADC();
        meas.current = readADC();
        meas.timestamp = xTaskGetTickCount();
        
        // 发送到队列,如果队列满则等待100ms
        if(xQueueSend(xMeasQueue, &meas, pdMS_TO_TICKS(100)) != pdPASS) {
            // 队列满了,处理异常
            vLogError("Queue full, dropping measurement");
        }
        vTaskDelay(pdMS_TO_TICKS(1)); // 1kHz采样
    }
}

// 数据处理任务:接收测量结果
void vDataProcessTask(void *pvParameters) {
    MeasResult_t meas;
    while(1) {
        // 等待队列数据,阻塞等待
        if(xQueueReceive(xMeasQueue, &meas, portMAX_DELAY) == pdPASS) {
            // 处理数据...
            vCalculateRMS(meas.voltage, meas.current);
        }
    }
}

我的经验:队列深度怎么定?我一般按“最坏情况下的积压量”来算。比如ADC每秒产生1000个数据,数据处理任务每秒只能处理500个,那队列深度至少要1000。但别忘了,队列太深会占用大量RAM。折中方案是:队列深度设成“最大可容忍的丢包数”,比如100个,满了就丢。这在万用表里是可以接受的——丢几个采样点不影响最终读数。

4.2 信号量:ADC与数据处理的“握手信号”

信号量,我把它理解成一个“令牌”。谁拿到令牌谁就能干活。在万用表里,ADC任务和数据任务之间需要同步——ADC采完一组数据,通知数据处理任务“你可以开工了”。

我曾经犯过一个低级错误:用全局变量做同步标志。ADC任务置一个flag,数据处理任务轮询这个flag。结果呢?轮询浪费CPU,而且flag的读写不是原子的,偶尔会出bug。后来全部换成信号量,问题迎刃而解。

信号量分两种:二值信号量计数信号量。二值信号量就像一把钥匙,只能一个人用。计数信号量像停车场,可以停多辆车。

类型 适用场景 初始值
二值信号量 ADC完成一次采样,通知数据处理 0
计数信号量 多个ADC通道依次完成,统一处理 0

看代码:

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

// ADC任务:采样完成后释放信号量
void vADCTask(void *pvParameters) {
    while(1) {
        // 开始采样...
        vStartADCConversion();
        
        // 等待转换完成(中断中处理)
        ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
        
        // 读取结果
        g_voltage = readADCResult();
        
        // 释放信号量,通知数据处理任务
        xSemaphoreGive(xADCSemaphore);
    }
}

// 数据处理任务:等待信号量
void vDataProcessTask(void *pvParameters) {
    while(1) {
        // 等待ADC任务释放信号量
        if(xSemaphoreTake(xADCSemaphore, portMAX_DELAY) == pdTRUE) {
            // 拿到信号量,开始处理
            vProcessMeasurement(g_voltage);
        }
    }
}

注意:信号量的“give”和“take”必须在不同任务中。如果你在同一个任务里又give又take,那信号量就变成了一个普通的计数器,失去了同步的意义。我曾经见过有人这么写,结果任务自己跟自己握手,死锁了。

4.3 事件标志组:按键事件的“状态面板”

事件标志组,你可以把它想象成一个8位或16位的状态寄存器。每一位代表一个事件。按键按下、按键释放、长按、双击——每个事件占一个bit。多个任务可以同时等待不同的事件组合。

嗯,这里要注意:事件标志组和信号量的区别。信号量是“点对点”的同步,一个任务通知另一个任务。事件标志组是“多对多”的——多个任务可以设置事件,多个任务可以等待事件。在万用表的按键处理中特别有用。

举个例子:用户按下了“功能键”,同时旋转了“量程旋钮”。这两个事件可能来自不同的中断服务程序,但都需要通知同一个按键处理任务。用事件标志组,两个中断各自设置自己的bit,按键任务等待这两个bit都置位后,再执行切换量程的操作。

// 定义事件标志组句柄
EventGroupHandle_t xKeyEventGroup;

// 定义事件位
#define KEY_PRESS_BIT     (1 << 0)  // 按键按下
#define KEY_RELEASE_BIT   (1 << 1)  // 按键释放
#define KEY_LONGPRESS_BIT (1 << 2)  // 长按
#define KEY_DOUBLECLICK_BIT (1 << 3) // 双击

// 创建事件标志组
xKeyEventGroup = xEventGroupCreate();

// 按键中断服务程序
void vKeyISR(void) {
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    
    // 检测到按键按下
    xEventGroupSetBitsFromISR(xKeyEventGroup, 
                               KEY_PRESS_BIT, 
                               &xHigherPriorityTaskWoken);
    
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

// 按键处理任务
void vKeyProcessTask(void *pvParameters) {
    EventBits_t uxBits;
    const TickType_t xTicksToWait = pdMS_TO_TICKS(100);
    
    while(1) {
        // 等待按键按下事件,清除该位
        uxBits = xEventGroupWaitBits(
            xKeyEventGroup,     // 事件组句柄
            KEY_PRESS_BIT | KEY_RELEASE_BIT | KEY_LONGPRESS_BIT, // 等待的位
            pdTRUE,             // 退出时清除位
            pdFALSE,            // 任意一个位满足即可
            xTicksToWait        // 超时时间
        );
        
        if(uxBits & KEY_PRESS_BIT) {
            vHandleKeyPress();
        }
        if(uxBits & KEY_RELEASE_BIT) {
            vHandleKeyRelease();
        }
        if(uxBits & KEY_LONGPRESS_BIT) {
            vHandleLongPress();
        }
    }
}

我的建议:事件标志组的位定义,最好用枚举或者宏定义,别用魔数。我见过有人直接写 xEventGroupWaitBits(0x05),三个月后自己都看不懂0x05代表什么。另外,事件标志组的位数有限(通常是8位或24位),别浪费。把不常用的事件合并一下,比如“按键按下”和“按键释放”可以共用一位,用参数区分。

4.4 三种机制的选型对比

最后,我整理了一个对比表,方便你根据场景选择:

通信机制 核心用途 数据量 典型场景
消息队列 传递数据 大(可携带结构体) ADC结果→数据处理
信号量 任务同步 无(仅信号) ADC完成→通知处理
事件标志组 多事件管理 无(仅标志位) 按键事件→任务响应

你可能会问:能不能混用?当然可以。我在一个项目中同时用了消息队列和信号量——ADC用信号量通知数据处理任务“有数据来了”,数据处理任务再从消息队列里取具体数据。这样既保证了同步的实时性,又避免了数据丢失。

好了,这一章就到这里。下一章咱们聊聊任务优先级怎么定,以及怎么避免优先级反转这个坑。到时候我会分享一个我踩过的真实案例,保证让你印象深刻。