LK 博客
RTOS设计与开发(2):PendSV 作为上下文切换异常
嵌入式
约 1 分钟阅读 0 赞 0 条评论 鸿蒙黑体

RTOS设计与开发(2):PendSV 作为上下文切换异常

Yukikaze
Yukikaze @Yukikaze
累计点赞 0 登录后每个账号只能点一次
内容长度 0 正文词元数
正文
目录会跟随阅读位置移动。
阅读进度

一、这一部分要解决什么问题

在 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-R3
  • R12
  • LR
  • PC
  • xPSR

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-R3
  • R12
  • LR
  • PC
  • xPSR

时刻 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-R3
  • R12
  • LR
  • PC
  • xPSR

然后进入 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-R3
  • R12
  • LR
  • PC
  • xPSR

第九步:任务 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 去哪。

作者名片

Yukikaze
Yukikaze
@Yukikaze

私にできることなら何でもするから

评论区
文章作者和管理员都可以管理这里的评论。
0 条评论
登录后即可参与评论。 去登录
还没有评论,欢迎留下第一条交流内容。