调度器核心概念:任务控制块、状态机与上下文切换
调度器这东西,说白了就是操作系统的“交通警察”。它管着哪个任务该跑、哪个任务该等、哪个任务该睡。而要实现这一切,有三个东西是绕不开的:任务控制块(TCB)、任务状态机、上下文切换(Context Switch)。今天咱们就把这三个核心概念掰开揉碎了讲清楚。
1. 任务控制块(TCB)——任务的“身份证”
每个任务在系统里都有一个唯一的身份标识,这就是TCB。我习惯把它理解成任务的“档案袋”。里面装着任务的所有关键信息:栈指针、优先级、状态、名字、还有一堆寄存器备份。
你看下面这个结构体,就是一个典型的TCB定义:
typedef struct tcb {
uint32_t *sp; // 栈指针(核心!)
uint32_t priority; // 任务优先级
uint32_t state; // 当前状态
uint32_t delay_ticks; // 延时计数
void (*entry)(void*);// 任务入口函数
char name[16]; // 任务名字(调试用)
struct tcb *next; // 链表指针
} tcb_t;
这里最关键的字段就是 sp——栈指针。为什么说它核心?因为上下文切换时,CPU就是靠这个指针找到任务上次执行到哪里的。我在项目中遇到过一个问题:某个任务跑着跑着就飞了,查了两天才发现是TCB里的栈指针被意外改写了。从那以后,我每次创建任务都会把TCB放在只读区域,只让调度器去操作它。
TCB 的核心字段一览:
| 字段 | 作用 | 我的建议 |
|---|---|---|
| sp | 栈指针,保存CPU寄存器现场 | 务必在切换时原子操作 |
| priority | 任务优先级,决定调度顺序 | 不要动态改,容易出优先级反转 |
| state | 任务当前状态 | 用枚举,别用魔数 |
| delay_ticks | 延时计数,用于时间片管理 | 注意溢出,用32位无符号 |
| entry | 任务入口函数指针 | 创建后不要修改 |
2. 任务状态机——任务的一生
任务不是一直在跑的。它会在几个状态之间跳来跳去。这就是任务状态机。常见的状态有四个:就绪(Ready)、运行(Running)、阻塞(Blocked)、挂起(Suspended)。
你想想看,一个任务刚创建时,它处于就绪态。调度器选中它,它就变成运行态。如果它调用了 delay(),就进入阻塞态。等延时结束,又回到就绪态。就这么简单。
但实际项目中,状态转换的边界往往很模糊。我曾经在一个多传感器采集系统里,发现某个任务莫名其妙卡在阻塞态出不来了。后来一查,是信号量释放时忘了把任务状态改回就绪。嗯,这种低级错误,排查起来真要命。
避坑指南:状态转换必须用switch-case或状态表实现,别用if-else堆。我曾经用if-else写过状态机,结果加了三个状态后代码就成了一团乱麻。后来全部改成状态表,清爽多了。
下面这张图,就是任务状态机的标准流转关系:
3. 上下文切换(Context Switch)——调度器的“换人”操作
上下文切换,说白了就是CPU从一个任务换到另一个任务时,要把当前任务的“现场”保存好,再把新任务的“现场”恢复出来。这个“现场”就是CPU寄存器组:PC、SP、LR、R0-R12、PSR等等。
我刚开始写调度器时,觉得上下文切换不就是压栈出栈嘛,有什么难的?结果第一次跑起来,任务切换后直接进HardFault。查了半天才发现,我忘了保存LR寄存器——返回地址丢了,CPU当然不知道往哪跳。
下面是一个典型的上下文切换代码(Cortex-M3/M4架构):
; 任务切换入口
PendSV_Handler:
; 保存当前任务上下文
MRS R0, PSP ; 获取当前任务栈指针
STMFD R0!, {R4-R11} ; 保存R4~R11到栈
; 更新TCB中的栈指针
LDR R1, =current_tcb
LDR R2, [R1]
STR R0, [R2] ; TCB->sp = 当前栈指针
; 切换到下一个任务
BL get_next_task ; 获取下一个就绪任务
; 恢复新任务上下文
LDR R1, =current_tcb
LDR R2, [R1]
LDR R0, [R2] ; 获取新任务的栈指针
LDMFD R0!, {R4-R11} ; 恢复R4~R11
MSR PSP, R0 ; 更新PSP
; 异常返回时自动恢复PC、LR、R0-R3、PSR
BX LR
警告:上下文切换必须在临界区或关中断环境下执行。我曾经在一个产品中忘了关中断,结果切换过程中被高优先级中断打断,TCB里的栈指针被写了一半——系统直接崩溃。从那以后,我所有调度器代码都加了 __disable_irq() / __enable_irq() 保护。
4. 切换代价——你不得不知道的性能账
上下文切换不是免费的。每次切换,CPU都要花几十到几百个时钟周期来保存和恢复寄存器。如果切换太频繁,CPU大部分时间都在“换人”,而不是在“干活”。
我做过一个测试:在STM32F407上,一个简单的上下文切换大约需要80个时钟周期(关中断+压栈+出栈+开中断)。如果系统时钟168MHz,那就是约0.5微秒。看起来不多对吧?但如果你的系统有10个任务,每个任务只跑100微秒就切换一次,那切换开销就占了5%的CPU时间。
切换代价对比表(基于Cortex-M4 @168MHz):
| 操作 | 时钟周期 | 时间(微秒) | 占比(10任务/100us时间片) |
|---|---|---|---|
| 关中断 | 2 | 0.012 | 0.01% |
| 保存R4-R11 | 16 | 0.095 | 0.10% |
| 更新TCB指针 | 8 | 0.048 | 0.05% |
| 获取下一个任务 | 30 | 0.179 | 0.18% |
| 恢复R4-R11 | 16 | 0.095 | 0.10% |
| 开中断 | 2 | 0.012 | 0.01% |
| 总计 | 74 | 0.441 | 0.44% |
所以我的建议是:时间片不要设得太短。一般5~10毫秒比较合理。太短了切换开销占比高,太长了实时性又差。这个平衡点,得根据你的具体应用来调。
个人经验:如果你用的是FreeRTOS或uC/OS,它们的上下文切换已经优化得很好了。但如果你自己写调度器,记得把切换路径上的所有函数都inline掉,或者直接用汇编写。我见过有人用C函数调用做切换,结果每次切换多花了40个周期——纯粹是函数调用开销,完全没必要。
5. 三个概念的关系——调度器的“铁三角”
TCB、状态机、上下文切换,这三者不是孤立的。它们构成了调度器的核心骨架:
- TCB 是数据基础,存着任务的所有信息。
- 状态机 是逻辑规则,决定了任务什么时候能跑、什么时候不能跑。
- 上下文切换 是执行动作,真正把CPU从一个任务换到另一个任务。
你想想看,调度器每次运行时,先查状态机,找到就绪态中优先级最高的任务;然后从它的TCB里取出栈指针;最后执行上下文切换,把CPU交给这个任务。这三步缺一不可。
嗯,到这里,调度器的三个核心概念就讲完了。记住:TCB是身份证,状态机是规则,上下文切换是动作。搞懂了这三样,调度器的大门就算被你踹开了。