调度器核心原理:任务控制块、任务状态机与上下文切换
调度器,说白了就是单片机里的「交通警察」。它决定哪个任务该跑、哪个任务该等、哪个任务该睡。我刚开始做嵌入式系统时,总觉得调度器是个黑盒子,直到有一次产品在现场死机,排查了三天才发现是任务状态机乱了。嗯,从那以后,我把调度器的每个细节都啃透了。
这一章,我们聚焦调度器的三个核心构件:任务控制块(TCB)、任务状态机和上下文切换机制。这三样东西搞明白了,调度器在你眼里就不再是玄学。
1. 任务控制块(TCB)——任务的「身份证」
每个任务在调度器里都有一个对应的数据结构,这就是TCB。它记录了任务的所有关键信息。你可以把TCB想象成任务的档案袋,调度器每次切换任务,都要先翻这个档案袋。
一个典型的TCB长这样:
typedef struct {
uint32_t *sp; // 栈指针(核心!)
uint32_t stack_size; // 栈大小
uint8_t priority; // 优先级
uint8_t state; // 当前状态
void (*entry)(void*); // 任务入口函数
void *arg; // 入口参数
uint32_t delay_ticks; // 延时计数(阻塞用)
uint32_t *stack_base; // 栈底地址
char name[16]; // 任务名(调试用)
} TCB_t;
关键字段解读:
- sp(栈指针):这是TCB里最重要的字段。它指向任务当前的栈顶位置。上下文切换时,CPU寄存器的值就保存在这里。
- state:任务当前处于什么状态,后面会细讲。
- delay_ticks:任务需要阻塞多少个系统滴答。我习惯用这个字段实现延时阻塞,而不是用死循环等待。
- name:调试时特别有用。我曾经在排查一个优先级反转问题时,就是靠打印任务名才理清调用链的。
我的习惯:TCB数组通常静态分配,大小在编译时就确定。比如#define MAX_TASKS 16,然后声明TCB_t task_pool[MAX_TASKS]。动态分配在单片机上风险太高,我踩过内存碎片化的坑。
2. 任务状态机——就绪/运行/阻塞/挂起
任务不是一直在跑的。它有四种基本状态,状态之间的转换构成了调度器的核心逻辑。
| 状态 | 含义 | 典型触发条件 |
|---|---|---|
| 就绪(Ready) | 任务具备运行条件,等待CPU | 创建完成、阻塞结束、被唤醒 |
| 运行(Running) | 任务正在占用CPU | 调度器选中该任务 |
| 阻塞(Blocked) | 任务等待某个事件或延时 | 调用延时函数、等待信号量 |
| 挂起(Suspended) | 任务被强制暂停,不参与调度 | 调试暂停、低功耗模式 |
状态转换的规则其实不复杂:
- 就绪 → 运行:调度器从就绪队列里挑一个优先级最高的任务。
- 运行 → 就绪:时间片用完,或者被更高优先级任务抢占。
- 运行 → 阻塞:任务主动调用延时或等待资源。
- 阻塞 → 就绪:延时到期,或者等待的资源可用。
- 运行/就绪/阻塞 → 挂起:外部强制暂停。
- 挂起 → 就绪:恢复执行。
我曾经踩过的坑:任务在阻塞状态下被误唤醒。有一次我写了一个按键扫描任务,它等待一个信号量。结果信号量被多个任务同时释放,导致这个任务在未预期的时机被唤醒,状态机乱了。后来我加了一个「状态校验」——每次唤醒后先检查当前状态是否真的是阻塞,不是的话直接丢弃唤醒信号。
你想想看,如果状态机乱了,调度器会做出什么荒唐事?它可能把一个正在运行的任务再次调度进来,导致栈溢出;也可能把一个阻塞中的任务当成就绪任务,直接切换过去。嗯,这就是为什么状态机必须严谨。
3. 上下文切换机制——任务的「换人」操作
上下文切换,就是保存当前任务的运行现场,恢复下一个任务的运行现场。这个过程必须由汇编代码完成,因为C语言无法直接操作CPU寄存器。
一个典型的上下文切换流程:
- 保存现场:把当前CPU的通用寄存器、程序计数器(PC)、状态寄存器(PSR)压入当前任务的栈中。
- 更新TCB:把当前的栈指针(SP)写入当前任务的TCB.sp字段。
- 选择新任务:调度器从就绪队列中选出下一个要运行的任务。
- 恢复现场:从新任务的TCB.sp取出栈指针,从栈中弹出寄存器值,然后跳转到新任务上次被中断的位置继续执行。
下面是一个Cortex-M3内核的PendSV中断服务函数示例,它专门用来做上下文切换:
__asm void PendSV_Handler(void)
{
// 保存当前任务的寄存器
MRS R0, PSP // 获取当前任务的栈指针
STMDB R0!, {R4-R11} // 压入R4~R11(共8个寄存器)
// 更新当前任务的TCB.sp
LDR R1, =current_tcb
LDR R1, [R1]
STR R0, [R1] // 保存栈指针到TCB
// 调用调度函数,选择下一个任务
BL scheduler_select
// 恢复新任务的寄存器
LDR R1, =current_tcb
LDR R1, [R1]
LDR R0, [R1] // 取出新任务的栈指针
LDMIA R0!, {R4-R11} // 弹出R4~R11
MSR PSP, R0 // 更新栈指针
// 返回时自动弹出R0~R3、R12、LR、PC、PSR
BX LR
}
注意:Cortex-M3在进入中断时,硬件会自动压入R0~R3、R12、LR、PC、PSR。所以软件只需要压入R4~R11。这个细节搞错了,任务切换后寄存器值全乱套。我调试第一个调度器时,就是在这里卡了两天。
上下文切换的时机通常有两个:
- 系统滴答定时器中断:每个时间片到期时触发,用于时间片轮转调度。
- 任务主动让出CPU:比如调用delay()或等待信号量时,任务主动触发PendSV。
我个人习惯把PendSV的优先级设为最低。为什么?因为这样可以等所有其他中断处理完再切换,避免在中断服务里做上下文切换导致优先级反转。
4. 核心逻辑关系图
下面这张图把TCB、状态机和上下文切换的关系串起来了。你仔细看,调度器的所有操作都围绕这三个要素展开。
调试小技巧:如果你怀疑调度器出了问题,先检查TCB.sp的值是否合理。我常用的方法是:在调试器里打印所有任务的TCB.sp,然后跟每个任务的栈底地址对比。如果某个任务的sp跑出了栈范围,那肯定是栈溢出或者上下文切换时保存了错误的指针。