
做嵌入式这些年我调试过不少诡异问题任务莫名卡死、优先级倒挂、一开中断就复位。刨到根上几乎都跟任务切换Context Switch有关。任务切换说穿了是三件事的协同把当前任务的寄存器现场存进它的栈从下一个任务的栈里把寄存器恢复出来中间由调度器决定“谁先谁后”。这章我打算把这条链路拆开讲透不只讲结论也讲寄存器、栈、调度器各自该干什么、哪些坑我替大家踩过了。如果你刚把某个RTOS跑起来或者正打算手写一个迷你调度器这篇文章应该能省你几个晚上。1. 一次切换到底在切什么把“现场”这个词拆开1.1 任务执行的本质CPU状态本身就是任务的一部分很多初学者会把“任务”理解成一段函数代码只要把函数入口地址记住随时可以跳进去运行。实际上任务能正常运行靠的不只是代码段还有CPU当前那一整套运行状态。程序计数器指向下一条要执行的指令栈指针指向当前函数调用栈的顶端状态寄存器记录条件标志、中断屏蔽位通用寄存器里可能放着循环变量、中间计算结果、函数参数、局部变量地址……这些东西加在一起才是任务真正“活”着的状态。当操作系统要把任务A换下、把任务B换上时它不能只把PC切到B的某个位置。因为B的代码执行到一半停了它的PC、SP、通用寄存器都停在某些特定值上。这些值一旦被A覆盖B下次回来就没法继续。所以任务切换的第一个关键动作就是把CPU当前的全部“现场”保存下来再把下一个任务的“现场”完整恢复回去。这就是上下文切换的本义。我见过一个很形象的类比寄存器就是舞台上的演员栈就是后台休息室调度器是导演。换演员上场时必须让下台演员把手里的道具寄存器放进休息室自己的柜子里上台演员再从柜子里拿出他上次放下的道具。关键是后台休息室必须每人一格不能几个人共用同一个格子否则上一场的道具还没拿完就被下一场的人覆盖掉了。1.2 为什么现场必须放在私有的栈里而不是全局变量有人会问保存现场为什么一定要用栈我定义一个全局结构体数组每个任务一份不也能存寄存器吗答案是不行原因在于“嵌套打断”。一个任务在执行过程中可能被中断打断一次现场存进全局变量A接着在中断服务程序里如果又发生更高优先级的中断或者同一个任务在中断嵌套结束后再次被调度那么第二次的现场需要再保存一份。如果用固定内存第二次保存就会覆盖第一次保存的现场等第一次上下文恢复时拿到的已经是错乱的数据。栈是后进先出结构每次打断都往栈顶压一份恢复时从栈顶弹出一份天然支持任意深度的嵌套。这也是为什么每个任务必须有自己独立的任务栈——不仅任务本身的函数调用要用上下文现场也要存到这里。还有一点容易被忽略函数调用本身也依赖栈。任务A调用了一个三层深的函数它的局部变量、返回地址都在栈里。切换出去再切换回来如果栈不是原来的栈函数调用链就断了。每个任务独立栈保证了A的函数调用链和B的函数调用链互不干扰。这也就是为什么“每个任务一个栈”是RTOS的地基。1.3 只保存PC远远不够寄存器才是大头假设任务A正在执行这样一段代码// 任务A的某个循环 for (i 0; i 100; i) { sum arr[i]; }编译器很可能把i放在R4把arr的基址放在R5把sum放在R6。如果任务切换只保存PC、只恢复PC那么任务A恢复运行时R4/R5/R6里面全是任务B留下的值循环变量、数组地址、累加结果全部错乱。这种bug极其隐蔽因为它不像栈溢出那样直接死机而是表现为“计算偶尔不对、结果随机变化”。所以一个完整的上下文至少包括程序计数器PC、栈指针SP、状态寄存器、以及所有可能被任务使用的通用寄存器。具体是哪些寄存器取决于CPU架构。Cortex-M要保存R0-R12、SP、LR、xPSRRISC-V要保存ra、sp、gp、tp、t0-t6、s0-s11、a0-a7x86桌面系统更复杂还要考虑段寄存器、控制寄存器等。这也是为什么任务切换代码几乎必须用汇编写——C语言没法精确控制“把所有寄存器压栈”这个动作。2. 寄存器入栈的完整账本哪些硬件管、哪些必须自己来2.1 不同架构的上下文保存策略差异不同CPU架构对上下文切换的“硬件支持”差别很大。先放一张对比表后面重点展开Cortex-M因为这是当前MCU上RTOS最常见的平台。CPU架构上下文保存方式说明ARM Cortex-M硬件自动压栈软件手动压栈异常进入时自动保存8个寄存器其余靠切换代码RISC-V软件手动保存trap/中断时通常由软件或机器模式代码保存全部寄存器x86硬件/软件结合可以使用TSS任务切换指令也可以由内核软件保存通用寄存器Cortex-M能成为RTOS的“黄金平台”一个重要原因就是它的异常机制为任务切换提供了极大的便利硬件在进入异常时自动保存了一部分现场异常返回时又自动恢复。这份“免费午餐”省了不少代码量也降低了切换延迟。2.2 Cortex-M异常进入时硬件自动压栈了哪些寄存器当Cortex-M收到一个异常或中断请求并准备响应时处理器会自动把以下寄存器按顺序压入当前使用的栈xPSR程序状态寄存器记录条件标志、中断号等PC被打断位置的下一条指令地址LR异常返回时要用的返回地址或EXC_RETURNR12通用寄存器R3、R2、R1、R0通用寄存器这8个寄存器是硬件压栈的不需要切换代码操作。压栈使用的栈指针取决于当前是线程模式还是处理模式如果任务运行在线程模式且使用PSP异常压栈就压在PSP指向的任务栈里如果工作在MSP模式就压在MSP指向的内核栈里。RTOS通常让普通任务跑在线程模式PSP让中断和内核运行在MSP这样任务切换时自动压栈会直接发生在任务自己的栈上非常干净。2.3 为什么R4-R11必须由切换代码手动保存R0-R3和R12由硬件自动保存但R4-R11没有被硬件压栈。原因是R4-R11在ARM过程调用标准中属于“被调用者保存”寄存器一个函数如果使用R4-R11需要在返回前恢复原值这样调用者不用管它们。但任务切换不是普通函数调用任务是被异步打断的编译器不会在打断点生成保存R4-R11的代码。所以切换代码必须亲自把这些寄存器压到任务栈里。这也是任务切换汇编代码中常见这两条指令的原因STMDB R0!, {R4-R11, LR} ; 把R4-R11和LR压栈 LDMIA R0!, {R4-R11, LR} ; 从新任务栈恢复R4-R11和LR这里的LR也需要仔细处理在异常处理函数里LR保存的是EXC_RETURN不是普通返回地址。不同RTOS实现会在栈里额外保存一份EXC_RETURN保证恢复时能拿到正确的值。这个细节如果搞错轻则任务回不到线程模式重则直接HardFault。2.4 新建任务时如何人工“预埋”一份初始现场任务第一次被调度时它从来没有运行过当然也没有任何寄存器现场。解决方法是在创建任务时手工构造一个看起来“任务刚被中断过”的栈帧然后把栈顶地址保存到任务控制块TCB里。调度器第一次选择这个任务时恢复过程会把这份伪造的现场弹出来CPU跳转到任务入口函数任务就像“从某次被打断的位置继续执行”一样启动了。一个典型的Cortex-M初始栈帧从栈底到栈顶大致是这样保存顺序内容说明高地址xPSR初始值常用0x01000000表示Thumb模式...PC任务入口地址bit0置1...LR可以设为0xFFFFFFFD用于异常返回...R12、R3、R2、R1、R0R0可设为任务入口参数...R11-R4手动保存区初始可为0低地址栈顶SP后续压栈位置任务运行时从这里继续向下用需要注意的是不同RTOS对“任务结束后CPU该去哪”的处理不完全一样。常见做法是把LR设成一个特殊值或一个退出函数地址。在Cortex-M上如果任务函数正常ret了异常返回会用LR中的EXC_RETURN所以初始LR通常直接放0xFFFFFFFD表示“返回线程模式并使用PSP”万一任务入口函数返回系统会走异常退出流程而不是跳到一个无效地址。2.5 浮点现场一个隐藏很深的坑如果MCU带FPU并且任务代码里有浮点运算那么还需要保存S0-S31这些浮点寄存器以及FPSCR状态寄存器。Cortex-M的硬件支持“惰性压栈”也就是在进入异常时不立即保存浮点寄存器等到确实要用FPU时才触发保存这是为了省中断延迟。但RTOS的任务切换必须明确处理浮点现场否则会出现一种很恶心的问题任务A算了一堆浮点中间结果切到任务BB也用了FPU等A回来A的浮点寄存器已经被B改得面目全非计算结果悄悄出错。这种错误不是每次必现往往跑几十秒才冒出来一次极其难查。解决办法是编译时和RTOS配置中同时打开FPU上下文支持并且每个任务的栈尺寸要多预留至少几十到上百字节给浮点现场。这是我实际项目中真实踩过的坑排查了一整天才定位到。3. 调度器不直接切任务PendSV这步棋的用意3.1 调度器的产出只有一个下一个任务是谁调度器要解决的核心问题很简单在所有就绪任务里选一个应该运行的任务。它可能基于优先级可能基于时间片轮转也可能结合两者。调度器可以运行在时钟节拍中断里也可以运行在任务主动让出CPU的API调用中还可以运行在某个中断释放信号量之后。但要注意调度器本身不负责“保存现场/恢复现场”。调度器做完决定后产出的只是一个“目标任务ID”或者“目标TCB指针”。真正把CPU从任务A切到任务B的动作还得由上下文切换代码来完成。把“决策”和“执行”分开是RTOS架构里很重要的一条原则。3.2 为什么切换动作要放在PendSV而不是SysTick里很多人第一次看RTOS源码会困惑SysTick中断不是已经周期性地进来了吗为什么不直接在SysTick里做完上下文切换还要多此一举触发一个PendSV原因在于中断嵌套。SysTick是一个普通中断它可能随时抢占其他中断服务程序。假设任务A正在运行UART中断正在处理此时SysTick到期并抢占UART中断。如果在SysTick里直接做上下文切换那么被打断的现场其实是“UART中断处理程序”而不是“任务A的正常代码流”。这时把现场保存到任务A的栈里语义上是错的任务A并不知道自己被中断了等它恢复时它其实还要回到UART中断里继续运行整个现场的关系就纠缠不清了。PendSV是一个可悬挂异常它的特点是可以被更高优先级的中断推迟直到所有更紧急的中断处理完才轮到它执行。RTOS把PendSV的优先级设置为最低然后无论是SysTick还是其他中断发现自己需要切换时只“置位PendSV”这一个动作。PendSV会耐心等其他所有ISR都结束然后在一个“干净”的环境里执行真正的上下文切换。这时候被打断的就是任务A的正常执行流保存的现场才是准确的任务现场。打个比方SysTick是那个告诉你“该换人了”的闹钟PendSV是“等所有乘客安全下车后再开始打扫车厢”的保洁员。先让该处理的中断处理完再在没人打扰的状态下完成切换这就是PendSV存在的意义。3.3 每个tick都切换是一种浪费next current要直接跳过基于时间片轮转的调度器会周期性进入SysTick每次都检查就绪队列。但很多情况下当前任务仍然是最高优先级任务调度器选出来的下一个任务还是它自己。如果这种场景还强行做一次完整的寄存器保存和恢复等于白白浪费几十个时钟周期还会让通过示波器观察GPIO翻转的人误以为系统在疯狂抖动。较好的实现是在SysTick里更新tick计数检查是否有优先级高于当前任务的任务就绪或者同优先级其他任务的时间片是否耗尽。如果没有变化直接返回不触发PendSV。只有确实需要切换时才置位PendSV。这样可以把无谓切换降到接近零。3.4 切换开销的定量概念上下文切换的耗时大概是多长以常见168MHz主频的Cortex-M为例一次完整切换包括进入异常、硬件压栈、保存R4-R11、调度器查询、恢复R4-R11、异常返回硬件出栈整体大约2到5微秒。如果换成低主频MCU比如几十MHz可能到10微秒以上。假设系统tick周期是1ms每秒进入1000次tick如果每次tick都做一个切换切换开销占比可能就是0.2%到1%还可以接受。但如果某个任务频繁主动让出CPU每秒切换成千上万次开销就很可观了。所以设计实时系统时切换频率和tick周期的选择要一起考虑不是tick越短越好。4. 一次任务切换的完整链路从Tick进入再到异常返回4.1 用两个任务演示完整流程假设系统里有两个任务任务A优先级低任务B优先级高两个任务都处于就绪状态当前运行的是任务A。我们走一遍任务A切换到任务B的完整过程。第一步任务A正在用户代码里跑SysTick中断到来。Cortex-M硬件检测到异常自动把xPSR、PC、LR、R12、R3-R0这8个寄存器压到任务A的PSP栈里然后将栈指针切到MSP跳转到SysTick的ISR入口。第二步SysTick ISR里做tick计数自增调用调度器的tick处理函数。调度器发现任务B优先级更高且已经就绪于是把“next_task”指向任务B的TCB并置位PendSV异常。第三步因为PendSV优先级最低如果此时还有其他更高优先级中断在排队PendSV会先等待。假设系统没有其他中断了PendSV立即开始执行。PendSV的汇编代码先关闭中断然后从当前PSP中计算出任务A的栈顶位置把R4-R11以及一些必要的额外寄存器压到任务A的栈里再把新的栈顶指针写回任务A的TCB。第四步从任务B的TCB中取出它的栈顶指针执行出栈操作恢复R4-R11把PSP设置为任务B的栈顶然后打开中断最后用异常返回指令退出PendSV。第五步异常返回时硬件从新的PSP指向的栈里自动弹出之前保存的8个寄存器任务B的PC被恢复CPU跳回任务B上次被打断的位置继续执行。任务B完全没有察觉到曾经被切换过。4.2 PendSV核心汇编一份可参考的示意代码下面是一段非常简化的PendSV处理示意代码主要用来展示流程不能直接复制到工程里用PendSV_Handler ; 进入PendSV时LR中就是EXC_RETURN后续需要妥善处理 CPSID I ; 关中断防止切换过程被打断 ; 拿到当前任务的PSP MRS R0, PSP ; 把R4-R11压到当前任务栈 ; 有些实现也会把EXC_RETURN一起压栈这里省略 STMDB R0!, {R4-R11, LR} ; 调用C函数完成 ; 1. 保存current_tcb-sp R0 ; 2. 根据调度结果返回next_tcb-sp ; 这里简化成一条伪指令实际会使用BL跳转到调度函数 BL switch_context ; R0此时是next_tcb-sp LDMIA R0!, {R4-R11, LR} ; 更新PSP MSR PSP, R0 ; 开中断 CPSIE I ; LR中是0xFFFFFFFD时返回线程模式并使用PSP BX LR这段代码里有几个关键点值得展开。第一为什么第一步要CPSID I因为保存R4-R11到栈和更新TCB的sp字段必须是原子操作。如果在这期间又被高优先级中断打断新的异常压栈可能会破坏正在保存的现场关系。虽然PendSV已经是最低优先级但某些内核实现仍然习惯用关中断保护这一小段。第二BL switch_context这一行实际没有那么简单。因为一旦执行BLLR会被覆盖PendSV返回时的EXC_RETURN就丢了。所以正常实现会在压栈时把原来的LR保存到任务栈中再从新任务的栈里恢复对应的EXC_RETURN或者在压栈时用另外的寄存器保存LR。这个细节正是无数坑的源头。第三任务B第一次启动时它的R4-R11区域里是初始化时预置的0值LR是手动设置的0xFFFFFFFD。恢复后BX LR触发异常返回硬件从PSP弹出的PC就是任务入口地址任务B就此启动。4.3 EXC_RETURN到底是什么Cortex-M在进入异常时LR寄存器并不保存普通返回地址而是保存一个称为EXC_RETURN的特殊值。常见值有三个EXC_RETURN值含义0xFFFFFFF1返回Handler模式使用MSP0xFFFFFFF9返回Thread模式使用MSP0xFFFFFFFD返回Thread模式使用PSPRTOS任务运行在线程模式且使用PSP所以任务被切换出去时最终返回到任务的EXC_RETURN应该是0xFFFFFFFD。恢复现场时如果LR拿到了这个值CPU就知道要从PSP指向的栈自动弹出现场并切回线程模式。如果这个值因为栈溢出被改成了0xFFFFFFF9CPU会错误地切到MSP去弹栈后果通常是立即HardFault。这条知识对排查“任务一切换就死机”类问题非常有用。我在实际调试时见过不止一次任务看起来好好的一开任务切换就进HardFault最后查到R4-R11恢复有问题或者EXC_RETURN被栈溢出冲掉。4.4 中断嵌套发生时切换被推迟到什么时候如果UART中断或者定时器中断正在执行PendSV只能等它们结束。所以一个高优先级中断唤醒了一个高优先级任务那个任务并不会立刻运行而是等当前ISR执行完毕、PendSV完成切换后才开始。这带来一个结论RTOS的任务响应时间 中断响应时间 当前ISR剩余执行时间 上下文切换时间。设计硬实时系统时中断服务函数里不应该做过长的处理否则会直接拉长高优先级任务的启动延迟。5. 手写最小调度器时的几个硬坑初始栈帧、临界区与栈深度5.1 第一个任务怎么“骗”CPU去运行系统上电后CPU运行在复位向量指向的启动代码里此时使用的还是MSP也没有任何任务上下文。要启动第一个任务不能直接调用任务函数条目因为任务函数期望运行在线程模式PSP且有自己的栈。标准做法是先为第一个任务初始化好栈帧然后通过异常返回机制启动。典型步骤是调用task_stack_init为任务构造初始栈帧并把栈顶地址存入TCB。设置PSP为第一个任务的栈顶地址设置CONTROL寄存器让线程模式使用PSP并处于特权级。触发一个SVC异常在SVC_Handler里把第一个任务的现场“恢复”出来。异常返回时预置的PC被弹出CPU跳转到任务入口。下面是一段示意性的栈初始化函数void task_stack_init(task_tcb_t *tcb, void (*entry)(void *arg), void *arg) { uint32_t *sp (uint32_t *)((uint8_t *)tcb-stack_top tcb-stack_size); // 模拟硬件压栈区xPSR、PC、LR、R12、R3-R0 *--sp 0x01000000; // xPSRThumb模式 *--sp (uint32_t)entry | 1; // PC任务入口bit0置1 *--sp 0xFFFFFFFD; // LR返回线程模式并使用PSP *--sp 0; // R12 *--sp 0; // R3 *--sp 0; // R2 *--sp 0; // R1 *--sp (uint32_t)arg; // R0任务参数 // 软件压栈区R11-R4 for (int i 0; i 8; i) { *--sp 0; } tcb-sp sp; }注意PC的bit0强制置1是Cortex-M的Thumb指令要求。如果把bit0设成0异常返回时会因为试图切换到ARM状态而触发错误。这个细节写错过一次就会理解为什么整整一天都在看HardFault。5.2 临界区不能太长但要足够短任务切换最敏感的阶段是从读取当前PSP、保存R4-R11、更新TCB到从下一个TCB恢复现场这一段。如果这段被其他中断乱入可能出现两个任务同时操作同一个TCB的局面数据一致性直接崩溃。简单的保护方式就是关中断即CPSID I或__disable_irq()。但关中断时间长了会拖慢系统实时性尤其对高优先级中断来说每多关1微秒都是损失。所以正规RTOS会用更精细的方式比如通过BASEPRI寄存器屏蔽“优先级低于某个阈值”的中断让高优先级实时中断仍然可以响应而普通中断被暂时按住。在实际项目中我还见过一个问题在临界区内调用了一个可能阻塞的库函数结果整个系统死锁。原因是临界区通常不允许切换任务库函数内部又去获取信号量信号量需要调度器运行才能释放但调度器被临界区挡住了。这属于死锁问题排查起来比上下文切换本身更痛苦。写代码时务必在临界区里保持简单只做指针操作、位图更新、TCB链表调整这类短小操作。5.3 任务栈大小到底该给多少每个任务的栈大小没有统一公式但至少要覆盖以下几部分任务函数本身的调用深度包括局部变量、数组、函数调用链任务主动调用内核API时可能进入的内核代码栈中断嵌套所需的压栈空间尤其是多个中断嵌套时的硬件自动压栈上下文切换时手动压入的寄存器现场如果使用FPU还要加上浮点寄存器现场。比较实用的做法是先把栈初始化为固定的填充字节比如0xA5A5A5A5然后运行一段时间再查看栈空间被改写了多少。很多RTOS内核自带栈水位检测工具原理就是在任务切换时扫描任务栈中仍保留填充字节的区域。没有工具也没关系可以在每次任务切换时把当前SP记录下来如果SP低于某个水位就打印告警。一般经验是估算值再额外加30%的余量别卡着极限给栈。5.4 从症状反推切换问题的排查手段任务切换相关的故障表现往往集中在几种固定模式。一种是一个任务跑几次后进入HardFault现场PC是个随机地址。优先怀疑返回地址被栈溢出破坏或者EXC_RETURN恢复错误。此时去读异常现场的PC、LR再看这个地址在不在代码区基本能判断方向。另一种是系统能跑但任务A里算出来的结果偶尔不对尤其是浮点运算时。优先查FPU上下文是否保存许多RTOS提供了内核编译开关打开后才能保存浮点寄存器。还有一种最阴的两个任务之间切换看起来很正常但全局变量被莫名其妙地改动。这种大概率是任务栈越界栈指针写进了相邻任务的控制块或另一个全局变量区域。用填充字节标记栈底再周期性检查是比较有效的早期发现手段。调试时我也常用一些小手段在PendSV入口把某个GPIO翻转一次用示波器量切换次数和切换频率在调度器选择next任务的地方用串口输出“A - B”这样的切换序列。这些土办法往往比逻辑分析仪还直观能快速看出系统是否存在频繁无效切换。6. 最后讲几句实操体会我把这些内容重新梳理一遍时最大的感受是任务切换不是一个孤立的知识点而是寄存器、栈、调度器三个部分紧密咬合的系统。寄存器是现场栈是存放现场的抽屉调度器是决定开哪个抽屉的人三者的协作顺序一旦错位整个系统就崩。真正把Cortex-M的硬件自动压栈、EXC_RETURN、PSP机制理解透之后再回头读任何RTOS内核的调度相关代码都会顺畅很多。如果让我给刚接触这块的开发者一个建议那就是不要只背概念拿一块熟悉的开发板自己写一个两个任务轮转的最小调度器任务A点灯任务B点灯用SysTick产生节拍用PendSV做切换。把SVC启动、PSP切换、初始栈帧构造这几个环节亲手跑通比读十篇文章都有用。等你能在一晚上把两个任务的循环切换调出来再去看那些商用RTOS的源码会感觉像看老朋友一样亲切。