ARMv7-A小系统实践(五):两个线程怎样真正切换起来
本章记录什么
这一章实现两个任务之间的协同式切换。重点不在调度算法,而在“暂停的计算怎样被表示”:哪些寄存器属于普通函数边界上的长期状态,任务栈与上下文对象分别保存什么,新任务从未运行过又怎样伪造出第一次恢复所需的现场。
在这一章之前做了什么
启动代码已经建立 SVC 栈并进入 C 环境,AAPCS32 也给出了 r4-r11、sp 和 lr 的保存责任。于是可以先在没有中断的情况下做最小闭环:任务 A 主动调用切换函数,汇编保存 A 的 callee-saved 状态,恢复 B 的状态;B 再切回 A,A 从原调用点之后继续执行。
这种主动切换故意避开 IRQ。原因是它把问题缩小到普通调用边界,容易观察、容易回退。下一章再处理任意指令点被打断时所需的完整异常现场。
系列目录 · 上一篇:启动、链接脚本与多模式栈
本章的阅读顺序
先读基础概念,确认每个术语解决什么问题;再看设计推导,理解实现为什么采用这种保存集合、初始化顺序或状态机;随后逐段阅读代码和执行轨迹;最后用验证方法与错误案例检查理解。文中保留寄存器名、指令名和标准缩写,叙述部分统一使用简体中文。
动手前的基础:调度器、上下文与异常现场
适用平台:Zynq-7000 / Cortex-A9 / ARM state 优先
学习目的:能读写最小任务上下文,能手工推演 A→B→A,能构造新任务第一次运行现场
阅读方式:正文替代本节英文汇编章节;RTEMS 和 x64 mini 只按文中路径定点对照
编写日期:2026-07-30
0. 这一目标不是“把汇编指令背完”
系统移植需要的不是会写所有 ARM 指令,而是能用一小组指令准确表达:
1 | |
本目标的最终问题是:
调度器选出任务 B 后,底层怎样让 CPU 不再从任务 A 的栈和执行点继续,而改为从 B 上次保存的位置继续?
答案可压缩为:
1 | |
因此本章围绕“对象布局、保存恢复、任务第一次启动、IRQ 尾部切换”组织,不按指令字母表讲解。
1. 本文覆盖的原始资料和准确范围
| 原始资料 | 准确范围 | 页数 | 本文用途 |
|---|---|---|---|
| Programmer’s Guide 第 5 章 | PDF/手册页 57–68 | 12 页 | 汇编源文件、GNU/Arm 语法、ARM/Thumb interworking |
| Programmer’s Guide 第 6 章 | PDF/手册页 69–86 | 18 页 | 指令、访存、多寄存器传输、分支和 PSR 操作 |
RTEMS ARM cpu_asm.S |
配套源码定点阅读 | — | ARM 普通上下文保存/恢复参考 |
RTEMS ARM arm_exc_interrupt.S |
配套源码定点阅读 | — | IRQ 尾部与普通上下文汇合参考 |
x64 mini x86_64-context-switch.S |
配套源码定点阅读 | — | 提取被移植系统的接口语义 |
Programmer’s Guide 第 6 章并非全部都要精读。本目标必须掌握:
1 | |
整数 SIMD、饱和运算、Cache preload、字节翻转当前只建立目录印象,不作为上下文切换前置条件。
原图和表对照
本章只建议打开:
| 原文图表 | 页码 | 用途 |
|---|---|---|
| Table 6-1: Condition code suffixes | 72 | 条件码与标志关系 |
| Table 6-2: PSR flag bits | 73 | N/Z/C/V |
| Figure 6-5: Stack push operation | 82 | 满递减栈和 PUSH |
2. 先分清四个经常混在一起的对象
2.1 调度器
调度器回答:
1 | |
输入通常是任务状态、优先级、时间片和就绪队列,输出是 heir。
2.2 上下文切换机制
底层汇编回答:
1 | |
它不应该重新实现任务选择策略,只负责保存和恢复。
2.3 Context_Control
它是内存中的一个小对象,记录任务暂停后恢复所需的 CPU 状态。它不是任务栈本身:
1 | |
Context_Control.sp 保存一个地址,这个地址落在该任务自己的栈区中。
2.4 异常帧
IRQ 可发生在任意指令点,异常帧通常包含:
1 | |
普通上下文和异常帧可能在抢占调度中互相转换或结合,但它们的最小保存集合不同。
3. 读汇编前先掌握“地址和值”
这是上下文汇编最常见的理解障碍。
假设:
1 | |
那么:
1 | |
C 语言等价关系:
1 | |
必须形成条件反射:
1 | |
A_ctx 是一个地址;*A_ctx 才是该上下文对象的内容。
4. 数据处理指令:只学上下文需要的部分
4.1 搬运与常量
1 | |
ldr r0, =symbol 不等于 ldr r0, [symbol]:
1 | |
4.2 加减和地址计算
1 | |
ADD/SUB 不带 S 时通常不更新条件标志,带 S 时更新:
1 | |
4.3 比较与测试
1 | |
CMP/TST 的输出在 CPSR 标志位,不写普通结果寄存器。
4.4 位操作
1 | |
常用于修改控制字段、提取中断 ID 和对齐地址。
5. 单寄存器访存与四种索引形式
5.1 基址加偏移
1 | |
5.2 预索引并回写
1 | |
逻辑:
1 | |
5.3 后索引
1 | |
逻辑:
1 | |
5.4 ! 不是装饰
对多寄存器指令:
1 | |
手工推演时必须在纸上单独写:
1 | |
6. STM/LDM:上下文切换的核心指令
6.1 一次保存多个寄存器
假设:
1 | |
执行:
1 | |
结果:
1 | |
IA 是 Increment After:从旧基址开始,之后递增。
6.2 一次恢复多个寄存器
1 | |
执行相反过程,并把 r0 增加 12。
6.3 最重要的内存顺序
无论寄存器列表文本怎样写:
编号最低的寄存器对应最低内存地址,编号最高的寄存器对应最高内存地址。
所以保存结构体布局必须与寄存器编号顺序一致。
6.4 常用寻址后缀
| 后缀 | 含义 | 常见用途 |
|---|---|---|
IA |
Increment After | 从连续对象头部装卸 |
IB |
Increment Before | 先加再访问 |
DA |
Decrement After | 递减型变体 |
DB |
Decrement Before | 满递减栈压栈 |
最常用对应:
1 | |
6.5 为什么恢复 PC 会立刻改变控制流
1 | |
最后一个内存字装入 PC 后,CPU 从新 PC 继续执行;不会再顺序执行这条指令后面的普通代码。这正是上下文恢复的“跳板”。
7. 分支、调用与返回
1 | |
上下文切换中的特殊现象:
1 | |
于是这次“调用”不会马上回到 A,而会像 B 上次的 context_switch 调用刚刚返回一样,在 B 中继续。
8. PSR 与屏蔽相关指令
8.1 读取和写入状态
1 | |
后缀 c/x/s/f 指控制、扩展、状态、标志域。只写必要字段,避免破坏无关状态。
8.2 中断屏蔽
1 | |
普通上下文是否保存 IRQ 屏蔽状态是 OS 设计问题。不能在最小 Context_Control 中没保存 CPSR,却又期待每个任务拥有互不相同的 I 位。
第一版可明确一种策略:
- 调度只在内核规定的屏蔽状态下进入;
- 切换汇编不把完整 CPSR 当普通任务私有状态;
- IRQ 尾部通过异常帧恢复异常前 CPSR。
8.3 屏障指令只先建立地图
Cortex-A9 SMP 与 MMIO 初始化还会用:
1 | |
volatile 只能约束编译器,不能替代 CPU 屏障。具体使用在 GIC、Cache、SMP 实现阶段结合手册裁决,本章不把所有屏障规则提前塞进来。
9. 一个可推演的最小 ARM 普通上下文
教学结构:
1 | |
对应偏移:
1 | |
最小切换:
1 | |
保存时,最后一项把当前 LR 写入 run->resume_pc;恢复时,同一个内存槽装入 PC。
为什么不保存 r0-r3/r12
这是假设切换发生在正常 AAPCS 调用边界:
- caller 已经不依赖它们跨调用保持;
- 编译器会把活跃值转移到 callee-saved 寄存器或当前任务栈;
- 未来恢复后,任务把这次
context_switch()当成正常返回。
为什么保存 SP
局部变量、返回地址和更深层调用链都在任务栈里。恢复 SP 后,后续 C 代码自然重新看到 B 自己的调用栈。
为什么保存 LR、恢复到 PC
在 A 中进入 context_switch 时,LR 是 A 调用后的继续点。保存它,就是保存 A 未来的 resume PC。恢复 B 时直接装 PC,立即跳到 B 的继续点。
10. 手工推演 A → B → A
假设:
1 | |
A 切到 B
A 调用:
1 | |
入口:
1 | |
保存后:
1 | |
恢复 B:
1 | |
CPU 现在使用 B 栈,并从 B 以前暂停的位置继续。
B 再切回 A
B 之后调用:
1 | |
保存 B 当前现场,再恢复:
1 | |
A 观察到最初那次 context_switch(&A_ctx,&B_ctx) 终于“返回”了。
关键认识:
这不是 A 调用一次、B 又重新调用同一个 A 函数。每个任务都恢复自己的历史调用链;一次切换函数的控制流可以隔很久后才在原任务中完成。
11. 新任务没有“上次暂停位置”,怎样第一次运行
新任务必须伪造一个能被恢复的上下文。
假设:
1 | |
可以约定:
1 | |
恢复新任务时:
1 | |
于是第一次跳到:
1 | |
初始化时必须决定的事项
- 栈顶按 8 字节对齐;
- 栈增长方向正确;
entry和arg存在哪;- 初始 IRQ 是否开放;
- task entry 返回后去哪里;
- 是否需要 TLS/thread ID;
- 是否需要 VFP 状态;
- ARM/Thumb 函数地址和状态是否匹配。
“把 PC 设成 entry 就完成了”往往不够,因为还缺参数和退出路径。
12. C 结构体与汇编偏移必须是同一份事实
最危险的 Bug 之一:
1 | |
工程上应:
- 用统一头文件或生成脚本定义偏移。
- 在 C 中使用
_Static_assert(offsetof(...))。 - 汇编常量从同一配置生成。
- 构建后用反汇编确认最终指令和偏移。
示例:
1 | |
不要只依赖注释中的布局。
13. 对照 x64 mini:移植的是语义,不是指令
被移植系统的 x86_64 实现位于:
1 | |
它已经表达了目标接口:
1 | |
x86 版本保存 RBX/RBP/RSP/R12-R15/RFLAGS,恢复目标 RSP 后用 ret 切换控制流。ARM 不能逐指令翻译,但可保持合同:
| x86_64 语义 | ARMv7-A 对应 |
|---|---|
| 保存 x86 callee-saved | 保存 ARM r4-r11 |
保存 RSP |
保存 SP |
| 新栈布置返回地址 | 构造 resume_pc 或初始栈帧 |
ret 从目标栈取 RIP |
LDM ... pc 或 BX 恢复继续点 |
RFLAGS 中断位策略 |
明确 ARM CPSR.I 的管理位置 |
先列接口和语义,再决定 ARM 数据结构;不要保留 x86 字段名只换成 ARM 指令。
14. 对照 RTEMS:成熟实现增加了哪些工程条件
核心参考:
1 | |
其中普通 ARM 保存主线是:
1 | |
这直接证明了本章的 AAPCS 推导。但成熟 RTEMS 还处理:
- per-CPU dispatch disable 状态;
- 线程 ID 寄存器;
- 可选
d8-d15VFP 非易失状态; - SMP 下任务不能同时在两个 CPU 运行;
LDREX/STREX独占状态;- 内存屏障;
- 临时 per-CPU 栈。
学习顺序应是:
1 | |
当前 soft-float 第一版通常可以不保存 VFP 上下文,但若代码或手写汇编实际启用 VFP/NEON,就必须重新定义规则。
15. 主动切换与 IRQ 尾部抢占
15.1 主动切换
任务自己调用 yield():
1 | |
这是第一版最容易验证的路径。
15.2 IRQ 抢占
定时器可能打断任意指令:
1 | |
不能在设备 handler 深处随意用普通两行切换,因为此时还存在:
- GIC active 状态;
- IRQ 嵌套计数;
- 设备清源;
- 异常帧;
- 当前使用的 IRQ/per-CPU 栈;
- 需要恢复的 SPSR。
因此正确路线是:
1 | |
16. SMP 中上下文切换多出的约束
双核不是把单核切换函数同时运行就结束了。必须保证:
- 同一任务不能同时在 CPU0 和 CPU1 执行;
run/heir状态更新对另一核可见;- 就绪队列和 per-CPU 当前任务有锁或原子协议;
- 必要位置使用 DMB;
- 目标核需要重调度时用 SGI 通知;
- 每核有安全的 IRQ 栈和临时切换栈;
- Cache 一致性和共享数据属性正确。
RTEMS 的 is_executing、LDREXB/STREXB 和 DMB 就在解决这些问题。
第一版建议只启 CPU0。单核主动切换正确之后,再引入 CPU1,否则错误变量会同时来自:
1 | |
17. 汇编 Debug 的固定方法
17.1 不要只看源代码,要看最终反汇编
确认:
- 编译器实际选择 ARM 还是 Thumb;
- 伪指令展开成什么;
- 符号地址最低位;
- 结构体偏移;
LDM/STM寄存器列表;- 链接器是否插入 veneer;
- PC 恢复点是否可执行。
17.2 第一次切换前记录
1 | |
17.3 切换失败按阶段断点
1 | |
若 LDM ... pc 后断点不命中,优先查 PC 地址、指令状态和 SP;不要先怀疑调度器。
17.4 栈哨兵
用固定模式填充任务栈,记录最大使用深度;给每个任务栈不同范围,能快速判断恢复了错误 SP 或发生重叠。
17.5 常见症状
| 症状 | 优先检查 |
|---|---|
| 第一次切换立即跑飞 | 新任务 sp/resume_pc/entry/arg |
| A→B 成功,B→A 失败 | A 保存的 LR、结构偏移、A 栈是否被破坏 |
| 加函数调用后失败 | LR、caller-saved 活跃值、SP 8 字节对齐 |
| 优化后失败 | callee-saved 违约、inline asm clobber |
| 双核偶发同一任务崩溃 | 任务同时在两核运行、缺原子协议/屏障 |
18. 本目标的掌握标准
完成本章后,应能做到:
- 把
r0、[r0]、[r0,#4]写出正确 C 等价。 - 手推
STMIA/LDMIA的每个内存地址和!回写。 - 解释
PUSH=STMDB sp!、POP=LDMIA sp!。 - 从
Context_Control画出字段偏移。 - 解释为什么保存 LR、恢复到 PC。
- 用具体 SP/PC 推演一次 A→B→A。
- 构造新任务的栈顶、entry、arg、trampoline 和退出路径。
- 区分 scheduler、普通上下文、异常帧。
- 说明主动切换为何先于 IRQ 抢占实现。
- 能说出 x64 mini 接口哪些语义保留、哪些指令必须重写。
30 秒复述版
调度器负责选 heir,汇编切换负责让 CPU 真正变成 heir。正常 AAPCS 调用边界上,任务长期状态主要是 r4-r11、sp 和继续地址。STM 把当前状态写入 run_ctx,LDM 从 heir_ctx 恢复;装入新 sp 后换栈,装入 pc 后换执行流。新任务没有历史继续点,所以初始化时伪造 sp、entry、arg 和 trampoline。IRQ 抢占则先有完整异常帧,必须等清源、EOI 和嵌套状态处理妥当后,在统一出口与调度结合。
19. 下一主题的接口
本章解决了“软件怎样保存和恢复任务”。下一章把它放进真实硬件链:
1 | |
这条链走通后,原定目标要求的“上下文调度移植 + 中断控制器移植”才真正汇合。
从 x86_64 语义推导 ARM 普通 context
四、先看 x86_64 版本,提取架构无关思想
1. 上下文结构
文件:
1 | |
Context_Control 的核心字段包括:
1 | |
注释明确说明它们是 x86-64 SysV ABI 的 callee-saved 寄存器。它并没有保存所有 x86 通用寄存器。
2. 上下文切换
文件:
1 | |
C 接口:
1 | |
x86-64 SysV ABI 将前两个参数放在 rdi、rsi。代码完成三件事:
1 | |
真正完成控制流切换的是:
1 | |
因此同一条 ret 有两种效果:
- 对运行过的任务:回到它上次
_CPU_Context_switch()之后; - 对新任务:跳到初始化时人为放在栈上的
entry_point。
3. 新任务初始现场
文件:
1 | |
初始化函数:
- 计算栈顶;
- 按 ABI 对齐;
- 在栈上放入
entry_point,伪造成ret的返回地址; - 把构造后的 SP 写入新上下文。
这叫“伪造初始上下文”,不是造假,而是把“新任务第一次运行”统一成“恢复一个已有上下文”的形式。
五、映射到 ARMv7-A
1. ARM 普通上下文结构
RTEMS 参考文件:
1 | |
ARMv4/ARMv7-A 路径的普通核心字段是:
1 | |
另有调度禁止状态,并可按配置增加:
d8-d15:AAPCS VFP callee-saved;- thread ID;
- SMP 的
is_executing。
第一轮先只抓住:
1 | |
2. ARM 上下文切换接口
参考文件:
1 | |
C 接口对应:
1 | |
根据 AAPCS32:
1 | |
核心保存:
1 | |
核心恢复:
1 | |
这里:
r13是 SP;- 保存时把 LR 写入上下文;
- 恢复时把相同槽位读入 PC,而不是 LR;
- PC 一改变,CPU 就从 heir 的恢复位置继续执行。
3. 为什么保存 LR、恢复 PC
任务 A 调用上下文切换时:
1 | |
BL 已把“A 调用完成后应该继续的位置”写入 A 的 LR。
保存 A:
1 | |
恢复 B:
1 | |
效果等价于“从 B 上次调用上下文切换的位置返回”。因此上下文切换在 A 的调用中进入,却可能在 B 的调用中返回。
这是第一次看最反直觉的地方,必须记住:
_CPU_Context_switch()的调用者和最终返回到的任务,不一定是同一个任务。
六、手工走一遍 A → B → A
假设:
1 | |
A 执行:
1 | |
第一步:参数
1 | |
第二步:保存 A
1 | |
第三步:恢复 B
1 | |
从这一刻开始,CPU 已在 B 的栈和控制流上运行。以后 B 调用切换回 A:
1 | |
A 看起来就像它最初调用的 _CPU_Context_switch() 终于返回了。
七、新任务没有“上次执行位置”怎么办
新任务从未执行,不能保存出真实的 LR 和 SP,所以初始化函数要构造:
1 | |
更完整的实现通常不直接跳入用户的 task_entry,而是跳到包装函数:
1 | |
原因:
- 正确准备参数;
- 统一中断状态;
- 任务函数意外返回时进入退出处理,而不是跳到随机地址;
- 可以执行线程本地存储、FPU 等初始化。
所以移植时需要明确:
1 | |
还是:
1 | |
第二种通常更稳健。
八、本节需要掌握的 ARM 汇编
1. 单寄存器访存
1 | |
上下文结构字段本质上就是“基地址 + 固定偏移”。
2. 多寄存器访存
1 | |
STM:Store Multiple,把多个寄存器写入连续内存。LDM:Load Multiple,从连续内存恢复多个寄存器。
没有 ! 时,基址寄存器不回写;上下文指针仍指向结构起点。
寄存器按编号与连续内存字对应,不按花括号中书写的随意顺序重新排列。移植时必须让 C 结构偏移与汇编的装载顺序完全一致。
3. 栈别名
1 | |
! 表示将更新后的地址写回 SP。
4. 调用和返回
1 | |
5. 状态寄存器
1 | |
- 最常见的应用:
1 | |
6. 中断屏蔽
1 | |
对“保存旧状态后临时关中断”的代码,不能只在结尾无条件 cpsie i,而应恢复进入前的 I 位,否则调用者原本要求关中断的语义会被破坏。
7. 条件执行与分支
1 | |
本节会读懂即可,异常返回的 RFE、带 ^ 的 LDM 等放到下一主题系统学习。
九、上下文结构与汇编必须严格同步
如果 C 中:
1 | |
汇编却按:
1 | |
恢复后 SP 和 PC 会互换,通常立即跑飞。
工程上应至少采用一种保证:
- 汇编偏移由公共头文件宏生成;
- C 使用
static_assert(offsetof(...))校验; - 构建时生成 offsets;
- 用
objdump和 GDB 检查实际布局。
这是上下文移植最常见、最致命、也最好验证的一类问题。
十、第一轮调试时看什么
切换前记录:
1 | |
单步保存后检查:
1 | |
单步恢复后检查:
1 | |
如果恢复后立刻跑飞,优先排查:
- 结构偏移错;
- 新任务栈方向或对齐错;
- LR/PC 初始值错;
- ARM/Thumb 状态位错;
- 任务入口返回后没有合法去向;
- 中断状态恢复时机错。
新任务初始上下文的完整实现
下面给出文章对应的完整实现片段。即使没有配套仓库,也可以沿着注释、寄存器访问和调用关系逐行阅读。代码之后的分析只讨论本章主题,不要求预先掌握其他目录结构。
1 | |
普通上下文切换的完整汇编
下面给出文章对应的完整实现片段。即使没有配套仓库,也可以沿着注释、寄存器访问和调用关系逐行阅读。代码之后的分析只讨论本章主题,不要求预先掌握其他目录结构。
1 | |
从规则走到实际实现
下面回到本章对应的小系统实现。这里不再假定读者已经打开源码,而是把关键地址、数据结构、指令序列和返回路径直接放在文章中解释。
进程、线程和任务在这里各指什么
线程是执行流:它有寄存器、继续执行位置、栈以及调度状态。进程通常还表示一组资源与地址空间边界,同一个进程的多个线程共享这组资源。
当前小系统中的“任务”更接近内核线程。任务共用一套地址空间,处于 SVC 模式,可以直接访问相同的全局变量;每个任务有独立栈和自己的保存现场。
| 项目 | 当前内核任务 | 带隔离的用户进程 |
|---|---|---|
| 通用寄存器与 SP/PC | 每个任务独立 | 每个执行线程独立 |
| 页表/地址空间 | 共享 | 通常按进程区分 |
| 权限级别 | SVC 特权态 | 还需要 User 与内核的边界 |
| 全局数据 | 直接共享 | 通常受各自虚拟地址空间约束 |
| 切换附加状态 | 当前很少 | 可能有 TTBR、ASID、TLS、用户栈等 |
所以换了 SP 不等于完成“进程切换”;开了 MMU 也不等于已经有多个进程。当前先解决同一地址空间中的执行流保存。
一个任务至少要保留两块东西
1 | |
Context_Control 只是栈外的一份索引和寄存器保存区。任务栈本身保存了层层调用关系,不能在切出以后借给另一个任务反复覆盖。
基础版中每个任务栈为 4096 字节,按 8 字节对齐。数组地址加长度得到栈顶,再向下取整对齐。栈向低地址增长,初始 SP 指向高地址边界。
保存 A、恢复 B,逐条看
来源:基础版 src/context_switch.S。
1 | |
C 调用时,r0 是 old,r1 是 next。第一段把 A 的 callee-saved 寄存器、SP、LR、控制状态写入 A 的对象。
mov r0, r1 改变的是接下来要读的对象。ldr sp, ... 之后,CPU 已经选用了 B 的栈。最后 BX LR 转到 B 的继续点,新的 C 调用链开始生效。
切换函数没有为 A 走到一个普通的“立即返回”。A 暂停在这次调用中;等 B 未来恢复 A 的 context,CPU 才从 A 保存的 LR 继续。这种延迟返回正是线程恢复的关键。
LR 为什么可以充当继续点
假设 A 中有:
1 | |
BL 到切换函数时,LR 指向调用之后。因此保存 LR 就保存了 A 将来进入 after_resume_a 所需的继续点。
这里没有显式保存当前 PC,是因为切换只发生在这个已知调用边界。IRQ 可以发生在任何指令位置,不能依靠“某个调用之后”代替任意被打断位置;这就是后续异常帧需要 return_pc 的原因。
从未运行过的 B,哪来的保存现场
初始化函数人为构造一份能被同一恢复代码接受的现场。来源:基础版 src/context.c 的核心赋值。
1 | |
这里选择关闭 IRQ 的 SVC 初始状态;实际函数通过 enable_irq 参数控制 I 位,其余 r7-r11 清零。
为什么把入口函数放 r4,而不是直接放到 LR?因为首次启动还要完成“把参数放到 r0、调用入口、处理任务返回”的流程。trampoline 承担这一层适配:
1 | |
恢复以后先进入 trampoline,它把 argument 放到 r0,调用 entry。entry 意外返回时调用 exit_handler,避免 CPU 继续执行未知内存。
这些 r4/r5/r6 是首次启动时的约定,不是线程生命周期内永久存放入口参数的专用寄存器。任务运行以后,它们会正常承担编译器分配的 callee-saved 值。
从有限测试开始,比直接死循环更容易定位
基础版先运行一个有限的测试:
1 | |
其中 boot 自己也有一个 Context_Control。A 最后切回 boot,就能继续执行后面的 GIC、Timer 和 SMP 自检。
这比一启动就跑永久任务多验证了一件事:保存的不仅是 A/B,两份任务栈以外的原始内核调用链也能恢复。检查成功后,再启动长期任务演示。
永久协同任务的逻辑可简化为:
1 | |
B 对称地更新自己的计数再切回 A。其执行顺序是任务主动决定的,属于协同切换。B 如果从不让出,A 就没有下一次运行机会。
基础版的 ANSI 界面会按采样限帧;想逐条核对严格的 A1/B1/A2/B2 顺序,应使用纯文本配置:
1 | |
界面采样没显示每一次 B,并不直接等于 B 没运行。状态观测和调度机制要分开验证。
这段汇编有哪些使用前提
第一,任务模式一致。本实现使用 SVC 任务,恢复 SP 后再写 CPSR 控制字段,在这个约束内有明确意义;不能随意构造 User/System/IRQ 混合 context 后声称仍是通用恢复函数。跨模式任务需要重新设计 banked SP、权限边界与返回机制。
第二,普通切换路径必须与异步中断互斥。当前永久协同演示关闭 IRQ,避免处于半保存、半恢复状态时被打断。将来若允许这种路径与 Timer 抢占共存,要定义在哪个时刻 current_task 切换、谁屏蔽中断、恢复后如何回到原屏蔽状态。
第三,现场仅覆盖当前整数模型。VFP/NEON、TLS、地址空间、调试状态等扩展,要按实际功能加入;不能仅扩大一个结构体而不修改所有恢复路径。
第四,普通调用允许改变条件标志。因此 cpsr_c 控制字段恢复与完整异常 CPSR 恢复不可混用。若程序通过非标准方式要求某些标志跨切换调用保持,就已经超出当前 ABI 合同。
怎样证明不是两个结构体互相赋值
在 GDB 中同时看四类证据:
1 | |
单步越过保存序列后,A 的 context 中 SP/LR 应对应 A 的栈和继续点。继续单步到恢复 SP 后,寄存器 SP 应落入 B 的数组范围。最后执行 BX,PC 应进入 B 的继续点或首次 trampoline。
再让 B 切回 A,检查 A 栈中的局部变量没有被 B 修改。寄存器值、内存对象、栈范围、控制流四者一起吻合,才构成完整证据。
从这里扩展到调度器
当前代码把 next 直接写死为另一任务。通用调度器还需要就绪队列、RUNNING/READY/BLOCKED、优先级、时间片、等待对象、退出与回收。它们决定“选谁”,底层 context 负责“怎样过去”。
把策略与机制分开以后,Timer 到来时可以换一种方式选择 next,而不必让入口汇编理解优先级算法。不过异步进入时不能直接套用普通 context 的最小保存集合,下一篇开始处理这一部分。
下一篇:异常入口与独立中断栈。
重点回看
协同切换发生在普通函数边界,保存 r4-r11、sp、lr 等长期状态即可。上下文对象不是任务栈,保存动作也不是结构体赋值。新任务通过预构造 SP、入口地址和参数获得第一次恢复现场,任务入口不得无目的返回。
本章总结
协同切换发生在普通函数边界,保存 r4-r11、sp、lr 等长期状态即可。上下文对象不是任务栈,保存动作也不是结构体赋值。新任务通过预构造 SP、入口地址和参数获得第一次恢复现场,任务入口不得无目的返回。
下一章处理异步异常现场,解释为什么 IRQ 需要比普通 context 更完整的保存集合。
ARMv7-A小系统实践(五):两个线程怎样真正切换起来
https://goko-son626.github.io/post/armv7a-04-context-threads.html
1.ARMv7-A小系统实践(十二):用QEMU和GDB把整条实现链验清楚
2.ARMv7-A小系统实践(十一):启动CPU1,走通SCU与SGI核间通信
3.ARMv7-A小系统实践(十):L1、L2、屏障与Cache一致性
4.ARMv7-A小系统实践(九):用短描述符页表建立MMU与内存保护
5.ARMv7-A小系统实践(八):从完整IRQ现场到按需切换,再看RTEMS
6.ARMv7-A小系统实践(七):GIC、定时器与一次完整的IRQ
7.ARMv7-A小系统实践(六):异常入口、返回地址与独立中断栈
8.ARMv7-A小系统实践(四):从ELF、模式栈到第一行串口输出

