灯光师们,欢迎回来。
上一讲我们手搓了任务控制块和最简单的上下文切换,让两个任务能“轮流”跑。但如果你真拿那套代码跑几个任务,很快就会发现一个尴尬的问题:任务A是个死循环,任务B永远没机会执行。没有调度策略的RTOS,就像没有交警的路口,谁嗓门大谁先走,最后全堵死。
这一篇,我们专门解决“任务究竟是怎么被选中上台的”这个问题。也就是说,调度器到底凭什么把CPU的执行权交给某个任务,而不是另一个?里面的优先级算法、就绪队列、上下文切换触发时机,每一环都是面试官最爱追问的硬核考点,也是你从“能跑Demo”到“真正懂RTOS”的分水岭。
这篇适合已经能写出简单任务创建代码、但还没理清“调度”本质的同学。我会从调度策略的底层逻辑讲起,把就绪表数据结构、PendSV上下文切换的汇编细节、时钟节拍与抢占时机都过一遍,最后附上我在实际调试中踩过的五个坑。看完你不仅能说清楚“任务是怎么被选中的”,还能自己动手改出一个像模像样的调度内核。
1. 调度这件事,本质上是解决“CPU给谁用”的效率问题
单核CPU同一时刻只能跑一个任务。所谓RTOS,不是让多个任务“同时”执行,而是把CPU时间切分成很多小段,在不同任务之间快速切换,让人感觉它们像在同时运行。调度器就是决定“CPU时间片分给谁”的那套算法和数据结构。
1.1 没有调度器会怎样:你亲手写的第一版内耗代码
假设你已经实现了前几讲的任务创建和上下文切换。一个最简单的调度器长这样:按顺序轮流执行每个任务。也就是Round-Robin轮转。每个任务跑固定时间(比如1ms)后,强制切换给下一个任务。
这种调度方式没有优先级概念,所有任务平等。它实际运行起来有个非常明显的问题:
假如任务A在等待一个外部中断(比如串口收到数据),但它没写阻塞代码,而是在循环里死等。这个时候任务A虽然什么都没干,却占着CPU不放,任务B只能干瞪眼。
这就是轮转调度的“平庸之恶”:资源利用率低。它可以把时间公平地分给所有人,但没法把时间优先给“最着急的人”。
所以工业级RTOS普遍不用“纯轮转”,而是用“优先级抢占 + 可选时间片轮转”的组合策略。你要理解的核心就是:任务调度器的本质,是一套“如何选人”的加权排队算法。
1.2 两种主流调度策略的取舍:优先级抢占 vs 时间片轮转
我用一张表格做一个快速对照,你之后设计调度器时心里就有谱了:
| 调度策略 | 核心规则 | 优点 | 缺点 | 典型场景 |
|---|---|---|---|---|
| 优先级抢占式 | 最高优先级的就绪任务立即获得CPU | 实时性可控,高优先级事件能及时响应 | 低优先级任务可能饿死;实现复杂度高 | 中断事件处理、控制类系统 |
| 时间片轮转 | 同优先级任务轮流执行固定时间 | 公平性高,实现简单 | 无优先级区分,实时性差 | 后台任务、UI刷新 |
| 混合策略 | 高优先级抢占 + 同优先级轮转 | 兼顾实时性和公平性 | 调度逻辑最复杂,需要权衡 | 绝大部分通用RTOS |
我的建议是:如果你在写学习项目,先实现“优先级抢占式”就够了。等这个跑稳了,再往同优先级里加“时间片轮转”。一颗诚实的调度器种子,一定是先从“抢”开始生长的。
2. 就绪队列:调度器手里的“候选人名单”
调度器要选任务,首先得知道“现在有哪些任务是可以被选的”。在RTOS内核里,这靠的就绪表(Ready Table)。就绪表是内核中最核心的数据结构之一,它的设计直接决定了调度器的查找速度。
2.1 位图法就绪表:用1个bit表示一个任务的就绪状态
任务状态我用一张表给你盘点清楚:
| 状态 | 含义 | 在就绪表中? | 举例 |
|---|---|---|---|
| 运行态 | 正在占用CPU | 是 | 当前正在执行的任务 |
| 就绪态 | 可运行但未运行 | 是 | 在排队等CPU的任务 |
| 阻塞态 | 等待某事件或信号量 | 否 | task在等串口接收完成 |
| 挂起态 | 被调用vTaskSuspend挂起 | 否 | 调试暂停的任务 |
| 初始态 | 刚创建还未启动 | 否 | 还没有丢进调度器 |
就绪表最经典的做法是用位图。比如一个32优先级的内核,就绪表就是一个32位变量。第n位为1,代表优先级n的任务处于就绪状态。
// 就绪表:bit0~bit31对应优先级0~31 // 数值越大优先级越高(这里定义优先级0最低,31最高) volatile uint32_t ready_table;查找当前最高优先级任务,理论上是扫描最高位。如果逐位for循环,最坏要查32次,这可不是RTOS该干的事。CM3/CM4内核有一条指令叫CLZ(Count Leading Zeros),数从最高位开始连续0的个数,一条指令就能定位到最高的1在哪。于是代码可以这么写:
uint8_t get_highest_ready_priority(void) { // CLZ 返回前面0的个数 // __CLZ是CMSIS提供的编译器内置函数 return 31 - __CLZ(ready_table); }这样,O(1)复杂度就能找到“该选谁”。你以后看FreeRTOS的源码,它会按优先级分多个链表,本质上也是类似的“快速定位”思想,只是表结构更复杂。位图的思路既简单又能讲清原理,我建议你手搓内核时优先实现这一版。
2.2 任务状态跳转是调度器的活地图
光有就绪表还不够,要保证状态切换不出错,你必须让“任务状态机”成为一个闭环。画个表单帮你理一下:
| 当前状态 | 触发事件 | 新状态 | 就绪表操作 |
|---|---|---|---|
| 运行态 | 任务主动调用阻塞API(等待信号量) | 阻塞态 | 清就绪位 |
| 运行态 | 时间片耗尽,时间片轮转 | 就绪态 | 保持就绪位不变,换人选 |
| 阻塞态 | 等待的事件发生(中断里给信号量) | 就绪态 | 置就绪位 |
| 运行态 | 更高优先级任务就绪 | 就绪态 | 被抢占,清就绪位(或者保留) |
这里有一个很多人第一次手写内核时搞错的点:被抢占的任务到底该不该清就绪位?
答案是不该。因为它依然是可以执行的,只是没被选中而已。它只是从“运行态”被打回“就绪态”,但一直停留在就绪表中。等到它重新成为最高优先级,调度器直接再选中它。
2.3 我的实操习惯:在位图之上再造一层任务链表
位图能快速定位最高优先级任务,但一个优先级下可能有多个相同优先级的任务(比如两个普通任务都是优先级5)。位图本身只告诉优先级5有任务就绪,没告诉哪个任务。这时候我会在每个优先级上挂一个任务链表,就绪表中“哪个优先级有任务”用位图,同优先级内部再用链表维护多个任务。
这种双层设计,是工业级RTOS普遍采用的方案。FreeRTOS的pxReadyTasksLists就是按优先级拆成多个链表,配合uxTopReadyPriority来快速定位最高优先级。你手搓内核时,可以先简化为“每个优先级只允许一个任务”,把位图跑通后再扩展同优先级链表,稳扎稳打。
3. 优先级抢占的核心机制:PendSV异常与上下文切换的“换人”细节
调度器选出了“该上场的人”,下一步就是真正把CPU寄存器里的“旧演员”换下来,把“新演员”请上去。这一步叫上下文切换(Context Switch)。在Cortex-M内核上,它通常由PendSV异常来完成。
3.1 为什么选PendSV,而不是直接在SysTick里切换
你可能会问:既然SysTick定时器中断里就能做调度,为什么还要PendSV来切换?直接把切换代码写在SysTick_Handler里不行吗?
实践告诉我们:不行。原因有两个。第一,SysTick是中断,切换任务会改变栈指针和PC,如果此时另一个中断正在执行(比如UART中断嵌套),你在SysTick里强行切走当前任务,会让正在运行的中断上下文丢失,这是灾难性的。第二,PendSV异常被设计为“挂起后不立即执行”,可以等待所有更紧急的中断处理完毕后再进行任务切换。它的优先级可以配置成最低,这样它就像个“中场清洁工”,等所有演员谢幕后才开始换布景。
所以标准做法是:检测到需要调度时,不直接跳转,而是软件触发PendSV:
// 触发PendSV异常 SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk;PendSV执行时,一定是在所有高优先级中断都处理完后,此时做上下文切换最安全。
3.2 上下文切换的汇编原理:一个都不许漏
Cortex-M处理器在进入异常时,硬件会自动压栈一部分寄存器:xPSR、PC、LR、R12、R3-R0。但剩下的R4-R11,硬件不帮你保存,需要手动压栈。
省流版切换流程:
- 进入PendSV_Handler。
- 判断是第一次调度还是普通切换。如果是第一次,特殊处理。
- 拿出当前任务TCB里的栈顶指针(PSP)。
- 手动压栈R4-R11。
- 更新当前任务TCB中的栈顶指针。
- 从就绪表中取出新任务的TCB。
- 更新PSP为新任务的栈顶指针。
- 弹出新任务手动压栈的R4-R11。
- 利用异常返回机制,自动弹出新任务的xPSR、PC、LR、R12、R3-R0。
- 退出PendSV,开始运行新任务。
Cortex-M的异常返回有一个“魔法地址”。PendSV_Handler返回时,LR的值如果是0xFFFFFFFD,表示“返回线程模式,并使用PSP”,这样CPU就会切到新任务。
到这里你可能会背后一凉:这东西但凡漏保存一个寄存器,跑起来就是各种诡异跑飞。我调试时见过任务B的温度数据串到任务A的显示里,就是寄存器恢复不完整造成的。所以这句提醒送给你:
上下文切换不是函数调用,是寄存器级别的“偷天换日”。每个寄存器的保存和恢复都必须无懈可击,否则轻则数据错乱,重则HardFault。
4. 调度点:什么时候该“换人”
理解了“怎么换”,下一步是“什么时候换”。这直接影响到系统的实时性和公平性。RTOS里的换人时机主要有三个大方向:任务主动让出、时钟节拍强制轮转、中断唤醒高优先级任务。
4.1 任务主动让出CPU:最简单的调度触发
如果任务本身就没啥要紧事,调一个系统调用让出CPU,这是最直观、最可控的。比如:
void task_a(void *arg) { while (1) { // 点灯 task_yield(); // 主动让出CPU } }task_yield做的事其实很简单:把当前任务从运行态转为就绪态,然后触发调度器重新选择最高优先级任务。如果当前任务优先级仍然最高,那么让出后马上又被选中,等于白切换——所以主动让出适合“同优先级任务互相谦让”的场景。
4.2 时钟节拍:SysTick如何扮演“定时闹钟”
纯粹靠任务自觉让出CPU不现实。毕业设计里那个点灯任务,它就是死循环,你不给它打断,它永远不会让。所以RTOS要有一个硬件定时器,周期性产生中断,逼调度器“重新审视”一次任务选择。这个定时器在Cortex-M上一般是SysTick。
SysTick_Handler里做两件事:
- 时间片递减。
- 如果当前任务时间片用完,触发PendSV调度。
void SysTick_Handler(void) { TICK_CNT++; // 如果开了时间片轮转,递减当前任务的时间片 if (current_task->time_slice > 0) { current_task->time_slice--; if (current_task->time_slice == 0) { current_task->time_slice = DEFAULT_TIME_SLICE; // 把当前任务挪到同优先级就绪队列末尾(如果有链表) // 然后触发PendSV SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk; } } }注意一个细节:SysTick中断里你通常不直接做完整上下文切换,而是“申请”PendSV。刚才讲过,PendSV会等更高优先级中断处理完毕才执行,这样就能避免在UART中断嵌套中切任务导致现场破坏。
时钟节拍的周期决定了系统的时间精度。一般取1ms(即OS_TICK_HZ = 1000)。如果你把周期设太短(比如100us),SysTick中断频繁触发,调度开销会很大;设太长(比如10ms),时间片轮转不够精细。这就需要根据实际场景折中。
4.3 中断唤醒:高优先级任务“插队”的全过程
这是优先级抢占最精髓的部分。比如现在低优先级任务A正在跑,突然串口收到一帧数据,触发UART中断。中断服务函数里释放了一个信号量,而这个信号量正在被优先级高的任务C等待。于是任务C从阻塞态转为就绪态,优先级最高。中断退出后,调度器立刻选中任务C,任务A被抢占。
这整个过程分成三步:
- 中断触发,CPU自动跳转到中断服务函数。
- 中断服务函数里通过API释放信号量或发消息,使任务C就绪。
- 中断服务函数返回前,检查是否需要调度,如果需要,触发PendSV,中断完全退出后执行切换。
这就是“中断唤醒”的本质:它不是调度器主动去发现谁醒了,而是被系统调用“踢了一脚”,然后调度器才重新挑人。
实操时有个很常见的坑:中断服务函数里直接调了释放信号量的API,但忘记了那个API其实是“非中断安全版本”。比如FreeRTOS里xSemaphoreGiveFromISR和xSemaphoreGive是两个函数名,前者用于中断上下文,后者用于任务上下文。混用轻则警告,重则调度器数据被破坏。所以任何时候,在中断里调用内核API,先问自己一句:这个API是FromISR版本吗?
5. 从零实现一个调度器核心:可抄作业的最小代码
讲了这么多理论,最终还是要落到代码上。我给出一个能在STM32上跑起来的最小调度器骨架,不含全部外设驱动,但调度逻辑是完整的。
5.1 任务控制块与就绪表定义
#define MAX_TASKS 4 #define MAX_PRIORITY 32 typedef struct tcb { uint32_t *sp; // 栈指针,切换时核心 uint8_t priority; // 优先级 uint8_t state; // 0 ready, 1 blocked uint32_t time_slice; // 时间片计数 struct tcb *next; // 同优先级链表(简单实现可省略) } TCB; TCB task_tcbs[MAX_TASKS]; volatile TCB *current_task; volatile TCB *next_task; volatile uint32_t ready_table;5.2 找最高优先级任务
TCB *get_next_task(void) { uint8_t highest_prio = 31 - __CLZ(ready_table); // 遍历任务表,找到对应优先级的就绪任务 for (int i = 0; i < MAX_TASKS; i++) { if (task_tcbs[i].state == 0 && task_tcbs[i].priority == highest_prio) { return &task_tcbs[i]; } } return NULL; // 不应该发生 }这个版本是简化版,适合学习。每遍历一次最多查MAX_TASKS个任务,实际工程中一个优先级下可能有多个任务,就需要链表。但核心思想完全相同。
5.3 触发调度的通用接口
void schedule(void) { next_task = get_next_task(); if (next_task != current_task) { // 触发PendSV,真正切换将在中断退出前完成 SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk; } }注意一个细节:schedule函数里并没有直接做寄存器切换,它只是“预约”了一个PendSV。这个设计的精髓是“延后到安全时机再切换”。如果你忍不住在schedule里直接切栈、改PC,很快你会发现中断丢失问题。
5.4 PendSV_Handler最简汇编实现
__asm volatile ( " .syntax unified \n" " .thumb \n" " .thumb_func \n" " .global PendSV_Handler \n" "PendSV_Handler: \n" " MRS R0, PSP \n" " CBZ R0, first_run \n" " // 保存上下文,R4-R11压栈 \n" " STMDB R0!, {R4-R11} \n" " // 保存当前任务的SP到TCB \n" " LDR R1, =current_task \n" " LDR R1, [R1] \n" " STR R0, [R1] \n" "first_run: \n" " // 加载新任务TCB \n" " LDR R0, =next_task \n" " LDR R0, [R0] \n" " // 从TCB加载新任务的SP \n" " LDR R1, [R0] \n" " // 设置PSP \n" " MSR PSP, R1 \n" " // 恢复上下文 \n" " LDMIA R1!, {R4-R11} \n" " // 更新current_task = next_task \n" " LDR R0, =current_task \n" " LDR R1, =next_task \n" " LDR R1, [R1] \n" " STR R1, [R0] \n" " // 异常返回,切到线程模式+PSP \n" " MOV LR, #0xFFFFFFFD \n" " BX LR \n" );这个汇编看起来短,实际写的时候全是坑。比如“first_run”标签的处理,我第一次写就漏了CBZ判断,结果是第一次切换时,新任务的栈里没有自动压栈的xPSR、PC等寄存器,直接从损坏的栈指针开始跑,直接HardFault。调试这种问题,你会感受到“内核崩溃”和“业务代码崩溃”完全是两码事。
还有一点必须提醒:不同编译器对内联汇编的语法支持不太一样。上面的写法基于GCC,Keil的ARMCC语法略有差异,但逻辑一样。你要是用Keil,可以把这段汇编单独放到.s文件里。
6. 做实验必看的5个“调度大坑”与排查心得
这部分是我实际反复踩坑攒出来的经验,价值比上面的代码本身高。你如果能把这些坑提前记住,至少能省下一个星期的调试时间。
6.1 坑一:栈指针在第一次切换时是“非法”的
很多初学手搓内核的人,第一次任务切换直接跑飞。最常见的原因就是:任务创建时初始化栈的方式不对,栈指针没有指向一个包含初始xPSR/PC/LR的合法结构。第一次切换时,CPU从栈里弹出的PC是0,或者不是任务入口,直接HardFault。
排查技巧:在第一次进入PendSV_Handler时,打断电,查看新任务的PSP值。如果PSP指向的地址内容全是0或0xFFFFFFFF,基本就是初始化栈的代码写错了。你要把任务的初始xPSR(一般设为0x01000000,表示使用Thumb指令)、初始PC(任务入口)、LR(设为0xFFFFFFFD)都装进初始栈里。
6.2 坑二:优先级反转比我预想的更容易发生
经典三任务模型:任务H(高优先级)等信号量,任务L(低优先级)持有信号量,任务M(中优先级)不停地跑CPU。如果H等L释放信号量,而M抢占L,H就会被M“间接饿死”。
| 时间点 | 运行任务 | 事件 |
|---|---|---|
| t0 | L | L拿到信号量,开始干活 |
| t1 | M | M就绪,抢占L,L被挂起 |
| t2 | H | H就绪,抢占M,H等信号量,阻塞 |
| t3 | M | M继续运行,L永远没机会释放信号量 |
| t4 | H | H继续等待,相当于死锁 |
解决思路是优先级继承:当H等信号量时,把持有信号量的L临时提升到H的优先级,让它尽快运行并释放信号量。手搓内核初期可以先不实现,但你必须意识到这个问题存在。
6.3 坑三:时间片轮转没有真正“轮转”
如果你在SysTick里只递减了时间片计数,但忘了把当前任务挪到就绪队列末尾(对于单优先级链表),那么“轮转”永远不会发生。SysTick每次触发,当前任务的时间片减到0后又被重置为满值,但调度器选出的还是同一个任务,其他同优先级任务永远没有机会运行。
排查技巧:用逻辑分析仪同时抓两个任务的GPIO翻转波形。如果两个波形一个一直输出、另一个完全没变化,十有八九是时间片轮转逻辑里“队列尾部搬移”没做。
6.4 坑四:中断服务函数里进行阻塞操作
RTOS的临界资源操作非常讲究上下文。在中断里调用一个会阻塞的API(等待信号量),就是拿系统内核开玩笑。中断服务函数应该极快执行,不能“睡大觉”。真遇到这种情况,你应该把阻塞操作丢给任务去干,中断里只做最轻量级的置标志位。
我见过有人把printf直接写进UART中断,结果中断服务函数执行时间好几个毫秒。SysTick一中断,PendSV又等不到机会执行,系统卡死。排查到最后,居然是“中断里调printf”这个细节。
6.5 坑五:调度测量居然靠printf
最后是我最想吐槽的:调试调度器性能,别再用串口printf打时间戳。printf本身的阻塞时间比任务切换时间还长,测量结果毫无意义。正确做法是把某个GPIO翻转放在你要测量的代码段前后,用逻辑分析仪看脉冲宽度。
我在实际项目中测调度延迟,就是在PendSV入口把一个GPIO拉高,退出时拉低,然后用逻辑分析仪看这个高电平的时间。那才叫真实的调度开销,通常几个微秒,比printf靠谱一万倍。
7. 写在最后:调度器这条路,越往深走越有意思
我个人做了几年嵌入式开发,回头再想“任务调度”这件事,最大的感受是:它不像写业务代码,功能对了就行。调度器是很多任务的公共基础设施,任何一点点边缘case考虑不周,就会导致系统层面的随机故障。
如果你正在跟着这个系列手搓操作系统,我建议你按这个顺序往下走:先实现优先级抢占式调度,用两个不同优先级的点灯任务验证;然后加入时间片轮转,用两个同优先级任务轮流转;最后再挑战PendSV里用逻辑分析仪观测切换波形。每一步跑通后,再回头读FreeRTOS源码时,你会发现自己已经能看懂它在干什么了。
任务被“选中”的瞬间,背后藏着的其实是一套完整的优先级、就绪表、上下文切换与中断配合机制。把这些关节打通,你才算真正把RTOS的“魂”装进了自己的代码里。下一篇我会接着讲阻塞与唤醒机制的实现,也就是信号量和队列到底是怎么让任务“主动睡觉”的。