从零手写Cortex-M抢占式调度器:任务切换与实现详解

发布时间:2026/10/13 5:39:31
从零手写Cortex-M抢占式调度器:任务切换与实现详解
1. 先搞清楚抢占式调度器到底在“抢”什么很多人一听到抢占式调度器第一反应是“系统里同时跑多个任务一会儿切这个一会儿切那个”这个理解不算错但漏掉了最关键的字眼——“抢”。这个“抢”指的是内核有强制打断正在执行任务的能力而且是硬件级别的强制打断不是靠任务自己主动让位。写协作式调度器时一个任务如果写了个死循环却不调用yield整个系统就永远卡在那里但抢占式调度器不一样定时器中断每隔一段时间就会响一次内核在中断里看一眼“当前这个任务凭什么继续占用CPU还有没有更该跑的任务”然后强制换人。任务自己完全没有抵抗能力它甚至感知不到自己被切走又被切回来所有现场被完完整整保存好恢复后继续往下跑像中间什么事情都没发生。我这次要分享的是从零手写一个可运行的抢占式调度器。平台选的是Cortex-M3/M4加GCC工具链裸机环境。之所以选Cortex-M是因为它的异常模型非常完善SysTick定时器、PendSV异常、双栈机制都已经固化在硬件里手势完整适合一次性把调度器的骨架和细节看个透彻。选的这套实现思路也可以平移到RISC-V或者通用操作系统课程里做的线程调度实验上只不过寄存器保存的范围和异常入口要按各自架构调整。我会按一条从简到繁的路径来写先讲清楚任务长什么样、栈怎么初始化再深入到上下文切换的汇编然后看SysTick和PendSV如何配合完成“强制换人”最后给出调度策略和一组实测排错经验。目标是让你读完以后面对一个全新的平台也能自己动手把这个调度器复现出来。1.1 协作式与抢占式的差别一句话说透协作式调度器里切换调度点完全由任务自己控制。任务主动调用yield、sleep或者等待某个事件调度器才可能选择下一个任务。这种模型的优点是逻辑简单、竞争少但代价是任何一个“不老实”的任务比如一个纯while(1)循环里忘记让出CPU就能把整个系统冻住。抢占式调度器把控制权从任务手里收走了。定时器中断是常态化的“心跳”无论任务运行到哪一行代码只要心跳到来CPU就会暂时停下当前任务进入内核代码。内核在中断上下文里做一次“要不要换人”的判断如果发现另一个任务优先级更高或者当前任务的时间片已经耗尽就触发上下文切换。这两者最本质的差别不是“快”而是“权力来源”。协作式是任务之间互相商量着让位抢占式是内核凭中断强制接管。抢占式看起来像多任务并行但这里没有一个任务是真的同时在跑它只是“看起来”一直在跑因为所有任务都在毫秒级的时间尺度上被切开、轮换一个任务被打断时根本来不及察觉。1.2 一次不让被察觉的“换人”是怎么发生的假设系统里有TaskA和TaskB两个任务TaskA优先级低正在执行一个耗时较长的for循环TaskB优先级高因为某个事件被唤醒进入就绪状态。此时SysTick中断到来CPU自动压栈保存TaskA的现场跳进SysTick异常处理函数。处理函数先更新系统计数然后发现“TaskB已经就绪而且优先级比TaskA高”于是把PendSV异常挂起。SysTick退出后PendSV才真正开始执行。因为PendSV被配置成最低优先级它必须等其他所有中断处理完才有机会运行这就是它“延迟换人”的妙处。PendSV执行上下文切换把TaskA的R4-R11等寄存器压进TaskA自己的栈从TaskB的TCB里取出栈指针弹栈恢复TaskB的现场然后从异常返回到线程模式CPU跳进TaskB继续执行。整个过程对TaskB来说只是“我上一次被切走时停在这里现在从这里继续”。而对TaskA来说它甚至不知道自己在后台被晾了那么久。这种隐蔽性正是抢占式调度要提供的最基本保证。1.3 手写之前先把边界划清楚我在这份实现里给自己定了三条边界。第一只做最小内核任务创建、抢占式调度、时间片轮转、阻塞和唤醒不做信号量、不做消息队列更不做内存堆管理那些东西是后续锦上添花的内容。第二不引入任何RTOS封装直接操作寄存器、异常向量和汇编每一条关键路径都必须亲眼看到。第三代码量足够少核心切换部分控制在几十行汇编以内方便单步追踪。平台相关的依赖只集中在三小块SysTick的初始化与中断入口、PendSV异常入口、进入临界区时屏蔽中断的寄存器操作。其余代码包括TCB管理、就绪队列、时间片计算、任务栈初始化都是纯C逻辑。这样分层的意义在于想要移植到其他架构只需要重新实现这三小块调度核心完全不用动。如果你还没接触过RTOS用一个现成系统当然省力但亲手写出这个“强制换人”的机制之后你对任务调度、中断延迟、临界区的理解会比看十篇原理文章都来得深刻。2. 任务模型设计TCB、任务栈与第一次“假现场”调度器管理的最小单位是任务而任务在代码里不是函数不是进程而是一个结构体加一块独立的栈。先把这两个东西设计明白后面所有代码才有了落脚点。2.1 TCB的字段每一个都不是白给的我用的TCB结构体如下typedef struct tcb { uint32_t *sp; /* 任务当前栈指针切换现场时保存/恢复 */ uint32_t *stack_base; /* 栈底高地址 */ uint32_t *stack_limit; /* 栈顶低地址用于溢出检测 */ uint8_t priority; /* 优先级数字越小优先级越高 */ uint8_t state; /* 任务状态READY / RUNNING / BLOCKED */ uint32_t slice_remain; /* 剩余时间片单位是tick */ uint32_t slice_reset; /* 时间片初始值切回来时重新装载 */ void (*entry)(void *param); /* 任务入口函数 */ void *param; /* 入口函数参数 */ uint32_t magic; /* 魔数用来校验TCB完整性 */ } tcb_t;sp是绝对核心调度器本质上就是“保存当前sp换入下一个sp”。为什么还需要stack_base和stack_limit因为任务栈溢出是手写调度器最常见的崩溃元凶没有这两个边界值你只能在HardFault里猜测现场。priority决定抢占时的优先顺序state是任务状态机的基础。slice_remain和slice_reset是时间片轮转的关键任务被切走时剩余时间片要保留被重新选中后也不能整段清零只有在完整用掉一个时间片之后才重置否则很容易出现“高优先级任务抢占频繁低优先级任务的剩余时间片被反复累积”的问题。magic字段放一个固定值比如0xA5A5A5A5在每次调度前检查。一旦被改写说明某处越界把TCB冲了远比崩在角落里好定位。任务数量少的时候可以直接用一个静态数组保存所有TCB例如static tcb_t task_pool[MAX_TASKS]简单可靠不涉及动态内存管理。2.2 任务栈初始化伪造一个“被打断过的现场”新任务从来没有运行过没有所谓“保存好的现场”但调度器不管这些它在第一次切换时只会机械地“恢复现场”。所以我们要在任务栈里预先伪造一份现场让调度器以为这个任务之前已经运行过正停在一个特定位置。在Cortex-M上一次上下文切换恢复时需要从栈里依次弹出R4-R11再弹出硬件异常压栈的R0-R3、R12、LR、PC、xPSR。因此初始化任务栈时要在栈顶按这个顺序反向压入初始值static uint32_t *task_stack_init(void (*entry)(void *param), void *param, uint32_t *stack_top, uint32_t *stack_limit) { uint32_t *sp stack_top; uint32_t *p stack_limit; uint32_t pattern 0xA5A5A5A5; while (p sp) { *p pattern; /* 对整个栈区填充魔数便于溢出检测 */ } sp - 8; /* 先给R0-R3、R12、LR、PC、xPSR留出位置 */ sp[0] 0x01000000; /* xPSR必须置Thumb位 */ sp[1] (uint32_t)entry; /* PC任务入口地址 */ sp[2] (uint32_t)task_exit_cleanup; /* LR入口函数异常返回时的清理函数 */ sp[3] 0; /* R12 */ sp[4] 0; /* R3 */ sp[5] 0; /* R2 */ sp[6] 0; /* R1 */ sp[7] (uint32_t)param; /* R0入口函数的第一个参数 */ sp - 8; /* 再给R11-R4留出位置 */ for (int i 0; i 8; i) { sp[i] 0; } return sp; }为什么xPSR必须置为0x01000000因为Cortex-M只支持Thumb指令bit24如果没置位第一跳进任务就会触发UsageFault。为什么要把LR初始化成task_exit_cleanup而不是直接设为0任务入口函数理论上不该return但万一它真的通过某种错误路径返回CPU会跳回LR指向的地址。如果LR是0直接进HardFault排查起来还得翻反汇编如果LR指向一个清理函数就可以在清理函数里把自己的状态置为完成态并让出CPU行为可预期得多。R4-R11这些寄存器是汇编切换代码手动压栈的区域初始化为0即可。需要注意这里的“栈顶”通常指栈区最高地址。Cortex-M的栈是向下增长的任务栈初始化时从高地址往下写最终sp落在较低地址切换汇编后续使用STMDB/LDMIA等指令继续往下压栈或从低往高弹栈顺序才能完全对上。2.3 就绪队列与优先级先做朴素版再谈优化就绪队列的设计决定了调度器选下一个任务的成本。最朴素的做法是任务池里维护一个按优先级排列的ready数组每次调度时从头到尾扫描一遍找第一个state为READY且优先级数值最小的任务。对任务数量在十个以内的调度器来说O(n)遍历完全够用而且代码可读性极强非常推荐第一版先这么写。进一步优化的时候可以给每个优先级准备一个就绪链表再配一个32位位图记录哪些优先级下有待调度的任务配合Cortex-M的CLZ指令能在常数时间内找到最高优先级的非空链表。这个方案在任务数量多、优先级层次多的场景会明显提升确定性但代码复杂度也会上一个台阶。我个人的建议是先跑通朴素版用示波器观测任务切换周期之后再决定要不要优化。调度器的正确性永远比调度速度更重要先看效果再谈性能。3. 上下文切换整个调度器的心脏前面所有数据结构、所有策略最终都要落实到“保存一个任务的现场恢复另一个任务的现场”这几十条指令上。这一步做对了调度器成了大半做错了所有任务调度逻辑再好看也无济于事。3.1 上下文切换到底要保存什么现代CPU的寄存器数量都不少。Cortex-M3/M4里任务运行时的寄存器包括R0-R12、SP、LR、PC和xPSR。如果只靠软件一个寄存器一个寄存器地保存切换函数会非常冗长。但Cortex-M有一个非常体贴的机制进入异常处理时硬件会自动把xPSR、PC、LR、R12、R0-R3压栈到当前使用的栈指针上退出异常时硬件再自动弹栈恢复这正好覆盖了函数调用约定的易失寄存器部分。于是软件需要额外保存的寄存器就只剩下R4-R11这8个外加任务自己的栈指针SP。整个过程可以在PendSV异常入口用不到十行汇编完成。这也是Cortex-M上写调度器比x86省心得多的地方x86需要软件保存通用寄存器、段寄存器、标志寄存器等一大堆内容保存和恢复的顺序稍微写错一个现场就全毁了。有一个类比很贴切硬件自动压栈相当于先把演员手里最常用的几件道具放进储物柜软件要做的是把剩下的道具也归档并且记住“钥匙”放在哪一把。任务切换时先把手上的钥匙挂到当前任务的储物格再从目标任务的储物格取出另一把钥匙把道具一件件放回演员手里。3.2 PendSV为什么要这个“可挂起的异常”来干活既然是抢占式调度换人动作必然发生在中断上下文。最直接的想法是在SysTick中断里直接做上下文切换不就行了吗但这里有个隐患。SysTick的优先级只要不是最低它就能打断正在执行的其他中断比如UART接收中断。如果在定时器中断里直接切换了任务很可能出现“上一个中断还没处理完任务就已经换了”的混乱局面现场保护很难做完整。PendSV被设计出来就是专门干这事的。它是一个软件可以主动挂起的异常优先级可以配置到全系统最低。SysTick在心跳处理里发现需要调度时不直接切换而是把PendSV挂起PendSV因为优先级最低必须等到所有正在服务的高优先级中断退出后才能执行。这样一来上下文切换永远排在所有紧急中断之后既完成了抢占调度又不打扰实时性要求高的中断处理。在SysTick里做切换和在PendSV里做切换是两种经典设计路线我把它们的取舍列一下方案实现复杂度中断延迟影响适合场景直接在SysTick中切换简单少一段异常启动逻辑SysTick会打断其他中断切换可能夹杂在中断服务中间原型验证、单优先级轮转SysTick挂起PendSVPendSV中切换多一次异常进入/退出开销所有高优先级中断先跑完切换最干净正式工程、支持优先级抢占3.3 切换汇编每一行代码都踩在栈上首先要强调一个程序员容易踩的坑PendSV异常处理函数不能用普通的C函数写必须用naked属性或独立汇编文件。因为C编译器会在函数入口自动生成压栈和弹栈指令把这些指令混进现场保存流程里现场会直接被二次污染。我采用的做法是异常入口的现场保存与恢复用汇编完成调度策略选择“下一个该跑哪个任务”则用C函数完成。C函数返回值为选中任务的TCB指针这个返回值放在R0寄存器里正好可以被汇编继续使用。下面是一段去除冗余注释后的核心切换逻辑__attribute__((naked)) void PendSV_Handler(void) { __asm volatile( MRS R0, PSP\n /* R0 当前任务栈指针 */ STMDB R0!, {R4-R11}\n /* 先保存R4-R11到任务栈 */ LDR R1, current_tcb\n LDR R1, [R1]\n /* R1 current_tcb内容 */ STR R0, [R1]\n /* current_tcb-sp R0 */ PUSH {LR}\n /* 保存异常返回的EXC_RETURN */ BL scheduler_pick\n /* R0 下一个要运行的任务TCB */ POP {LR}\n LDR R1, current_tcb\n LDR R2, [R1]\n /* R2 current_tcb */ CMP R0, R2\n BEQ restore_only\n /* 选中的还是当前任务跳过保存逻辑 */ STR R0, [R1]\n /* current_tcb 新任务 */ LDR R0, [R0, #0]\n /* R0 新任务-sp */ LDMIA R0!, {R4-R11}\n /* 恢复R4-R11 */ MSR PSP, R0\n /* 更新PSP硬件弹栈区域已对准 */ ORR LR, LR, #0x04\n /* EXC_RETURN返回后切回PSP */ BX LR\n restore_only:\n LDR R0, [R0, #0]\n LDMIA R0!, {R4-R11}\n MSR PSP, R0\n ORR LR, LR, #0x04\n BX LR\n ); }很多初次接触这段代码的人会问为什么恢复现场时只恢复R4-R11R0-R3和PC、xPSR去哪了答案是它们已经躺在任务栈顶部了MSR PSP, R0把硬件栈指针指向那8个字之后BX LR触发异常返回硬件会自动完成剩余弹栈。所以栈指针的对齐位置至关重要R4-R11从栈里弹出8个字之后剩余的栈内容必须恰好是xPSR、PC、LR、R12、R0-R3的顺序。这就是第2章里初始化任务栈时严格按这个顺序反向压值的原因。3.4 双栈机制为什么要让任务和中断分开用栈Cortex-M硬件上天然支持两个栈指针MSP和PSP。我在这份实现里让所有任务都运行在PSP上中断服务程序统一跑在MSP上。这样做的第一个好处是中断嵌套不会大量消耗任务栈每个任务栈的大小只需要估算业务代码和切换现场所需深度内核栈单独规划。第二个好处是安全性某个任务栈溢出了最多坏掉自己不至于把内核的中断处理环境一起冲毁。如果你的目标平台只有单一栈指针比如早期RISC-V或很多教学用模拟CPU就必须自己规划“中断栈”和“任务栈”的边界进入中断时先切换到一块固定的内核栈再允许嵌套。总之中断入口不能图省事直接复用当前任务栈否则一旦出现两层以上的中断嵌套任务栈栈底很快就会被啃穿。这也是我建议手写调度器时无论如何都要给中断单独留一块栈的原因成本很低收益是长期稳定的。4. SysTick节拍、中断优先级与临界区谁在背后踢一脚、谁负责踩刹车抢占式调度的“抢占”不会凭空发生必须有一个周期性的硬件中断在背后踢脚。这个角色通常由系统的节拍定时器扮演在Cortex-M上就是SysTick。4.1 SysTick的节拍怎么算选多大合适SysTick是一个24位递减计数器时钟源通常是系统主频。配置方法是先设定重装载值计数器从该值递减到0时产生中断然后自动从重装载值重新开始。通过CMSIS函数SysTick_Config可以直接完成配置它的参数是两次中断之间的时钟周期数。uint32_t tick_hz 1000; /* 1ms一个节拍 */ SysTick_Config(SystemCoreClock / tick_hz);比如主频72MHzSystemCoreClock / 1000算出来是72000也就是SysTick每72000个周期中断一次正好1ms。这里有一个细节重装载值的计算公式实际是reload SystemCoreClock / tick_hz - 1但SysTick_Config内部自己减了1调用时传SystemCoreClock / tick_hz即可。节拍大小直接影响调度开销。如果tick设成1ms72MHz下就是72000个时钟周期而一次上下文切换只需要几十到一百个周期占用比例几乎可以忽略。如果又想把抢占粒度做细比如改成0.1ms一个tick那每个tick之间只有7200个周期来回切换的开销占比立刻飙升。我的建议是第一版先用1ms或10ms跑通之后再按系统实时性需求调整不要在原型阶段盲目追求细粒度。tick越小调度次数越多系统在“切换”上花的时间越多真正干活的时间反而少了。4.2 中断优先级怎么配SysTick、PendSV和外设中断的三角关系Cortex-M的中断优先级是一个强排队的系统数字越小优先级越高。手写调度器时要同时考虑三类异常普通外设中断UART、定时器、GPIO外部中断处理紧急硬件事件SysTick异常负责周期性产生系统心跳PendSV异常负责真正执行任务切换。我的配置原则是外设中断优先级最高SysTick居中PendSV最低。外设中断优先级最高是为了保证中断响应延迟不会因为调度器而变差SysTick居中是为了确保心跳本身不会被普通外设中断频繁打乱否则时间片计数会出现不可控的漂移PendSV最低是为了让上下文切换永远在所有中断处理完之后进行。这里PendSV最低优先级非常关键如果PendSV的优先级高于某个外设中断那么这个外设中断还没服务完任务切换就抢先发生了被中断的外设代码会卡在上文切换的乱局里。具体的优先级数值取决于硬件优先级位宽。假设系统有4位优先级典型配置可以是NVIC_SetPriority(SysTick_IRQn, 7); NVIC_SetPriority(PendSV_IRQn, 15);实际工程里SysTick的优先级还可以再细调。如果你的中段处理程序本身很短也可以把SysTick设得比部分外设中断更低让外设事件优先处理代价是心跳本身会被外设中断抖动。这是一个“响应延迟”和“节拍稳定”之间的权衡没有绝对答案但PendSV永远要留到最低这一点不会有争议。4.3 临界区防止换人发生在换人过程中的关键抢占式调度引入了一个新问题内核本身也是在一段一段代码间隙被各种中断打断的。如果刚好在修改任务队列、修改TCB链表的中间被打断又发生了一次调度两个执行流同时操作同一批数据队列结构会立刻损坏。所以调度器内部必须有一组“临界区”保护。进入临界区之前关中断退出以后开中断保证这段代码不可能被任何中断插进来。我的实现是最简单也最稳妥的PRIMASK方案static inline uint32_t enter_critical(void) { uint32_t primask __get_PRIMASK(); __disable_irq(); return primask; } static inline void exit_critical(uint32_t primask) { __set_PRIMASK(primask); }很多人会随手写成“进入时关中断退出时开中断”但这里有个隐蔽的坑如果临界区函数被嵌套调用或者在一个原本中断就是关闭状态的环境里被调用直接开中断会让中断提前打开临界区实际失效。正确做法是先把进入前的PRIMASK保存下来退出时恢复它。这是我在排错时碰到过的一个“间歇性bug源头”表面上看代码逻辑完全正确但偶尔在某个中断场景下就崩一次根源就在这个保存与恢复的细节上。临界区保护的对象主要是调度器状态比如current_tcb指针的修改、当前任务放回就绪队列的动作以及“选中下一个任务”的决策过程。放在PendSV里执行的任务选择函数scheduler_pick同样需要在临界区下跑完否则在选取过程中任务状态一变选出来的next可能已经不是最优解了。5. 调度策略落代码优先级抢占、时间片轮转与三大调度点有了数据结构、有了切换机制、有了定时器心跳和临界区保护现在可以把“策略”写成代码了。调度器的策略通常是一组规则什么情况下必须换人、换谁、时间片怎么算、任务状态怎么流转。5.1 优先级抢占的规则数字小的优先但不是绝对我实现的调度规则可以概括为三条只要就绪队列中存在比当前任务优先级更高的任务下一次调度就切换到那个高优先级任务同优先级任务之间轮流运行每人一个完整时间片没有任务就绪时就跑空闲任务。优先级抢占的关键不在“调度函数里怎么选”而在“高优先级任务醒来时怎么让内核知道”。以睡眠唤醒为例一个任务调用task_sleep(50)进入BLOCKED状态50个tick之后被唤醒并重新进入就绪队列。此时SysTick中断还在执行如果在SysTick里发现“有更高优先级任务刚刚就绪”就必须主动挂起PendSV否则当前任务会一直独占CPU高优先级任务的“抢占”就成了空话。规则里还有一个容易被忽略的点时间片耗尽不等于必须换人。当前任务的slice归零后把它重新放回就绪队列尾部但如果此时队列里没有另一个同优先级任务也没有更高优先级任务被唤醒那么scheduler_pick选出来的还是它自己。这种情况下PendSV可以直接跳过现场恢复不必做一次无效切换。这种“选出来还是自己”的路径很多初版调度器都会漏掉结果表现为无谓的切换导致系统运行变慢。5.2 时间片分配以tick为单位而不是以“毫秒”为单位SysTick每来一次就代表一个tick过去。时间片通常设成tick的整数倍比如tick是1ms时间片设10个tick那么每个任务轮流获得10毫秒的CPU时间。实现上只需要在每个tick里做一次递减void scheduler_tick(void) { if (current_tcb-state RUNNING current_tcb ! idle_tcb) { current_tcb-slice_remain--; if (current_tcb-slice_remain 0) { current_tcb-slice_remain current_tcb-slice_reset; current_tcb-state READY; enqueue_ready_tail(current_tcb); /* 放到同级队尾 */ trigger_pend_sv(); /* 请求一次调度 */ } } update_sleeping_tasks(); /* 递减所有阻塞任务的剩余时间 */ }把当前任务放回队列队尾、时间片重置为初值这个动作要在临界区里完成。因为如果正在操作队列时PendSV抢先执行上下文切换代码会读到半个队列状态。再强调一遍放回队尾只表示“它已经用完这一个时间片重新排队”并不意味着“下一个运行的一定不是它”。调度器的选择函数会对所有就绪任务做统一裁决。一个比较谨慎的设计是高优先级任务正在运行时低优先级任务的剩余时间片保持原样不要被tick递减。否则低优先级任务每次被抢占都白白损失时间片等它终于重新获得CPU时可能只剩下短短几个tick很快又被切走。这会造成低优先级任务“饿死”一样的行为。正确的语义是时间片只对自己运行的时间计数。5.3 调度点不止tick主动让出和阻塞也是入口抢占式调度虽然核心是tick中断但一个完整的调度器不可能只靠tick驱动。至少还有三个调度点需要处理第一个是主动让出即task_yield。调用它表示当前任务主动放弃剩余的CPU时间把自己放回就绪队列尾部然后触发一次调度。这个接口在协作式调度器里是“必须调用”在抢占式调度器里更多是“优化选择”任务发现自己已经完成了当前阶段的工作没必要占用剩余时间片主动让位能让同优先级任务更快跑起来。第二个是阻塞等待。比如task_sleep(100)把任务设为BLOCKED记录它期望被唤醒的时间点然后触发一次调度。由于当前任务已经处于阻塞状态scheduler_pick不会选中它CPU被让给其他任务。这个阻塞列表的管理简单实现可以每次tick遍历所有TCB检查剩余时间代价是O(n)任务多了以后可以改成按唤醒时间排序的定时器链表复杂度会显著下降。第三个是唤醒通常是其他任务调用task_wakeup或者某个事件触发。一旦BLOCKED任务被置回READY并进入就绪队列就必须马上判断它的优先级是否高于当前任务如果是就触发PendSV。这个“唤醒后立刻抢占”的动作是所有优先级抢占语义的关键路径。这三大调度点加上tick中断调度逻辑才算闭环。5.4 为什么必须有一个空闲任务最后一个调度策略问题是如果就绪队列空了调度器该选谁总不能一个任务都不选让系统停在一个“无上下文可切换”的尴尬状态。所以我们必须静态创建一个优先级最低的空闲任务它的入口函数是一个死循环static void idle_task_entry(void *param) { while (1) { __WFI(); /* 等待中断降低功耗 */ } }空闲任务平时不占用CPUtick来了就醒来发现还是没有其他任务就绪又睡回去。但它存在的意义是保证调度器在任何时刻都有一个合法的“当前任务”可以指向PendSV永远能把现场保存到某个确定的TCB里不需要特殊分支处理“无任务可切”的情况。很多轻量级调度器也把这个任务称为background task后续还可以在它身上统计CPU空闲率、执行栈回收等后台工作。5.5 任务状态机一张表理清所有转换前面提到了READY、RUNNING、BLOCKED三种状态把它们之间的关系整理成一张表写代码时就不容易漏状态转换当前状态触发事件新状态调度器动作RUNNINGtick中断且时间片耗尽READY放回就绪队列尾触发PendSVRUNNING主动yieldREADY放回就绪队列尾触发PendSVRUNNING阻塞等待/sleepBLOCKED注册唤醒时间触发PendSVBLOCKED唤醒时间到/事件触发READY放入就绪队列若优先级更高则触发PendSVREADY被调度器选中RUNNING执行上下文切换任何状态系统创建的任务结束待销毁调用清理函数置为完成态状态转换动作务必放到临界区里执行尤其是“BLOCKED到READY”和“RUNNING到READY”这两个路径任何一步遗漏都可能出现一个任务被重复放进就绪队列、或者一个任务永远卡在阻塞态的诡异行为。6. 实测与排错手写调度器最容易翻车的几个典型现场代码写完了编译也能过不代表调度器就能跑。实际上我第一次在Cortex-M模拟器里跑这套东西时几乎把所有典型雷都踩了一遍。这里把踩坑过程整理成几条排查链路按“现象—怀疑—定位—解决”的顺序写真正排错时可以按这个思路快速收敛。6.1 现象一任务一启动就HardFault整个系统复位循环第一版调度器最常见的死法就是启动后立刻硬故障。排查时先别急着怀疑“是不是汇编写错了”按照下面几步走第一步调试器里暂停看HardFault发生时的PC值。如果PC指向了某个任务入口地址附近说明栈初始化里的入口地址被正确装载了但任务入口函数本身有问题最常见的是入口函数返回了。Cortex-M任务入口正常情况下应当是一个永不返回的死循环一旦函数执行到末尾CPU会尝试跳回LR指向的地址。如果LR还是0直接进HardFault。所以我在栈初始化时把LR指向清理函数即task_exit_cleanup即使发生异常返回也能走到一个明显的地方停下来。第二步检查xPSR的bit24。如果初始xPSR没置Thumb位CPU会在第一次跳入任务时报告UsageFault。这是新手最容易犯的错因为调试器默认会在反汇编里自动切换Thumb模式不细看很难发现。第三步查看SP值在HardFault时是否指向有效地址。Cortex-M要求栈指针在异常入口处保持8字节对齐如果初始化栈顶时从某个非对齐地址开始压栈异常压栈行为会直接触发总线错误。一个简单的做法是确保传入的stack_top本身就是8字节对齐的地址。排查完这三个点第一版调度器的启动崩溃问题基本都能解决。6.2 现象二任务之间“串数据”同一个变量被神秘改写调度器能够稳定切换了但开始出现更隐蔽的问题某个任务里计算的临时变量到了另一个任务里居然看到被改动过的数值。这种“串数据”现象我倾向于先怀疑寄存器保存不完整尤其是FPU寄存器。Cortex-M4/M7如果开启了硬件浮点任务里的浮点运算会使用S0-S31以及FPSCR。我的切换汇编里只保存了R4-R11FPU寄存器一个都没动。于是TaskA做了一段浮点计算切到TaskB后TaskB再用浮点寄存器里的残留值计算结果就鬼畜了。Cortex-M的硬件其实支持懒压栈lazy stacking进入异常时FPU上下文可以延迟到真正使用FPU时才压栈但前提是在FPCCR里配好并正确使用FPU的异常返回机制。自己手写调度器时这部分配置很容易被忘掉。最省事的解决办法是第一版不要开启硬件浮点编译时强制软件浮点例如GCC的-mfloat-abisoft。这样所有浮点运算走软浮点库不碰FPU寄存器彻底规避这个问题。如果确实想用硬件浮点那就要在上下文切换里额外保存S0-S31和FPSCR或者妥善利用懒压栈机制。对刚入门的人来说前一种方案最稳。6.3 现象三某些中断被“神秘延迟”响应时间忽长忽短调度器跑起来后项目里原本一个要求纳秒级响应才够快的外设中断处理明显变慢了。排查了一会儿发现罪魁祸首是临界区进入临界区期间所有中断都被屏蔽而临界区里如果恰好在跑printf调试输出、或者某些耗时的队列操作屏蔽时间就可能拉到几十微秒到几百微秒。解决方法是给临界区“瘦身”。临界区里只做必须原子性保护的操作比如改几个链表指针、切换任务状态任何与调度无关的耗时代码都应该移出去。尤其是调试用的打印绝不能让它们出现在临界区里。另一个方案是用BASEPRI寄存器屏蔽低优先级中断而保留高优先级中断这样高优先级中断依然能响应临界区就不再是“全关中断”的粗暴模式。不过BASEPRI方案要求对中断优先级层次有清晰规划换来的好处是系统实时性显著提升。我还遇到过一个更隐蔽的“延迟”问题SysTick因为被外设中断频繁打断节拍本身发生了抖动导致所有基于tick的时间测量都不准了。如果观察到的“中断延迟”其实是从某个任务里测量另一个中断的间隔那就要区分是中断响应变慢还是tick漂移。把SysTick优先级调高、把PendSV保持最低之后这个现象就消失了。6.4 如何证明抢占真的生效调度器是个时间相关的系统代码不崩不代表抢占真的按预期工作。我用的验证方法非常朴素写两个任务一个低优先级、一个高优先级各自翻转一个GPIO引脚用示波器观察波形。低优先级任务入口函数是一个死循环循环里只做GPIO翻转高优先级任务每10个tick醒来一次也翻转另一个GPIO。如果调度器没有真正抢占低优先级任务会独占CPU高优先级任务永远得不到运行机会示波器上只能看到一路方波。调度器正常工作后第二路GPIO会以10ms左右为周期翻转同时第一路波形会因为被抢占而出现间歇性停顿频率明显降低。这个测试最直观也最能说明抢占的本质低优先级任务“不愿意”让位高优先级任务照样按自己的节奏跑。如果示波器上高优先级任务的翻转周期不稳定下一步就去查tick配置和优先级设置而不是纠结调度代码的某个分支。6.5 写在最后的一些经验习惯自己写完这套调度器之后我养成了一个排错习惯任务栈初始化时用固定的0xA5填充整个栈区跑上一段时间后暂停系统检查栈区里有多少字节已经从0xA5变成了其他值。这些被改写的字节区域就是任务的栈使用深度。这个检查能让你一眼看出某个任务的实际栈需求远比自己估算靠谱。另一个习惯是每加一个新的调度能力先用两个固定任务反复跑同一组测试确保上下文切换路径稳定之后再往里面加第三个任务。手写调度器这个项目最大的敌人从来不是某个具体的bug而是你在一堆尚未验证的假设上叠加新逻辑最终让现场无法追踪。