ARMv7-A小系统实践(五):两个线程怎样真正切换起来

本章记录什么

这一章实现两个任务之间的协同式切换。重点不在调度算法,而在“暂停的计算怎样被表示”:哪些寄存器属于普通函数边界上的长期状态,任务栈与上下文对象分别保存什么,新任务从未运行过又怎样伪造出第一次恢复所需的现场。

在这一章之前做了什么

启动代码已经建立 SVC 栈并进入 C 环境,AAPCS32 也给出了 r4-r11splr 的保存责任。于是可以先在没有中断的情况下做最小闭环:任务 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
2
3
4
寄存器 ↔ 内存
当前栈 ↔ 目标任务栈
当前返回点 ↔ 目标任务继续点
CPSR/SPSR ↔ 异常状态

本目标的最终问题是:

调度器选出任务 B 后,底层怎样让 CPU 不再从任务 A 的栈和执行点继续,而改为从 B 上次保存的位置继续?

答案可压缩为:

1
2
3
4
把 A 必须长期保持的寄存器写入 A_ctx
从 B_ctx 读回 B 的寄存器
恢复 B 的 SP,身份切换到 B 的栈
恢复 B 的 PC,控制流切换到 B 的继续点

因此本章围绕“对象布局、保存恢复、任务第一次启动、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
2
3
4
5
6.1 Instruction set basics              69–73
6.2 Data processing 的基础部分 73–76
6.3 Memory instructions 79–82
6.4 Branches 82
6.6.2 SVC / 6.6.3 PSR modification 84

整数 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
怎样从 run 真正变成 heir?

它不应该重新实现任务选择策略,只负责保存和恢复。

2.3 Context_Control

它是内存中的一个小对象,记录任务暂停后恢复所需的 CPU 状态。它不是任务栈本身:

1
2
3
4
5
6
7
TCB
├─ 调度信息
├─ Context_Control
│ ├─ r4-r11
│ ├─ sp
│ └─ resume_pc
└─ stack area ────────── 被 sp 指向

Context_Control.sp 保存一个地址,这个地址落在该任务自己的栈区中。

2.4 异常帧

IRQ 可发生在任意指令点,异常帧通常包含:

1
2
3
4
r0-r12
异常返回 PC
异常前 CPSR
必要的额外状态

普通上下文和异常帧可能在抢占调度中互相转换或结合,但它们的最小保存集合不同。


3. 读汇编前先掌握“地址和值”

这是上下文汇编最常见的理解障碍。

假设:

1
2
r0 = 0x1000
内存[0x1000] = 0xAABBCCDD

那么:

1
2
3
mov r1, r0          @ r1 = 0x1000,复制地址值
ldr r2, [r0] @ r2 = 0xAABBCCDD,访问该地址的内存
str r3, [r0] @ 内存[0x1000] = r3

C 语言等价关系:

1
2
3
r1 = r0;
r2 = *(uint32_t *)r0;
*(uint32_t *)r0 = r3;

必须形成条件反射:

1
2
3
r0       寄存器里装的值,可能恰好是地址
[r0] 访问 r0 所指内存
[r0,#8] 访问 r0+8 所指内存

A_ctx 是一个地址;*A_ctx 才是该上下文对象的内容。


4. 数据处理指令:只学上下文需要的部分

4.1 搬运与常量

1
2
3
4
5
mov  r0, r1
mov r0, #1
mvn r0, #0
ldr r0, =symbol @ 常见汇编器伪指令,可能生成字面量池访问
adr r0, local_label @ 计算附近标签地址

ldr r0, =symbol 不等于 ldr r0, [symbol]

1
2
ldr r0, =symbol    得到 symbol 的地址/常量
ldr r1, [r0] 再读取 symbol 的内容

4.2 加减和地址计算

1
2
add r0, r1, #16
sub sp, sp, #64

ADD/SUB 不带 S 时通常不更新条件标志,带 S 时更新:

1
2
subs r0, r0, #1
bne loop

4.3 比较与测试

1
2
3
4
cmp r0, #0          @ 计算 r0-0,只保留标志
tst r0, #0x80 @ 按位与,只保留标志
beq is_zero
bne not_zero

CMP/TST 的输出在 CPSR 标志位,不写普通结果寄存器。

4.4 位操作

1
2
3
and r0, r0, #mask
orr r0, r0, #bits
bic r0, r0, #bits @ r0 &= ~bits

常用于修改控制字段、提取中断 ID 和对齐地址。


5. 单寄存器访存与四种索引形式

5.1 基址加偏移

1
2
3
ldr r4, [r0]         @ r4 = *(uint32_t *)(r0)
ldr r5, [r0, #4] @ r5 = *(uint32_t *)(r0 + 4)
str r6, [r0, #8] @ *(uint32_t *)(r0 + 8) = r6

5.2 预索引并回写

1
ldr r4, [r0, #4]!

逻辑:

1
2
r0 = r0 + 4
r4 = [r0]

5.3 后索引

1
ldr r4, [r0], #4

逻辑:

1
2
r4 = [旧 r0]
r0 = 旧 r0 + 4

5.4 ! 不是装饰

对多寄存器指令:

1
2
stmia r0,  {r4-r7}   @ r0 最终不变
stmia r0!, {r4-r7} @ r0 最终增加 16

手工推演时必须在纸上单独写:

1
2
3
base before
访问的每个地址
base after

6. STM/LDM:上下文切换的核心指令

6.1 一次保存多个寄存器

假设:

1
2
r0=0x1000
r4=0x44, r5=0x55, r6=0x66

执行:

1
stmia r0!, {r4-r6}

结果:

1
2
3
4
[0x1000] = 0x44
[0x1004] = 0x55
[0x1008] = 0x66
r0 = 0x100C

IA 是 Increment After:从旧基址开始,之后递增。

6.2 一次恢复多个寄存器

1
ldmia r0!, {r4-r6}

执行相反过程,并把 r0 增加 12。

6.3 最重要的内存顺序

无论寄存器列表文本怎样写:

编号最低的寄存器对应最低内存地址,编号最高的寄存器对应最高内存地址。

所以保存结构体布局必须与寄存器编号顺序一致。

6.4 常用寻址后缀

后缀 含义 常见用途
IA Increment After 从连续对象头部装卸
IB Increment Before 先加再访问
DA Decrement After 递减型变体
DB Decrement Before 满递减栈压栈

最常用对应:

1
2
push {r4-r7, lr}     == stmdb sp!, {r4-r7, lr}
pop {r4-r7, pc} == ldmia sp!, {r4-r7, pc}

6.5 为什么恢复 PC 会立刻改变控制流

1
ldmia r1, {r4-r11, sp, pc}

最后一个内存字装入 PC 后,CPU 从新 PC 继续执行;不会再顺序执行这条指令后面的普通代码。这正是上下文恢复的“跳板”。


7. 分支、调用与返回

1
2
3
4
5
b    label           @ 无条件跳转
beq label @ Z=1 时跳转
bl func @ LR=返回点,调用 func
bx lr @ 返回,并支持状态互操作
blx r4 @ 调用函数指针

上下文切换中的特殊现象:

1
2
3
A 执行 BL context_switch
context_switch 保存 A 的 LR
却把 B 保存的继续地址装入 PC

于是这次“调用”不会马上回到 A,而会像 B 上次的 context_switch 调用刚刚返回一样,在 B 中继续。


8. PSR 与屏蔽相关指令

8.1 读取和写入状态

1
2
3
4
mrs r0, cpsr
mrs r1, spsr
msr cpsr_c, r0
msr spsr_cxsf, r1

后缀 c/x/s/f 指控制、扩展、状态、标志域。只写必要字段,避免破坏无关状态。

8.2 中断屏蔽

1
2
3
cpsid i              @ CPSR.I=1,屏蔽 IRQ
cpsie i @ CPSR.I=0,允许 IRQ
cpsid if @ 屏蔽 IRQ 和 FIQ

普通上下文是否保存 IRQ 屏蔽状态是 OS 设计问题。不能在最小 Context_Control 中没保存 CPSR,却又期待每个任务拥有互不相同的 I 位。

第一版可明确一种策略:

  • 调度只在内核规定的屏蔽状态下进入;
  • 切换汇编不把完整 CPSR 当普通任务私有状态;
  • IRQ 尾部通过异常帧恢复异常前 CPSR。

8.3 屏障指令只先建立地图

Cortex-A9 SMP 与 MMIO 初始化还会用:

1
2
3
dmb                  @ 约束显式内存访问顺序
dsb @ 等待先前相关访问完成
isb @ 刷新流水线,使上下文改变对后续指令生效

volatile 只能约束编译器,不能替代 CPU 屏障。具体使用在 GIC、Cache、SMP 实现阶段结合手册裁决,本章不把所有屏障规则提前塞进来。


9. 一个可推演的最小 ARM 普通上下文

教学结构:

1
2
3
4
5
6
7
8
9
10
11
12
typedef struct {
uint32_t r4;
uint32_t r5;
uint32_t r6;
uint32_t r7;
uint32_t r8;
uint32_t r9;
uint32_t r10;
uint32_t r11;
uint32_t sp;
uint32_t resume_pc;
} Context_Control;

对应偏移:

1
2
3
4
5
6
7
0x00 r4
0x04 r5
...
0x1C r11
0x20 sp
0x24 resume_pc
总计 0x28 = 40 字节

最小切换:

1
2
3
4
5
6
@ void context_switch(Context_Control *run, Context_Control *heir)
@ r0 = run, r1 = heir

context_switch:
stmia r0, {r4-r11, sp, lr}
ldmia r1, {r4-r11, sp, pc}

保存时,最后一项把当前 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
2
3
4
A_ctx 在 0x1000
B_ctx 在 0x2000
A 当前 sp = 0x8100,lr = A_after_switch
B_ctx.sp = 0x9100,B_ctx.resume_pc = B_after_switch

A 切到 B

A 调用:

1
context_switch(&A_ctx, &B_ctx);

入口:

1
2
3
r0=0x1000
r1=0x2000
当前 CPU 寄存器属于 A

保存后:

1
2
A_ctx.sp        = 0x8100
A_ctx.resume_pc = A_after_switch

恢复 B:

1
2
sp = 0x9100
pc = B_after_switch

CPU 现在使用 B 栈,并从 B 以前暂停的位置继续。

B 再切回 A

B 之后调用:

1
context_switch(&B_ctx, &A_ctx);

保存 B 当前现场,再恢复:

1
2
sp = A_ctx.sp = 0x8100
pc = A_ctx.resume_pc = A_after_switch

A 观察到最初那次 context_switch(&A_ctx,&B_ctx) 终于“返回”了。

关键认识:

这不是 A 调用一次、B 又重新调用同一个 A 函数。每个任务都恢复自己的历史调用链;一次切换函数的控制流可以隔很久后才在原任务中完成。


11. 新任务没有“上次暂停位置”,怎样第一次运行

新任务必须伪造一个能被恢复的上下文。

假设:

1
void task_entry(void *arg);

可以约定:

1
2
3
4
r4 = task_entry
r5 = arg
sp = 对齐后的新任务栈顶
resume_pc = thread_start_trampoline

恢复新任务时:

1
ldmia new_ctx, {r4-r11, sp, pc}

于是第一次跳到:

1
2
3
4
5
6
thread_start_trampoline:
mov r0, r5 @ AAPCS 第一个参数
blx r4 @ 调 task_entry(arg)
bl thread_exit @ 任务函数若返回,执行受控退出
1:
b 1b @ thread_exit 不应返回,防御性兜底

初始化时必须决定的事项

  • 栈顶按 8 字节对齐;
  • 栈增长方向正确;
  • entryarg 存在哪;
  • 初始 IRQ 是否开放;
  • task entry 返回后去哪里;
  • 是否需要 TLS/thread ID;
  • 是否需要 VFP 状态;
  • ARM/Thumb 函数地址和状态是否匹配。

“把 PC 设成 entry 就完成了”往往不够,因为还缺参数和退出路径。


12. C 结构体与汇编偏移必须是同一份事实

最危险的 Bug 之一:

1
2
3
C 结构体增加一个字段
汇编偏移没有同步
恢复时把 sp 当 r11、把数据当 pc

工程上应:

  1. 用统一头文件或生成脚本定义偏移。
  2. 在 C 中使用 _Static_assert(offsetof(...))
  3. 汇编常量从同一配置生成。
  4. 构建后用反汇编确认最终指令和偏移。

示例:

1
2
3
_Static_assert(offsetof(Context_Control, sp) == 0x20, "bad sp offset");
_Static_assert(offsetof(Context_Control, resume_pc) == 0x24, "bad pc offset");
_Static_assert(sizeof(Context_Control) == 0x28, "bad context size");

不要只依赖注释中的布局。


13. 对照 x64 mini:移植的是语义,不是指令

被移植系统的 x86_64 实现位于:

1
2
基础版教学实现/src/os_kernel/cpu/x86_64/cpu/x86_64-context-switch.S
基础版教学实现/src/os_kernel/cpu/x86_64/cpu/x86_64-context-initialize.c

它已经表达了目标接口:

1
2
3
_CPU_Context_Initialize()
_CPU_Context_switch(run, heir)
_CPU_Context_restore(new_context)

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 ... pcBX 恢复继续点
RFLAGS 中断位策略 明确 ARM CPSR.I 的管理位置

先列接口和语义,再决定 ARM 数据结构;不要保留 x86 字段名只换成 ARM 指令。


14. 对照 RTEMS:成熟实现增加了哪些工程条件

核心参考:

1
rtems-6.2/cpukit/score/cpu/arm/cpu_asm.S

其中普通 ARM 保存主线是:

1
2
3
stm r0, {r4, r5, r6, r7, r8, r9, r10, r11, r13, r14}
...
ldm r1, {r4, r5, r6, r7, r8, r9, r10, r11, r13, pc}

这直接证明了本章的 AAPCS 推导。但成熟 RTEMS 还处理:

  • per-CPU dispatch disable 状态;
  • 线程 ID 寄存器;
  • 可选 d8-d15 VFP 非易失状态;
  • SMP 下任务不能同时在两个 CPU 运行;
  • LDREX/STREX 独占状态;
  • 内存屏障;
  • 临时 per-CPU 栈。

学习顺序应是:

1
2
3
先手工证明最小两行保存/恢复
再逐项解释 RTEMS 为什么增加字段
最后按 x64 mini 的真实需求选择,而不是整段复制

当前 soft-float 第一版通常可以不保存 VFP 上下文,但若代码或手写汇编实际启用 VFP/NEON,就必须重新定义规则。


15. 主动切换与 IRQ 尾部抢占

15.1 主动切换

任务自己调用 yield()

1
2
3
4
任务位于正常 C 调用边界
调度器选择 heir
context_switch(run, heir)
利用 AAPCS 保存最小普通上下文

这是第一版最容易验证的路径。

15.2 IRQ 抢占

定时器可能打断任意指令:

1
2
3
4
5
IRQ 入口建立完整异常帧
C timer handler 设置 need_reschedule
IRQ 尾部调用调度决策
若换任务,把被中断现场与任务上下文正确衔接
最终恢复选中任务的 PC/CPSR

不能在设备 handler 深处随意用普通两行切换,因为此时还存在:

  • GIC active 状态;
  • IRQ 嵌套计数;
  • 设备清源;
  • 异常帧;
  • 当前使用的 IRQ/per-CPU 栈;
  • 需要恢复的 SPSR。

因此正确路线是:

1
2
3
4
先让两个任务主动 yield 成功
再让 tick 只计数
再让 tick 设置调度请求
最后在 IRQ 统一出口切换

16. SMP 中上下文切换多出的约束

双核不是把单核切换函数同时运行就结束了。必须保证:

  • 同一任务不能同时在 CPU0 和 CPU1 执行;
  • run/heir 状态更新对另一核可见;
  • 就绪队列和 per-CPU 当前任务有锁或原子协议;
  • 必要位置使用 DMB;
  • 目标核需要重调度时用 SGI 通知;
  • 每核有安全的 IRQ 栈和临时切换栈;
  • Cache 一致性和共享数据属性正确。

RTEMS 的 is_executingLDREXB/STREXBDMB 就在解决这些问题。

第一版建议只启 CPU0。单核主动切换正确之后,再引入 CPU1,否则错误变量会同时来自:

1
2
3
4
5
6
上下文布局
启动次核
GIC per-CPU 状态
原子操作
Cache/屏障
调度器并发

17. 汇编 Debug 的固定方法

17.1 不要只看源代码,要看最终反汇编

确认:

  • 编译器实际选择 ARM 还是 Thumb;
  • 伪指令展开成什么;
  • 符号地址最低位;
  • 结构体偏移;
  • LDM/STM 寄存器列表;
  • 链接器是否插入 veneer;
  • PC 恢复点是否可执行。

17.2 第一次切换前记录

1
2
3
4
5
6
7
run_ctx 地址
heir_ctx 地址
heir_ctx.r4-r11
heir_ctx.sp
heir_ctx.resume_pc
目标栈区 low/high
resume_pc 所在符号

17.3 切换失败按阶段断点

1
2
3
4
5
进入 context_switch
完成 STM 后
执行 LDM 前
LDM 恢复后的目标第一条指令
task_entry 第一条 C 指令

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
2
3
4
5
6
7
8
定时器事件
→ GIC pending/active
→ ARM IRQ 异常入口
→ 完整异常帧
→ C dispatcher
→ 清设备源与 EOI
→ IRQ 尾部选择是否上下文切换
→ 异常返回

这条链走通后,原定目标要求的“上下文调度移植 + 中断控制器移植”才真正汇合。

从 x86_64 语义推导 ARM 普通 context

四、先看 x86_64 版本,提取架构无关思想

1. 上下文结构

文件:

1
教学实现/src/os_kernel/cpu/include/rtems/score/cpu.h

Context_Control 的核心字段包括:

1
rbx, rsp, rbp, r12, r13, r14, r15

注释明确说明它们是 x86-64 SysV ABI 的 callee-saved 寄存器。它并没有保存所有 x86 通用寄存器。

2. 上下文切换

文件:

1
教学实现/src/os_kernel/cpu/x86_64/cpu/x86_64-context-switch.S

C 接口:

1
2
3
4
void _CPU_Context_switch(
Context_Control *run,
Context_Control *heir
);

x86-64 SysV ABI 将前两个参数放在 rdirsi。代码完成三件事:

1
2
3
把当前 callee-saved 寄存器写入 *run
从 *heir 读取另一任务的寄存器
ret

真正完成控制流切换的是:

1
2
恢复 heir->rsp
ret 从 heir 的栈顶弹出地址到 RIP

因此同一条 ret 有两种效果:

  • 对运行过的任务:回到它上次 _CPU_Context_switch() 之后;
  • 对新任务:跳到初始化时人为放在栈上的 entry_point

3. 新任务初始现场

文件:

1
教学实现/src/os_kernel/cpu/x86_64/cpu/x86_64-context-initialize.c

初始化函数:

  1. 计算栈顶;
  2. 按 ABI 对齐;
  3. 在栈上放入 entry_point,伪造成 ret 的返回地址;
  4. 把构造后的 SP 写入新上下文。

这叫“伪造初始上下文”,不是造假,而是把“新任务第一次运行”统一成“恢复一个已有上下文”的形式。


五、映射到 ARMv7-A

1. ARM 普通上下文结构

RTEMS 参考文件:

1
RTEMS-6.2/cpukit/score/cpu/arm/include/rtems/score/cpu.h

ARMv4/ARMv7-A 路径的普通核心字段是:

1
r4, r5, r6, r7, r8, r9, r10, r11, sp, lr

另有调度禁止状态,并可按配置增加:

  • d8-d15:AAPCS VFP callee-saved;
  • thread ID;
  • SMP 的 is_executing

第一轮先只抓住:

1
r4-r11 + sp + lr

2. ARM 上下文切换接口

参考文件:

1
RTEMS-6.2/cpukit/score/cpu/arm/cpu_asm.S

C 接口对应:

1
2
3
4
void _CPU_Context_switch(
Context_Control *run,
Context_Control *heir
);

根据 AAPCS32:

1
2
r0 = run
r1 = heir

核心保存:

1
stm r0, {r4-r11, r13, r14}

核心恢复:

1
ldm r1, {r4-r11, r13, pc}

这里:

  • r13 是 SP;
  • 保存时把 LR 写入上下文;
  • 恢复时把相同槽位读入 PC,而不是 LR;
  • PC 一改变,CPU 就从 heir 的恢复位置继续执行。

3. 为什么保存 LR、恢复 PC

任务 A 调用上下文切换时:

1
bl _CPU_Context_switch

BL 已把“A 调用完成后应该继续的位置”写入 A 的 LR。

保存 A:

1
A.lr 槽位 = A 的返回位置

恢复 B:

1
把 B.lr 槽位直接装入 PC

效果等价于“从 B 上次调用上下文切换的位置返回”。因此上下文切换在 A 的调用中进入,却可能在 B 的调用中返回。

这是第一次看最反直觉的地方,必须记住:

_CPU_Context_switch() 的调用者和最终返回到的任务,不一定是同一个任务。


六、手工走一遍 A → B → A

假设:

1
2
3
4
5
A 的 SP = 0x80001000
A 的 LR = A_after_switch
B 的上下文已经保存:
B.sp = 0x80002000
B.lr = B_after_switch

A 执行:

1
_CPU_Context_switch(&A_ctx, &B_ctx);

第一步:参数

1
2
r0 = &A_ctx
r1 = &B_ctx

第二步:保存 A

1
2
3
A_ctx.r4-r11 = 当前 r4-r11
A_ctx.sp = 0x80001000
A_ctx.lr = A_after_switch

第三步:恢复 B

1
2
3
r4-r11 = B_ctx.r4-r11
sp = 0x80002000
pc = B_after_switch

从这一刻开始,CPU 已在 B 的栈和控制流上运行。以后 B 调用切换回 A:

1
2
3
保存 B 的新状态
恢复 A_ctx.sp
恢复 A_ctx.lr → pc

A 看起来就像它最初调用的 _CPU_Context_switch() 终于返回了。


七、新任务没有“上次执行位置”怎么办

新任务从未执行,不能保存出真实的 LR 和 SP,所以初始化函数要构造:

1
2
3
new_ctx.sp = 新任务对齐后的栈顶
new_ctx.lr = task_entry 或启动包装函数
new_ctx.r4-r11 = 初始值

更完整的实现通常不直接跳入用户的 task_entry,而是跳到包装函数:

1
2
3
4
5
6
static void thread_begin(void)
{
enable_or_restore_interrupt_state();
current_task->entry(current_task->arg);
thread_exit();
}

原因:

  • 正确准备参数;
  • 统一中断状态;
  • 任务函数意外返回时进入退出处理,而不是跳到随机地址;
  • 可以执行线程本地存储、FPU 等初始化。

所以移植时需要明确:

1
恢复 PC 到 task_entry

还是:

1
恢复 PC 到 thread_begin,再由它调用 task_entry

第二种通常更稳健。


八、本节需要掌握的 ARM 汇编

1. 单寄存器访存

1
2
ldr r2, [r0, #8]     // r2 = *(uint32_t *)(r0 + 8)
str r3, [r1, #12] // *(uint32_t *)(r1 + 12) = r3

上下文结构字段本质上就是“基地址 + 固定偏移”。

2. 多寄存器访存

1
2
stm r0, {r4-r11, r13, r14}
ldm r1, {r4-r11, r13, pc}

STM:Store Multiple,把多个寄存器写入连续内存。
LDM:Load Multiple,从连续内存恢复多个寄存器。

没有 ! 时,基址寄存器不回写;上下文指针仍指向结构起点。

寄存器按编号与连续内存字对应,不按花括号中书写的随意顺序重新排列。移植时必须让 C 结构偏移与汇编的装载顺序完全一致。

3. 栈别名

1
2
push {r4, lr}     // 常见别名:stmdb sp!, {r4, lr}
pop {r4, pc} // 常见别名:ldmia sp!, {r4, pc}

! 表示将更新后的地址写回 SP。

4. 调用和返回

1
2
bl function      // LR = 返回信息;PC = function
bx lr // 跳回 LR,并按地址最低位处理 ARM/Thumb 状态

5. 状态寄存器

1
2
mrs r0, cpsr     // CPSR → r0
msr cpsr_c, r0 // r0 的控制字段 → CPSR
  • 最常见的应用:
1
2
3
4
5
mrs r0, cpsr        @ 1. 读出 CPSR 到 r0
orr r0, r0, #0x80 @ 2. 将 Bit 7 (I 位) 置 1,代表禁用 IRQ 中断
msr cpsr_c, r0 @ 3. 将修改后的低 8 位写回 CPSR,正式关闭中断!``

`MRS/MSR` 的具体权限和字段限制必须按 ARM ARM 查证,不要把任意数值直接写入 CPSR。

6. 中断屏蔽

1
2
cpsid i          // 屏蔽 IRQ
cpsie i // 允许 IRQ

对“保存旧状态后临时关中断”的代码,不能只在结尾无条件 cpsie i,而应恢复进入前的 I 位,否则调用者原本要求关中断的语义会被破坏。

7. 条件执行与分支

1
2
3
4
5
6
7
8
// r0 - 0
cmp r0, #0
// 如果r0为0,则跳转到zero_case
beq zero_case
// 如果r0不为0,则跳转到nonzero_case
bne nonzero_case
// 相当于 goto target
b target

本节会读懂即可,异常返回的 RFE、带 ^LDM 等放到下一主题系统学习。


九、上下文结构与汇编必须严格同步

如果 C 中:

1
2
3
4
5
6
typedef struct {
uint32_t r4;
uint32_t r5;
uint32_t sp;
uint32_t lr;
} Context_Control;

汇编却按:

1
2
3
4
offset 0  = r4
offset 4 = r5
offset 8 = lr
offset 12 = sp

恢复后 SP 和 PC 会互换,通常立即跑飞。

工程上应至少采用一种保证:

  • 汇编偏移由公共头文件宏生成;
  • C 使用 static_assert(offsetof(...)) 校验;
  • 构建时生成 offsets;
  • objdump 和 GDB 检查实际布局。

这是上下文移植最常见、最致命、也最好验证的一类问题。


十、第一轮调试时看什么

切换前记录:

1
2
3
4
run 地址
heir 地址
当前 SP
run/heir 中保存的 SP、LR

单步保存后检查:

1
2
3
run->r4-r11 是否等于切换前寄存器
run->sp 是否位于当前任务栈范围
run->lr 是否指向切换调用后的代码

单步恢复后检查:

1
2
3
4
SP 是否进入 heir 栈范围
即将装入 PC 的地址是否为代码地址
SP 是否保持 8 字节对齐
ARM/Thumb 状态是否与目标地址约定一致

如果恢复后立刻跑飞,优先排查:

  1. 结构偏移错;
  2. 新任务栈方向或对齐错;
  3. LR/PC 初始值错;
  4. ARM/Thumb 状态位错;
  5. 任务入口返回后没有合法去向;
  6. 中断状态恢复时机错。

新任务初始上下文的完整实现

下面给出文章对应的完整实现片段。即使没有配套仓库,也可以沿着注释、寄存器访问和调用关系逐行阅读。代码之后的分析只讨论本章主题,不要求预先掌握其他目录结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#include <stddef.h>
#include <stdint.h>

#include "context.h"

extern void context_start_trampoline(void);

_Static_assert(offsetof(Context_Control, r4) == ARM_CONTEXT_R4, "r4 offset");
_Static_assert(offsetof(Context_Control, r5) == ARM_CONTEXT_R5, "r5 offset");
_Static_assert(offsetof(Context_Control, r6) == ARM_CONTEXT_R6, "r6 offset");
_Static_assert(offsetof(Context_Control, r7) == ARM_CONTEXT_R7, "r7 offset");
_Static_assert(offsetof(Context_Control, r8) == ARM_CONTEXT_R8, "r8 offset");
_Static_assert(offsetof(Context_Control, r9) == ARM_CONTEXT_R9, "r9 offset");
_Static_assert(offsetof(Context_Control, r10) == ARM_CONTEXT_R10, "r10 offset");
_Static_assert(offsetof(Context_Control, r11) == ARM_CONTEXT_R11, "r11 offset");
_Static_assert(offsetof(Context_Control, sp) == ARM_CONTEXT_SP, "sp offset");
_Static_assert(offsetof(Context_Control, lr) == ARM_CONTEXT_LR, "lr offset");
_Static_assert(offsetof(Context_Control, cpsr) == ARM_CONTEXT_CPSR, "cpsr offset");
_Static_assert(sizeof(Context_Control) == ARM_CONTEXT_SIZE, "context size");

enum {
ARM_CPSR_MODE_SVC = 0x13u,
ARM_CPSR_FIQ_DISABLE = 1u << 6,
ARM_CPSR_IRQ_DISABLE = 1u << 7
};

void _CPU_Context_Initialize(
Context_Control *context,
void *stack_begin,
size_t stack_size,
void (*entry)(void *),
void *argument,
void (*exit_handler)(void),
bool enable_irq
)
{
uintptr_t stack_top = (uintptr_t)stack_begin + stack_size;
stack_top &= ~(uintptr_t)0x7u;

context->r4 = (uint32_t)(uintptr_t)entry;
context->r5 = (uint32_t)(uintptr_t)argument;
context->r6 = (uint32_t)(uintptr_t)exit_handler;
context->r7 = 0u;
context->r8 = 0u;
context->r9 = 0u;
context->r10 = 0u;
context->r11 = 0u;
context->sp = (uint32_t)stack_top;
context->lr = (uint32_t)(uintptr_t)context_start_trampoline;
context->cpsr = ARM_CPSR_MODE_SVC | ARM_CPSR_FIQ_DISABLE;

if (!enable_irq) {
context->cpsr |= ARM_CPSR_IRQ_DISABLE;
}
}

普通上下文切换的完整汇编

下面给出文章对应的完整实现片段。即使没有配套仓库,也可以沿着注释、寄存器访问和调用关系逐行阅读。代码之后的分析只讨论本章主题,不要求预先掌握其他目录结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#include "context_offsets.h"

.syntax unified
.cpu cortex-a9
.arm

.section .text, "ax", %progbits
.align 2

.global _CPU_Context_switch
.type _CPU_Context_switch, %function
_CPU_Context_switch:
stmia r0, {r4-r11}
str sp, [r0, #ARM_CONTEXT_SP]
str lr, [r0, #ARM_CONTEXT_LR]
mrs r2, cpsr
str r2, [r0, #ARM_CONTEXT_CPSR]

mov r0, r1
b .Lrestore
.size _CPU_Context_switch, . - _CPU_Context_switch

.global _CPU_Context_restore
.type _CPU_Context_restore, %function
_CPU_Context_restore:
.Lrestore:
ldmia r0, {r4-r11}
ldr sp, [r0, #ARM_CONTEXT_SP]
ldr lr, [r0, #ARM_CONTEXT_LR]
ldr r1, [r0, #ARM_CONTEXT_CPSR]
msr cpsr_c, r1
bx lr
.size _CPU_Context_restore, . - _CPU_Context_restore

.global context_start_trampoline
.type context_start_trampoline, %function
context_start_trampoline:
mov r0, r5
blx r4
blx r6
.Ltask_exit_returned:
wfi
b .Ltask_exit_returned
.size context_start_trampoline, . - context_start_trampoline

.global scheduler_restore_first
.type scheduler_restore_first, %function
scheduler_restore_first:
mov sp, r0
ldmia sp!, {r0-r12, lr}
rfefd sp!
.size scheduler_restore_first, . - scheduler_restore_first

.section .note.GNU-stack, "", %progbits

从规则走到实际实现

下面回到本章对应的小系统实现。这里不再假定读者已经打开源码,而是把关键地址、数据结构、指令序列和返回路径直接放在文章中解释。

进程、线程和任务在这里各指什么

线程是执行流:它有寄存器、继续执行位置、栈以及调度状态。进程通常还表示一组资源与地址空间边界,同一个进程的多个线程共享这组资源。

当前小系统中的“任务”更接近内核线程。任务共用一套地址空间,处于 SVC 模式,可以直接访问相同的全局变量;每个任务有独立栈和自己的保存现场。

项目 当前内核任务 带隔离的用户进程
通用寄存器与 SP/PC 每个任务独立 每个执行线程独立
页表/地址空间 共享 通常按进程区分
权限级别 SVC 特权态 还需要 User 与内核的边界
全局数据 直接共享 通常受各自虚拟地址空间约束
切换附加状态 当前很少 可能有 TTBR、ASID、TLS、用户栈等

所以换了 SP 不等于完成“进程切换”;开了 MMU 也不等于已经有多个进程。当前先解决同一地址空间中的执行流保存。

一个任务至少要保留两块东西

1
2
3
4
5
task_a_context                       task_a_stack
r4-r11 局部变量
sp ─────────────────────────────→ 保存的返回地址
lr 上层函数调用现场
cpsr 尚未返回的调用链

Context_Control 只是栈外的一份索引和寄存器保存区。任务栈本身保存了层层调用关系,不能在切出以后借给另一个任务反复覆盖。

基础版中每个任务栈为 4096 字节,按 8 字节对齐。数组地址加长度得到栈顶,再向下取整对齐。栈向低地址增长,初始 SP 指向高地址边界。

保存 A、恢复 B,逐条看

来源:基础版 src/context_switch.S

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
_CPU_Context_switch:
stmia r0, {r4-r11}
str sp, [r0, #ARM_CONTEXT_SP]
str lr, [r0, #ARM_CONTEXT_LR]
mrs r2, cpsr
str r2, [r0, #ARM_CONTEXT_CPSR]

mov r0, r1
b .Lrestore

_CPU_Context_restore:
.Lrestore:
ldmia r0, {r4-r11}
ldr sp, [r0, #ARM_CONTEXT_SP]
ldr lr, [r0, #ARM_CONTEXT_LR]
ldr r1, [r0, #ARM_CONTEXT_CPSR]
msr cpsr_c, r1
bx lr

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
2
3
do_work_a();
_CPU_Context_switch(&task_a_context, &task_b_context);
after_resume_a();

BL 到切换函数时,LR 指向调用之后。因此保存 LR 就保存了 A 将来进入 after_resume_a 所需的继续点。

这里没有显式保存当前 PC,是因为切换只发生在这个已知调用边界。IRQ 可以发生在任何指令位置,不能依靠“某个调用之后”代替任意被打断位置;这就是后续异常帧需要 return_pc 的原因。

从未运行过的 B,哪来的保存现场

初始化函数人为构造一份能被同一恢复代码接受的现场。来源:基础版 src/context.c 的核心赋值。

1
2
3
4
5
6
7
8
9
uintptr_t top = (uintptr_t)stack_begin + stack_size;
top &= ~(uintptr_t)0x7u;

context->r4 = (uint32_t)(uintptr_t)entry;
context->r5 = (uint32_t)(uintptr_t)argument;
context->r6 = (uint32_t)(uintptr_t)exit_handler;
context->sp = (uint32_t)top;
context->lr = (uint32_t)(uintptr_t)context_start_trampoline;
context->cpsr = 0x13u | (1u << 6) | (1u << 7);

这里选择关闭 IRQ 的 SVC 初始状态;实际函数通过 enable_irq 参数控制 I 位,其余 r7-r11 清零。

为什么把入口函数放 r4,而不是直接放到 LR?因为首次启动还要完成“把参数放到 r0、调用入口、处理任务返回”的流程。trampoline 承担这一层适配:

1
2
3
4
5
6
7
context_start_trampoline:
mov r0, r5
blx r4
blx r6
.Ltask_exit_returned:
wfi
b .Ltask_exit_returned

恢复以后先进入 trampoline,它把 argument 放到 r0,调用 entry。entry 意外返回时调用 exit_handler,避免 CPU 继续执行未知内存。

这些 r4/r5/r6 是首次启动时的约定,不是线程生命周期内永久存放入口参数的专用寄存器。任务运行以后,它们会正常承担编译器分配的 callee-saved 值。

从有限测试开始,比直接死循环更容易定位

基础版先运行一个有限的测试:

1
boot → A1 → B1 → A2 → B2 → A3 → B3 → A恢复 → boot恢复

其中 boot 自己也有一个 Context_Control。A 最后切回 boot,就能继续执行后面的 GIC、Timer 和 SMP 自检。

这比一启动就跑永久任务多验证了一件事:保存的不仅是 A/B,两份任务栈以外的原始内核调用链也能恢复。检查成功后,再启动长期任务演示。

永久协同任务的逻辑可简化为:

1
2
3
4
5
for (;;) {
++cooperative_a_count;
console_dashboard_update_tasks(/* 当前统计 */);
_CPU_Context_switch(&task_a_context, &task_b_context);
}

B 对称地更新自己的计数再切回 A。其执行顺序是任务主动决定的,属于协同切换。B 如果从不让出,A 就没有下一次运行机会。

基础版的 ANSI 界面会按采样限帧;想逐条核对严格的 A1/B1/A2/B2 顺序,应使用纯文本配置:

1
2
ZYNQ_CONSOLE_ANSI=OFF ./scripts/build.sh
./scripts/run_qemu.sh

界面采样没显示每一次 B,并不直接等于 B 没运行。状态观测和调度机制要分开验证。

这段汇编有哪些使用前提

第一,任务模式一致。本实现使用 SVC 任务,恢复 SP 后再写 CPSR 控制字段,在这个约束内有明确意义;不能随意构造 User/System/IRQ 混合 context 后声称仍是通用恢复函数。跨模式任务需要重新设计 banked SP、权限边界与返回机制。

第二,普通切换路径必须与异步中断互斥。当前永久协同演示关闭 IRQ,避免处于半保存、半恢复状态时被打断。将来若允许这种路径与 Timer 抢占共存,要定义在哪个时刻 current_task 切换、谁屏蔽中断、恢复后如何回到原屏蔽状态。

第三,现场仅覆盖当前整数模型。VFP/NEON、TLS、地址空间、调试状态等扩展,要按实际功能加入;不能仅扩大一个结构体而不修改所有恢复路径。

第四,普通调用允许改变条件标志。因此 cpsr_c 控制字段恢复与完整异常 CPSR 恢复不可混用。若程序通过非标准方式要求某些标志跨切换调用保持,就已经超出当前 ABI 合同。

怎样证明不是两个结构体互相赋值

在 GDB 中同时看四类证据:

1
2
3
4
5
break _CPU_Context_switch
info registers r0 r1 sp lr cpsr
x/11wx $r0
x/11wx $r1
disassemble _CPU_Context_switch

单步越过保存序列后,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

作者

GoKo Mell

发布于

2026-06-20

更新于

2026-06-20

许可协议

评论

:D 一言句子获取中...