夜雨聆风学习资料网

ARTICLE · 991507

FreeRTOS上下文切换源码剖析:30行汇编撑起的多任务幻觉

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 存在哪

创建方式
TCB 与任务栈的位置
xTaskCreate()
(动态)
heap_4 堆中:栈在下、TCB 在上(Cortex-M 栈向下生长,栈溢出只会砸到自己栈底,不会污染 TCB)
xTaskCreateStatic()
(静态)
用户提供的全局/静态区
(本项目实测):静态创建时把栈/TCB 放在 main() 局部变量里,调度器启动后 MSP 重置导致内存被覆盖——必须放全局或 static。

2.3 现场保存在哪:任务的私有栈

上下文(寄存器组)不保存在 TCB 里,TCB 只存一个"栈顶指针"做锚点。完整现场在任务自己的栈上,布局如下(栈向下生长,地址从高到低):

2.4 需要保存哪些寄存器、为什么

Cortex-M 的 AAPCS 调用约定把寄存器分成两类:

类别
寄存器
谁保存
为什么
调用者保存
(caller-saved)
R0~R3、R12、LR、PC、xPSR
硬件自动
(异常进入时压入 PSP 栈)
中断/异常随时打断程序,硬件必须无差别保存
被调用者保存
(callee-saved)
R4~R11
软件手动
(PendSV 汇编压入)
C 编译器假定函数调用者已保存;但切换现场时"没有人会回来恢复",只能调度器代劳
特殊
EXC_RETURN(LR 的异常形态)
PendSV 里压入 MSP
决定异常返回模式(返回线程/Handler、用 MSP/PSP)
不需保存
MSP 本身
—
任务不用 MSP(MSP 属于内核和中断嵌套)
CM4F 移植层还要多保存 S0~S15、FPSCR(FPU 寄存器),并置 EXC_RETURN 的 bit4=0;此次 CM3 无 FPU,无此负担。

三、时序图(按代码逐行对应)

以"SysTick 节拍触发任务A → 任务B"为例,参与者:任务A、SysTick ISR、PendSV Handler、内核 C 代码、硬件。四个色块对应切换的四个阶段(与第一节全景图编号一致)。

模块速读:

色块
阶段
核心动作
代码位置
暖黄
① 触发
写 ICSR 挂起 PendSV
port.c:461 / portmacro.h:88
淡蓝
② 保存
A 的 16 字现场 → A 的栈,锚点入 TCB
port.c:414-421
淡紫
③ 选择
纯 C 决策:pxCurrentTCB 换指向
tasks.c:5056
淡绿
④ 恢复
B 的栈 → 寄存器,异常返回
port.c:433-438

关键点:任务B"恢复运行"的起点,就是它上次被切走时的断点(或首次调度时伪造的栈帧)。

3.1 补充:物理寄存器与内存快照的关系

常见困惑:寄存器是一个任务一份吗?不是。物理上全芯片只有一份寄存器(R0~R15、xPSR),任何时刻只能装一个任务的值。"每个任务有自己的寄存器"是逻辑幻觉——由任务私有栈上的 64 字节快照 + 切换时搬运制造:切走时 stmdb 把物理寄存器的值抄写到任务栈(快照诞生);切回时 ldmia 把快照重新装载回物理寄存器(快照销毁)。新任务从未运行过没有快照,所以 pxPortInitialiseStack 手工伪造一份。

下面两张时序图把"压栈/弹栈"放到显微镜下,参与者是四个存储角色:物理寄存器(全芯片唯一)、PSP、任务栈内存、TCB 锚点。

压栈:任务A → 内存

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

两个关键观察:

  1. 同一个杯子的饮料更换
    ——两张图里的"物理寄存器"是同一个参与者:压栈结束时它的值作废,弹栈结束时被 B 的快照覆盖。整个 PendSV 期间物理寄存器经历了:A的值 --stmdb--> 抄到A栈(作废) --ldmia--> B的值从B栈倒进来。
  2. stmdb/ldmia的地址对偶
    ——保证压了几个字、r0 就移动几个字:
指令
全称
地址方向
用途
stmdb r0!, {r4-r11}
Store Multiple Decrement Before
先减后存
压栈(栈向下生长)
ldmia r0!, {r4-r11}
Load Multiple Increment After
先取后增
弹栈

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) */    isb    ldr 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_PRIORITY    msr basepri, r0             /* 屏蔽 syscall 级中断,保护下面的C调用 */    dsb    isb    bl vTaskSwitchContext       /* ★C函数:选出下一个任务 */    mov r0, #0    msr 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的"硬件帧" */    isb    bx 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, = pxCurrentTCB    ldr r1, [ r3 ]           /* r1 = pxCurrentTCB */    ldr r0, [ r1 ]           /* r0 = 第一个任务的栈顶 */    ldmia r0 !, { r4 - r11 } /* 弹 R4~R11 */    msr psp, r0              /* PSP 指向该任务硬件帧 */    isb    mov r0, # 0    msr basepri, r0    orr r14, # 0xd           /* EXC_RETURN: 返回线程模式+PSP */    bx r14                   /* "假装异常返回"→进入第一个任务 */}

prvStartFirstTask 先把 MSP 复位到向量表第 0 项(栈顶),cpsie 开中断,svc 0 触发。此后 PendSV 接管一切切换,SVC 再也不被使用。

五、栈帧

一次完整切换,任务栈上正好 16 个字(64 字节):

保存者
内容
字数
硬件(异常进入)
xPSR、PC、LR、R12、R3、R2、R1、R0
8
汇编(PendSV)
R4、R5、R6、R7、R8、R9、R10、R11
8

加 MSP 上临时压的 {r3, r14}(内核栈,切完即弹),这就是切换的全部开销——没有多余的一个字节。

六、本机制与工程踩坑记录的关联

坑
关联机制
configKERNEL_INTERRUPT_PRIORITY
 必须为最低(15<<4)
PendSV/SysTick 必须让位于所有 ISR(本文第一节)
中断里只能用 FromISR API
portYIELD_FROM_ISR
 同样写 ICSR 挂 PendSV,但选任务要等 ISR 退出
临界区内禁止 vTaskDelay
临界区 BASEPRI 屏蔽了 PendSV,阻塞让出会带着屏蔽态切换,中断永久关闭
任务栈不足会污染 TCB
heap_4 中栈在下 TCB 在上的分配顺序(本文 2.2 节)

相关学习资料