
RTOS设计与开发(2):PendSV 作为上下文切换异常
一、这一部分要解决什么问题
在 Cortex-M 的 RTOS 里,任务切换一般不是“直接跳到另一个函数”,而是:
保存当前任务上下文 → 切换栈指针 → 恢复下一个任务上下文
而完成这件事的标准套路就是:
- SysTick:更新时间基准,判断是否需要调度
- PendSV:真正执行上下文切换
所以这部分的核心问题其实只有一个:
为什么 Cortex-M 要把“是否切换”和“真正切换”拆成 SysTick + PendSV 两步?
二、前置基础:这一切建立在什么之上
在学习 PendSV 之前,必须先会这些基础:
1. 两种模式
- Thread mode:普通任务运行时所处的模式
- Handler mode:异常处理函数运行时所处的模式
2. 两套栈指针
- MSP(Main Stack Pointer):异常处理通常使用
- PSP(Process Stack Pointer):任务运行通常使用
3. 异常进入时硬件自动压栈的寄存器
发生异常时,CPU 会自动把下面 8 个寄存器压入当前任务栈:
R0-R3R12LRPCxPSR
4. 仍需软件手动保存的寄存器
硬件不会自动保存:
R4-R11
所以 RTOS 必须在 PendSV 中手动保存和恢复它们。
5. EXC_RETURN
异常处理中的LR常常不是普通返回地址,而是一个特殊值:
EXC_RETURN
它用于告诉 CPU:
- 这是一次异常返回
- 返回到 Thread mode 还是 Handler mode
- 返回时用 MSP 还是 PSP
三、为什么 PendSV 适合做任务切换
PendSV 最大的特点不是“它也是异常”,而是:
它可以被挂起(pending),并且通常被设置为最低优先级。
这使它特别适合做任务切换,因为:
1. 切换动作可以延后
调度请求可以先挂起,不必立刻执行。
2. 不会抢占更重要的中断
高优先级中断先处理完,最后再统一切任务,系统更稳。
3. 多个“想切换”的请求可以合并
哪怕多个地方都提出“该切换了”,最终往往只需要一次 PendSV。
所以一句话概括:
PendSV 不是最紧急的异常,但它是最适合做上下文切换的异常。
四、SysTick 和 PendSV 的分工
SysTick 干什么
SysTick 是系统节拍中断,常常每隔固定时间触发一次,比如 1ms。
它负责:
tick++- 更新时间片
- 处理延时计数
- 判断是否需要重新调度
但是它通常不直接切任务。
PendSV 干什么
PendSV 真正负责:
- 保存当前任务上下文
- 切换到下一个任务的栈
- 恢复下一个任务上下文
所以更准确地说:
SysTick 负责提出“该不该切”的问题,PendSV 负责完成“怎么切”的动作。
五、什么是 TCB
TCB 全称:
- Task Control Block(任务控制块)
你可以把它理解成:
RTOS 给每个任务准备的一份“小档案”
里面通常会有:
- 任务当前保存的栈指针(最关键)
- 任务状态
- 优先级
- 任务名
- 栈边界或栈大小等信息
最关键的是:
TCB 至少要记住这个任务当前的 PSP 值
因为:
- PSP 指向任务自己的栈
- 栈里保存着这个任务的上下文
- 有了 PSP,才能恢复这个任务
所以可以记成一句特别重要的话:
TCB 给出入口,PSP 给出位置,栈里保存内容。
六、任务切换的本质是什么
任务切换本质上不是“跳转到另一个任务函数”,而是:
切换 PSP,并按新 PSP 所指向的任务栈恢复上下文。
具体来说:
当前任务 A 被切走时
- 硬件自动压
R0-R3,R12,LR,PC,xPSR - PendSV 手动压
R4-R11 - 保存当前 PSP 到 A 的 TCB
下一个任务 B 被切入时
- 从 B 的 TCB 取出它保存的 PSP
- 把
PSP = PSP_B - 手动恢复
R4-R11 - 异常返回时,硬件自动恢复剩下的 8 个寄存器
所以最核心的一句话是:
“从任务 A 切到任务 B”的真正分水岭,是把 B 的 PSP 装入 PSP 寄存器。
七、pending 到底是什么意思
pending 的意思不是“正在执行”,而是:
已经提出请求,但还没有真正执行。
对于 PendSV 来说:
- 设置 pending = “待会做一次上下文切换”
- 不是立刻跳进
PendSV_Handler
常见软件触发方式:
SCB->ICSR = SCB_ICSR_PENDSVSET_Msk;
这句代码的含义是:
登记一次 PendSV 请求,让它进入待处理状态。
所以它做的不是“立即切任务”,而是:
先挂起,等时机合适再执行。
八、为什么要拆成 SysTick + PendSV 两步
因为上下文切换最好:
等别的高优先级中断都处理完之后,再统一做。
这样做有两个直接好处:
1. 避免在高优先级中断里切任务
否则切换过程会被夹在各种异常之间,逻辑变乱。
2. 合并多次切换请求
多个地方都可以提出“该调度了”,但真正执行切换只需一次 PendSV。
所以:
SysTick + PendSV 不是一个异常,而是一套完整的任务切换机制。
其中:
- SysTick 是一个独立异常
- PendSV 是另一个独立异常
- 它们经常前后配合
九、tail-chaining 是什么
tail-chaining 是 Cortex-M 异常机制中的一个高效设计:
前一个异常刚结束,CPU 发现马上还有下一个待处理异常,于是直接进入下一个异常,而不先回 Thread mode 兜一圈。
在 RTOS 里的典型表现就是:
任务A -> SysTick -> PendSV -> 任务B
而不是:
任务A -> SysTick -> 回任务A -> 再进PendSV -> 任务B
它省掉的是:
SysTick 结束后先回任务 A,再重新进 PendSV 这一步
所以 tail-chaining 的意义是:
- 减少异常切换开销
- 提高任务调度效率
- 特别适合 RTOS 这种频繁调度场景
十、完整时序:任务 A 切换到任务 B
下面是一次完整任务切换的标准过程。
时刻 1:任务 A 运行
- 处于 Thread mode
- 使用 PSP
- A 正常执行
时刻 2:SysTick 到来
- 硬件自动把 A 的
R0-R3,R12,LR,PC,xPSR压入 A 的 PSP CPU 进入 Handler mode 使用 MSP 运行SysTick_Handler
时刻 3:SysTick 判断需要调度
例如:
- 时间片耗尽
- 更高优先级任务 ready
- 延时任务到期
于是置位 PendSV pending。
时刻 4:SysTick 结束
若没有更高优先级异常挡着,CPU 通过 tail-chaining 直接进入 PendSV。
时刻 5:PendSV 保存任务 A 的剩余上下文
- 读取当前 PSP
- 手动把
R4-R11压入 A 的 PSP - 此时 A 的完整上下文都已保存在 A 的任务栈中
时刻 6:保存 A 的 PSP 到 A 的 TCB
current_tcb->sp = PSP;
时刻 7:调度器选出任务 B
- 找到 B 的 TCB
- 取出它保存的 PSP
时刻 8:装入 B 的 PSP
PSP = next_tcb->sp;
这一刻开始,恢复动作的对象就从 A 变成了 B。
时刻 9:恢复任务 B 的 R4-R11
PendSV 手动恢复 B 的高寄存器。
时刻 10:异常返回
此时 LR = EXC_RETURN,CPU 自动从 B 的 PSP 指向的异常栈帧中恢复:
R0-R3R12LRPCxPSR
时刻 11:任务 B 继续运行
- 返回 Thread mode
- 使用 PSP
- 从 B 上次停下来的位置继续跑
十一、第一次启动任务,为什么也能走这套模型
第一次启动任务时,CPU其实并不知道这是“第一次”。
它只认一件事:
当前 PSP 指向的栈中是否有一份合法的异常返回现场。
所以 RTOS 在创建任务时,会提前在任务栈中伪造一份初始上下文,通常包括:
PC= 任务入口函数地址xPSR= 合法初值LR= 任务退出兜底函数- 以及其他寄存器初值
然后把这个初始 PSP 存进任务的 TCB。
以后当调度器第一次选中这个任务时:
PSP = tcb->sp- 恢复上下文
- 异常返回
- CPU 根据伪造好的 PC 开始执行任务入口函数
所以第一次启动任务的本质是:
把“第一次运行”伪装成“一次可恢复的现场”
因此更准确的说法是:
第一次启动任务,不一定通过 PendSV 进入,但它复用了与 PendSV 切换相同的“恢复上下文 + 异常返回”思想。
十二、为什么任务函数一般不能 return
任务函数表面上像普通函数,但本质不是被普通函数调用进去的。
它不是这样进入的:
TaskA();
而是通过:
- 预造初始栈帧
- 恢复上下文
- 异常返回
- 按 PC 跑到任务入口
所以它没有天然合法的“普通函数返回路径”。
如果任务函数 return,会发生什么
CPU 会根据:
任务自己的 LR
决定跳去哪。
注意这里的 LR 是:
- 任务上下文中的 LR
不是异常处理中的 EXC_RETURN。
为了防止任务误 return,RTOS 会在创建任务时把任务初始 LR 设成一个兜底函数,比如:
- 报错函数
- 死循环函数
- assert 失败处理函数
所以一句很重要的话是:
任务入口靠 PC 启动,任务退出看 LR 去哪。
十三、案例:从任务 A 切到任务 B
下面用一个小案例把这章串起来。
场景设定
系统里有两个任务:
任务 A
- 正在运行
- 优先级较低
- 使用自己的 PSP_A
任务 B
- 已经 ready
- 优先级比 A 高
- 之前已经运行过一次,所以它的 TCB 中保存了 PSP_B
过程展开
第一步:任务 A 正在运行
当前:
- Thread mode
- PSP = PSP_A
- A 正在执行自己的代码
第二步:SysTick 到来
CPU 自动把 A 的基础现场压入 PSP_A:
R0-R3R12LRPCxPSR
然后进入 SysTick_Handler。
第三步:SysTick 发现 B 可以抢占 A
调度器判断:
- B 优先级更高
- 应该切换到 B
于是 SysTick 不直接切,而是:
SCB->ICSR = SCB_ICSR_PENDSVSET_Msk;
PendSV 被挂起。
第四步:SysTick 结束,tail-chaining 到 PendSV
因为没有更高优先级异常,CPU 不先回 A,而是直接进入 PendSV_Handler。
第五步:PendSV 保存 A 的完整上下文
PendSV 手动把 A 的 R4-R11 压入 PSP_A。
然后保存:
A_TCB->sp = PSP_A;
现在 A 的完整现场已经存进它自己的任务栈里,并且位置记录在 A 的 TCB 中。
第六步:切到 B
调度器决定下一个任务是 B,于是:
PSP = B_TCB->sp;
这一句是整个切换的核心分水岭。
它意味着:
从现在起,要恢复的已经不是 A,而是 B 了。
第七步:PendSV 恢复 B 的 R4-R11
PendSV 根据 PSP_B 手动恢复 B 的高寄存器。
第八步:异常返回
CPU 通过 EXC_RETURN 进入异常返回流程,从 B 的异常栈帧中自动恢复:
R0-R3R12LRPCxPSR
第九步:任务 B 继续运行
CPU 进入 Thread mode,开始从 B 的 PC 所指向的位置继续执行。
这不是“调用了 B”,而是:
恢复了 B。
这个案例最值得记住的点
1. 保存 A 只是“结束旧任务”
A_TCB->sp = PSP;
2. 真正切到 B 的关键动作是
PSP = B_TCB->sp;
3. 任务切换本质上是
- 保存上下文
- 切换栈指针
- 恢复上下文
而不是普通函数跳转。
十四、常见误区整理
误区 1:PendSV 和 SysTick 是一个异常
错。
它们是两个独立异常,只是在 RTOS 中经常前后配合。
误区 2:PendSV 在 Handler mode 用 MSP,所以任务上下文也保存在 MSP
错。
PendSV 自己运行在 Handler mode,确实用 MSP; 但它保存的是任务上下文,所以必须保存到任务自己的 PSP。
误区 3:切换任务就是切 TCB
不够准确。
更准确地说:
TCB 是索引入口,真正的上下文在任务栈里,PendSV 通过 TCB 找到 PSP,再恢复任务。
误区 4:第一次启动任务是直接调用任务函数
错。
第一次启动本质上是:
预造初始栈帧 → 恢复上下文 → 异常返回 → 从 PC 指向的入口开始运行
误区 5:任务函数 return 会正常返回给“调用者”
错。
任务不是被普通函数调用进去的,return 后会根据任务自己的 LR 去跳,通常会进入 RTOS 的错误兜底逻辑,或者直接跑飞。
十五、这一章最核心的几句话
建议直接背下来:
1
SysTick 负责发现“该不该切”,PendSV 负责完成“怎么切”。
2
一个任务的完整上下文必须完整地保存在它自己的 PSP 栈里。
3
TCB 记录任务保存下来的 PSP,PSP 指向任务栈,任务栈中保存真正的上下文。
4
任务切换不是函数跳转,而是上下文保存与恢复。
5
从任务 A 切到任务 B 的真正分水岭,是把 B 的 PSP 装入 PSP 寄存器。
6
第一次启动任务,本质上是伪造一个可恢复的初始上下文。
7
任务入口靠 PC 启动,任务退出看 LR 去哪。