第四章:任务间通信机制
好,咱们进入第四章。这一章我特别想跟你聊聊,因为任务间通信这块,是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用信号量通知数据处理任务“有数据来了”,数据处理任务再从消息队列里取具体数据。这样既保证了同步的实时性,又避免了数据丢失。
好了,这一章就到这里。下一章咱们聊聊任务优先级怎么定,以及怎么避免优先级反转这个坑。到时候我会分享一个我踩过的真实案例,保证让你印象深刻。