
刚把上一节的任务创建和上下文切换调通很多朋友就急着问任务建好了切换函数也写出来了那系统到底是怎么决定“下一个该跑谁”的这事儿不搞清楚后面写信号量、消息队列这些同步机制会越来越懵。这一节我们就专门把任务调度这条主线拆开揉碎。我会带着你在我们这颗“手搓RTOS”内核里亲手实现一个基于优先级的抢占式调度器。不绕弯子直接从数据结构、核心算法、汇编级上下文切换三个层面把“选中任务上台”这件事讲明白。1. 调度器设计思路为什么选“优先级抢占”而不是“时间片轮转”1.1 先给调度器画个像调度器是RTOS里最核心的模块它的职责概括起来就一句话在任意时刻从所有“准备好运行”的任务里面挑出最该运行的那个把CPU交给它。这句话看着简单背后有几个问题必须想清楚什么叫“准备好运行”什么叫“最该运行”怎么“把CPU交出去”第一个问题靠任务状态管理解决第二个问题靠调度算法解决第三个问题靠上下文切换实现。这一节我们重点解决前两个第三个在上一节已经实现了切换的原语这里只需要把它挂到调度逻辑上。我们这颗小内核的目标平台是Cortex-M3/M4所以天然具备了硬件级的中断支持和SysTick定时器。这让我们能直接走“PendSV SysTick”这套经典路线也就是FreeRTOS和RT-Thread早期版本采用过的同款方案。1.2 优先级抢占让“最紧急的人”先上台我们在设计这颗RTOS时决定采用“固定优先级 抢占式调度”的模型。固定优先级意味着每个任务在创建时分配一个优先级运行期间不动态调整抢占式意味着当一个更高优先级的任务进入就绪态时系统会立刻暂停当前任务把CPU让给高优先级任务。这个选择有几个原因第一响应时间可预测。工业控制、传感器采集这类场景最高优先级任务必须在确定时间内执行完毕时间片轮转做不到这一点。第二实现复杂度可控。优先级调度只需要一个就绪队列按优先级排序每次调度时找最高优先级任务即可。动态优先级调度还需要考虑老化、防饿死等问题在MCU上性价比不高。第三好调试。固定优先级下任务执行顺序是确定的出问题容易复现。1.3 就绪队列的数据结构选择就绪队列是调度器最核心的数据结构。我们的第一版实现里任务数量上限是32个优先级范围0到310为最高。这个容量下最合适的就绪队列结构是“位图 优先级索引”。这里解释一下位图的思路用一个32位无符号整数每一位代表一个优先级。第n位置1就代表“优先级为n的任务处于就绪状态”。volatile uint32_t ready_prio_map; /* 就绪优先级位图bit n为1表示优先级n有任务就绪 */查询最高优先级任务时只要从最高位开始找第一个非0位即可。Cortex-M3有一条CLZ指令Count Leading Zeros可以一条指令算出最高位的1在第几位效率极高。int32_t highest_ready_prio_get(void) { return 31 - __CLZ(ready_prio_map); }这比遍历链表的O(n)复杂度快得多而且代码量极少。这也是为什么在大厂老牌RTOS里位图法始终是被优先推荐的实现方式。1.4 这个设计避开了哪些坑直接说结论写调度器最大的坑不是“调不动”而是“调出乱子”。如果一开始就上“多级时间片”或者“完全公平调度”状态多、分支多、测试难很容易把自己绕晕。固定优先级抢占模型帮我们挡掉了三类麻烦调度决策逻辑简单每次切换只需要查位图不需要维护复杂的调度历史。任务状态迁移清晰只有就绪、运行、阻塞、挂起四种状态调试时好定位。中断嵌套、临界区问题可控后面写同步机制时能明显体会到这个基础的重要性。2. 任务状态与TCB扩展给调度器提供“决策依据”2.1 TCB里得存哪些字段上一节我们创建了最简TCB用来保存堆栈指针和任务入口。现在要让调度器跑起来TCB必须补充下面这些字段typedef struct tcb { uint32_t *sp; /* 栈指针 */ uint8_t priority; /* 任务优先级 */ uint8_t state; /* 任务状态就绪/运行/阻塞/挂起 */ uint32_t slice_remain; /* 剩余时间片留给后续扩展 */ void (*entry)(void *arg); /* 任务入口 */ void *arg; /* 入口参数 */ struct tcb *next; /* 就绪链表或阻塞链表用 */ } tcb_t;每个任务一创建就根据优先级挂到对应的优先级链表上。同一个优先级上可能有多个任务它们之间用next指针串成兄弟链表。2.2 任务状态机从“排队等待”到“上台”稍微正式一点我们把任务状态定义为以下四种OS_TASK_READY已就绪排队等待CPUOS_TASK_RUNNING正在运行不多于一个OS_TASK_BLOCKED因等待某个事件/资源而暂停需要被唤醒后才回到就绪OS_TASK_SUSPENDED被显式挂起只有调用恢复接口才能回到就绪调度器最关心的其实只有OS_TASK_READY状态。每次调度时从就绪队列里挑一个任务切过去这就是“选中上台”。2.3 创建任务时顺带挂入就绪队列任务创建的末尾要主动把新建任务放进就绪队列。如果新任务的优先级比当前运行任务更高立刻触发一次调度。int32_t task_create(tcb_t *tcb, uint8_t prio, void (*entry)(void *), void *arg) { /* 栈初始化、SP设置……这些步骤省略 */ tcb-priority prio; tcb-state OS_TASK_READY; prio_list_insert(tcb); /* 挂入对应优先级的就绪链表 */ ready_prio_map | (1U prio); /* 在就绪位图中标记该优先级 */ /* 如果新任务优先级高于当前任务触发调度 */ if (prio current_task-priority) { schedule(); } return 0; }这一步顺带实现了“创建后立刻抢占”的效果。很多初学者在这里有个困惑为什么任务刚创建就能跑原因就在这里——它已经进就绪队列了而且因为优先级比当前任务高调度器会立刻让它插队上台。3. 调度器核心链路从“割让CPU”到“新任务接管”3.1 调度发生的三种时机调度器不是每时每刻都在跑它只在几种特定时机被触发主动调度任务调用延时、等待信号量等API时主动让出CPU。这是“礼让式”切换。SysTick中断系统节拍到来检查是否有更高优先级任务就绪。这是“抢占式”切换的核心。中断退出前如果在中断处理过程中唤醒了一个高优先级任务中断结束时要切到它。这三种时机最后都会汇聚到同一个函数PendSV_Handler我们在上一节已经搭好了PendSV的切换脚手架这里只需要把调度决策塞进去。3.2 SysTick节拍里找“下一个上台的人”SysTick是内核的“心跳”一般配置成1ms一次。每次进入SysTick中断系统节拍计数加一然后查询就绪位图看看最高优先级任务是不是当前任务。void SysTick_Handler(void) { tick_count; /* 检查是否有更高优先级任务就绪 */ int32_t highest highest_ready_prio_get(); if (highest ! current_task-priority) { pending_task prio_list_get_task(highest); /* 触发PendSV在合适的时机完成上下文切换 */ SCB-ICSR | SCB_ICSR_PENDSVSET_Msk; } }这里有个重要细节SysTick里只是“登记”一下把目标任务放到pending_task变量里真正的栈切换放到PendSV里做。原因是SysTick也是一个中断在中断里直接做栈切换容易搞乱现场。PendSV被设置成最低优先级中断它会等所有其他中断处理完后再执行保证上下文切换的安全性。3.3 PendSV切换动作保全现场、换人上台回顾一下上一节已经实现的PendSV切换逻辑这里我们把它的角色说得更明确保存当前任务的寄存器到它的栈。更新当前任务TCB里的sp字段指向保存完现场后的栈顶。从pending_task或调度算法选出的新任务的TCB里取出目标栈指针。把current_task更新为目标任务。从目标任务的栈里恢复寄存器然后触发任务入口继续执行。void PendSV_Handler(void) { /* 保存现场到当前任务栈 */ __asm volatile( MRS r0, PSP\n STMDB r0!, {r4-r11}\n ); current_task-sp get_psp(); /* 切换current_task为选中的新任务 */ current_task pending_task; /* 恢复新任务的现场 */ set_psp(current_task-sp); __asm volatile( LDMIA sp!, {r4-r11}\n BX lr\n ); }这四步就是“上台交接”的完整仪式。注意上面对硬件的寄存器操作依赖Cortex-M的PSP进程栈指针机制这是RTOS切换的根基如果上一节没有把PSP用好这一节要先回到上一节补课。3.4 临界区处理调度器最容易被“翻车”的地方调度代码里存在多处“先查询、再决策、再切换”的非原子操作。如果中途被打断可能出现“两个任务同时被选中”或者“就绪位图更新到一半被切走”的尴尬局面。通用的做法是关中断。在进入调度决策之前用关中断保存PRIMASK的方式锁住CPU防止调度过程被其他中断打断。uint32_t critical_enter(void) { uint32_t primask; __asm volatile(MRS %0, PRIMASK : r(primask)); __asm volatile(CPSID I); return primask; } void critical_exit(uint32_t primask) { __asm volatile(MSR PRIMASK, %0 : : r(primask)); }这个临界区保护的方式后面写队列、信号量的时候会反复用到现在就必须养成习惯。4. 完整调度器实现代码逐段解析4.1 内核初始化入口初始化时先创建一个“空闲任务”优先级最低的idle任务。没有其他任务就绪时CPU就停留在空闲任务里这样可以保证就绪队列永远不会空。void kernel_init(void) { /* 初始化就绪队列 */ ready_prio_map 0; current_task NULL; pending_task NULL; /* 创建idle任务优先级31 */ idle_tcb (tcb_t *)idle_stack[IDLE_STACK_SIZE - 1]; task_create(idle_tcb, 31, idle_task_entry, NULL); }idle任务里通常放一句死循环循环里执行WFI指令让CPU进入低功耗等待有中断时自动唤醒。void idle_task_entry(void *arg) { while (1) { __WFI(); } }这里有个值得注意的细节调度器的“兜底方案”必须可靠。如果就绪队列空了系统就不知道往哪切几乎所有RTOS都会保留一个idle任务这也算行业共识了。4.2 任务延时让出CPU主动触发调度为了让任务能“休息一下”我们实现一个任务延时函数把当前任务从就绪队列移除挂到一个延时链表上然后直接触发调度。void task_delay(uint32_t ticks) { uint32_t primask critical_enter(); /* 把当前任务从就绪队列移除 */ prio_list_remove(current_task); ready_prio_map ~(1U current_task-priority); /* 记录唤醒时间挂入延时链表 */ current_task-wake_tick tick_count ticks; delay_list_insert(current_task); current_task-state OS_TASK_BLOCKED; /* 直接切换到下一个就绪任务 */ pending_task get_next_ready_task(); trigger_pendsv(); critical_exit(primask); }注意这里调用了trigger_pendsv但没有立刻做上下文切换。原因是当前函数仍在关中断状态直接切换会丢掉这段执行上下文。正确流程是先把现场交给PendSV退出临界区后PendSV自然触发切换。4.3 延时唤醒把“睡醒的任务”放回队列SysTick每来一次除了检查抢占外还要扫描延时链表把已经到唤醒时间的任务重新放回就绪队列。void tick_update(void) { tcb_t *task delay_list_head; while (task) { tcb_t *next task-next; if (task-wake_tick tick_count) { delay_list_remove(task); task-state OS_TASK_READY; prio_list_insert(task); ready_prio_map | (1U task-priority); } task next; } }这就是任务从“等待”回到“就绪”的完整通路。注意这里要先把next指针提前保存因为任务从延时链表移除后它的next指针就不再指向原先的下一个节点了直接遍历会丢节点。4.4 调度算法入口统一封装为了应对后续可能需要支持时间片调试纠错我们把调度决策封装成一个独立函数便于替换算法模块。每处切换场景调用它来获取“下一个上台的任务”。tcb_t *schedule_select(void) { int32_t highest highest_ready_prio_get(); if (highest 0) { return idle_tcb; /* 没有就绪任务返回idle */ } return prio_list_get_task(highest); }这样后续如果要用“同优先级时间片轮转”只需要修改prio_list_get_task的逻辑在同一个优先级链表上做轮转即可不影响其他模块。5. 实战验证造两个任务点灯亲眼看调度过程5.1 测试任务设计我习惯用两盏灯来验证调度行为LED0闪烁周期500msLED1闪烁周期1000ms。两个任务各负责一盏灯通过task_delay让出CPU。void led0_task(void *arg) { while (1) { GPIO_ToggleBits(LED0_GPIO, LED0_PIN); task_delay(500); } } void led1_task(void *arg) { while (1) { GPIO_ToggleBits(LED1_GPIO, LED1_PIN); task_delay(1000); } }另一个任务是串口打印任务优先级设为低于LED任务每隔一段时间打印一次“当前运行任务”信息用于观察调度行为。5.2 预期行为与实际现象如果调度器工作正常LED0应该比LED1闪烁得快一倍。更关键的观察点是如果给串口打印任务一个很低的优先级它会在两个LED任务都延时阻塞的时候被调度上台运行打印完又继续阻塞把时间让给负责亮的LED任务。实测现象和理论是一致的。这其实也验证了一个很重要的点RTOS的调度是“事件驱动”的任务只有在阻塞、唤醒、创建、延时这种关键时刻才发生切换而不是像大家想的那样“每毫秒强切一次”。5.3 用调试器活捉“切换现场”如果你手头有J-Link或者ST-Link调试验证时有一个很好用的方法在PendSV_Handler入口打断点然后单步执行依次查看以下值当前任务的栈指针sp和上一次切换时的地址对比栈里保存的r4-r11和任务入口代码里的局部变量是否对应pending_task的优先级和就绪位图的关系我第一次跑通的时候就是靠这个办法抓到了“就绪位图更新顺序不对”的问题原本应该先移除旧任务再插入新任务写成先插后删导致同一次调度里选出了两个相同优先级的任务系统直接跑飞了。6. 常见调度bug实测与排查清单6.1 两个任务同时被选中现象系统周期性死机或者跳飞到HardFault。排查思路检查就绪位图的操作顺序。在彻底分析之前可以先在PendSV入口打印当前就绪位图和pending_task两三次记录对比之后基本能定位到是谁多加了位图标记。6.2 任务切过去后栈乱了现象新任务跑进去局部变量是随机值或者一跑就死。排查思路八成是PSP切换时机错了。注意Cortex-M进入中断时硬件会自动把xPSR、PC、LR、r12、r0-r3压入当前使用的栈。如果是线程模式用PSP中断里压栈就是在PSP上完成的换任务时这个栈不能动要等到PendSV尾部再切。6.3 SysTick里直接做了切换现象低优先级任务永远不执行高优先级任务频繁被打断。排查思路检查SysTick_Handler里是否直接调用了上下文切换函数。SysTick本质上也是一个中断在中断里直接改PSP是自找麻烦。正确做法是只设置PENDSV位让PendSV在所有中断退干净后再切换。6.4 优先级反转现象高优先级任务迟迟得不到执行。排查思路如果系统里用了信号量检查低优先级任务是否占用了高优先级任务等待的资源。嵌入式里解决优先级反转的通用方案是优先级继承我们这颗小内核暂时没实现先通过合理设计任务优先级避开这个问题。我把写调度器期间遇到的常见问题整理成了一张速查表现象可能原因排查方法任务A跑一次后不久卡死就绪位图没清干净在PendSV入口打印ready_prio_map新任务创建后不执行新任务优先级低于当前任务检查优先级数值0是最高任务延时不准SysTick频率配置不对检查SystemCoreClock和SysTick_Config参数串口打印任务抢不到CPU优先级过高阻塞LED任务调整优先级让打印任务处于最低优先级HardFault栈溢出或栈指针错乱查看栈使用情况检查任务栈大小配置6.5 几个现场调优心得实际调试时我最建议在代码里加一个“调度次数计数器”每次PendSV递增一次。测试一分钟后打印出来和理论切换次数对比能快速判断调度器是否存在“空转切换”。举例来说两个LED任务各自延时500ms理论上1秒内应该发生4次切换LED0切LED1、LED1切LED0各来回两次。如果计数远大于预期大概率是就绪位图在某个任务延时期间被频繁误触发。7. 调度器测试手册至少跑通这4个用例写完调度器别急着写消息队列先用这4个用例把它捶打一遍基本能保证调度器这个地基是稳的。用例一是“优先级抢占测试”创建高、中、低三个优先级任务让低优先级任务先跑中途被中、高优先级任务抢占最终用串口打印出执行顺序验证抢占逻辑是否正确。用例二是“延时让出CPU测试”所有任务都通过task_delay让自己进入阻塞验证空闲任务能否被正确调度到以及SysTick的延时唤醒是否准时。用例三是“任务创建即抢占测试”在低优先级任务运行过程中创建一个高优先级任务观察它能否立刻抢到CPU执行。用例四是“长时间稳定性测试”让两个LED任务连续跑24小时观察是否存在调度计数异常任务是否会出现“卡死”现象。这个用例最耗时但也是对调度器可靠性的最好验证。跑完这4个用例调度这块就算基本过关了。8. 调试技巧与后续扩展最后分享几个实战调试技巧。调度器可以先用软件模拟环境来验证基本逻辑不用一上来就烧到真机上。比如在PC上用C语言把就绪位图和调度算法核心逻辑单独抽出来跑一遍输入不同的任务优先级组合看输出结果是否符合预期这个习惯能省下大量真机调试时间。另外在串口驱动稳定之后一定要写一个调度日志模块每次调度时把旧任务优先级、新任务优先级、触发原因SysTick/任务延时/中断唤醒记录到一个环形缓冲区里。出问题的时候把日志dump出来一眼就能看清切换链条。等这份调度器稳定之后下一节我们可以开始写信号量和互斥量。到时候你会发现有了这套可靠的调度底座同步机制其实就是“把当前任务塞进等待链表然后让出CPU”的简单组合。很多朋友在初学阶段觉得信号量难本质上是调度器没吃透基本功补上之后一切都顺了。