从 FreeRTOS 源码,洞察任务切换到底是怎么发生的?
用过 FreeRTOS 的朋友都知道,多个任务能”同时”运行。但你有没有想过,CPU 明明只有一个,它是怎么做到让多个任务轮流执行的?今天我们就来扒开源码,看看任务切换这件事到底是怎么发生的。
先打个比方:任务切换就像换班
想象一下工厂里的流水线。
张三正在岗位上干活,突然广播响了:”张三下班,李四上岗!”
张三不能直接拍拍屁股走人。他得先把手头的活记下来——做到哪一步了、工具放在哪、材料还剩多少。这些信息写在交接本上,然后才能离开。
李四来了,也不能直接开干。他得先翻开自己上次的交接本,看看之前做到哪了,把工具和材料恢复到上次的状态,然后才能继续。
FreeRTOS 的任务切换,干的就是这个事:
-
• 保存现场:把当前任务的”工作状态”存起来 -
• 恢复现场:把下一个任务的”工作状态”拿出来
所谓的”工作状态”,在 CPU 里就是一堆寄存器的值。这个过程,专业点叫”上下文切换”(Context Switch)。
任务的本质:一个函数 + 一块栈 + 一个控制块
在聊切换之前,得先搞清楚”任务”到底是什么。
你调用 xTaskCreate() 创建任务时,FreeRTOS 在背后做了三件事:
-
1. 分配一个任务控制块(TCB):记录任务的名字、优先级、状态等信息 -
2. 分配一块栈空间:任务运行时用来保存局部变量、函数调用关系、以及上下文 -
3. 初始化栈:在栈里预先填好”假的”寄存器值,让任务第一次被调度时能正常启动
TCB 里有个关键成员:pxTopOfStack——它指向任务栈的栈顶。任务切换时,就是通过这个指针来保存和恢复上下文的。
简单说:任务 = 函数入口 + 独立的栈 + TCB 管理信息。切换任务,本质上就是切换栈指针,让 CPU 去执行另一个栈上保存的代码流程。
什么时候会发生任务切换?
任务切换不会凭空发生,总得有个触发条件。常见的场景有这么几种:
1. 时间片轮转(Tick 中断)
FreeRTOS 有个心跳定时器,默认每 1ms 跳一次(可配置)。每次 Tick 中断,系统会检查:有没有同优先级的任务在排队?有的话就轮换一下。
2. 高优先级任务就绪
低优先级任务正在跑,突然一个高优先级任务被唤醒了(比如收到了信号量)。这时候必须立即切换,让高优先级的先跑。
3. 当前任务主动让出 CPU
任务调用 vTaskDelay()、xQueueReceive() 等阻塞 API 时,自己把自己挂起了,CPU 当然得切给别人。
4. 当前任务被挂起或删除
调用 vTaskSuspend() 或 vTaskDelete() 后,当前任务没法继续了,必须切换。
5. 任务优先级被修改
运行中途通过 vTaskPrioritySet() 改了优先级,可能触发重新调度。
不管是哪种情况,最终都会调用到一个关键函数:portYIELD()。它的作用就是告诉系统:”我要触发一次任务切换”。
为什么用 PendSV 来做切换?
这里要解释一个设计选择。
在 ARM Cortex-M 上,FreeRTOS 用 PendSV 中断 来完成任务切换。为啥不直接在 Tick 中断里切换呢?
原因是:中断可能嵌套。
假设这样一个场景:Tick 中断触发,正准备切换任务,突然来了个更高优先级的外设中断。如果在 Tick 里直接切换上下文,这时候栈的状态会乱套——你还没保存完,新中断又压了一堆东西进来。
PendSV 的妙处在于:它的优先级被设成最低。
这意味着:只有当所有其他中断都处理完了,PendSV 才会执行。在 PendSV 里做上下文切换,能保证不会被其他中断打断,栈的状态是干净的。
工作流程是这样的:

一句话总结:Tick 负责”决定要不要切”,PendSV 负责”真正去切”。
核心流程:上下文切换三步走
现在进入正题。PendSV 中断里到底做了什么?
整个上下文切换可以拆成三步:

这里有个细节要注意:ARM Cortex-M 在进入中断时,硬件会自动保存一部分寄存器(R0-R3、R12、LR、PC、xPSR),这部分不用软件操心。软件需要手动保存的是 R4-R11 这几个寄存器。
退出中断时同理:硬件会自动恢复那几个寄存器,软件只需要恢复 R4-R11。
源码解析:PendSV 中断处理函数
下面是 Cortex-M3/M4 上 PendSV 的核心汇编代码(精简版):
PendSV_Handler: ; 关中断,防止切换过程被打断 CPSID I ; 获取当前任务的栈指针(PSP) MRS R0, PSP ; 把 R4-R11 压入当前任务的栈 STMDB R0!, {R4-R11} ; 把更新后的栈顶保存到当前 TCB LDR R1, =pxCurrentTCB LDR R2, [R1] STR R0, [R2] ; pxCurrentTCB->pxTopOfStack = R0 ; ========== 分割线:上面保存旧任务,下面恢复新任务 ========== ; 调用 C 函数,选出下一个任务 BL vTaskSwitchContext ; 获取新任务的 TCB LDR R1, =pxCurrentTCB LDR R2, [R1] LDR R0, [R2] ; R0 = 新任务的 pxTopOfStack ; 从新任务的栈弹出 R4-R11 LDMIA R0!, {R4-R11} ; 更新 PSP 为新任务的栈指针 MSR PSP, R0 ; 开中断 CPSIE I ; 返回,硬件自动恢复 R0-R3、PC 等 BX LR
逐段解释:
-
1. CPSID I:关中断。虽然 PendSV 优先级最低,但保险起见还是关掉。 -
2. MRS R0, PSP:读取进程栈指针。FreeRTOS 的任务都用 PSP(进程栈),而不是 MSP(主栈)。 -
3. STMDB R0!, {R4-R11}:把 R4-R11 压栈。STMDB 是”先减后存”,R0 自动更新。 -
4. 保存栈顶到 TCB:这样下次恢复时能找到。 -
5. BL vTaskSwitchContext:调用 C 函数,选出下一个任务,更新 pxCurrentTCB。 -
6. 从新 TCB 读取栈顶:准备恢复新任务的上下文。 -
7. LDMIA R0!, {R4-R11}:从栈弹出 R4-R11。LDMIA 是”先取后加”。 -
8. MSR PSP, R0:更新 PSP,指向新任务的栈。 -
9. BX LR:返回。硬件会根据 LR 的特殊值(EXC_RETURN)自动恢复剩余寄存器。
源码解析:vTaskSwitchContext
PendSV 汇编里调用的 vTaskSwitchContext() 是个 C 函数,它的核心工作就是:找到下一个该运行的任务。
void vTaskSwitchContext( void ){ // 如果调度器被挂起,不允许切换 if( uxSchedulerSuspended != 0 ) { xYieldPending = pdTRUE; return; } // 选择最高优先级就绪任务 taskSELECT_HIGHEST_PRIORITY_TASK();}
taskSELECT_HIGHEST_PRIORITY_TASK 是个宏,展开后大概是这样:
// 找到最高优先级的就绪列表(不为空的)while( listLIST_IS_EMPTY( &pxReadyTasksLists[ uxTopPriority ] ) ){ uxTopPriority--;}// 从这个列表里取下一个任务listGET_OWNER_OF_NEXT_ENTRY( pxCurrentTCB, &pxReadyTasksLists[ uxTopPriority ] );
逻辑很直接:从最高优先级往下找,找到第一个非空的就绪列表,然后从里面取一个任务。如果同优先级有多个任务,就轮流取(时间片轮转)。
执行完这个函数后,全局变量 pxCurrentTCB 就指向了新任务的 TCB。回到汇编代码,就能从新 TCB 里拿到新任务的栈指针,完成切换。
图解:栈里到底存了什么?
为了让你更直观地理解,下面画一下任务栈在切换前后的样子:

要点:
-
• 进入 PendSV 时,硬件已经把 R0-R3、R12、LR、PC、xPSR 压到任务 A 的栈上了 -
• 软件再把 R4-R11 压进去,保存完成 -
• 切换到任务 B 时,软件先恢复 R4-R11 -
• 退出 PendSV 时,硬件自动恢复剩下的寄存器,PC 指向任务 B 之前被中断的位置 -
• 任务 B 就像什么都没发生一样,继续执行
串一遍完整流程
最后,我们把整个任务切换的过程串起来走一遍:

整个过程对任务来说是透明的——任务 A 不知道自己被暂停了,任务 B 也不知道自己是被”复活”的。它们只是在各自的时间线上继续执行,仿佛独占 CPU 一样。
总结
回到开头的问题:任务切换是怎么发生的?
用一句话概括:触发 PendSV 中断,保存当前任务的寄存器到栈,选出下一个任务,从它的栈恢复寄存器,然后继续执行。
核心就是三个字:换栈跑。
如果你觉得这篇文章有帮助,欢迎点赞、转发。有问题也欢迎在评论区讨论,我们下期见!
夜雨聆风
