任务切换的核心
1.什么是任务切换
任务切换概念
单片机正常情况下只有一个主程序在跑。在引入FreeRTOS之后,系统会有多个任务,每个任务都像一段独立的小程序,有自己的栈空间,自己的执行现场。
但CPU同一时刻只能执行同一个任务。所谓任务切换,就是:
CPU暂停当前任务,保存它的运行现场,再恢复另一个的任务的现场,让那个任务继续运行
这个过程也叫上下文切换(Context Switch)。
从任务自身角度来说,它不知道切换发生过,只感觉自己一直在运行,切换是内核和硬件悄悄完成的。
举个例子:
- 你在写
TaskA的代码,写到一半被打断 - 内核把你的“写到哪了、变量是多少、栈顶在哪”记下来
- 换
TaskB运行一段时间 - 再切回 TaskA 时,从上次停下的地方继续写
这个“记下来”和“恢复回来”,就是任务切换。
切换时需要保存和恢复内容:
任务切换,本质是保存旧任务现场,恢复新任务的现场。
什么是"现场"
现场就是任务被切走时,CPU和任务栈里与执行相关的关键信息。主要包括:
| 内容 | 作用 |
|---|---|
| 程序计数器PC | 记录任务执行到哪条指令 |
| 栈指针SP | 当前任务栈的位置 |
| 通用寄存器 | 保存任务运行时的中间数据 |
| 状态寄存器xPSR | 保存CPU状态信息 |
在FreeRTOS,这些现场主要保存在两处:
- 任务栈里
- TCB的
pxTop0fStack中
TCB第一个成员就是栈顶指针:
1volatileStackType_t*pxTopOfStack;/* Points to the location of the las切换时,旧任务的栈顶地址会写回这里,方便下次恢复。
Cortex-M3 上哪些寄存器要保存
本工程使用的是 ARM Cortex-M3,相关代码在port.c。
进入中断时,硬件会自动把一部分寄存器压栈:
| 寄存器 | 是否硬件自动保存 |
|---|---|
| R0 | 是 |
| R1 | 是 |
| R2 | 是 |
| R3 | 是 |
| R12 | 是 |
| LR | 是 |
| PC | 是 |
| xPSR | 是 |
| R4 ~ R11 | 否,软件保存 |
所以在xPortPendSVHandler()里,软件只需要保存 R4 ~ R11:
1 mrs r0, psp 2 isb 3 4 ldr r3, =pxCurrentTCB /* Get the location of the current TCB. */ 5 ldr r2, [r3] 6 7 stmdb r0!, {r4-r11} /* Save the remaining registers. */ 8 str r0, [r2] /* Save the new top of stack into the first恢复时,再把它们弹回来:
1 ldr r1, [r3] 2 ldr r0, [r1] /* The first item in pxCurrentTCB is the ta 3 ldmia r0!, {r4-r11} /* Pop the registers and the critical nestin 4 msr psp, r0 5 isb任务栈里保存的内容
任务创建时,内核会先把栈布置成“像刚从异常返回一样”。对应函数是pxPortInitialiseStack():
1StackType_t*pxPortInitialiseStack(StackType_t*pxTopOfStack,TaskFunction_t pxCode,void*pvP2{34pxTopOfStack--;5*pxTopOfStack=portINITIAL_XPSR;6pxTopOfStack--;7*pxTopOfStack=((StackType_t)pxCode)&portSTART_ADDRESS_MASK;8pxTopOfStack--;9*pxTopOfStack=(StackType_t)prvTaskExitError;/* 对应压入LR */10pxTopOfStack-=5;11*pxTopOfStack=(StackType_t)pvParameters;/* 对应硬件保存5个寄存器 */12pxTopOfStack-=8;/* 留8个位置给软件手动压栈 */1314returnpxTopOfStack;15}栈帧布局如下:
所以一次完整切换,实际上保存的是:
硬件保存:R0,R1,R2,R3,R12,LR,PC,xPSR 软件保存:R4~R11 TCB记录:pxTopOfStack恢复现场数据流向
当调度器决定要运行 TaskB 时,它会找到 TaskB 的 TCB,取出里面的pxTopOfStack(也就是 TaskB 的栈顶指针,指向 TaskB 在 RAM 里的那块区域)。
接下来,FreeRTOS 的PendSV异常服务程序(汇编代码)会做以下事情:
- 软件出栈(R4 ~ R11):执行
ldmia r0!, {r4-r11}。这句汇编的意思是:把r0指向的 RAM 地址里的数据,连续装载(Load)到 CPU 的R4到R11寄存器里,并同步更新r0的地址。 - 修改 SP 指针:把更新后的地址写进硬件寄存器
PSP(进程栈指针)。 - 硬件出栈(R0~R3, R12, LR, PC, xPSR):当汇编指令执行到最后,触发异常返回(比如
bx r14)。此时,Cortex-M3 的硬件会自动接管,它会顺着PSP指向的 RAM 地址,自动把剩下的 8 个寄存器(R0~R3, R12, LR, PC, xPSR)的值弹回 CPU 中。
任务指针,TCB
当前运行的任务指针
除了寄存器和栈,内核还要知道“当前是谁在运行”。
这个指针就是:
1PRIVILEGED_DATA TCB_t*volatilepxCurrentTCB=NULL;切换前:
1 pxCurrentTCB -> TaskA切换后:
1 pxCurrentTCB -> TaskB所以任务切换时,真正变化的核心内容可以总结为:
| 项目 | 切换前 | 切换后 |
|---|---|---|
| 当前任务 | TaskA | TaskB |
| 栈顶指针 | TaskA 的栈顶 | TaskB 的栈顶 |
| CPU 寄存器 | TaskA 的现场 | TaskB 的现场 |
任务管理块TCB:
FreeRTOS 每创建一个任务,内核都会为它分配一块管理结构,叫做TCB(Task Control Block,任务控制块)。
可以把 TCB 理解成任务的“档案”:内核靠它知道任务是谁、优先级多少、栈在哪、当前处于什么状态。
源码中类型名是TCB_t,定义在tasks.c。
TCB 里最重要的几个成员
1typedefstructtskTaskControlBlock2{3volatileStackType_t*pxTopOfStack;/* 任务栈顶指针 */4ListItem_t xStateListItem;/* 状态链表节点 */5ListItem_t xEventListItem;/* 事件链表节点 */6UBaseType_t uxPriority;/* 任务优先级 */7StackType_t*pxStack;/* 任务栈起始地址 */8charpcTaskName[16];/* 任务名 */9...10}TCB_t;pxTopOfStack——任务栈顶
是FreeRTOS定义在RAM内存里的一个软件变量(指针),这是TCB里最重要的成员,而且必须是结构体第一个成员。
作用:
- 记录任务栈当前用到哪里
- 任务切换时,保存/恢复栈顶就靠它
- 本质上是:保存SP的值
任务切换时,汇编代码会直接读取TCB第一个成员:
ldr r0, [r1] /* 从TCB取出栈顶 */xStateListItem——状态节点
嵌在TCB里链表节点,表示任务当前状态、
任务处于不同状态时,会被挂到不同链表:
| 状态 | 挂到哪个链表 |
|---|---|
| 就绪 | 就绪链表 |
| 阻塞 | 延时链表 |
| 挂起 | 挂起链表 |
xEventListItem— 事件节点
也是嵌在 TCB 里的链表节点,表示任务是否在等待事件。
比如:
- 等队列数据
- 等信号量
一个任务可以同时: - 用
xStateListItem挂到延时链表(等超时) - 用
xEventListItem挂到队列等待链表(等数据)
uxPriority— 优先级
任务优先级,数字越大优先级越高。
调度器会优先运行优先级高的任务。
pxStack— 栈起始地址
记录任务栈从哪里开始。
和pxTopOfStack的区别:
| 成员 | 含义 |
|---|---|
pxStack | 栈底 |
pxTopOfStack | 当前栈顶 |
pcTaskName— 任务名
任务名字,主要用于调试。
几个关键关系
任务句柄就是 TCB 指针
1TaskHandle_t xHandle;2xTaskCreate(Task1,"Task1",128,NULL,1,&xHandle);这里的xHandle,本质上就是:
1TaskHandle_t->TCB*当前运行任务
内核里有一个全局指针:
1TCB_t*volatilepxCurrentTCB=NULL;它指向当前正在运行的任务 TCB。
任务切换完成后,pxCurrentTCB就会指向新的任务。
TCB 和任务栈
每个任务都有两部分:
1 TCB -> 管理信息 2 任务栈 -> 运行时数据关系如下:
1TCB2├─ pxStack->栈底3└─ pxTopOfStack->当前栈顶TCB 整体结构
1TCB2├─ pxTopOfStack 栈顶,任务切换时用3├─ xStateListItem 状态节点,挂就绪/延时/挂起链表4├─ xEventListItem 事件节点,挂队列/信号量链表5├─ uxPriority 优先级6├─ pxStack 栈起始地址7└─ pcTaskName 任务名总结
TCB 就是 FreeRTOS 管理任务的核心结构,每个任务一个 TCB。
最需要记住的 3 点:
pxTopOfStack:任务切换的关键xStateListItem/xEventListItem:任务挂到内核链表上的“挂钩”pxCurrentTCB:指向当前正在运行的任务
一句话总结:
TCB 是任务档案,任务栈是运行空间,链表节点是排队位置。
2.当前任务与就绪链表
pxCurrentTCB作用
内核里有一个全局指针
PRIVILEGED_DATA TCB_t * volatile pxCurrentTCB = NULL;它指向当前正在CPU上运行的任务TCB
pxCurrentTCB————>某个任务TCB作用很简单:告诉内核“现在是谁在跑”
| 场景 | pxCurrentTCB 指向 |
|---|---|
| TaskA 正在运行 | TaskA 的 TCB |
| 切换到 TaskB 后 | TaskB 的 TCB |
| 调度器还没启动 | NULL |
任务切换完成后,pxCurrentTCB会更新为新任务:
1taskSELECT_HIGHEST_PRIORITY_TASK();// 内部会更新 pxCurrentTCBPendSV 切换任务时,也是通过它找到新任务的栈:
1 ldr r1, [r3] // 读取 pxCurrentTCB 2 ldr r0, [r1] // 读取 pxTopOfStackpxReadyTasksLists[]就绪链表
所有准备好、可以随时运行的任务,都放在就绪链表里。
1PRIVILEGED_DATAstaticList_t pxReadyTasksLists[configMAX_PRIORITIES]={0};这是一个数组,每个优先级对应一条链表:
1 pxReadyTasksLists[0] -> 优先级 0 的就绪任务 2 pxReadyTasksLists[1] -> 优先级 1 的就绪任务 3 pxReadyTasksLists[2] -> 优先级 2 的就绪任务 4 ... 5 pxReadyTasksLists[55] -> 优先级 55 的就绪任务要点:
- 一个优先级一条链表
- 同优先级多个任务挂在同一条链表里
- 调度器选任务时,只从就绪链表里选
任务如何进入就绪链表
从xTaskCreate()开始看最清楚。
创建任务的入口
1xTaskCreate(Task1,"Task1",128,NULL,1,NULL);这个函数内部主要做 3 件事:
1prvInitialiseNewTask(pxTaskCode,pcName,(uint32_t)usStackDepth,pvParameters,uxPriority,pxCreatedTask,pxNewTCB,NULL);/* 初始化TCB */2prvAddNewTaskToReadyList(pxNewTCB);/* 挂入就绪链表 */流程如下:
1 xTaskCreate() 2 | 3 |-- ① 分配 TCB 和任务栈 4 |-- ② prvInitialiseNewTask() 初始化 TCB 5 └-- ③ prvAddNewTaskToReadyList() 挂入就绪链表第一步:分配内存
pxNewTCB = pvPortMalloc(sizeof(TCB_t));//分配TCB pxNewTCB -> pxStack = pvPortMalloc(栈大小);//分配任务栈每个任务都需要两块内存:
TCB ————> 管理信息 任务栈 ————> 运行数据第二步:初始化 TCBprvInitialiseNewTask()负责把 TCB 填好,主要工作:
1pxNewTCB->uxPriority=uxPriority;// 设置优先级2strcpy(pxNewTCB->pcTaskName,pcName);// 设置任务名34vListInitialiseItem(&(pxNewTCB->xStateListItem));// 初始化状态节点5vListInitialiseItem(&(pxNewTCB->xEventListItem));// 初始化事件节点67listSET_LIST_ITEM_OWNER(&(pxNewTCB->xStateListItem),pxNewTCB);8listSET_LIST_ITEM_OWNER(&(pxNewTCB->xEventListItem),pxNewTCB);910pxNewTCB->pxTopOfStack=pxPortInitialiseStack(...);// 初始化任务栈第三步:挂入就绪链表prvAddNewTaskToReadyList()负责把新任务挂到就绪链表:
1prvAddTaskToReadyList(pxNewTCB);这个函数里还会处理pxCurrentTCB:
1if(pxCurrentTCB==NULL)2{3pxCurrentTCB=pxNewTCB;- 第一个创建的任务,会直接成为
pxCurrentTCB - 同时初始化所有内核链表(就绪链表、延时链表等)
真正挂链:prvAddTaskToReadyList()
1#defineprvAddTaskToReadyList(pxTCB)\2taskRECORD_READY_PRIORITY((pxTCB)->uxPriority);\3vListInsertEnd(&(pxReadyTasksLists[(pxTCB)->uxPriority]),&((pxTCB)->xStateListItem));两步:
① 记录最高就绪优先级
1taskRECORD_READY_PRIORITY(pxTCB->uxPriority);例如优先级 2 的任务就绪,就记住“当前最高就绪优先级至少是 2”。
② 挂到对应优先级链表
1vListInsertEnd(&(pxReadyTasksLists[uxPriority]),&(pxTCB->xStateListItem));把任务的xStateListItem插到对应优先级的就绪链表里。
完整创建流程
以创建一个优先级为 1 的任务为例:
1 xTaskCreate(Task1, "Task1", 128, NULL, 1, NULL) 2 | 3 |-- 分配 TCB + 栈/* 本质只是分配内存 */ 4 | 5 |-- prvInitialiseNewTask() 6 | |-- 设置优先级 = 1 7 | |-- 设置任务名 = "Task1" 8 | |-- 初始化 xStateListItem 9 | |-- 初始化任务栈 10 | 11 |-- prvAddNewTaskToReadyList() 12 |-- pxCurrentTCB = 新任务 (如果是第一个任务) 13 |-- taskRECORD_READY_PRIORITY(1) /* 记录最高就绪优先级 */ 14 |-- vListInsertEnd( pxReadyTasksLists[1], xStateListItem ) 15 | 16 v 17 pxReadyTasksLists[1]: Task1 <--> 哨兵任务创建完成后,就已经在就绪链表里等着运行了。
其他进入就绪链表的场景
不只是创建任务,以下情况也会进入就绪链表,本质都是调用prvAddTaskToReadyList():
以下是图片中提取的内容:
| 场景 | 说明 |
|---|---|
| 任务创建 | xTaskCreate() |
| 延时结束 | vTaskDelay() 时间到 |
| 等到队列数据 | 队列收到数据,任务被唤醒 |
| 等到信号量 | 信号量释放,任务被唤醒 |
taskSELECT_HIGHEST_PRIORITY_TASK()如何选出下一个任务
任务切换时,vTaskSwitchContext()会调用这个宏,决定下一个运行谁:
1#definetaskSELECT_HIGHEST_PRIORITY_TASK()2{3UBaseType_t uxTopPriority=uxTopReadyPriority;/* */4/* 找到最高优先级非空链表 */5while(listLIST_IS_EMPTY(&(pxReadyTasksLists[uxTopPriority])))6{7--uxTopPriority;8}910listGET_OWNER_OF_NEXT_ENTRY(pxCurrentTCB,&(pxReadyTasksLists[uxTopPriority]));11uxTopReadyPriority=uxTopPriority;12}第 1 步:找最高优先级非空链表
1while(listLIST_IS_EMPTY(&(pxReadyTasksLists[uxTopPriority])))2{3--uxTopPriority;4}- 从记录的最高优先级往下找,直到找到有任务的就绪链表。
- 实际在任务状态更新时,有时uxTopReadyPriority已自动切到下一个最高优先级任务就绪链表(硬件优化),
- 但是当不开启硬件优化时,判空延迟更新uxTopReadyPriority,需要判空来兜底容错。
1uxTopReadyPriority=523pxReadyTasksLists[5]->空,继续4pxReadyTasksLists[4]->空,继续5pxReadyTasksLists[3]->有 TaskA,停止第 2 步:同优先级任务轮转选取
1listGET_OWNER_OF_NEXT_ENTRY(pxCurrentTCB,&(pxReadyTasksLists[uxTopPriority]));在同优先级链表中取下一个任务,更新pxCurrentTCB。同优先级多个任务会轮流执行。
总体流程:
1 xTaskCreate 创建 TaskA 2 | 3 |-- 初始化 TCB 4 |-- prvAddTaskToReadyList() 5 | └-- TaskA 挂入 pxReadyTasksLists[2] 6 | 7 |-- vTaskStartScheduler() 启动调度器 8 | 9 v 10 taskSELECT_HIGHEST_PRIORITY_TASK() 11 | 12 |-- 找到 pxReadyTasksLists[2] 非空 13 |-- 选出 TaskA 14 |-- pxCurrentTCB = TaskA 15 | 16 v 17 TaskA 开始运行3 .任务切换的 C 语言层
vTaskSwitchContext()的作用
PendSV 中断里会调用这个函数:
1 bl vTaskSwitchContext源码如下:
1voidvTaskSwitchContext(void)2{3if(uxSchedulerSuspended!=(UBaseType_t)pdFALSE)/* 调度器是否被挂起 */4{5xYieldPending=pdTRUE;6}7else8{9xYieldPending=pdFALSE;10taskCHECK_FOR_STACK_OVERFLOW();/* 检查栈是否溢出 */11taskSELECT_HIGHEST_PRIORITY_TASK();/* 选出下一个任务,更新`pxCurrentTCB` */12}13}它做什么vTaskSwitchContext()不负责保存/恢复寄存器,只做三件事:
| 步骤 | 代码 | 作用 |
|---|---|---|
| 1 | 检查调度器是否挂起 | 挂起时不切换 |
| 2 | taskCHECK_FOR_STACK_OVERFLOW() | 检查栈是否溢出 |
| 3 | taskSELECT_HIGHEST_PRIORITY_TASK() | 选出下一个任务,更新pxCurrentTCB |
它不做什么
- 不操作 PSP 栈指针
- 不保存 R4-R11
- 不恢复寄存器
这些都由 PendSV 的汇编代码完成。
在切换流程中的位置
1 PendSV 中断 2 | 3 |-- 保存旧任务 R4-R11 <- 汇编层 4 | 5 |-- vTaskSwitchContext() <- C 语言层 6 | └-- taskSELECT_HIGHEST_PRIORITY_TASK() 7 | └-- pxCurrentTCB = 新任务 8 | 9 └-- 恢复新任务 R4-R11 <- 汇编层一句话:
C 层负责“选题”,汇编层负责“换场”。
调度器挂起时为什么不切换
什么是调度器挂起
调用vTaskSuspendAll会挂起调度器
1 void vTaskSuspendAll( void ) 2 { 3 ++uxSchedulerSuspended; 4 }uxSchedulerSuspended是挂起调度器:
- 每调用一次
vTaskSuspendAll(),计数 +1 - 每调用一次
vTaskResumeAll(),计数 -1 - 计数为0时,调度器恢复正常。
挂起时为什么不切换
调度器挂起期间,内核可能在批量修改任务链表。如果这时任务发生任务切换,链表可能处于不完整状态,导致系统出错。
所以vTaskSwitchContext()里第一件事开始检查:
1 if( uxSchedulerSuspended != pdFALSE ) 2 { 3 xYieldPending = pdTRUE; // 不切换,只记个标记 4 } 5 else 6 { 7 taskSELECT_HIGHEST_PRIORITY_TASK(); // 正常切换 8 }挂起期间:切换请求被延迟,不会立刻执行。
xYieldPending作用
1 static volatile BaseType_t xYieldPending = pdFALSE;一个延迟切换标记:
- pdFALSE:没有待处理的切换请求
- pdTRUE:有切换请求,但当前没执行,先记录
什么时候置位
情况1:调度器挂起期间收到切换请求
1 if( uxSchedulerSuspended != pdFALSE ) 2 { 3 xYieldPending = pdTRUE; // 现在不能切,先记着 4 }情况2:调度器恢复后,发现有更高优先级任务
1 if( pxTCB->uxPriority > pxCurrentTCB->uxPriority ) 2 { 3 xYieldPending = pdTRUE; 4 }情况3:挂起期间积累的tick需要切换
1 if( xTaskIncrementTick() != pdFALSE ) 2 { 3 xYieldPending = pdTRUE; 4 }什么时候执行切换
调度器恢复后,vTaskResumeAll()会检查这个标记:
1 if( xYieldPending != pdFALSE ) 2 { 3 #if( configUSE_PREEMPTION != 0 ) 4 { 5 xAlreadyYielded = pdTRUE; 6 } 7 #endif 8 taskYIELD_IF_USING_PREEMPTION(); 9 }如果xYieldPending= pdTRUE,就触发真正的任务切换。
SysTick里也会检查:
1 if( xYieldPending != pdFALSE ) 2 { 3 xSwitchRequired = pdTRUE; // 触发 PendSV 4 }完整流程示例:
1 调度器正常运行 2 | 3 |-- vTaskSuspendAll() uxSchedulerSuspended = 1 4 | 5 |-- 期间某操作需要切换 6 | └-- vTaskSwitchContext() 7 | └-- xYieldPending = pdTRUE (不切换,只标记) 8 | 9 |-- xTaskResumeAll() uxSchedulerSuspended = 0 10 └-- 检查 xYieldPending == pdTRUE 11 └-- taskYIELD_IF_USING_PREEMPTION() 12 └-- 触发 PendSV, 真正切换 13 | 14 v 15 新任务开始运行三个变量关系
1 uxSchedulerSuspended -> 调度器是否挂起 2 xYieldPending -> 是否有待处理的切换请求 3 pxCurrentTCB -> 当前运行的是哪个任务| 变量 | 作用 |
|---|---|
uxSchedulerSuspended | 控制能不能切换 |
xYieldPending | 记录有没有切换请求被延迟 |
pxCurrentTCB | 记录当前运行谁 |
配合关系:
1 切换请求到来 2 | 3 |-- 调度器未挂起 -> 立刻切换,更新 pxCurrentTCB 4 | 5 └-- 调度器已挂起 -> xYieldPending = pdTRUE,等恢复后再切一句话总结:
vTaskSwitchContext()负责选题;调度器挂起时切换会被推迟,并用xYieldPending记住这个请求,等调度器恢复后再执行。