3. 任务调度机制:抢占式调度与时间片轮转
调度器,说白了就是RTOS的大脑。它决定哪个任务先跑,哪个任务后跑。我刚开始学FreeRTOS时,总觉得调度是个玄学。后来亲手调了几个项目,才摸清它的脾气。
这一节,我们重点聊三个东西:抢占式调度、时间片轮转,以及调度器的启动与挂起。最后我会带你看一个实战例子,观察不同优先级下任务的执行顺序。
3.1 抢占式调度:高优先级任务说了算
抢占式调度,是FreeRTOS最核心的调度方式。它的规则很简单:谁优先级高,谁先跑。
具体来说:
- 每个任务都有一个优先级,数值越大优先级越高(0是最低)。
- 当高优先级任务就绪时,它会立即抢占正在运行的低优先级任务。
- 被抢占的任务会被挂起,等下次轮到它时再继续执行。
关键点:抢占是立即发生的,不需要等当前任务主动让出CPU。这是抢占式调度和协作式调度的最大区别。
我在项目中遇到过一个问题:一个低优先级的任务正在写Flash,突然被高优先级任务抢占了。结果Flash写入被中断,数据损坏了。嗯,这里要注意——临界区保护是必须的。后面讲同步机制时会细说。
3.2 时间片轮转:同优先级任务轮流跑
如果多个任务优先级相同,怎么办?这时候就轮到时间片轮转出场了。
时间片轮转的规则:
- 每个任务分配一个固定的时间片(通常1个tick,即1个系统时钟节拍)。
- 时间片用完后,任务自动让出CPU,切换到下一个同优先级任务。
- 如果所有同优先级任务都跑了一遍,再从头开始。
说白了,就是大家轮流用CPU,谁也别想独占。你想想看,如果没有时间片轮转,一个低优先级的任务可能永远得不到CPU——这叫饥饿。
小技巧:时间片轮转默认是开启的。如果你不想用,可以在FreeRTOSConfig.h中把configUSE_TIME_SLICING设为0。我个人习惯是保持开启,除非有特殊需求。
3.3 调度器启动与挂起
调度器不是一上电就开始工作的。你需要手动启动它。同样,你也可以挂起它,暂时停止调度。
3.3.1 vTaskStartScheduler:启动调度器
vTaskStartScheduler() 是调度器的启动函数。调用它之后,FreeRTOS才开始管理任务。在这之前,所有任务都处于就绪态,但没人调度它们。
void main(void)
{
// 创建任务
xTaskCreate(task1, "Task1", 100, NULL, 1, NULL);
xTaskCreate(task2, "Task2", 100, NULL, 2, NULL);
// 启动调度器
vTaskStartScheduler();
// 永远不会执行到这里
while(1);
}
注意:vTaskStartScheduler() 一旦调用,就不会返回。如果它返回了,说明内存不足,或者创建空闲任务失败了。我曾经踩过这个坑——堆栈设得太小,调度器启动失败,程序直接卡死。排查了半天才发现是configTOTAL_HEAP_SIZE设得太小。
3.3.2 vTaskSuspendAll:挂起调度器
有时候,你需要暂时停止调度。比如在临界区操作共享数据时,不希望被其他任务打断。这时候可以用vTaskSuspendAll()。
void critical_section(void)
{
// 挂起调度器
vTaskSuspendAll();
// 执行临界区代码
// 此时不会被其他任务抢占
shared_data++;
// 恢复调度器
xTaskResumeAll();
}
警告:挂起调度器期间,不要调用任何可能阻塞的任务API(比如vTaskDelay、xQueueReceive等)。否则会导致死锁。我刚开始用FreeRTOS时就犯过这个错,调试了一整天。
3.4 空闲任务与钩子函数
当所有任务都阻塞或挂起时,CPU不能闲着。FreeRTOS会自动创建一个空闲任务,优先级为0(最低)。空闲任务的作用就是占着CPU,啥也不干。
但你可以利用空闲任务做点事——通过钩子函数。钩子函数是用户自定义的函数,在空闲任务每次循环时被调用。
void vApplicationIdleHook(void)
{
// 空闲时进入低功耗模式
__WFI(); // Wait For Interrupt
}
我在项目中常用空闲钩子做两件事:
- 进入低功耗模式:省电,适合电池供电设备。
- 统计CPU利用率:通过记录空闲任务运行的时间,反推CPU负载。
提示:钩子函数中不要调用阻塞API,比如vTaskDelay。因为空闲任务本身优先级最低,一旦阻塞,整个系统就卡住了。
3.5 实战:观察不同优先级下的任务执行顺序
理论说完了,我们来动手。下面是一个简单的例子,创建两个任务,优先级不同,看看它们怎么跑。
void task1(void *pvParameters)
{
while(1)
{
printf("Task1 running\n");
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
void task2(void *pvParameters)
{
while(1)
{
printf("Task2 running\n");
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
void main(void)
{
// 创建任务:task1优先级1,task2优先级2
xTaskCreate(task1, "Task1", 100, NULL, 1, NULL);
xTaskCreate(task2, "Task2", 100, NULL, 2, NULL);
vTaskStartScheduler();
}
运行结果:
Task2 running
Task2 running
Task2 running
...(一直跑task2)
Task1 running // 只有task2阻塞时,task1才能跑
为什么会这样?因为task2优先级更高,只要它就绪,就会抢占task1。task1只有在task2阻塞(比如调用vTaskDelay)时才有机会运行。
如果把两个任务优先级设为相同:
xTaskCreate(task1, "Task1", 100, NULL, 1, NULL);
xTaskCreate(task2, "Task2", 100, NULL, 1, NULL);
运行结果:
Task1 running
Task2 running
Task1 running
Task2 running
...(轮流跑)
这就是时间片轮转的效果。两个任务轮流执行,每个任务跑一个时间片后自动切换。
总结一下:
- 优先级不同:高优先级任务抢占低优先级任务。
- 优先级相同:时间片轮转,轮流执行。
- 空闲任务:优先级0,只在没有其他任务就绪时运行。
嗯,调度机制这块就聊到这儿。下一节我们会讲任务同步与通信,那是多任务编程的另一个核心。到时候见。