第三章:任务划分与优先级设计

好,咱们进入正题。这一章要聊的,是RTOS调度设计的核心——任务划分与优先级。说白了,就是决定「谁先跑、谁后跑、谁可以打断谁」。我在做第一款万用表产品时,这块没想清楚,结果调试阶段差点把自己逼疯。嗯,今天就把这些经验掰开揉碎了讲给你听。

3.1 五大任务的职责边界

一个典型的万用表固件,至少包含这五个任务。我习惯先画一张任务职责表,贴在工位上——你想想看,连边界都划不清,调度怎么可能不乱?

任务名称 核心职责 典型周期 我踩过的坑
ADC采样任务 启动转换、读取原始值、触发DMA 1ms ~ 10ms 采样率不够,波形全失真
数据处理任务 滤波、校准、量程切换、单位换算 10ms ~ 50ms 浮点运算卡死低优先级任务
显示刷新任务 更新LCD/OLED缓冲区、刷屏 20ms ~ 100ms 刷新太快,肉眼看到闪烁
按键扫描任务 去抖、识别长按/短按、触发回调 20ms ~ 50ms 去抖时间太短,误触发严重
通信任务 UART/SPI/I2C收发、协议解析 事件驱动 缓冲区溢出,丢数据

核心原则:每个任务只做一件事,且这件事的输入输出必须清晰。我曾经把一个滤波算法塞进ADC采样任务里,结果采样周期被拖到50ms,高频信号全没了。后来拆成两个任务,问题迎刃而解。

3.2 优先级设计的「三把尺子」

优先级怎么定?我总结了三把尺子,你照着量就行:

  1. 实时性要求——越不能等的,优先级越高。ADC采样丢了1ms,波形就少一个点;显示慢100ms,用户只是觉得卡。所以ADC > 显示。
  2. 数据依赖链——谁生产数据,谁消费数据?生产者优先级要高于消费者。ADC采样是生产者,数据处理是消费者。如果数据处理优先级更高,它可能抢在ADC采样前运行,拿到的是旧数据。
  3. 执行时间——短任务优先级高,长任务优先级低。为什么?短任务跑得快,不会长时间阻塞高优先级任务。按键扫描只有几十微秒,给它高优先级没问题;通信任务可能一次收发几百字节,优先级给高了会饿死其他任务。

我的习惯:先按「实时性要求」排一个初版优先级,再按「数据依赖链」微调,最后用「执行时间」做最终校验。三步走完,基本不会出大问题。

3.3 一个具体的优先级分配案例

以我去年做的一款4位半万用表为例,优先级从高到低是这样分配的:

优先级 任务 理由
5(最高) ADC采样任务 1ms周期,丢一个点测量精度就下降
4 按键扫描任务 用户交互必须及时响应,但去抖允许20ms延迟
3 数据处理任务 依赖ADC数据,但可以容忍10ms以内的延迟
2 通信任务 事件驱动,平时不占CPU,但收发时不能被打断太久
1(最低) 显示刷新任务 人眼对50ms以上的刷新才敏感,优先级最低最安全

注意:这个分配不是死的。如果你的万用表有蓝牙通信,且协议要求每10ms发一次数据包,那通信任务的优先级就要提到3甚至4。优先级设计没有银弹,只有权衡。

3.4 优先级反转——你一定会遇到

优先级反转是什么?简单说:高优先级任务在等一个资源,而这个资源被低优先级任务占着,中优先级任务又抢了CPU,导致高优先级任务迟迟跑不了。

我在项目中遇到过:ADC采样任务(优先级5)要读取一个共享缓冲区,但数据处理任务(优先级3)正在写这个缓冲区,用了互斥锁。这时候按键扫描任务(优先级4)突然来了,它不碰那个缓冲区,所以一直跑。ADC采样任务只能干等。结果呢?采样周期从1ms变成了10ms,波形全乱了。

解决办法有三个:

  • 优先级继承——RTOS自动把持有锁的低优先级任务临时提升到高优先级任务的级别。我推荐用FreeRTOS的互斥量(Mutex),它自带优先级继承。
  • 关中断保护——如果临界区代码只有几行,直接关中断。但注意,关中断时间不能超过10微秒,否则会影响系统实时性。
  • 避免共享资源——用消息队列代替全局变量。ADC采样任务把数据发到队列,数据处理任务从队列取。没有锁,就没有反转。

我的建议:能用消息队列就别用全局变量。消息队列虽然多花几个字节内存,但换来的是调度确定性。你想想看,一个优先级反转导致的bug,查起来可能花掉你一周时间,那点内存算什么?

3.5 任务周期与时间片——别让CPU空转

每个任务都要有明确的周期或触发条件。我见过有人把显示刷新任务写成死循环while(1),里面加个delay(20)。这其实是在浪费CPU——delay期间,低优先级任务也跑不了。

正确的做法是用RTOS的延时函数或事件标志:

// 错误示范:忙等待
void DisplayTask(void *pvParameters) {
    while(1) {
        RefreshDisplay();
        delay_ms(20);  // CPU空转20ms!
    }
}

// 正确示范:阻塞等待
void DisplayTask(void *pvParameters) {
    TickType_t xLastWakeTime = xTaskGetTickCount();
    while(1) {
        RefreshDisplay();
        vTaskDelayUntil(&xLastWakeTime, pdMS_TO_TICKS(20));  // 阻塞20ms,让出CPU
    }
}

vTaskDelayUntil 和 vTaskDelay 的区别,我简单说一下:前者是绝对延时,保证周期稳定;后者是相对延时,可能因为中断或高优先级任务而累积误差。ADC采样任务一定要用 vTaskDelayUntil,显示任务用 vTaskDelay 就够了。

3.6 避坑指南——我踩过的三个雷

雷区一:给通信任务分配了最高优先级。结果通信数据量一大,ADC采样被饿死,测量值全跳变。后来我把通信任务改成事件驱动,平时挂起,有数据时才唤醒。

雷区二:数据处理任务里做了浮点运算和查表,一次执行要5ms。而按键扫描任务优先级比它低,导致按键响应延迟到50ms以上。解决办法是把浮点运算拆成多步,每步之间让出CPU。

雷区三:显示刷新任务用了DMA传输,但DMA中断优先级比任务优先级高。结果DMA中断频繁打断ADC采样,采样周期抖动很大。后来我把DMA中断优先级降到比ADC采样任务低一级,问题解决。

3.7 小结——优先级设计的「黄金法则」

最后,我送你三条黄金法则,记在笔记本上:

  1. 生产者优先级 > 消费者优先级——数据不能断。
  2. 短任务优先级 > 长任务优先级——别让短任务等太久。
  3. 硬实时任务优先级 > 软实时任务优先级——ADC采样是硬实时,显示刷新是软实时。

嗯,这一章就到这里。下一章我们会深入每个任务的具体实现,从ADC采样开始。到时候我会把DMA配置、多通道切换、过采样技术都讲透。你先把优先级设计想清楚,后面写代码才不会翻车。