ARTICLE · 991507
FreeRTOS上下文切换源码剖析:30行汇编撑起的多任务幻觉
平台:STM32F103RET6(Cortex-M3)内核:FreeRTOS(RVDS/ARM_CM3 移植层)日期:2026-09-01本文所有代码与行号均取自本工程实际源码
一、全景:一次上下文切换的完整链路
上下文切换不是"一个函数",而是一条由硬件自动压栈 + 汇编手动保存 + C 函数选任务三段组成的流水线:

为什么用 PendSV 而不是直接在 SysTick 里切? PendSV 被配置为最低优先级(本工程 configKERNEL_INTERRUPT_PRIORITY = 15<<4)。如果切换请求到来时有更高优先级的 ISR 正在运行,PendSV 会一直排队等着,保证所有中断处理完毕后才切换——现场永远不会保存在一个"被打断了一半"的状态。这就是 Cortex-M 移植层的精髓:切换动作被"压弯"到中断嵌套的最外层。
二、用什么结构体、保存在何处
2.1 核心结构:任务控制块 TCB
typedef struct tskTaskControlBlock{volatile StackType_t * pxTopOfStack; /* ← 必须是第一个成员! */...ListItem_t xStateListItem; /* 挂入就绪/阻塞/挂起链表 */ListItem_t xEventListItem; /* 挂入事件等待链表 */UBaseType_t uxPriority; /* 优先级 */StackType_t * pxStack; /* 任务栈起始地址 */char pcTaskName[ configMAX_TASK_NAME_LEN ];...} tskTCB;
pxTopOfStack必须是第一个成员——这不是风格要求,而是硬约束。汇编里只做了 ldr r0, [r1](取 TCB 首地址的内容)就拿到栈顶,如果它不在偏移 0 的位置,每次切换都要多算一次偏移,汇编层拒绝付出这个代价。
2.2 TCB 存在哪
xTaskCreate() | |
xTaskCreateStatic() |
(本项目实测):静态创建时把栈/TCB 放在 main()局部变量里,调度器启动后 MSP 重置导致内存被覆盖——必须放全局或 static。
2.3 现场保存在哪:任务的私有栈
上下文(寄存器组)不保存在 TCB 里,TCB 只存一个"栈顶指针"做锚点。完整现场在任务自己的栈上,布局如下(栈向下生长,地址从高到低):

2.4 需要保存哪些寄存器、为什么
Cortex-M 的 AAPCS 调用约定把寄存器分成两类:
| 调用者保存 | 硬件自动 | ||
| 被调用者保存 | 软件手动 | ||
CM4F 移植层还要多保存 S0~S15、FPSCR(FPU 寄存器),并置 EXC_RETURN 的 bit4=0;此次 CM3 无 FPU,无此负担。
三、时序图(按代码逐行对应)
以"SysTick 节拍触发任务A → 任务B"为例,参与者:任务A、SysTick ISR、PendSV Handler、内核 C 代码、硬件。四个色块对应切换的四个阶段(与第一节全景图编号一致)。

模块速读:
关键点:任务B"恢复运行"的起点,就是它上次被切走时的断点(或首次调度时伪造的栈帧)。
3.1 补充:物理寄存器与内存快照的关系
常见困惑:寄存器是一个任务一份吗?不是。物理上全芯片只有一份寄存器(R0~R15、xPSR),任何时刻只能装一个任务的值。"每个任务有自己的寄存器"是逻辑幻觉——由任务私有栈上的 64 字节快照 + 切换时搬运制造:切走时 stmdb把物理寄存器的值抄写到任务栈(快照诞生);切回时ldmia把快照重新装载回物理寄存器(快照销毁)。新任务从未运行过没有快照,所以pxPortInitialiseStack手工伪造一份。
下面两张时序图把"压栈/弹栈"放到显微镜下,参与者是四个存储角色:物理寄存器(全芯片唯一)、PSP、任务栈内存、TCB 锚点。
压栈:任务A → 内存

弹栈:内存 → 物理寄存器(换B的值进来)

两个关键观察:
- 同一个杯子的饮料更换
——两张图里的"物理寄存器"是同一个参与者:压栈结束时它的值作废,弹栈结束时被 B 的快照覆盖。整个 PendSV 期间物理寄存器经历了: A的值 --stmdb--> 抄到A栈(作废) --ldmia--> B的值从B栈倒进来。 stmdb/ldmia的地址对偶——保证压了几个字、r0 就移动几个字:
stmdb r0!, {r4-r11} | |||
ldmia r0!, {r4-r11} |
str r0, [r2] 存的锚点在恢复侧被 ldr r0, [r1] 原样取回——锚点在两侧是同一个值,这就是切换不迷路的坐标系统。
四、FreeRTOS源代码
4.1 伪造初始栈帧:pxPortInitialiseStack()
新任务从未运行过,没有"现场"可保存——所以手工伪造一个,让 PendSV 恢复它时像恢复一个被打断的老任务。
StackType_t * pxPortInitialiseStack( StackType_t * pxTopOfStack,TaskFunction_t pxCode,void * pvParameters ){pxTopOfStack--; /* 8字节对齐让位 */*pxTopOfStack = portINITIAL_XPSR; /* xPSR: bit24(T)必须=1 */pxTopOfStack--;*pxTopOfStack = ( ( StackType_t ) pxCode ) & portSTART_ADDRESS_MASK; /* PC=任务入口 */pxTopOfStack--;*pxTopOfStack = ( StackType_t ) prvTaskExitError; /* LR=退出陷阱 */pxTopOfStack -= 5; /* R12,R3,R2,R1 */*pxTopOfStack = ( StackType_t ) pvParameters; /* R0=任务参数! */pxTopOfStack -= 8; /* R11~R4(垃圾值即可) */return pxTopOfStack; /* 写入 TCB->pxTopOfStack */}
三个细节:
- R0 = pvParameters:AAPCS 规定第一个参数走 R0,伪造栈帧就是把"函数调用的现场"伪装成"中断打断的现场",于是恢复时硬件自动把 R0 弹给任务函数——参数就这样传进去了;- xPSR 的 bit24 必须置 1:Thumb 状态位,为 0 会 HardFault;- LR = prvTaskExitError:任务函数禁止 return(返回即断言+死循环)——本工程实测过这个 assert 触发。
4.2 触发切换:两个入口,同一个动作
入口一:SysTick 节拍抢占:
void xPortSysTickHandler(void){vPortRaiseBASEPRI(); /* 屏蔽 syscall 级中断 */{if( xTaskIncrementTick() != pdFALSE ){portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT; /* 挂起 PendSV */}}vPortClearBASEPRIFromISR();}
入口二:任务主动让出:
#define portYIELD() \{ \/* Set a PendSV to request a context switch. */ \portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT; \}
vTaskDelay()、xQueueReceive() 阻塞时,内部最终都落到 portYIELD()——写一次 ICSR 寄存器,就等于"预定了一次切换"。两个入口汇聚到同一扇门(PendSV),这就是"单一切换点"设计。
4.3 切换本体:xPortPendSVHandler():
__asm voidxPortPendSVHandler(void){extern uxCriticalNesting;extern pxCurrentTCB;extern vTaskSwitchContext;mrs r0, psp /* r0 = 任务A的栈顶(PSP) */isbldr r3, =pxCurrentTCB /* r3 = &pxCurrentTCB */ldr r2, [ r3 ] /* r2 = TCB_A */stmdb r0 !, { r4 - r11 } /* ★手动保存:R4~R11 压入任务A的栈 */str r0, [ r2 ] /* ★TCB_A->pxTopOfStack = 新栈顶 */stmdb sp !, { r3, r14 } /* 在MSP上保存 pxCurrentTCB指针 和 EXC_RETURN */mov r0, #configMAX_SYSCALL_INTERRUPT_PRIORITYmsr basepri, r0 /* 屏蔽 syscall 级中断,保护下面的C调用 */dsbisbbl vTaskSwitchContext /* ★C函数:选出下一个任务 */mov r0, #0msr basepri, r0 /* 解除屏蔽 */ldmia sp !, { r3, r14 } /* 取回 pxCurrentTCB指针 和 EXC_RETURN */ldr r1, [ r3 ] /* r1 = TCB_B(已被上面改写) */ldr r0, [ r1 ] /* r0 = TCB_B->pxTopOfStack */ldmia r0 !, { r4 - r11 } /* ★手动恢复:从任务B的栈弹 R4~R11 */msr psp, r0 /* PSP 指向任务B的"硬件帧" */isbbx r14 /* 异常返回→硬件弹R0-R3,R12,LR,PC,xPSR */}
注意 stmdb(store multiple decrement before)和 ldmia(load multiple increment after)的对偶关系:一个向下压、一个向上弹,配合满递减栈。
4.4 选任务:vTaskSwitchContext()
void vTaskSwitchContext(void){if( uxSchedulerSuspended != ( UBaseType_t ) 0U ){xYieldPendings[ 0 ] = pdTRUE; /* 调度器挂起→延迟切换 */}else{xYieldPendings[ 0 ] = pdFALSE;traceTASK_SWITCHED_OUT();/* ...运行时统计、栈溢出检查(方法2在此) */taskSELECT_HIGHEST_PRIORITY_TASK(); /* ★pxCurrentTCB = 最高优先级就绪任务 */traceTASK_SWITCHED_IN();/* ...TLS块切换 */}}
taskSELECT_HIGHEST_PRIORITY_TASK() 的逻辑:从 pxReadyTasksLists[] 最高优先级的非空链表头取 TCB,pxCurrentTCB = listGET_OWNER_OF_HEAD_ENTRY(...)。这里只改一个指针,不碰任何寄存器——保存/恢复现场全部由 PendSV 汇编负责,职责清晰分层。
4.5 第一个任务怎么启动:SVC 一次性的"反向操作"
调度器启动时还没有"被打断的任务",用的是 SVC 异常做一次反向切换(xPortStartScheduler → prvStartFirstTask):
__asm voidvPortSVCHandler(void){ldr r3, = pxCurrentTCBldr r1, [ r3 ] /* r1 = pxCurrentTCB */ldr r0, [ r1 ] /* r0 = 第一个任务的栈顶 */ldmia r0 !, { r4 - r11 } /* 弹 R4~R11 */msr psp, r0 /* PSP 指向该任务硬件帧 */isbmov r0, # 0msr basepri, r0orr r14, # 0xd /* EXC_RETURN: 返回线程模式+PSP */bx r14 /* "假装异常返回"→进入第一个任务 */}
prvStartFirstTask 先把 MSP 复位到向量表第 0 项(栈顶),cpsie 开中断,svc 0 触发。此后 PendSV 接管一切切换,SVC 再也不被使用。
五、栈帧
一次完整切换,任务栈上正好 16 个字(64 字节):
加 MSP 上临时压的 {r3, r14}(内核栈,切完即弹),这就是切换的全部开销——没有多余的一个字节。
六、本机制与工程踩坑记录的关联
configKERNEL_INTERRUPT_PRIORITY | |
FromISR API | portYIELD_FROM_ISR |
vTaskDelay | |