3、数据采集任务调度:FreeRTOS任务创建、任务优先级设计、任务间通信(队列、信号量)、时间片轮转

好,咱们进入第三章。这一章是气象站软件架构的「心脏」部分——任务调度。说白了,就是让传感器采集、数据处理、显示刷新、通信上传这些活儿,在单片机上井井有条地跑起来。

我刚开始做嵌入式那会儿,总喜欢写一个大循环,把所有事情按顺序排好。后来发现,一旦传感器多了、功能复杂了,这种「裸奔」方式根本扛不住。一个I2C读操作卡住,整个系统就瘫痪了。嗯,这就是为什么我们要用FreeRTOS。

3.1 任务创建:别一股脑全塞进去

在气象站项目里,我习惯把任务拆成四个核心模块:

  • 采集任务:读取温湿度、气压、风速风向
  • 处理任务:滤波、校准、计算平均值
  • 显示任务:刷新OLED或LCD屏幕
  • 通信任务:通过LoRa或WiFi上传数据

创建任务的API很简单,就是xTaskCreate()。但这里有个坑——任务栈大小。我见过太多人把栈设成128字节,结果程序跑着跑着就莫名其妙复位了。

⚠️ 避坑指南: 我曾经在一个项目中,把采集任务的栈设成了256字节,结果每次读取风速传感器时,系统就死机。查了两天才发现,是栈溢出把任务控制块给踩了。后来我养成了一个习惯:所有任务栈至少给512字节,涉及浮点运算的给1024字节以上。
// 创建采集任务
xTaskCreate(
    vDataCollectionTask,    // 任务函数
    "Collect",              // 任务名
    1024,                   // 栈大小(字)
    NULL,                   // 参数
    3,                      // 优先级
    NULL                    // 任务句柄
);

3.2 任务优先级设计:谁先跑,谁后跑

优先级设计是个艺术活。你想想看,气象站里哪个任务最不能等?

我个人习惯把采集任务设为最高优先级。为什么?因为传感器数据是时效性的。你延迟100ms去读风速,可能一阵风就过去了。显示任务可以慢一点,哪怕卡顿一帧,人眼也看不出来。

任务名称 优先级 理由
采集任务 4(最高) 传感器数据时效性要求高
通信任务 3 丢包重传需要及时响应
处理任务 2 计算可以等,但不能太久
显示任务 1(最低) 刷新慢一点没关系
💡 我的经验: 优先级不要超过5级。FreeRTOS虽然支持255级,但级数越多,调度开销越大。我一般只用3-4个优先级,够用就好。

3.3 任务间通信:队列和信号量

任务之间怎么传数据?直接访问全局变量?别闹了,那会出大问题的。FreeRTOS提供了两种好用的机制:队列信号量

3.3.1 队列:数据搬运工

采集任务读到的温湿度数据,怎么传给处理任务?用队列。我习惯把队列长度设成10,这样即使处理任务偶尔卡一下,数据也不会丢。

// 创建队列
QueueHandle_t xSensorQueue;
xSensorQueue = xQueueCreate(10, sizeof(SensorData_t));

// 采集任务发送数据
SensorData_t data;
data.temperature = 25.6f;
data.humidity = 60.2f;
xQueueSend(xSensorQueue, &data, portMAX_DELAY);

// 处理任务接收数据
SensorData_t receivedData;
if (xQueueReceive(xSensorQueue, &receivedData, pdMS_TO_TICKS(100)) == pdPASS) {
    // 处理数据
}
🔑 关键点: 队列发送时,我建议用xQueueSend()而不是xQueueSendToFront()。除非你有特殊需求,否则先进先出是最自然的。

3.3.2 信号量:任务同步的开关

信号量更像是一个「门禁」。比如,采集任务完成一次完整的数据采集后,通知处理任务开始工作。这时候用二值信号量最合适。

我记得有一次,我在项目中用了计数信号量来控制多个传感器同时采集。结果发现,信号量计数值没处理好,导致任务一直阻塞。后来我加了个超时机制,问题就解决了。

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

// 采集任务:采集完成后释放信号量
xSemaphoreGive(xDataReady);

// 处理任务:等待信号量
if (xSemaphoreTake(xDataReady, pdMS_TO_TICKS(1000)) == pdPASS) {
    // 开始处理数据
}
⚠️ 注意: 信号量创建后,一定要先Give一次,否则第一个Take会永远阻塞。我刚开始用的时候,就因为这个bug调试了一下午。

3.4 时间片轮转:让每个任务都有机会

优先级高的任务一直占着CPU怎么办?这时候就需要时间片轮转。FreeRTOS默认是抢占式调度,但同优先级的任务之间,靠时间片来轮流执行。

我建议把显示任务通信任务设为同一优先级。这样,当显示任务在刷新屏幕时,通信任务也能分到时间片去发送数据。不会出现「显示卡住,通信也卡住」的情况。

// 在FreeRTOSConfig.h中配置时间片
#define configUSE_TIME_SLICING 1   // 启用时间片轮转
#define configTICK_RATE_HZ 1000    // 系统时钟节拍,1ms一个tick
💡 实用技巧: 时间片长度默认是一个tick(1ms)。如果你的任务执行时间很短,比如只是读一个寄存器,那1ms足够了。但如果任务里有延时操作,比如I2C等待应答,我建议在任务里主动调用taskYIELD(),让出CPU。

3.5 实战中的调度策略

说了这么多,咱们看看实际的气象站里,任务是怎么调度的。

  1. 采集任务每1秒唤醒一次,读取所有传感器。读完后,把数据塞进队列,然后释放信号量。
  2. 处理任务等待信号量。收到信号后,从队列里取出数据,做滑动平均滤波。处理完再发给显示和通信任务。
  3. 显示任务每500ms刷新一次屏幕。如果没新数据,就显示上一次的值。
  4. 通信任务每10秒上传一次数据。如果上传失败,重试3次,再失败就丢弃。

这个架构我用了好几年,在多个气象站项目里验证过。说白了,就是让每个任务各司其职,互不干扰。你想想看,如果采集任务和显示任务抢CPU,那传感器数据可能就丢了。

🎯 核心思想: 任务调度不是让所有任务同时跑,而是让正确的任务在正确的时间跑。优先级解决「谁先跑」,时间片解决「谁跑多久」,队列和信号量解决「怎么传数据」。

嗯,这一章的内容就到这里。下一章咱们聊聊传感器驱动层设计,包括I2C、SPI、单总线这些协议的封装技巧。到时候我会分享一个我踩过的坑——关于风速传感器读取时序的,保证让你少走弯路。