
RTOS设计与开发(1):从栈开始理解 Cortex-M3 异常模型
一、“从水下的第一个生命的萌芽开始......”
如果想从 0 设计一个运行在 Cortex-M3 上的 RTOS,那么有一部分内容几乎绕不过去:
- 栈到底是什么
- MSP / PSP 是什么
- Thread mode / Handler mode 是什么
- 异常发生时 CPU 自动保存了什么
- 任务切换时为什么还要手动保存 R4-R11
- EXC_RETURN 到底在干什么
一开始看这些名词会非常乱,但它们本质上都在回答同一个问题:
Cortex-M3 遇到异常时,CPU 如何把当前任务停下来,又如何在以后恢复回来。
而 RTOS 的任务切换,本质上就是对这个机制的利用。
二、什么是栈
1. 栈的本质
栈(Stack)本质上是RAM 中的一块按特殊规则使用的内存区域。
这块区域主要用来保存“临时数据”和“执行现场”,例如:
- 局部变量
- 函数调用时的返回信息
- 临时保存的寄存器内容
- 异常/中断发生时的现场信息
栈可以理解成一个“后进先出”的结构,也就是:
- 后放进去的内容
- 先拿出来
这和一摞盘子很像:最后放上去的盘子,最先被拿走。
2. 什么叫压栈、出栈
- 压栈(push):把数据放进栈里
- 出栈(pop):把数据从栈里拿出来
在 ARM 中,常能看到这样的汇编:
PUSH {r4-r7}
POP {r4-r7}
含义分别是:
PUSH:把寄存器内容保存到栈中POP:从栈中恢复寄存器内容
3. 栈指针 SP
栈需要一个寄存器来记录“当前栈顶位置”,这个寄存器叫:
SP = Stack Pointer = 栈指针
要注意:
- 栈是 RAM 中的一块区域
- SP是一个寄存器,用来指向当前栈顶地址
在 Cortex-M3 中,栈通常是向低地址增长的,也就是:
- 压栈时,SP 变小
- 出栈时,SP 变大
三、寄存器是什么,和栈有什么关系
CPU 内部有一组寄存器,它们是 CPU 最快、最直接使用的数据存储位置。
常见寄存器包括:
R0-R12:通用寄存器SP:栈指针LR:链接寄存器PC:程序计数器xPSR:程序状态寄存器
可以这样理解:
- 寄存器:CPU 手边的小盒子,速度快但数量少
- RAM:容量大,但速度比寄存器慢
- 栈:RAM 中专门用于临时保存现场的区域
很多时候,CPU 会先用寄存器计算;当寄存器不够用,或者需要保存现场时,就把数据压进栈中。
四、几个关键寄存器的作用
1. PC
PC = Program Counter = 程序计数器
它保存的是:
下一条要执行的指令地址
程序能继续往下跑,核心就靠 PC。
2. LR
LR = Link Register = 链接寄存器
普通函数调用时,可以先把它理解成:
返回地址线索
函数调用结束后,CPU 需要知道该回到哪里继续执行,LR 在这里起重要作用。
3. xPSR
xPSR = Program Status Register
它保存程序当前状态,例如:
- 条件标志位
- Thumb 状态
- 当前异常相关信息
所以程序恢复时,不仅要知道“回到哪里”,还要知道“以什么状态回去”。
五、为什么 RTOS 特别在乎栈
RTOS 的任务切换,本质上就是:
把当前任务的执行现场完整保存,再恢复另一个任务的执行现场。
任务的执行现场包括:
- 当前寄存器内容
- 执行到哪条指令
- 当前函数调用层次
- 局部变量和中间结果
这些信息很多都保存在栈里。
所以 RTOS 必须保证:
每个任务都有自己的栈。
这样任务 A 被切走时,现场留在任务 A 自己的栈中;任务 B 运行时,使用任务 B 自己的栈;以后再切回任务 A 时,就能从它自己的栈中继续恢复执行。
六、Thread mode 和 Handler mode
Cortex-M3 运行时有两种主要模式:
1. Thread mode
这是“正常工作模式”,通常用来执行:
main() 普通函数 RTOS 中的任务函数
也就是说,大多数应用代码都运行Thread mode。
2. Handler mode
这是“异常处理模式”,用于执行:
中断服务函数 系统异常处理 PendSV、SysTick、HardFault 等异常处理代码
也就是说:
普通代码跑在 Thread mode,异常/中断处理代码跑在 Handler mode。
七、MSP 和 PSP 是什么
Cortex-M3 有两个栈指针寄存器:
- MSP = Main Stack Pointer = 主栈指针
- PSP = Process Stack Pointer = 进程栈指针 / 任务栈指针
注意这里很容易混淆:
- CPU 里只有一个 MSP 寄存器和一个 PSP 寄存器
- 任务可以有很多个栈空间
- 当前运行哪个任务时,PSP 就指向哪个任务的栈顶
所以更准确地说:
每个任务有自己的栈空间;CPU 用 PSP 指向当前任务的栈。
1. MSP 的用途
MSP 一般用于:
- 系统启动初期
- main() 早期运行
- 异常/中断处理
- 内核相关代码
它很像“系统栈”。
2. PSP 的用途
PSP 一般用于:
- RTOS 中各个普通任务的运行
它很像“当前任务栈”。
3. RTOS 中的常见分工
在很多 Cortex-M RTOS 中:
- Thread mode 通常使用 PSP
- Handler mode 一定使用 MSP
这样可以把:
- 任务栈
- 系统/异常栈
分开管理,互不混淆。
八、异常发生时,为什么要保存现场
假设任务 A 正在运行,此时突然发生中断或异常。
CPU 需要跳去执行异常处理代码,但异常处理结束后,任务 A 还得继续跑。 所以在跳走之前,必须先保存任务 A 的现场。
Cortex-M3 在异常进入时,会由硬件自动压栈一部分关键寄存器。
这部分寄存器是:
R0-R3R12LRPCxPSR
九、自动压栈寄存器
1. 自动压栈的内容
异常进入时,硬件自动保存:
R0R1R2R3R12LRPCxPSR
总共 8 个寄存器。
- 由于 Cortex-M3 是 32 位架构:
一个寄存器 = 32 位 = 4 字节
所以总大小是:
8 × 4 = 32 字节
2. 为什么自动压这几个
因为这几项构成了最小可恢复现场。
尤其是:
PC:决定恢复后从哪条指令继续执行xPSR:决定恢复后的状态LR:保留原任务被打断时的返回链路信息R0-R3, R12:保存常用临时寄存器
所以:
异常进入时,硬件自动压栈是为了保存最基本的返回现场。
十、为什么 R4-R11 需要手动保存
自动压栈只保存了最基本的一部分,但对于任务切换来说,还不够。
因为R4-R11中也可能保存着任务还要继续使用的重要中间结果。
例如:
- 任务 A 运行时把某些关键中间值放在
R4、R5 - 切换到任务 B 后,B 也会使用这些寄存器
- 如果不提前保存任务 A 的
R4-R11 - 那么切回 A 时,这些值就会被破坏
于是任务 A 就无法像没被打断过一样继续运行。
所以在 RTOS 的上下文切换中,通常要由软件手动保存:
R4-R11
手动保存的本质
可以把它理解成:
- 自动压栈:保证“能返回”
- 手动保存R4-R11:保证“返回后内容没乱”
这就是为什么完整任务切换必须同时处理两类寄存器。
十一、异常入口时,自动压栈压到哪里
这个问题非常关键。
假设:
- 任务 A 正在 Thread mode 下运行
- 它使用的是PSP
此时发生异常,自动压栈的那 8 个寄存器会先压到:
任务 A 自己的栈,也就是 PSP 指向的任务栈中
为什么?
因为保存的是“被打断任务的现场”, 那么现场当然必须留在它自己的栈里,这样以后才能从它自己的栈恢复回来。
随后 CPU 才会进入 Handler mode,并开始使用 MSP。
所以可以记成一句:
异常入口先按原栈保存现场,再进入 Handler mode 使用 MSP。
十二、EXC_RETURN 是什么
EXC_RETURN 是 Cortex-M3 异常机制中的一个非常关键的概念。
它不是某个小标志位,而是:
写入 LR 的一个特殊返回码
1. 普通函数调用时的 LR
在普通代码中,LR 通常保存普通函数返回地址相关信息。
2. 异常处理时的 LR
当进入异常处理时,当前寄存器中的 LR 会被设置成一个特殊值,也就是:
EXC_RETURN
这个值的作用是告诉 CPU:
- 这不是普通函数返回
- 这是一次异常返回
- 返回时该按异常返回规则处理
- 该使用哪种栈指针恢复
- 应该回到哪种模式
所以:
EXC_RETURN 负责“按什么方式返回”
而不是决定“回到哪条指令”。
3. 真正决定回到哪条指令的是谁
真正决定恢复后从哪条指令继续执行的是:
栈中恢复出来的 PC
所以要明确分工:
- LR 中的 EXC_RETURN:决定“怎么返回”
- 栈中保存的 PC:决定“返回到哪条指令”
十三、为什么要区分“栈里的 LR”和“当前 LR”
这也是非常容易混的地方。
当异常发生时:
- 硬件会把“旧任务当时的 LR”压入栈中
- 当前寄存器中的 LR 被设置为 EXC_RETURN
所以要区分:
1. 栈里的 LR
它属于“旧任务现场的一部分”。
2. 当前寄存器里的 LR
它属于“当前异常处理上下文”,用于控制异常返回。
简而言之:
栈里的 LR 是被打断任务的历史现场;当前 LR 是异常返回的控制信息。
十四、PendSV 为什么能做任务切换
在 Cortex-M RTOS 中,任务切换常用 PendSV 异常来实现。
原因在于:
- 它是专门适合做延迟调度与上下文切换的异常
- 可以在异常上下文中安全地切换任务现场
但这里最重要的一点是:
PendSV 只是在 Handler mode 中完成“切换动作”,并不是让新任务在 Handler mode 下运行。
也就是说:
- 任务 A 正常运行:Thread mode
- 触发 PendSV:进入 Handler mode
- 在 PendSV 中保存 A、恢复 B
- 异常返回
- 任务 B 开始运行:仍然是 Thread mode
所以:
切换过程在 Handler mode,任务运行在 Thread mode。
十五、一次任务切换的完整过程
下面以“任务 A 切换到任务 B”为例,梳理一遍全过程。
场景开始:任务 A 运行
- 当前模式:Thread mode
- 当前栈指针:PSP_A
- 任务 A 正在执行自己的普通代码
第一步:触发 PendSV 异常
CPU 检测到 PendSV 需要处理,开始异常入口操作。
硬件自动压栈
将任务 A 当前的:
R0-R3R12LRPCxPSR
压入任务 A 自己的栈(也就是 PSP_A 指向的栈)。
这一步保存的是任务 A 的最小恢复现场。
第二步:进入 Handler mode
自动压栈完成后:
- CPU 进入Handler mode
- 当前异常处理代码开始运行
- 异常处理使用MSP
同时,当前寄存器中的 LR 被赋予EXC_RETURN的身份。
第三步:PendSV_Handler 中执行上下文切换
在PendSV_Handler中,RTOS 会做这些事情:
- 手动保存任务 A 的
R4-R11 - 保存当前任务 A 的
PSP_A - 调度器选出要运行的下一个任务 B
- 读取任务 B 上次保存的
PSP_B - 恢复任务 B 的
R4-R11
此时仍然处于Handler mode。
第四步:异常返回
PendSV_Handler结束时:
- CPU 看到当前
LR = EXC_RETURN - 知道这不是普通函数返回,而是异常返回
于是 CPU 按异常返回规则:
- 从任务 B 的栈中恢复自动压栈那部分内容
恢复出:
R0-R3R12LRPCxPSR
其中:
恢复出来的 PC 决定任务 B 从哪条指令继续执行。
第五步:任务 B 运行
异常返回完成后:
- CPU 退出Handler mode
- 回到Thread mode
- 当前使用PSP_B 任务 B 从自己的 PC 所指位置继续执行
此时任务 B 不是在 Handler mode 中运行,而是作为普通任务,在 Thread mode 下继续执行。
十六、一张最小流程图总结
任务A运行(Thread mode, PSP_A)
↓
发生PendSV异常
↓
硬件自动压栈到任务A自己的栈(R0-R3, R12, LR, PC, xPSR)
↓
进入Handler mode,开始使用MSP
↓
PendSV_Handler中手动保存R4-R11
↓
保存任务A的PSP_A
↓
选择任务B
↓
恢复任务B的PSP_B
↓
恢复任务B的R4-R11
↓
LR = EXC_RETURN,执行异常返回
↓
CPU从任务B栈中恢复自动压栈内容
↓
恢复出PC
↓
任务B在Thread mode下继续运行
十七、最终总结
学习 Cortex-M3 异常模型时,最重要的是把下面这些关系彻底捋顺:
1. 栈与栈指针
栈是 RAM 中的一块区域 SP 是指向当前栈顶的寄存器
2. 两种模式
Thread mode:任务/普通代码运行 Handler mode:异常/中断处理运行
3. 两种栈指针
MSP:主栈指针,异常/系统使用 PSP:任务栈指针,当前任务使用
4. 自动压栈
异常进入时,硬件自动保存:
- R0-R3
- R12
- LR
- PC
- xPSR
作用是保存“最小可恢复现场”。
5. 手动保存
RTOS 任务切换时,软件还必须手动保存:
- R4-R11
作用是保证任务恢复后,重要中间结果不丢失。
6. EXC_RETURN
- EXC_RETURN 是写入 LR 的特殊返回码
- 它告诉 CPU:这是一次异常返回,应按异常规则恢复
- 真正决定恢复后从哪条指令继续执行的,是栈中恢复出来的 PC