4. 任务调度机制:抢占式调度、时间片轮转、调度锁、临界区保护

各位同学,咱们今天聊点硬核的——任务调度。说白了,调度就是RTOS的“大脑”,它决定哪个任务在什么时候跑。我做了这么多年嵌入式,见过太多系统崩溃、任务饿死、数据错乱的案例,十有八九都是调度没玩明白。

嗯,咱们先理清一个概念:调度不是越复杂越好,而是越匹配你的场景越好。电力监控系统里,有的任务要求毫秒级响应(比如过流保护),有的任务可以慢悠悠跑(比如数据记录)。怎么让它们和谐共处?这就是调度机制要解决的问题。

4.1 抢占式调度:谁急谁先跑

抢占式调度,是RTOS最核心的调度方式。它的规则很简单:高优先级的任务可以打断低优先级任务的执行。你想想看,如果系统正在处理一个按键扫描,突然来了一个过流中断,难道还要等按键扫完再处理?那设备早烧了。

核心要点:每个任务都有优先级,RTOS内核会在以下时刻检查是否需要抢占:

  • 任务创建时
  • 任务从阻塞态变为就绪态时
  • 中断服务程序退出时
  • 任务主动释放CPU(比如调用延时函数)时

我在项目中遇到过一个问题:一个数据采集任务优先级设得太高,结果它一直占着CPU,低优先级的通信任务根本跑不起来。后来我把采集任务里加了个阻塞延时,问题就解决了。记住:高优先级不代表一直跑,该让就让

举个实际例子。假设有三个任务:

  • Task_A:优先级10,负责过流检测,每10ms执行一次
  • Task_B:优先级8,负责数据上报,每100ms执行一次
  • Task_C:优先级5,负责LED闪烁,每500ms执行一次

当Task_C正在闪烁LED时,Task_A就绪了。这时候RTOS会立刻挂起Task_C,让Task_A先跑。等Task_A跑完,再恢复Task_C继续执行。这就是抢占——不需要商量,优先级说了算

我的建议:优先级分配要留有余地。别把优先级用满,留几个给未来可能新增的紧急任务。我曾经因为优先级分配太死,后来加功能时不得不重构整个调度策略,那叫一个痛苦。

4.2 时间片轮转:大家轮流来

抢占式调度解决了“急事急办”,但有个问题:如果多个任务优先级相同,谁先跑?这时候就需要时间片轮转了。

时间片轮转,说白了就是给每个任务分配固定的CPU时间片。时间片到了,不管任务跑没跑完,都得让给下一个同优先级的任务。我习惯把时间片设成1个系统Tick,也就是1ms左右。太短了切换开销大,太长了轮转效果不明显。

你想想看,电力监控系统里可能有多个数据采集任务,它们优先级相同,都是中等优先级。如果没有时间片轮转,第一个采集任务会一直占着CPU,直到它主动让出。但万一它是个死循环呢?其他采集任务就永远没机会跑了。

时间片轮转的典型配置(以FreeRTOS为例):

// 在FreeRTOSConfig.h中配置
#define configUSE_TIME_SLICING      1   // 启用时间片轮转
#define configTICK_RATE_HZ         1000 // 系统Tick频率,1ms一个Tick

// 创建两个同优先级任务
xTaskCreate(Task1, "Task1", 256, NULL, 5, NULL);
xTaskCreate(Task2, "Task2", 256, NULL, 5, NULL);

这里要注意:时间片轮转只对同优先级的任务生效。如果Task1优先级是5,Task2优先级是6,那Task2会一直抢占Task1,根本不会轮到Task1跑。嗯,这个坑我踩过——当时两个任务优先级设反了,结果低优先级的任务一直饿死。

避坑指南:我曾经在一个项目里把时间片设成10ms,结果任务切换时明显感觉到卡顿。后来改成1ms,流畅多了。但也不是越小越好——如果时间片小于系统Tick精度,那时间片轮转就形同虚设了。

4.3 调度锁:关键时刻别乱动

调度锁,也叫调度器上锁。它的作用是禁止任务切换。你可能会问:这不是和RTOS的初衷矛盾吗?别急,听我解释。

有些操作是不可分割的。比如你要更新一个全局变量,这个操作需要三步:读、改、写。如果在读完之后、写之前,任务被切换了,另一个任务也来修改这个变量,那数据就乱套了。

调度锁就是用来保护这种“临界区”的。它告诉RTOS:在我解锁之前,别给我切换任务

调度锁的使用示例:

// 上锁,禁止任务切换
taskENTER_CRITICAL();

// 执行不可分割的操作
volatile uint32_t *reg = (uint32_t *)0x40001000;
*reg |= 0x01;  // 设置某个寄存器位

// 解锁,恢复任务切换
taskEXIT_CRITICAL();

但要注意:调度锁不能滥用。我见过有人把整个任务都包在调度锁里,那跟裸机编程有什么区别?RTOS的优势全没了。调度锁应该只保护真正需要保护的代码段,而且执行时间要尽可能短。

我的经验:调度锁内的代码执行时间不要超过100微秒。如果超过这个时间,考虑用信号量或互斥量代替。我曾经在一个项目里用调度锁保护了一个耗时的数据拷贝操作,结果导致高优先级任务响应延迟,差点没通过验收。

4.4 临界区保护:不止是调度锁

临界区保护,比调度锁更严格。它不仅禁止任务切换,还禁止中断响应。说白了,就是让CPU只干一件事——执行临界区代码。

为什么要这么狠?因为有些资源连中断服务程序都会访问。比如一个全局变量,任务在修改它,中断也在修改它。如果只用调度锁,中断来了还是会打断任务,数据照样乱。

临界区保护示例:

// 进入临界区,关中断
UBaseType_t uxSavedInterruptStatus = taskENTER_CRITICAL_FROM_ISR();

// 执行需要保护的代码
shared_counter++;

// 退出临界区,恢复中断
taskEXIT_CRITICAL_FROM_ISR(uxSavedInterruptStatus);

这里有个关键点:临界区内的代码必须极短。因为关中断意味着系统无法响应任何外部事件——包括定时器、通信、传感器等。如果临界区执行时间太长,系统会错过重要事件。

保护方式 禁止任务切换 禁止中断 适用场景 执行时间要求
调度锁 任务间共享资源 < 100μs
临界区 任务与中断共享资源 < 10μs
互斥量 长时间共享资源 无严格限制

我曾经犯过的错:在一个电力监控项目中,我用临界区保护了一个SPI通信过程。SPI通信本身就要几十微秒,加上临界区关中断,导致系统错过了两次过流采样。后来改成用互斥量加中断屏蔽的方式,问题才解决。记住:临界区不是万能药,用错了反而会出大问题

4.5 实际项目中的调度策略选择

说了这么多,到底怎么选?我个人的经验是:

  • 紧急任务(如过流保护、故障检测):用抢占式调度,优先级设最高
  • 周期性任务(如数据采集、状态刷新):同优先级用时间片轮转
  • 共享资源访问:短操作用临界区,长操作用互斥量
  • 批量数据更新:用调度锁,但要控制执行时间

你想想看,电力监控系统里,过流保护任务优先级最高,它必须能随时抢占其他任务。数据采集任务有多个,优先级相同,用时间片轮转让它们公平使用CPU。而通信任务优先级可以低一些,但要注意别被饿死。

嗯,最后说一句:调度策略没有银弹。每个系统都有自己的特点,最好的策略是在实际测试中调出来的。我建议你在开发初期就做好调度性能的监控,比如记录每个任务的执行时间、等待时间、切换次数。这些数据会告诉你调度策略是否合理。

我的习惯:在每个任务里加一个执行时间统计,用GPIO翻转来测量。示波器一看就知道任务有没有被抢占、有没有超时。这个方法简单粗暴,但非常有效。

好了,任务调度这块就聊到这儿。下一节咱们讲任务间通信——信号量、消息队列、事件标志组,这些都是RTOS的“交通规则”,学会了它们,你的系统才能真正跑起来。