- 任务调度模块: PendSV 上下文切换汇编实现详解)
所有代码位于port.c一、前置知识Cortex-M 异常堆栈框架与双 SP 设计1. 两个栈指针Cortex-M 有两个独立的 SP由CONTROL寄存器 bit 0 选择MSPMain Stack Pointer复位后默认使用异常中断/SVC/PendSV服务例程硬件强制使用 MSPPSPProcess Stack PointerThread 模式任务运行态使用 PSPFreeRTOS 的分工中断/异常处理→ 用 MSP共享 1 份栈任务代码执行→ 用 PSP每个任务私有 1 份栈2. 异常自动保存与恢复的硬件帧8 个寄存器进入任何异常时Cortex-M 硬件自动把以下 8 个寄存器压入当前 SP进入 Handler 模式后就是 MSP但异常发生时任务用 PSP所以实际压入 PSP压入顺序由高地址→低地址栈向下生长 高地址 xPSR ← 硬件自动先压 PC ← 返回地址 LR (R14) R12 R3 R2 R1 低地址 R0 ← 最后一个硬件压入8 regs × 4 byte 32 字节。异常返回时硬件自动从栈中弹出这 8 个。3. 需软件手工保存的寄存器8 个寄存器硬件不保存R4~R11被调者保存寄存器这正是 PendSV 的核心工作。二、任务初始栈帧的构造创建任务时pxPortInitialiseStack()伪造一个如任务刚被异常打断的栈代码pxPortInitialiseStack()StackType_t * pxPortInitialiseStack( StackType_t * pxTopOfStack, TaskFunction_t pxCode, void * pvParameters ) { pxTopOfStack--; // 预留对齐/异常返回所需空槽 *pxTopOfStack portINITIAL_XPSR; // xPSRbit241Thumb 态必为 1 pxTopOfStack--; *pxTopOfStack pxCode; // PC任务函数入口异常返回时跳转至此 pxTopOfStack--; *pxTopOfStack portTASK_RETURN_ADDRESS; // LRprvTaskExitError()任务不能返回 pxTopOfStack - 5; // R12, R3, R2, R1 不关心留 4 个空槽 *pxTopOfStack (StackType_t)pvParameters; // R0任务函数参数 pvParameters pxTopOfStack - 8; // R11~R4 软件保存区8 个空槽 return pxTopOfStack; // 返回新栈顶保存到 TCB.pxTopOfStack }初始栈布局栈底在高地址向下生长高地址 ┌─────────────────────────────── pxStack uxStackDepth - 1 │ xPSR (0x01000000) │ ← 硬件压入区顶 │ PC (任务函数地址) │ 异常返回时 bx lr 弹出给 PC │ LR (prvTaskExitError)│ 任务返回会到这里死循环 │ R12 (未初始化) │ │ R3 (未初始化) │ │ R2 (未初始化) │ │ R1 (未初始化) │ │ R0 (pvParameters) │ ← 硬件压入区底 ├───────────────────────────────┤ │ R11 (未初始化) │ │ R10 (未初始化) │ │ R9 (未初始化) │ │ R8 (未初始化) │ │ R7 (未初始化) │ │ R6 (未初始化) │ │ R5 (未初始化) │ 低地址 │ R4 (未初始化) │ ← pxTopOfStack 指向这里返回值 └───────────────────────────────┘ ◄── 硬件自动保存区8 regs─►◄── 软件保存区8 regs─► 32 bytes 32 bytes关键约定TCB.pxTopOfStack始终指向软件保存区的底部即 R4 的位置。这是 PendSV 和 SVC 都依赖的不变量。三、启动第一个任务SVC 流程1. prvPortStartFirstTask代码prvPortStartFirstTask()ldr r0, 0xE000ED08 ; VTOR向量表偏移寄存器地址 ldr r0, [r0] ; 读 VTOR → 得到向量表基址 ldr r0, [r0] ; 读向量表第 1 项复位向量 初始 MSP 值 msr msp, r0 ; 恢复 MSP 到复位后的初始值启动文件用的栈现在不再用它 cpsie i ; 开全局中断 cpsie f ; 开故障异常 dsb / isb ; 内存/指令屏障 svc 0 ; 触发 SVC 异常进入 vPortSVCHandler为什么用 SVC 0 启动第一个任务因为启动第一个任务与任务切换共享相同的从异常栈帧返回机制——直接用 SVC 伪造一次异常返回硬件会自动帮你把硬件帧xPSR/PC/R0…弹出完美契合pxPortInitialiseStack构造的初始栈。2. vPortSVCHandler仅启动第一个任务代码vPortSVCHandler()ldr r3, pxCurrentTCB ; r3 pxCurrentTCBTCB_t** 全局变量 ldr r1, [r3] ; r1 pxCurrentTCBTCB_t*指向首个任务的 TCB ldr r0, [r1] ; r0 pxCurrentTCB-pxTopOfStack首字段即软件保存区底部 R4 位置 ldmia r0!, {r4-r11} ; 从 r0 位置加载 R4~R11r0 递增 8*4 → 指向硬件保存区底部R0 的位置 msr psp, r0 ; PSP r0任务的硬件帧之后的 SP任务运行时用 PSP isb mov r0, #0 msr basepri, r0 ; BASEPRI0 → 开所有优先级中断清关中断状态 orr r14, #0xd ; 修改 EXC_RETURNbit01→用 PSP 返回bit31→Thread模式 bx r14 ; 异常返回 → 硬件自动从 PSP 弹出 xPSR/PC/…/R0 → 首任务开始执行关键 EXC_RETURN 编码0xFFFFFFFD原始 LROR 0xD 0xFFFFFFFD没变。含义是返回到 Thread 模式出栈使用 PSP不是 MSP无浮点状态CM3 无 FPU启动后状态PSP 指向任务硬件栈顶R0 之上任务代码以R0 pvParameters、PC pxCode开始运行。四、核心PendSV Handler完整上下文切换代码xPortPendSVHandler()这是 RTOS 运行过程中所有任务切换的唯一入口。SysTick、portYIELD()、中断 API 解阻塞等都通过置 PendSV 悬挂位来触发它。触发方式示例portYIELD()→portNVIC_INT_CTRL_REG PENDSVSET_BITportmacro.h#L85-L94xPortSysTickHandler()→xTaskIncrementTick()需要切换时挂 PendSV port.c#L501-L508为什么用 PendSV 而不是直接在 SysTick 里切换PendSV 优先级可被设为最低portNVIC_PENDSV_PRI写 0xFF见 port.c#L390。若 SysTick 执行时有更高优先级中断正在运行或即将到来PendSV 会延后到所有中断处理完才执行保证上下文切换不抢占 ISR、不破坏中断响应时间。PendSV 汇编逐行讲解【阶段 1保存旧任务的软件上下文】mrs r0, psp ; r0 当前任务的 PSP此时 PSP 已指向硬件压入之后的位置 ; 也就是硬件已把 xPSR/PC/.../R0 压入 PSP所以 PSP 现在 ; 位于初始栈帧图中 R0 之下 / R4 位置正上方 isb ; *重要*进入异常时硬件已经用 PSP 压了 8 个寄存器PSP 已减 32 ; 所以现在 r0(PSP) 的值 原任务 TCB.pxTopOfStack 32正好在 R4 之上 ldr r3, pxCurrentTCB ; r3 pxCurrentTCB ldr r2, [r3] ; r2 pxCurrentTCB旧任务 TCB 指针 stmdb r0!, {r4-r11} ; 把 R4~R118 个寄存器用先减后存压入 PSP 栈 ; 操作后 r0 恰好 R4 的位置 约定中的 pxTopOfStack ; stmdb 是 decrement before即先把 r0 减 4*8 32再依次存 R4..R11 str r0, [r2] ; 把最新栈顶r0写回 TCB 的首字段 pxTopOfStack ; [r2] r2 0x00 TCB.pxTopOfStack → 这就是为什么 TCB 结构体 ; 要求 pxTopOfStack 必须是第一字段汇编里硬编码偏移 0保存后旧任务栈状态高地址 ┌──────────────┐ │ xPSR硬件 │ ← 硬件自动压入 │ PC 硬件│ │ LR 硬件│ │ R12 硬件│ │ R3 硬件│ │ R2 硬件│ │ R1 硬件│ │ R0 硬件│ ← PSP 在进入 PendSV 时在这里 ├──────────────┤ │ R11软件 │ ← stmdb r0! {r4-r11} 依次压入 │ R10软件 │ │ ... │ │ R4 软件 │ ← r0 在这里保存到 TCB.pxTopOfStack ✓ 低地址 └──────────────┘【阶段 2选新任务调用 C 代码】在调用 C 函数前要保护调用者保存寄存器这里 r3、r14 还需要用所以压 MSP 栈stmdb sp!, {r3, r14} ; 压 r3 pxCurrentTCB和 r14(EXC_RETURN) 到 **MSP** 栈 ; PendSV 是 Handler 模式sp 此时就是 MSP mov r0, %0 ; %0 configMAX_SYSCALL_INTERRUPT_PRIORITY编译时立即数 msr basepri, r0 ; 把 BASEPRI 设为最大系统调用优先级 ; → 屏蔽所有优先级 ≤ 该值数值更大的中断 ; → 但高于 configMAX_SYSCALL_INTERRUPT_PRIORITY 的中断仍能进来 ; 实时性保障高优先级硬件中断不受 RTOS 临界区影响 bl vTaskSwitchContext ; 调用 C 函数 ; tasks.c 的 vTaskSwitchContext() → taskSELECT_HIGHEST_PRIORITY_TASK() ; 副作用把全局 pxCurrentTCB 更新为下一任务的 TCB 指针 mov r0, #0 msr basepri, r0 ; BASEPRI0 → 恢复所有中断 ldmia sp!, {r3, r14} ; 从 MSP 栈恢复 r3 和 r14 ; r3 又回到 pxCurrentTCB这里的设计哲学BASEPRI 临界区只保护vTaskSwitchContext内部的就绪表修改避免关所有中断硬件实时中断高于阈值可照常响应。这比cpsid i全关细粒度得多。【阶段 3恢复新任务的软件上下文】ldr r1, [r3] ; r1 新的 pxCurrentTCBvTaskSwitchContext 已更新它 ldr r0, [r1] ; r0 新任务 TCB.pxTopOfStack → 即新任务 R4 所在地址 ldmia r0!, {r4-r11} ; 从 r0 弹出 R4~R11r0 递增 32 ; 弹出后 r0 恰好 硬件保存区底部R0 的位置 msr psp, r0 ; PSP r0 → 硬件异常返回时从这里开始自动弹硬件帧 isb bx r14 ; 异常返回EXC_RETURN→ 触发硬件 ; 1) 用 PSP 弹 xPSR, PC, LR, R12, R3, R2, R1, R0 ; 2) CPU 回到 Thread 模式用 PSP 继续执行新任务 ; 3) PC 值 当初新任务被切走时保存的返回地址整段流程的一句话总结PendSV 用 PSPSTMDB 存旧任务 R4~R11 并更新 TCB.pxTopOfStack → BASEPRI 关部分中断后调用vTaskSwitchContext选出新 TCB改pxCurrentTCB → 从新 TCB.pxTopOfStack 用 LDMIA 恢复 R4~R11 →bx r14让硬件用 PSP 弹其余 8 个寄存器 → 新任务继续运行。五、PendSV 切换全景时序图任务 A (Running) 任务 B (Ready) │ ▼ SysTick 中断来了 ┌─────────────────────────────────────┐ │ [硬件] 用 PSP 压 xPSR/PC/LR/R12/ │ ← 8 个寄存器 │ R3/R2/R1/R0PSP - 32 │ │ 进入 Handler 模式用 MSP │ │ PendSV Handler 执行 │ │ stmdb r0!, {r4-r11} → 压 A 栈 │ │ str r0, [TCB_A.pxTopOfStack] │ │ ── vTaskSwitchContext() ── │ │ 选出 TCB_B → pxCurrentTCB B │ │ ldr r0, [TCB_B.pxTopOfStack] │ │ ldmia r0!, {r4-r11} ← 弹 B 栈 │ │ msr psp, r0 │ │ bx r14 (异常返回) │ │ [硬件] 用 PSP 弹 xPSR/PC/.../R0 │ ← 这次弹的是 B 的硬件帧 │ PSP 32 │ └─────────────────────────────────────┘ │ ▼ 任务 B (Running) 任务 A (Ready)Video_2026-09-06_105658六、Cortex-M4F 差异含 FPU如果是带 FPU 的 CM4F/CM7pxPortInitialiseStack与 PendSV 额外处理 S0~S15/FPSCR硬件根据LR.EXC_RETURNbit4 自动决定是否压 S0~S15/FPSCR惰性压栈由CONTROL.FPCA跟踪软件需额外保存 S16~S31若启用 lazy stacking 优化则可跳过这就是为什么 CM4F 目录下的 PendSV 会更长用tst lr, #0x10判断是否有浮点帧。基本逻辑与 CM3 相同仅多出 32 个 FP regs 的条件保存/恢复。七、移植/调试高频坑点基于经验总结根据过往 HardFault 排查经验以下几点是改 PendSV 必崩的根源问题现象正确做法TCB 首字段不是 pxTopOfStackPendSVstr r0, [r2]把错误偏移当栈顶写破坏 TCB 其他字段必须保证pxTopOfStack是 TCB 结构体首个成员tasks.c 注释强制要求 tasks.c#L376InitialiseStack 与 PendSV 对 pxTopOfStack 语义不一致第一次 SVC 返回就 HardFault取到错误 PC/xPSR固化约定pxTopOfStack (R4_slot)软件保存区底部。InitialiseStack 最后-8返回的位置必须等于 PendSVstmdb r0! {r4-r11}后str r0,[r2]的 r0向量表没正确映射 vPortSVCHandler / xPortPendSVHandlerSVC/PendSV 一触发就进 Default_Handler/HardFault启动文件弱符号与 FreeRTOS 函数名必须对齐用宏映射比改启动文件好PendSV/SysTick 优先级没配为最低上下文切换抢占了其他中断造成数据破坏portNVIC_SHPR3_REG同时改 InitialiseStack StartFirstTask PendSV 三件事问题空间爆炸定位困难单点小步验证先静态核对手绘栈帧图偏移量再一项项改每步可回滚对 PSP/MSP 使用混淆任务第一次运行前 PSP 还没设好就bx lr严格按prvPortStartFirstTask→ SVC →vPortSVCHandler显式msr psp, r0再bx r14