本文是切换汇编系列第五篇 。前四篇讲完了语法打底(第 1、2 篇)、首任务启动(第 3 篇)、临界区与触发(第 4 篇);本篇讲最后也是最重要的一段------
HalPendSV的现场搬运,也就是"任务切换"这四个字真正发生时,CPU 和那几十行汇编在干什么。工程限定 :OpenHarmony LiteOS-M v3.0-LTS + STM32MP157 M4 独立启动(Cortex-M4 + 硬件 FPU,GCC + Makefile)。讲解对象仍是 LiteOS-M v3.0-LTS 的
los_dispatch.S,本工程一行未改。
文章目录
-
- 〇、先看地图:本篇三站路线图(整体)
- 一、第①站:开场------舞台换演员(存旧取新总逻辑)
-
- [1.1 整体:舞台换演员](#1.1 整体:舞台换演员)
- [1.2 局部:进场前提------硬件已经做了什么](#1.2 局部:进场前提——硬件已经做了什么)
- 二、第②站:五阶段逐行精讲
-
- [2.1 整体:五阶段总览](#2.1 整体:五阶段总览)
- [2.2 局部:阶段① 序章------保存中断状态,关中断](#2.2 局部:阶段① 序章——保存中断状态,关中断)
- [2.3 局部:阶段② 问调度器------C 算法与汇编的交接面](#2.3 局部:阶段② 问调度器——C 算法与汇编的交接面)
-
- [为什么 push r12?](#为什么 push r12?)
- [为什么 push lr?](#为什么 push lr?)
- [`cmp` 在 `mov` 之前的排序技巧](#
cmp在mov之前的排序技巧)
- [2.4 局部:阶段③ 存旧任务------把 R4-R12 和 d8~d15 收进旧任务自己的栈](#2.4 局部:阶段③ 存旧任务——把 R4-R12 和 d8~d15 收进旧任务自己的栈)
-
- [`stmfd r0!, {r4-r12}`:寄存器号小的放低地址](#
stmfd r0!, {r4-r12}:寄存器号小的放低地址) - [`str r0, r6`:旧任务的全部家当=一个栈指针](#
str r0, [r6]:旧任务的全部家当=一个栈指针)
- [`stmfd r0!, {r4-r12}`:寄存器号小的放低地址](#
- [2.5 局部:阶段④ 换任务------改两个指针](#2.5 局部:阶段④ 换任务——改两个指针)
- [2.6 局部:阶段⑤ 恢复新任务------按存旧的镜像流程反向搬运](#2.6 局部:阶段⑤ 恢复新任务——按存旧的镜像流程反向搬运)
- [2.7 局部:对称性一览------存旧 ↔ 取新逐位镜像](#2.7 局部:对称性一览——存旧 ↔ 取新逐位镜像)
- [三、第③站:栈帧几何------204 字节与 `TaskContext` 逐槽对齐](#三、第③站:栈帧几何——204 字节与
TaskContext逐槽对齐) -
- [3.1 整体:204 字节怎么摞](#3.1 整体:204 字节怎么摞)
- [3.2 局部:204 字节逐槽账单表](#3.2 局部:204 字节逐槽账单表)
- 四、源码小毛刺(诚实档)
- 五、小结:本篇你该带走的三句话
〇、先看地图:本篇三站路线图(整体)
读完本篇,你应该能回答三个问题:
HalPendSV整体在搬运什么? ------ 答:把旧任务没来得及自己收的 callee-saved 收进旧任务栈,再从新任务栈取出摆好。- 这几十行汇编能分成几个阶段? ------ 答:五阶段------存旧 → 过户 → 取新 → 恢复 PSP → 异常返回。
- 204 字节的栈帧是怎么和
TaskContext结构体逐槽对齐的? ------ 答:软件存低地址、硬件弹高地址,合计 204 字节(FPU)或 68 字节(非 FPU)。

本篇先看整体,再逐站拆:
- 第①站 开场 :
HalPendSV像舞台总监,帮旧演员把道具收进柜子、从新演员柜子取出。 - 第②站 五阶段逐行:序章(存中断状态)→ 问调度器 → 存旧 → 过户 → 取新 + 异常返回。
- 第③站 栈帧几何 :204 字节的来龙去脉,与
TaskContext逐槽对齐(铁律③具象化)。

一句话记住:
HalPendSV五阶段------存旧 → 过户 → 取新 → 恢复 PSP → 异常返回。
一、第①站:开场------舞台换演员(存旧取新总逻辑)
1.1 整体:舞台换演员
正常任务切换(本篇的 HalPendSV)像舞台换演员:
- 旧演员正在演,被导演(PendSV 异常)打断。
- 旧演员下场前把戏服道具 (R4-R12、d8~d15 这些 callee-saved 寄存器)收进自己的柜子(旧任务自己的栈)。
- 柜子钥匙(栈指针)记在 TCB 的
stackPointer字段里。 - 新演员上场前从自己的柜子取出来穿戴好(恢复 R4-R12、d8~d15)。
- 硬件自动把"上场台词本"(R0~R3、R12、LR、PC、xPSR 这 8/26 字硬件帧)弹回去------新演员从上次被打断的台词继续演。
这就是 HalPendSV 的全部逻辑:存旧(把 callee-saved 收进旧任务栈)→ 取新(从新任务栈恢复 callee-saved)。硬件帧由 CPU 自动压弹,软件只补存 callee-saved 那批。
【类比·舞台换演员】
HalPendSV是舞台总监------旧演员被叫下场(PendSV 进场),先帮他收戏服道具进自己柜子(存旧 R4-R12、d8~d15),柜子钥匙交给 TCB 保管;然后从新演员的柜子取出他的戏服道具(取新),让他穿戴好上场。硬件负责自动弹"台词本"(PC 槽指向断点),新演员从被打断的台词接着演。
1.2 局部:进场前提------硬件已经做了什么
PendSV 异常触发后硬件自动进场,此时:
- Handler 模式(异常态)。
- 用 MSP(异常态用主栈)。
- 硬件已把 8/26 字硬件帧 压进旧任务的 PSP 栈(FPU 活跃时是 26 字扩展帧)。
- LR = EXC_RETURN (FPU 口径下是
0xFFFFFFED)。
剩下的 callee-saved 寄存器(R4-R12、d8~d15),就是这段汇编要搬运的货。
二、第②站:五阶段逐行精讲
2.1 整体:五阶段总览
先把五阶段摊开看一眼,后面每一段代码都会回到这张图:

| 阶段 | 干什么 | 关键指令 |
|---|---|---|
| ① 序章 | 存中断状态 + 关中断 | mrs r12, PRIMASK / cpsid I |
| ② 问调度器 | 调 C 算法选新任务 | push {r12,lr} / blx OsSchedTaskSwitch |
| ③ 存旧 | 收进旧任务自己的栈 | stmfd r0!, {r4-r12} / str r0, [r6] |
| ④ 过户 | 改两个指针 | str r0, [r5](runTask = newTask) |
| ⑤ 取新 | 恢复新任务 + 异常返回 | ldmfd r1!, {r4-r12} / msr psp, r1 / bx lr |
源码按五阶段拆解(每段都是原文照抄,注解是我加的)。每阶段先说"做什么",再说"代码怎么做"。
2.2 局部:阶段① 序章------保存中断状态,关中断
asm
HalPendSV:
mrs r12, PRIMASK @ r12 = 进异常前的中断屏蔽状态
cpsid I @ 关中断:搬运现场的过程不能被打断
做什么:记下中断屏蔽状态(等下恢复用),然后关中断。
为什么拿 r12 当仓库?r0~r3、lr 马上都有大用(要传参、存 EXC_RETURN),r12 是 caller-saved 的临时寄存器,此刻闲着------下文会看到它一路被"借"得恰到好处。
【类比·r12 当仓库】r12 像进门时手里拎的塑料袋------临时装一下钥匙(PRIMASK 快照),等会出门(恢复时)再倒出来。r0~r3、lr 这些"正经工位"接下来都有正经活,r12 这种"临时杂役"此刻空着,正好借用。
2.3 局部:阶段② 问调度器------C 算法与汇编的交接面
注:下面出现的
HalTaskSwitch:是HalPendSV函数内部的一个局部标号,不是独立函数,仅用于阶段③/⑤ 的跳转。
asm
HalTaskSwitch:
push {r12, lr} @ r12(PRIMASK快照)和lr(EXC_RETURN)要过C函数这一关,先保护
blx OsSchedTaskSwitch @ 调C函数:调度算法选出新任务,返回值放r0(0=不用切)
pop {r12, lr} @ 取回两件行李
cmp r0, #0 @ 判断返回值,更新标志位
mov r0, lr @ 顺手把EXC_RETURN暂存进r0(mov不改标志位!)
bne TaskContextSwitch @ 返回值非0 → 要切任务
msr PRIMASK, r12 @ 不用切:恢复中断状态
bx lr @ 异常返回,回到被打断的原任务,一切如旧
做什么 :调 C 函数 OsSchedTaskSwitch 让调度算法选新任务;返回 0 不用切(直接异常返回回原任务),返回非 0 要切(跳到阶段③)。
三个细节全是真功夫:
为什么 push r12?
AAPCS 里 r12 是 caller-saved------C 函数有权随意用掉它。r12 里装着 PRIMASK 快照,过 blx 这关必须先 push 后 pop,否则 C 函数可能把它弄丢。
【类比·push r12】像你要进 C 函数这个"可能有污染的车间",手里的贵重物品(PRIMASK 快照)得先存进门口储物柜(
push {r12, lr}),出来再取回(pop)。
为什么 push lr?
blx 会把返回地址写进 lr,覆盖掉 EXC_RETURN 。丢了它,最后 bx lr 就不是异常返回而是跳飞。
cmp 在 mov 之前的排序技巧
mov 不改标志位,所以 bne 判断的仍是 cmp r0, #0 的结果。要是先 mov r0, lr 再 cmp,比较的对象就错了------r0 已经被覆盖成 EXC_RETURN 了。
【类比·排序技巧】像考试时先判分(cmp 更新标志位)再誊抄答案(mov 不改标志位)------顺序不能反,反了就误判。
bne看的是 cmp 那一瞬间的判分结果,mov 后誊抄不动判分。
2.4 局部:阶段③ 存旧任务------把 R4-R12 和 d8~d15 收进旧任务自己的栈
asm
TaskContextSwitch:
mov lr, r0 @ EXC_RETURN从r0放回lr(r0接下来要用)
mrs r0, psp @ r0 = 旧任务的PSP(硬件帧基址)
stmfd r0!, {r4-r12} @ ★R4~R12压入旧任务栈,r0随之下移36字节
@ 注意:r12此刻=PRIMASK快照,正好落进帧的uwPriMask槽!
ldr.w r3, =OS_FPU_CPACR @ 运行时再判一次FPU
ldr r3, [r3]
and r3, r3, #OS_FPU_CPACR_ENABLE
cmp r3, #OS_FPU_CPACR_ENABLE
bne __DisabledFPU1
vstmdb r0!, {d8-d15} @ FPU路径:d8~d15(=S16~S31)再压64字节
__DisabledFPU1:
ldr r5, =g_losTask
ldr r6, [r5] @ r6 = 旧任务TCB
str r0, [r6] @ ★TCB->stackPointer = r0:旧任务的全部家当=一个栈指针
★ 号两行是全文最核心的两笔:
stmfd r0!, {r4-r12}:寄存器号小的放低地址
9 个寄存器入栈,从低到高正好是 R4, R5, ..., R11, R12------与 TaskContext 的 uwR4...uwR11, uwPriMask 槽位严丝合缝。
妙在 r12 恰好装着 PRIMASK,顺势落进 uwPriMask 槽------每个任务的中断屏蔽状态就这样跟着自己的栈走,恢复时各回各家。
【类比·r12 落 uwPriMask 槽】像收行李时箱子按编号从低到高摞(R4 在最底,R12 在最上),而 r12 里恰好装着 PRIMASK 快照------它自然落进最上面那个标着"中断状态"的格子。这是巧合也是设计:
stmfd {r4-r12}把 r12 当最后一个压,正好对上TaskContext把uwPriMask放在 R4~R11 之后。
str r0, [r6]:旧任务的全部家当=一个栈指针
旧任务的"全部状态"(R4-R12、d8~d15 都在栈上了)最终浓缩成一个栈指针 存进 TCB 的 stackPointer 字段。第 3 篇讲过"TCB 只记一个栈指针,寄存器快照全在任务栈上"------这就是落笔处。
【类比·浓缩成一个栈指针】旧演员下台前,戏服道具全收进自己柜子,柜子钥匙交给 TCB 保管。TCB 不需要记每件道具在哪------记一把钥匙(栈指针)就够,下次取时开柜子全在里面。
2.5 局部:阶段④ 换任务------改两个指针
asm
ldr r0, [r5, #4] @ r0 = g_losTask.newTask(调度器在阶段②选好的)
str r0, [r5] @ g_losTask.runTask = newTask:官册过户
ldr r1, [r0] @ r1 = 新任务的栈指针(TCB第一个成员)
做什么 :把"调度器选好的新任务"正式登记为"当前运行任务"------runTask = newTask。然后取新任务的栈指针 r1。
所谓"切换任务",在数据结构层面就一句 runTask = newTask。
2.6 局部:阶段⑤ 恢复新任务------按存旧的镜像流程反向搬运
(承接阶段④,r1 已装好新任务栈指针。)
asm
and r3, r3, #OS_FPU_CPACR_ENABLE @ ←源码原样:这行其实是无效指令,见第四节"小毛刺"
cmp r3, #OS_FPU_CPACR_ENABLE
bne __DisabledFPU2
vldmia r1!, {d8-d15} @ FPU路径:先弹新任务的d8~d15,r1上移64字节
__DisabledFPU2:
ldmfd r1!, {r4-r12} @ 弹R4~R12(含新任务自己的PRIMASK→r12),r1上移36字节
msr psp, r1 @ PSP指向新任务的硬件帧基址
msr PRIMASK, r12 @ ★恢复的是【新任务】自己的中断屏蔽状态
bx lr @ ★异常返回:硬件弹硬件帧,PC=新任务断点,切换完成
做什么 :按存旧的镜像流程反向搬运------先弹 FPU 寄存器(如果有),再弹 R4~R12(含新任务自己的 PRIMASK),把 PSP 推到新任务的硬件帧基址,恢复新任务的中断状态,最后 bx lr 异常返回。
2.7 局部:对称性一览------存旧 ↔ 取新逐位镜像
| 动作 | 存旧任务 | 恢复新任务 |
|---|---|---|
| FPU 寄存器 | vstmdb r0!, {d8-d15} 向下压 |
vldmia r1!, {d8-d15} 向上弹 |
| 通用寄存器 | stmfd r0!, {r4-r12} |
ldmfd r1!, {r4-r12} |
| 栈指针落账 | str r0, [r6] 存进旧 TCB |
msr psp, r1 送进 PSP |
| 中断状态 | r12→uwPriMask 槽随栈保存 | uwPriMask 槽→r12→msr PRIMASK |
恢复时用的是新任务自己的 PRIMASK ------阶段⑤的 msr PRIMASK, r12 里的 r12,此时是从新任务栈的 uwPriMask 槽弹出来的,不是旧任务的。每个任务的中断屏蔽状态各回各家。
三、第③站:栈帧几何------204 字节与 TaskContext 逐槽对齐
3.1 整体:204 字节怎么摞
【看不懂图没关系】下面这张 ASCII 图信息密,看不懂别慌------记住一句话:"软件存低地址、硬件弹高地址,合计 204 字节与 TaskContext 对齐"。图是给动手验证时对照用的,不是要背的。
FPU 口径,新任务栈从低到高:
低地址 ┌──────────────────────────┐ ← TCB->stackPointer(r1 起点)
│ d8~d15(S16~S31) 64字节 │ ← vldmia 先弹这 64B
├──────────────────────────┤
│ R4~R11 + PriMask 36字节 │ ← ldmfd 再弹这 36B
├──────────────────────────┤ ← msr psp 后 PSP 在这
│ S0~S15+FPSCR+保留 72字节 │ ┐
├──────────────────────────┤ ├ 26字扩展硬件帧
│ R0,R1,R2,R3,R12,LR,PC,xPSR│ ┘ ← bx lr 硬件自动弹这 104B
高地址 └──────────────────────────┘
合计 64+36+72+32 = 204 字节 = sizeof(TaskContext) ✓
204 字节 = 64 + 36 + 72 + 32 ,每一处都必须和
TaskContext结构体对应。任何一处的编译期大小与运行期偏移对不上,就是 68/204 错位 HardFault。

3.2 局部:204 字节逐槽账单表
| 区域 | 槽位 | 字节数 | 由谁压入 | 由谁弹出 | 对应 TaskContext 字段 |
|---|---|---|---|---|---|
| 软件帧(低地址) | d8d15(S16S31) | 64 | vstmdb r0!, {d8-d15} |
vldmia r1!, {d8-d15} |
uwS16~uwS31 |
| 软件帧 | R4~R11 + PriMask | 36 | stmfd r0!, {r4-r12} |
ldmfd r1!, {r4-r12} |
uwR4~uwR11, uwPriMask |
| 扩展硬件帧 | S0~S15 + FPSCR + 保留 | 72 | 异常进入时硬件自动 | 异常返回时硬件自动 | uwS0~uwS15, uwFPSCR, uwReserved |
| 核心硬件帧 | R0,R1,R2,R3,R12,LR,PC,xPSR | 32 | 异常进入时硬件自动 | 异常返回时硬件自动 | uwR0~uwR12, uwLR, uwPC, uwxPSR |
| 合计 | 204 |
非 FPU 工程 :去掉 d8~d15(64B)和 S0~S15+FPSCR+保留(72B),剩下 36+32 = 68 字节。这就是为什么同一套代码会出现 68/204 两种口径。
对照 los_arch_context.h 的 TaskContext 结构体:S16~S31 在前、R4~PriMask 居中、R0~xPSR 在后、S0~FPSCR 垫底------结构体、压栈顺序、弹出顺序三方完全一致 。非 FPU 工程少掉两段浮点区,68 字节,同样三方一致。这正是铁律③的具象化------编译期结构体大小与运行期汇编偏移严格对齐。
四、源码小毛刺(诚实档)
【先说清】这不是 bug,是历史遗留,不影响运行。提它只是教你怎么读源码------问每条指令"它改变了什么"。
阶段⑤第二行的 and r3, r3, #OS_FPU_CPACR_ENABLE 是无效指令 ------r3 自阶段③做掩码后未被任何指令改过,与同一个立即数再做一次位与,结果不变,等价于 NOP;真正起作用的是后面的 cmp/bne。
这是官方源码里的冗余(也许是早期版本中途动过 r3 的残留)。读内核要敢于下这个判断:每条指令都问一句"它改变了什么",答不上来的就是摆设。当然,改不改它不归我们管------arch 层一行不动是移植纪律。
【类比·冗余指令】像菜谱里写"先把鸡蛋打碗里,再把鸡蛋打碗里"------第二步是空操作,不影响结果。读源码遇到这种"貌似多余"的指令,要敢于判它 NOP,别硬找它的用途。
五、小结:本篇你该带走的三句话
- 存旧取新是镜像对称 :
stmfd↔ldmfd、vstmdb↔vldmia,软件只搬运 callee-saved(R4-R12、d8d15),硬件帧(R0R3、R12、LR、PC、xPSR)由 CPU 自动压弹------AAPCS 的分工决定了硬件帧和软件帧的分界。 - r12 一举两得 :序章借它存 PRIMASK 快照,阶段③恰好顺势落进
uwPriMask槽,于是"每个任务带着自己的中断屏蔽状态上车下车",恢复时各回各家。 - 切换 = 一个栈指针的过户 :数据结构层面就一句
runTask = newTask;旧任务的全部家当浓缩成 TCB 里一个stackPointer,新任务的现场从它自己的栈里原样取出。
下篇预告:《LiteOS-M 切换汇编逐行图解(6):一次完整调度接力与调试实战》------用一次
LOS_TaskDelay(200)把四函数串成接力,给出完整调度时序图,并给 objdump / GDB 实战命令,亲手把本篇的栈帧几何对一遍账。全系列收官。