2. 任务调度基础:任务的概念与状态、调度器的工作原理、上下文切换机制
好,咱们正式开始聊多核MCU上最核心的东西——任务调度。
很多人一上来就盯着“多核”两个字,觉得调度肯定复杂得要命。其实不然。多核调度是建立在单核调度基础之上的。单核调度你搞明白了,多核无非就是多了几个执行单元,核心逻辑没变。
所以这一章,咱们先把地基打牢。把任务是什么、状态怎么变、调度器怎么工作、上下文切换到底在切什么,这几个问题彻底讲透。
2.1 任务的概念与状态
先说任务。任务是什么?说白了,任务就是一段代码,加上它运行所需要的“环境”。
这段代码通常是一个无限循环的函数,里面放着你要做的事情。比如读传感器、发数据、控制电机。而“环境”呢,就是它的栈空间、优先级、状态等等。
我个人习惯把任务看作一个“独立的小人”。每个小人都有自己的工作(代码),自己的工具箱(栈),自己的优先级(谁先干活)。RTOS就是那个管理这群小人的管家。
2.1.1 任务的五态模型
任务在运行过程中,状态是会变的。RTOS里最常见的模型是五态模型。我画了一张图,你看一眼就明白了。
这张图我建议你多看两眼。任务调度本质上就是管理这些状态之间的跳转。
- 就绪态:任务已经准备好,随时可以运行,就等CPU分配。所有能跑的任务都在就绪队列里排队。
- 运行态:任务正在占用CPU执行代码。单核下同一时刻只有一个任务在运行态。
- 阻塞态:任务在等某个事件,比如等一个信号量、等一段延时结束、等消息队列有数据。这时候任务不参与CPU竞争。
- 挂起态:被人为暂停了。调用
vTaskSuspend()就会进入这个状态。只有调用vTaskResume()才能恢复。 - 终止态:任务函数执行完毕返回了,或者被删除了。任务的生命周期结束。
我的经验: 很多新手容易把阻塞态和挂起态搞混。我简单说一下区别——阻塞态是任务主动“等东西”,等到了自动恢复;挂起态是别人“按住了它”,必须有人去“松开”才能恢复。在调试死锁问题时,搞清楚任务到底卡在哪个状态,能省你半天时间。
2.2 调度器的工作原理
调度器是RTOS的大脑。它的工作只有一个:决定下一个该谁跑。
听起来简单,但这里面门道不少。调度器需要回答三个问题:什么时候切换?切换到谁?怎么切?
2.2.1 调度策略
常见的调度策略就三种。我列个表,你一看就清楚。
| 调度策略 | 核心逻辑 | 适用场景 | 典型RTOS |
|---|---|---|---|
| 抢占式调度 | 高优先级任务就绪时,立即打断低优先级任务 | 实时性要求高的系统 | FreeRTOS, RT-Thread |
| 时间片轮转 | 同优先级任务轮流执行固定时间片 | 多个同等重要任务 | FreeRTOS, uC/OS |
| 协作式调度 | 任务主动让出CPU,否则一直运行 | 简单系统、裸机风格 | Contiki, 部分小型OS |
现在主流的RTOS,比如FreeRTOS,默认就是抢占式调度。为什么?因为实时系统最怕的就是“高优先级任务等低优先级任务”。你想想看,一个紧急的中断处理任务,如果被一个跑循环的普通任务堵住了,那系统就废了。
抢占式调度的核心逻辑就一句话:谁优先级高,谁先跑;谁先就绪,谁先跑。
2.2.2 调度时机
调度器什么时候干活?不是随时都在干。它只在特定时刻才会触发调度。这些时刻包括:
- 任务主动阻塞:任务调用延时、等待信号量等API时,主动让出CPU。
- 任务创建/恢复:新任务就绪,或者挂起的任务被恢复,可能影响优先级排序。
- 时间片用完:SysTick中断触发,当前任务的时间片到了。
- 中断退出时:中断服务程序执行完毕,返回前检查是否有更高优先级的任务就绪。
嗯,这里要注意第四点。中断退出时的调度,是抢占式调度的关键。我在项目中遇到过一个问题:一个外设中断里释放了信号量,但中断退出后没有立即调度,导致高优先级任务迟迟没跑起来。后来发现是中断嵌套没处理好。所以,中断退出时一定要检查是否需要重新调度。
2.3 上下文切换机制
上下文切换,是调度器最底层的操作。说白了,就是把当前任务的状态“存起来”,再把下一个任务的状态“恢复出来”。
这个“状态”包括什么?主要是CPU寄存器的值。比如程序计数器PC、栈指针SP、通用寄存器R0-R12等等。这些寄存器保存了任务当前的执行现场。
2.3.1 切换流程
上下文切换的流程,我拆成几步来讲:
- 触发切换:通常是PendSV异常(ARM Cortex-M)或软件中断。
- 保存当前任务上下文:把当前CPU寄存器的值压入当前任务的栈中。
- 更新当前任务状态:把任务从运行态改为就绪态(如果是被抢占)或阻塞态(如果是主动让出)。
- 选择下一个任务:调度器从就绪队列中选出优先级最高的任务。
- 恢复新任务上下文:从新任务的栈中弹出寄存器的值。
- 跳转到新任务:执行新任务的代码。
整个过程,快的只需要几十个CPU周期。我见过优化得好的RTOS,一次上下文切换只要不到1微秒(在100MHz的MCU上)。
2.3.2 关键数据结构:任务控制块
每个任务都有一个任务控制块(TCB)。TCB就是任务的“身份证”,里面存了任务的所有信息。
// FreeRTOS 任务控制块简化结构
typedef struct tskTaskControlBlock {
volatile StackType_t *pxTopOfStack; // 栈顶指针,上下文切换的核心
ListItem_t xStateListItem; // 状态链表节点
ListItem_t xEventListItem; // 事件链表节点
UBaseType_t uxPriority; // 任务优先级
StackType_t *pxStack; // 栈起始地址
char pcTaskName[configMAX_TASK_NAME_LEN]; // 任务名称
// ... 其他成员
} tskTCB;
这里最关键的是 pxTopOfStack。它指向任务栈的当前顶部。上下文切换时,CPU寄存器的值就保存在这个栈里。切换出去时,pxTopOfStack 指向保存了寄存器值的栈位置;切换回来时,从这个位置恢复寄存器。
我曾经踩过的坑: 有一次我自定义了一个任务栈,但栈大小没算对,导致栈溢出。结果上下文切换时,保存的寄存器数据被覆盖了,任务恢复后PC指针乱飞,系统直接跑飞。从那以后,我每个任务都会预留至少20%的栈余量,并且在调试阶段开启栈溢出检测。
2.3.3 多核下的上下文切换
多核MCU的上下文切换,和单核有什么不同?
核心逻辑是一样的。但多核下,每个核心都有自己的调度器实例。每个核心独立运行调度算法,独立管理自己的就绪队列。
区别在于:
- 全局就绪队列:所有核心共享一个就绪队列,任务可以在不同核心间迁移。
- 私有就绪队列:每个核心有自己的就绪队列,任务绑定在特定核心上。
- 混合模式:部分任务可迁移,部分任务绑定核心。
多核调度时,还要考虑一个关键问题:核间同步。一个核心修改了全局就绪队列,另一个核心可能正在读取。这时候就需要用自旋锁或关中断来保护临界区。
我建议你先掌握单核的上下文切换,再去看多核。单核的搞透了,多核无非就是加了一把锁。
2.4 本章小结
这一章我们讲了三个核心概念:
- 任务的状态:就绪、运行、阻塞、挂起、终止。状态转换是调度的基础。
- 调度器的工作原理:抢占式、时间片轮转、协作式。主流RTOS用抢占式。
- 上下文切换机制:保存现场、恢复现场。TCB是切换的关键数据结构。
这些内容,是后面所有章节的基石。多核任务调度、资源管理、核间通信,都建立在这些基础之上。你把这些搞明白了,后面学起来会轻松很多。