4、任务调度机制:抢占式调度与时间片轮转、调度器启动与挂起、我在项目中遇到的优先级反转问题
4.1 抢占式调度:说停就停,说上就上
抢占式调度,说白了就是高优先级的任务可以「抢」低优先级任务的CPU使用权。
我刚开始用RTOS时,总觉得这机制有点粗暴。后来做除颤仪才明白——关键时刻,就得这么干。
举个例子。除颤仪的心电采集任务优先级设为5,按键扫描任务优先级设为2。当心电数据到来时,即使按键任务正跑到一半,系统也会立刻挂起它,让心电任务先跑。
为什么会这样?因为心电数据是实时信号,晚处理1毫秒都可能影响波形重建。按键嘛,按慢点没事。
抢占式调度的核心规则:
- 就绪态中,优先级最高的任务先运行
- 高优先级任务就绪时,立即抢占当前任务
- 同优先级任务之间,按时间片轮转
代码里怎么体现?看这个FreeRTOS的配置:
// 抢占式调度配置
#define configUSE_PREEMPTION 1 // 1=抢占,0=合作
#define configUSE_TIME_SLICING 1 // 1=时间片轮转
#define configPRIO_BITS 4 // 优先级位数
// 创建任务时指定优先级
xTaskCreate(vECGTask, "ECG", 256, NULL, 5, NULL); // 高优先级
xTaskCreate(vKeyTask, "KEY", 128, NULL, 2, NULL); // 低优先级
注意那个configUSE_PREEMPTION。设成0就是合作式调度了——任务自己主动让出CPU。我见过有人为了「省资源」关掉抢占,结果系统响应一塌糊涂。
警告:医疗设备中,千万别关抢占式调度。除非你清楚知道自己在做什么,并且做过完整的时序分析。
4.2 时间片轮转:大家都是兄弟
同优先级的任务怎么办?总不能一个跑死,其他饿着吧。
时间片轮转就是干这个的。每个任务分一个时间片(通常1-10ms),时间到了就换下一个。你想想看,就像几个人分吃一块蛋糕,每人咬一口,轮着来。
我习惯把时间片设成5ms。为什么?因为除颤仪的按键消抖需要10-20ms,5ms的轮转粒度刚好能覆盖。设太短了,上下文切换开销大;设太长了,响应变慢。
// 时间片配置
#define configTICK_RATE_HZ 1000 // 系统时钟节拍,1ms
#define configUSE_TIME_SLICING 1 // 启用时间片
// 同优先级任务,自动轮转
xTaskCreate(vTaskA, "A", 128, NULL, 3, NULL);
xTaskCreate(vTaskB, "B", 128, NULL, 3, NULL);
xTaskCreate(vTaskC, "C", 128, NULL, 3, NULL);
这三个任务优先级都是3。系统会按创建顺序,每人跑一个tick(1ms),然后切换。嗯,这里要注意——时间片轮转只在同优先级任务间生效。不同优先级?直接抢占。
我的经验:时间片别设太小。我曾经设成1ms,结果任务切换太频繁,光保存恢复上下文就占了30%的CPU。后来改成5ms,好了很多。
4.3 调度器启动与挂起:别在错误的时间切换
调度器不是一开始就工作的。你得先创建好所有任务,然后调用启动函数。
启动前,系统只有一个初始化任务在跑。启动后,调度器才开始按优先级和时间片分配CPU。
// 调度器启动
int main(void)
{
// 硬件初始化
hardware_init();
// 创建所有任务
xTaskCreate(vECGTask, "ECG", 256, NULL, 5, NULL);
xTaskCreate(vDispTask, "DISP", 192, NULL, 4, NULL);
xTaskCreate(vKeyTask, "KEY", 128, NULL, 2, NULL);
// 启动调度器——从此开始多任务
vTaskStartScheduler();
// 正常情况下不会跑到这里
while(1);
}
挂起调度器呢?就是暂停任务切换。我一般在两种场景用:
- 临界区保护:操作共享数据时,防止被其他任务打断
- 系统初始化:某些外设配置需要原子操作
// 挂起调度器
vTaskSuspendAll();
// 这里操作共享数据,不会被切换
shared_data.value = 100;
shared_data.flag = 1;
// 恢复调度器
xTaskResumeAll();
注意:挂起调度器的时间要尽量短。我见过有人挂起后做延时操作,结果高优先级任务等了几十毫秒才响应——这在除颤仪里是致命的。
4.4 优先级反转:我在项目中遇到的坑
优先级反转,这词听着专业,其实场景很常见。
三个任务:高优先级A、中优先级B、低优先级C。A和C共享一个互斥锁。正常情况:A等C释放锁,C赶紧跑完。但这时候B突然就绪了——B优先级比C高,于是C被B抢占。A还在等C,C却在等B。结果A这个最高优先级的任务,反而被B这个中等优先级的任务「反转」了。
我在除颤仪项目中就遇到过。心电采集任务(高优先级)和显示刷新任务(低优先级)共享一个缓冲区。中间插了个按键扫描任务(中优先级)。
结果呢?心电数据丢包了。查了两天才发现是优先级反转。显示任务拿着锁,被按键任务抢了CPU,心电任务干等着。
解决方案:优先级继承
当低优先级任务持有高优先级任务需要的锁时,临时把低优先级任务的优先级提升到高优先级任务的级别。这样中优先级任务就抢不了它了。
// FreeRTOS中启用优先级继承
#define configUSE_MUTEXES 1 // 使用互斥量(带优先级继承)
#define configUSE_RECURSIVE_MUTEXES 0
// 创建互斥量(不是二值信号量!)
SemaphoreHandle_t xMutex = xSemaphoreCreateMutex();
// 任务中使用
void vHighTask(void *pvParameters)
{
// 请求互斥量
if(xSemaphoreTake(xMutex, portMAX_DELAY) == pdTRUE)
{
// 访问共享资源
process_ecg_data();
// 释放
xSemaphoreGive(xMutex);
}
}
注意,我用的是xSemaphoreCreateMutex(),不是xSemaphoreCreateBinary()。互斥量自带优先级继承机制,二值信号量没有。这个区别很关键。
避坑指南:我曾经直接用二值信号量做互斥,结果优先级反转问题复现了。后来全部换成互斥量,问题解决。记住:共享资源保护,用互斥量;事件通知,用信号量。
4.5 实战建议:调度策略选择
说了这么多,总结一下我的选择原则:
| 场景 | 推荐策略 | 说明 |
|---|---|---|
| 硬实时任务(心电采集) | 抢占式 + 高优先级 | 必须立即响应,不容延迟 |
| 软实时任务(显示刷新) | 抢占式 + 中优先级 | 允许少量延迟,但不能丢帧 |
| 后台任务(日志记录) | 时间片轮转 + 低优先级 | 有空就跑,没空拉倒 |
| 共享资源访问 | 互斥量 + 优先级继承 | 防止优先级反转 |
最后说一句:调度机制是RTOS的灵魂。你选对了,系统稳如老狗;选错了,调试到怀疑人生。我当年就是吃了优先级反转的亏,才老老实实把调度原理啃透的。
嗯,这一章就到这。下一章我们聊聊任务间通信——队列、信号量、事件组,这些才是真正让任务协同工作的关键。