uC/OS-II Cortex-M3任务切换:PendSV与栈初始化

发布时间:2026/9/18 20:26:28
uC/OS-II Cortex-M3任务切换:PendSV与栈初始化 任务切换这四个字几乎是把 uC/OS-II 内核源码翻烂和只背过 API 的人之间最明显的一条分界线。前面六篇里我们把就绪表、优先级位图、TCB、任务创建、时间片这些东西一块块拆开了但那些代码说到底都还是纯 C 的可移植部分——你在 PC 上编译也能跑。真正把一个 RTOS 和一堆函数库区分开的是那几百行汇编它决定了上一个任务的现场怎么存下来、下一个任务的现场怎么恢复。这一篇我们就盯着OSCtxSw、OSIntCtxSw、PendSV_Handler和OSTaskStkInit这几段代码把 uC/OS-II 在 Cortex-M3我手边拿 GD32F103 做验证上的任务切换从头到尾过一遍。如果你正在做 RTOS 移植、面试前突击 rtos 项目细节或者单纯想知道为什么第一个任务跑起来要伪装成从中断返回这篇基本能把你脑子里那团浆糊搅清楚。1. 先把调度和切换这两件事掰开1.1 OS_Sched 只负责选人动手的是别人很多人读源码时会把OS_Sched()当成切换函数这是个很容易带偏后面所有理解的误解。我们把os_core.c里这段代码摆出来看void OS_Sched (void) { #if OS_CRITICAL_METHOD 3u OS_CPU_SR cpu_sr 0u; #endif OS_ENTER_CRITICAL(); if (OSIntNesting 0u) { /* 不在中断里才允许调度 */ if (OSLockNesting 0u) { /* 没被上锁才允许调度 */ OS_SchedNew(); /* 找最高优先级就绪任务 */ OSTCBHighRdy OSTCBPrioTbl[OSPrioHighRdy]; if (OSPrioHighRdy ! OSPrioCur) { /* 真的需要换人 */ OS_TASK_SW(); /* 只是通知要换 */ } } } OS_EXIT_CRITICAL(); }OS_SchedNew()做的事情我们已经很熟了从OSRdyGrp里查OSUnMapTbl拿到组号 y再从OSRdyTbl[y]查一次表拿到组内位号拼成OSPrioHighRdy。这两次查表是整个 uC/OS-II 调度里唯一的算法剩下的全是记账把OSTCBHighRdy指向对应的 TCB。真正动手的只有最后一行OS_TASK_SW()。而在几乎所有的 Cortex-M3 移植版本里os_cpu.h里写的是#define OS_TASK_SW() OSCtxSw()也就是说OS_Sched里那行宏展开以后就是一次函数调用。那OSCtxSw里到底干了什么如果你去看官方 Cortex-M3 移植版的os_cpu_a.asm会发现它短得让人怀疑人生OSCtxSw LDR R0, NVIC_INT_CTRL LDR R1, NVIC_PENDSVSET STR R1, [R0] BX LR四行。它一个寄存器都没搬只是往0xE000ED04ICSR 寄存器的第 28 位写了个 1把 PendSV 异常挂起。真正的寄存器搬运被推迟到了PendSV_Handler里。这个把重活甩给 PendSV的设计是 Cortex-M 移植版比老 ARM7 移植版好看得多的地方我在第 4 节会专门讲为什么这么设计。1.2 三个切换入口别混为一谈读源码的过程中你会撞见三种切换的叫法初学时特别容易搞混我干脆列个表对比一下入口调用者运行上下文在 Cortex-M3 上的实际动作OS_TASK_SW()OS_Sched()任务级在OS_ENTER_CRITICAL临界区内宏展开即OSCtxSw()OSCtxSw()上面的宏任务级临界区内置位 PendSV返回OSIntCtxSw()OSIntExit()中断级尾部同样是置位 PendSV返回注意最后一个在 Cortex-M3 上OSIntCtxSw()和OSCtxSw()的代码几乎可以完全等价。这一点跟老 ARM7 时代差别巨大——ARM7 版里OSIntCtxSw需要往上调整 SP 来丢弃已经压好的寄存器而 Cortex-M3 因为硬件自动压栈机制的存在这事被硬件代劳了。这就是为什么现在很多人移植 uC/OS-II 到 Cortex-M 上会觉得比书上讲的简单书上的例子大多是 ARM7 的。这里有个关键细节必须点出来否则你会觉得代码看懂了但跑起来对不上OS_Sched是在OS_ENTER_CRITICAL()之后才调OS_TASK_SW()的而OS_ENTER_CRITICAL在 Cortex-M 上是CPSID I置 PRIMASK。PRIMASK 会屏蔽掉所有可配置优先级的中断和异常包括 PendSV。所以OSCtxSw里那三行写完 PendSV 挂起位之后PendSV不会立刻执行它一直要等到os_cpu.h里OS_EXIT_CRITICAL展开的那条CPSIE I执行完才真正开始跑。换句话说完成任务切换的那条指令其实藏在OS_EXIT_CRITICAL里。这一点我当初就是卡了两天才反应过来因为单看OSCtxSw完全看不出来切换点在哪。1.3 为什么这个小细节值得单独说想明白上面这条之后整个执行流就通了任务 A 调用OSTimeDly(10)把自己从就绪表里摘掉然后调OS_Sched()OS_Sched进了临界区找到任务 B调OSCtxSw()挂起 PendSV然后OS_EXIT_CRITICAL()打开中断PendSV 立刻执行现场切换返回时已经在任务 B 的栈上了。那 B 恢复之后从哪儿继续执行从它自己上次调OS_EXIT_CRITICAL的那条CPSIE I后面继续。这就是为什么一个任务不管因为什么原因阻塞恢复后都能像没被打断一样从原地继续跑——它恢复的位置严格来说不是任务代码里的哪一行而是它最后一次调用OS_Sched时那个临界区的出口。理解了这个你再去看OSSchedLock()/OSSchedUnlock()就会觉得特别自然OSLockNesting非零的时候OS_Sched直接跳过整个查找和切换流程中断照常响应但就是不许换人——这个语义叫只锁调度不锁中断跟OS_ENTER_CRITICAL是完全不同的两件事。很多新手把两者混用结果要么是临界区开得太长导致中断响应抖动要么是以为加了临界区就不会被换走。2. 上下文到底要保存什么从 OSTCBStkPtr 说起2.1 TCB 里唯一被内核托管的字段我们把OS_TCB结构体再翻一遍你会发现它有将近三十个字段OSTCBStkPtr、OSTCBStkBottom、OSTCBStkSize、OSTCBPrio、OSTCBDly、OSTCBStat、OSTCBBitX、OSTCBBitY、OSTCBNext、OSTCBPrev……但请注意一个事实除了OSTCBStkPtr配上OSTCBStkBottom/OSTCBStkSize用于统计和检查其他字段全是内核自己记账用的软件状态没有一个跟 CPU 的硬件寄存器有关。这是理解上下文切换最核心的一句话。CPU 的寄存器组R0–R15、xPSR、CONTROL、PRIMASK 等等在每个任务眼里都是私有的但物理上只有一套。那怎么让每个任务都觉得自己独占 CPU答案就是每个任务自己有一块栈切换时把当前寄存器组全部压进当前任务的栈里把下一个任务的栈指针交给 CPU再从新栈里把寄存器组弹回来。而当前任务的栈指针存在哪——就存在OSTCBCur-OSTCBStkPtr这一个字段里。所以OSTCBStkPtr不是一个普通的指针它是内核跟硬件状态之间唯一的锚点。你把这个字段写坏了等于把整个任务的执行现场烧掉了。2.2 Cortex-M3 偷偷帮你压了 8 个寄存器老 ARM7 遇到异常时硬件只做两件事切模式、把 LR 改成返回地址剩下的 PCR15和 CPSR 全部要软件手工压栈。Cortex-M3 把这个活儿升级了——异常进入时硬件自动把下面 8 个寄存器压到当前使用的栈上顺序从高地址到低地址寄存器说明1xPSR程序状态寄存器含 T 位、条件标志、IPSR2PC返回地址被打断那条指令的地址3LR (R14)调用返回地址4R12调用暂存寄存器5R3参数寄存器6R2参数寄存器7R1参数寄存器8R0参数寄存器 / 返回值压完之后SP 会下移 32 字节。这 8 个寄存器正好就是 AAPCS 里定义的调用者保存caller-saved部分——也就是函数调用时编译器不保证它们值还在的那些。硬件帮你压了就不用再写 8 条STMFD指令异常响应速度直接快了不少。那剩下的呢R4 到 R11 是被调用者保存callee-saved的按约定应该由被调用的函数负责保存。中断处理程序在 Cortex-M 里就是一个普通的 C 函数编译器会在函数入口按需把这些寄存器压到当时的栈上。但问题在于任务上下文切换时当时用的栈是任务自己的 PSP而中断处理程序是跑在 MSP 上的——这就引出了后面 MPU 和 PSP/MSP 分工的问题。2.3 手工补上的那 8 个寄存器既然硬件只压了一半那另一半就得软件自己动手。这就是PendSV_Handler里那句STMFD R0!, {R4-R11}的来历。把硬件压的和软件压的叠在一起一个完整的任务栈帧长这样假设OSTCBStkPtr指向的地址是低地址端相对偏移内容由谁压入0x00R4软件STMFD R0!, {R4-R11}0x04R5软件0x08R6软件0x0CR7软件0x10R8软件0x14R9软件0x18R10软件0x1CR11软件0x20R0硬件自动0x24R1硬件自动0x28R2硬件自动0x2CR3硬件自动0x30R12硬件自动0x34LR硬件自动0x38PC硬件自动0x3CxPSR硬件自动总共 64 字节正好 8 字节的整数倍。这个表看起来很枯燥但它是后面所有调试的基础。你的 HardFault 处理程序要打印出错指令地址就是去取栈帧0x38位置的 PC要看当时程序的运行模式就去看0x3C的 xPSR。这个后面第 5 节我会给代码。提示栈增长方向。Cortex-M 的栈是向下增长的也就是压栈时 SP 变小。所以在 uC/OS-II 里创建任务时传的ptos指向的是栈数组的最高地址TaskStk[SIZE-1]不是TaskStk[0]。写反了会出现任务刚创建就踩掉别的任务的数据这种诡异现象。2.4 MSP 与 PSP 的分工以及 CONTROL 寄存器Cortex-M3 有两个栈指针MSP主栈指针和 PSP进程栈指针。它俩物理上是两个独立寄存器但同一个时刻只有一个生效——由 CONTROL 寄存器的 bit1 决定0 用 MSP1 用 PSP。uC/OS-II 在 Cortex-M 上的惯例是所有任务跑在 PSP 上所有中断处理程序跑在 MSP 上。为啥要这么分我总结下来有三个好处都是踩过坑才体会到的任务的栈可以开得比较小每任务几百字节到几 KB而 MSP 只需要给中断嵌套用两者互不干扰。任务栈溢出了不会第一时间把中断栈也冲掉你还有机会进 HardFault 看一眼。切换时不用保存 MSP因为中断上下文天生就是临时的硬件进异常自动切到 MSP出异常自动切回 PSP。软件连一行代码都不用写。嵌套中断完全不需要关心——中断一层套一层全在 MSP 上栈深度由嵌套层数和每个 ISR 的局部变量决定跟任务数量无关。还有一个容易被忽略的点在OSStartHighRdy里有一句MSR PSP, R0把 PSP 先清零MOV R0, #0这是配合PendSV_Handler里那句CBZ R0, OS_CPU_PendSVHandler_nosave用的——第一次切换时 PSP 是 0说明没有旧任务需要保存直接跳到不保存的那一段。这个技巧很巧妙用一个寄存器值当布尔标志用省掉了一个全局变量。3. OSTaskStkInit把第一次切换伪装成中断返回3.1 第一个任务是怎么起跑的OSStart()的代码很短void OSStart (void) { if (OSRunning OS_FALSE) { OS_SchedNew(); /* 找优先级最高的任务 */ OSPrioCur OSPrioHighRdy; OSTCBCur OSTCBHighRdy; OSTaskSwHook(); OSStartHighRdy(); /* 一去不回头 */ } }OSStartHighRdy()在 Cortex-M3 移植版里大致长这样OSStartHighRdy LDR R0, NVIC_SYSPRI14 LDR R1, NVIC_PENDSV_PRI STRB R1, [R0] ; 把 PendSV 优先级设成最低 MOV R0, #0 MSR PSP, R0 ; PSP 0标记没有旧上下文 LDR R0, OSRunning MOV R1, #1 STRB R1, [R0] ; OSRunning TRUE LDR R0, NVIC_INT_CTRL LDR R1, NVIC_PENDSVSET STR R1, [R0] ; 挂起 PendSV CPSIE I ; 打开中断PendSV 立刻执行 BX LR这里有个非常有意思的设计内核启动第一个任务的路径和一次普通的中断退出路径走的是同一段代码。OSStartHighRdy什么都不干就是把 PendSV 挂起来剩下的全交给PendSV_Handler。而PendSV_Handler归根结底就是一句BX LR在恢复完寄存器之后让硬件从异常返回。那问题来了硬件从异常返回时会去栈上取 xPSR、PC、LR 这些寄存器然后跳回 PC 指向的地址。如果我们希望返回到任务的入口函数那这个栈帧就必须提前被布置好长得跟刚才真的发生过一次中断一模一样。这件事就是OSTaskStkInit的职责。3.2 OSTaskStkInit 逐行拆解官方 Cortex-M3 版的os_cpu_c.c里这个函数大概是这样我按自己的理解加了注释OS_STK *OSTaskStkInit (void (*task)(void *p_arg), void *p_arg, OS_STK *ptos, INT16U opt) { OS_STK *stk; (void)opt; /* opt 是 OSTaskCreateExt 才用得上这里忽略 */ stk ptos; /* ptos 指向栈顶高地址端 */ *(stk) (OS_STK)0x01000000L; /* xPSR —— 关键是 T 位 1 */ *(--stk) (OS_STK)task; /* PC —— 任务入口函数的地址 */ *(--stk) (OS_STK)0xFFFFFFFEL; /* LR —— 一个回不来的地址 */ *(--stk) (OS_STK)0x12121212L; /* R12 —— 填充值方便调试辨认 */ *(--stk) (OS_STK)0x03030303L; /* R3 */ *(--stk) (OS_STK)0x02020202L; /* R2 */ *(--stk) (OS_STK)0x01010101L; /* R1 */ *(--stk) (OS_STK)p_arg; /* R0 —— 任务函数的第一个参数 */ *(--stk) (OS_STK)0x11111111L; /* R11 */ *(--stk) (OS_STK)0x10101010L; /* R10 */ *(--stk) (OS_STK)0x09090909L; /* R9 */ *(--stk) (OS_STK)0x08080808L; /* R8 */ *(--stk) (OS_STK)0x07070707L; /* R7 */ *(--stk) (OS_STK)0x06060606L; /* R6 */ *(--stk) (OS_STK)0x05050505L; /* R5 */ *(--stk) (OS_STK)0x04040404L; /* R4 */ return (stk); /* 返回的就是最终的 OSTCBStkPtr */ }OSTaskCreate/OSTaskCreateExt拿到这个返回值之后直接存进OSTCBStkPtr。等这个任务第一次被调度到时PendSV_Handler从OSTCBStkPtr开始往上弹寄存器最后BX LR触发异常返回硬件从栈上取 xPSR 和 PCPC 里装的就是task的地址——任务就这样凭空跑起来了。3.3 三个写错就进 HardFault 的地方这段代码看起来平平无奇但有三处细节我在移植时全踩过第一个是 xPSR 的 T 位。0x01000000里那个 1 代表的是 xPSR 的第 24 位也就是 TThumb位。Cortex-M 只支持 Thumb 指令集从异常返回时如果 T 位是 0硬件认为你要切到 ARM 状态——而 Cortex-M 根本没有 ARM 状态结果就是直接进 HardFault。我记得当时偷懒把 xPSR 填成了 0现象是任务创建成功、就绪表也对、OSStart 之后直接死机查了一整晚。第二个是 LR 的值。这里填0xFFFFFFFE是个约定俗成的垃圾值如果任务函数真的返回了跳过去就会出问题。更地道的做法是填OS_TaskReturn的地址——uC/OS-II 在os_task.c里提供了这个函数它的行为是删掉自己、然后触发一次调度。这样如果哪个任务代码不小心跑到了}外面你会先看到任务被删除而不是莫名其妙的 HardFault。代价是多一次函数地址取址对启动性能有洁癖的话可以忽略这点开销。第三个是 8 字节对齐。AAPCS 规定函数入口处 SP 必须 8 字节对齐。虽然上面算下来一个完整栈帧正好 64 字节看着挺工整但如果你传入的ptos本身没对齐比如栈数组定义时没用对齐属性整条链就歪了。编译器一旦发现要对一个 8 字节的double或long long用LDRD/STRD指令对齐不对就立刻 HardFault。我的做法是/* Keil MDK */ __align(8) OS_STK Task1Stk[TASK1_STK_SIZE]; /* GCC / arm-none-eabi */ OS_STK Task1Stk[TASK1_STK_SIZE] __attribute__((aligned(8)));顺手在调试版本里加一句断言卡一下((uint32_t)stk 0x07) 0能省掉很多时好时坏的玄学问题。4. OSCtxSw 与 OSIntCtxSw 的实现细节4.1 为什么要把切换动作挂起给 PendSV回到第 1 节那个问题为什么OSCtxSw不直接搬寄存器非要绕一圈让 PendSV 去干原因有两个第二个才是决定性的。第一个原因是挂起可以合并。PendSV 是一个可挂起的系统异常多次置位只会在中断处理完后执行一次。这正好符合切换的语义一个时间片里就算被触发十次实际也只需要换一次。如果用普通函数直接切就得自己防重入。第二个原因是优先级。假设我们在某个中断服务程序里比如串口接收调用了OSSemPost让一个更高优先级的任务就绪了。如果这个 ISR 里直接做上下文切换那么剩下的 ISR 代码会被打断中断嵌套计数错乱而且万一这个 ISR 正在服务一个高优先级外设切换出去可能导致该外设的数据来不及取走而丢包。PendSV 的解法非常优雅把 PendSV 的优先级设成最低0xE000ED22这个地址写入0xFF然后把要切换这件事挂起。这样无论在多深的中断嵌套里发起切换请求PendSV 都会老老实实等所有中断处理完最后一个执行。等它执行的时候系统已经回到所有中断都退了只是还没回到任务这个干净的时机点切换就是安全的了。注意0xE000ED22是 PendSV 优先级的字节地址。这一条写入必须发生在第一次切换之前也就是OSStartHighRdy里。很多人习惯在main里统一调NVIC_SetPriority(PendSV_IRQn, ...)也行但一定要确认执行到了否则 PendSV 会以复位默认优先级跑可能出现高优先级外设中断还没执行完任务已经被换走了这种极难复现的偶发问题。4.2 PendSV_Handler 逐行看PendSV_Handler CPSID I ; 切上下文期间必须关中断 MRS R0, PSP ; 取当前任务的栈指针 CBZ R0, PendSV_nosave ; PSP 0 说明是第一个任务跳过保存 STMFD R0!, {R4-R11} ; 手工压剩下的 8 个寄存器 LDR R1, OSTCBCur ; 把新栈指针写回当前任务的 TCB LDR R1, [R1] STR R0, [R1] PendSV_nosave PUSH {R14} ; 保存 LR因为 BLX 会覆盖它 LDR R0, OSTaskSwHook BLX R0 ; 调用用户钩子函数 POP {R14} ; 恢复 LR LDR R0, OSPrioCur ; 更新当前优先级 LDR R1, OSPrioHighRdy LDRB R2, [R1] STRB R2, [R0] LDR R0, OSTCBCur ; 更新当前 TCB LDR R1, OSTCBHighRdy LDR R2, [R1] STR R2, [R0] LDR R0, [R2] ; 从新 TCB 取出栈指针 LDMFD R0!, {R4-R11} ; 弹出被调用者保存寄存器 MSR PSP, R0 ; 交给 PSP ORR LR, LR, #0x04 ; EXC_RETURN 的 bit2 置 1 CPSIE I ; 开中断 BX LR ; 返回硬件自动弹剩下的 8 个几个值得停下来打量的地方CPSID I放在最前面。整个切换过程必须在临界区里完成否则保存到一半被中断进来OSTCBStkPtr就成了半成品。虽然 PendSV 本身优先级最低但优先级更低不代表不会被抢占——NMI 和 HardFault 还是能插进来。CBZ R0, PendSV_nosave。这就是第 2.4 节说的技巧第一次切换时 PSP 是 0没有上下文需要保存。所以第一次不进STMFD直接跳到后面去装栈。这样写比在内核里加一个OSRunning判断要清爽得多。PUSH {R14}和POP {R14}那对。因为BLX R0会把返回地址写进 LR而 LR 这时候还存着EXC_RETURN的值一会儿要用。所以必须手动把 LR 压到 MSP 上暂存一下。这个坑非常隐蔽如果你忘了这对 PUSH/POP症状是调用OSTaskSwHook之后系统挂掉因为BX LR的时候 LR 变成了钩子函数里的某个返回值。顺序很关键。OSPrioCur/OSTCBCur必须在取新栈之前更新。如果你把这两步挪到LDMFD之后那么在OSTaskSwHook里如果它被放到后面读到的还是旧任务的信息。官方版本把 Hook 放在更新之前是因为它的语义就是在切换发生之前给你一次机会你要读OSTCBCur/OSTCBHighRdy都拿得到旧值和新值。ORR LR, LR, #0x04。这是整个函数里最容易被忽略的一句。LR在异常里存的是 EXC_RETURN 值bit2 表示返回后使用哪个栈指针0 用 MSP1 用 PSP。我们要回到任务就必须用 PSP。硬件在异常进入时已经自动把这位填好了因为我们是从任务 PS