本文是《RTOS移植不是玄学:8个标准环节+4条铁律》铁律①展开系列的第一篇。那篇说"上下文切换必须写汇编",本系列把那段汇编掰开揉碎、从零讲起。
本篇读者画像 :没读过汇编、或读过一点但记不住寄存器。我们从"汇编是什么"开始,只讲三件事------为什么这块阵地必须用汇编 、栈是什么 、Cortex-M 的 16 个寄存器地图与栈/AAPCS 分工。这三件事是看懂后续所有汇编的钥匙。
工程限定 :OpenHarmony LiteOS-M v3.0-LTS + STM32MP157 M4 独立启动(Cortex-M4 + 硬件 FPU,GCC + Makefile)。讲解对象是内核 arch 层的
los_dispatch.S,本工程一行未改。
文章目录
-
- 〇、先看地图:本篇三站路线图(整体)
- [一、第①站:为什么 RTOS 切换必须写汇编](#一、第①站:为什么 RTOS 切换必须写汇编)
-
- [1.1 整体:C 是"人能看懂的话",汇编是"CPU 能听懂的话"](#1.1 整体:C 是"人能看懂的话",汇编是"CPU 能听懂的话")
- [1.2 局部:C 够不着的三个特殊寄存器](#1.2 局部:C 够不着的三个特殊寄存器)
- [1.3 局部:`los_dispatch.S` 就是 LiteOS-M 的"切换母语独白"](#1.3 局部:
los_dispatch.S就是 LiteOS-M 的"切换母语独白") - [1.4 局部:Thumb-2 方言与 `.fpu` 准入门槛](#1.4 局部:Thumb-2 方言与
.fpu准入门槛)
- 二、第②站:栈------每个任务的"私有笔记本"
-
- [2.1 整体:栈就是程序运行时的"临时记忆本"](#2.1 整体:栈就是程序运行时的"临时记忆本")
- [2.2 局部:为什么每个任务必须有自己的栈](#2.2 局部:为什么每个任务必须有自己的栈)
- [2.3 重点·图解:栈向低地址生长,SP 永远指向最后写的位置](#2.3 重点·图解:栈向低地址生长,SP 永远指向最后写的位置)
- [2.4 局部:`stmfd r0!, {r4-r12}` 一步一看](#2.4 局部:
stmfd r0!, {r4-r12}一步一看) - [2.5 局部:取的时候 `ldmfd` 镜像对称](#2.5 局部:取的时候
ldmfd镜像对称)
- [三、第③站:16 个寄存器地图](#三、第③站:16 个寄存器地图)
-
- [3.1 整体:先给整张地图](#3.1 整体:先给整张地图)
- [3.2 局部:三件重点记住](#3.2 局部:三件重点记住)
- [四、AAPCS 分工:谁是公共工位,谁是私有笔记](#四、AAPCS 分工:谁是公共工位,谁是私有笔记)
-
- [4.1 整体:AAPCS 就是"谁该负责收拾"](#4.1 整体:AAPCS 就是"谁该负责收拾")
- [4.2 局部:这条表就是上下文切换的全部逻辑](#4.2 局部:这条表就是上下文切换的全部逻辑)
- 五、小结:本篇你该带走的三句话
〇、先看地图:本篇三站路线图(整体)
读完本篇,你应该能回答两个问题:
- 为什么 RTOS 的任务切换必须写汇编,不能用 C?
- Cortex-M 的 16 个寄存器里,哪些是"任务的私有笔记",哪些是"公共工位"?这条分界为什么决定了上下文切换的全部逻辑?

本篇不讲具体指令(那是第 2 篇的事),只搭三块地基:
- 第①站 为什么必须写汇编 :C 够不着的特殊寄存器(PSP/CONTROL/PRIMASK),只能用
mrs/msr这组"母语"碰。 - 第②站 栈 = 任务的私有笔记本 :每个任务为什么要有自己的栈、栈向低地址怎么"长"、压栈指令
stmfd到底干了啥。 - 第③站 16 寄存器地图 + AAPCS:哪些是"公共工位"硬件自动收、哪些是"私有笔记"软件手动收------这条分界线就是上下文切换的全部逻辑。
本系列主线先剧透一句:任务切换本质上像工人交接班------旧任务把"工作笔记"收进自己的柜子,新任务从自己柜子取出接着干。后面五篇(第 2 篇指令与异常、第 3 篇首任务启动、第 4 篇临界区与触发、第 5 篇 HalPendSV、第 6 篇完整调度接力)就是把这段"交接"从根上掰开揉碎。

一、第①站:为什么 RTOS 切换必须写汇编
1.1 整体:C 是"人能看懂的话",汇编是"CPU 能听懂的话"
先打个比方,把心理门槛拆掉。
你写 C 代码,是"用一种人能看懂的语言写指令",然后让编译器 当翻译官,把 C 翻译成 CPU 真能执行的 0/1 序列。问题是------翻译官不是万能的 。有些活儿,C 语言根本没有对应的语法让翻译官去表达,这时候就得绕过翻译官,直接用 CPU 的母语------汇编------跟 CPU 对话。
LiteOS-M 切换任务时,要碰三个东西:
- PSP(进程栈指针):当前任务用哪个栈
- CONTROL(控制寄存器):切 MSP/PSP 的开关
- PRIMASK(中断屏蔽寄存器):能不能被中断打断
这三个是 CPU 内部的"特殊寄存器",C 语言没有语法能直接读写它们 ------翻译官够不着。CPU 母语里只有两条专门指令能碰它们:mrs(读)和 msr(写)。这就是铁律①的物理根源------切换必须写汇编,不是因为程序员炫技,是因为只有 CPU 母语能说清这件事。
【类比·为什么是汇编】想象你在国外工厂工作,平时用英语(C 语言)跟翻译官沟通。但工厂地下室有几台机器,只能用当地母语(汇编)的专用旋钮才能启动------翻译官不懂当地母语,要启动机器,你必须自己学几句当地话。
mrs/msr就是这几句"专用旋钮"的话。
1.2 局部:C 够不着的三个特殊寄存器
| 寄存器 | 名字 | 作用 | 为什么 C 够不着 |
|---|---|---|---|
| PSP | Process Stack Pointer | 当前任务自己的栈指针 | 没有 C 语法能读/写 |
| CONTROL | 控制寄存器 | bit1 选 MSP/PSP;bit2 标记 FP 上下文 | 没有 C 语法能读/写 |
| PRIMASK | 中断屏蔽 | 1 = 全局关中断 | 没有 C 语法能读/写 |
这三兄弟都只能通过 mrs rN, xxx 和 msr xxx, rN 访问。只要切换任务需要改 PSP/CONTROL/PRIMASK,C 就注定干不了,必须写汇编。
1.3 局部:los_dispatch.S 就是 LiteOS-M 的"切换母语独白"
los_dispatch.S(位置:kernel_liteos_m/kernel/arch/arm/cortex-m4/gcc/)就是 LiteOS-M 为 Cortex-M4 写的这段"母语独白"。它不长------全文件去掉版权头不到 170 行,真正的指令不到 100 条------却撑起了 4 件事:
| 函数 | 干什么 | 谁调它 |
|---|---|---|
HalStartToRun |
启动第一个任务(切 PSP + 直跳任务入口) | LOS_Start() 间接调 |
HalIntLock / HalIntUnLock / HalIntRestore |
关/开/恢复中断(临界区三件套) | LOS_IntLock() 等 C 接口 |
HalTaskSchedule |
触发一次 PendSV,请求切换 | LOS_Schedule() 等调度入口 |
HalPendSV |
真正的现场搬运:存旧任务、取新任务 | PendSV 异常(硬件调它) |
这四个函数是后面四篇逐行精讲的对象(第 2 篇讲指令与异常、第 3 篇首任务启动、第 4 篇临界区与触发、第 5 篇 HalPendSV 机制、第 6 篇完整调度接力与调试)。本篇和第 2 篇只解决"认字"------把看懂这四个函数背后的语法、寄存器、异常机制讲清。看完你还不懂这四个函数怎么干活,但你会看懂每一行汇编的字面意思。
【一句话】
los_dispatch.S= LiteOS-M 为 Cortex-M4 写的"切换四件套"母语独白,不到 100 条指令撑起启动/临界区/触发/搬运四件事。
1.4 局部:Thumb-2 方言与 .fpu 准入门槛
打开 los_dispatch.S,开头四行是"自我介绍"------告诉汇编器(另一道翻译官)我们在哪个世界:
asm
.syntax unified @ 用 UAL 统一汇编语法
.arch armv7e-m @ 目标架构:ARMv7-M 扩展
.fpu fpv5-d16 @ 声明 FPU 规格
.thumb @ 后续代码编成 Thumb 指令
零基础只需要记住三件事:
① Cortex-M 只跑 Thumb-2 一种指令集
老 ARM 芯片(ARM7/ARM9 那个年代)会两套指令集(ARM 32位/Thumb 16位)切换着用。Cortex-M(包括我们用的 M4)只跑一种方言 ------叫 Thumb-2 :16 位和 32 位指令混合编码,常用指令用 16 位省空间,复杂操作用 32 位保能力,用哪条由汇编器自动挑,你不用操心。
【类比·方言】Cortex-M 是只会一种方言的"单语者"------但这门方言(Thumb-2)词汇丰富,既精炼又强大,所以够用。你写源码时不用操心"这条该编成 16 位还是 32 位",汇编器替你挑。
② .fpu 是准入门槛,不是硬件开关
这里有个容易误解的点,先说清:
los_dispatch.S声明.fpu fpv5-d16- 但 STM32MP157 的 M4 实际 FPU 是 FPv4-SP(单精度)
这不是笔误。.fpu 是给汇编器 看的"许可清单"------告诉汇编器"我这份文件里允许出现哪些浮点指令"。fpv5-d16 是个更宽的清单,本文件只用 vpush/vpop/vldmia/vstmdb 这类基础指令,没越界。
真正决定运行时能不能用 FPU 的是 CPACR 寄存器 (在 SystemInit 里使能)------那是硬件开关,与汇编器的 .fpu 是两回事。
【类比·准入门槛 vs 硬件开关】
.fpu fpv5-d16像是给汇编器一张"我允许出现这类指令"的通行证;CPACR 才是真正插在 CPU 上的电源开关。通行证宽一点没关系,只要别真用越界指令;电源不开,再宽的通行证也跑不动。
③ 注释和标号
GNU as 的 ARM 语法里:
- 行注释用
@(Keil 汇编习惯用;,在 GNU as 里;是语句分隔符,别弄混) - 块注释
/* */也认 - 标号 (如
HalPendSV:)就是"一个地址的名字",后面跳转、取值都用它
【一句话】Thumb-2 是 Cortex-M 的唯一方言,
.fpu是汇编器的许可清单不是硬件开关,@是行注释。
二、第②站:栈------每个任务的"私有笔记本"
2.1 整体:栈就是程序运行时的"临时记忆本"
讲寄存器地图前,先把"栈"这个把人吓住的字拆掉。其实它就是程序运行时的临时记忆本:
- 每次调用函数,CPU 都要记住"调用完之后该回到哪里继续"------这叫返回地址
- 函数里用到的局部变量,也要找个地方临时搁着
- 这些东西就按"后进来先用"的顺序,摞在一个叫做栈的内存区域里
"后进来先用"是什么意思?想象一摞盘子:你最后放上去的那个盘子,会最先被拿走。栈就是这个"盘子摞":压进去的数据越多,栈顶(最后一个压进去的)越先被弹出来。
2.2 局部:为什么每个任务必须有自己的栈
如果 A、B 两个任务共用一本记忆本,A 写到一半被切换出去,B 一运行就把 A 那页覆盖了------等 A 再回来时,上次的数字全没了,切换就失败了。所以 RTOS 给每个任务发一本私有笔记本(任务栈),切换时只换手里拿的是哪一本,A 的本子原封不动。
Cortex-M 里,R13(SP)就是"当前在翻哪本笔记本"的页码指针。裸机程序默认用公共笔记本 MSP;RTOS 任务态要换成自己的 PSP,每个任务各用各的。
【类比·私有笔记本】裸机是一个人干活,一本公用笔记本足够;多任务是一群人干活,每人必须有自己的笔记本。SP 就是手里拿的那本的"当前页码"。切到 PSP,等于说"现在开始翻我自己的笔记本"。
2.3 重点·图解:栈向低地址生长,SP 永远指向最后写的位置
前面说了"私有笔记本",现在把"笔记本怎么长"讲细------这是后面所有压栈/弹栈的基础。
Cortex-M 用的栈叫满递减栈(Full Descending,缩写 FD):
- 向低地址生长:地址越来越小,像从高处往低处长。
- 满栈 :SP 永远指向最后一个写进去的位置。
- 递减 :每次压新数据,先减 SP,再存数据。
看下面这张图,我们以 stmfd r0!, {r4-r12} 为例,假设压栈前 r0(它就是当前 SP)指向 0x2000_0C00:

图上三个关键信息:
- 高地址在上,低地址在下。栈从"天花板"(高地址)开始向"地板"(低地址)生长。
- 压栈前 SP 在
0x2000_0C00,这是当前最后写进去的位置(对空栈区域来说,就是栈顶)。 stmfd r0!, {r4-r12}一次性压入 9 个寄存器 (R4、R5、R6、R7、R8、R9、R10、R11、R12),每个 4 字节,共 36 字节。压完后:- SP 先减 36:
0x2000_0C00 - 36 = 0x2000_0BDC - 然后按地址从低到高依次写入:R4 放进最低地址
0x2000_0BDC,R12 放进最高地址0x2000_0BFC - 最后 SP 指向最低那个(R4),也就是
0x2000_0BDC
- SP 先减 36:
为什么要叫"满"? 因为压完后 SP 不是指向空位置,而是指向最后一个写进去的单元(R4)。为什么要叫"递减"? 因为每压一次,SP 的数字都在变小。
2.4 局部:stmfd r0!, {r4-r12} 一步一看
把这条指令拆开,每个字母都有用:
| 字段 | 含义 | 人话解释 |
|---|---|---|
st |
store | 存 |
m |
multiple | 一次存多个寄存器 |
fd |
full descending | 满递减:先减地址,再写入 |
r0 |
基址寄存器 | 这里 r0 就是当前 SP |
! |
write-back(写回) | 存完后把新地址写回 r0 |
{r4-r12} |
寄存器列表 | 要存的 9 个寄存器 |
为什么要强调 !? 如果不写 !,r0 还是原来那个老 SP;写了 !,r0 自动变成新的 SP。后面的代码通常直接拿 r0 当新 SP 继续用,这就是"写回"的妙处。
在 LiteOS-M 的 HalPendSV 里,r0 经常是 mrs r0, psp 读出来的当前任务 PSP。执行 stmfd r0!, {r4-r12} 后,r0 就是更新后的 PSP,旧任务的 R4~R12 已经被安全地收进它自己的任务栈。
2.5 局部:取的时候 ldmfd 镜像对称
压栈是为了将来能原样取回。ldmfd(Load Multiple Full Descending)就是 stmfd 的镜像:
- 地址从低到高读:先读 R4 所在的最低地址,最后读 R12 所在的最高地址。
- SP 递增:每读一个,SP 往高地址回涨 4 字节。
- 写回
!:读完后 r0 自动回到原来的老 SP。
也就是说:
asm
stmfd r0!, {r4-r12} @ 存旧:SP 往下走,R4 到 R12 依次压栈
ldmfd r0!, {r4-r12} @ 取新:SP 往回涨,R4 到 R12 依次弹回寄存器
这两条指令的顺序、地址方向完全相反,但寄存器列表相同------这正是上下文切换"存旧取新"最频繁的动作。
【一句话】
stmfd是把多个寄存器按地址从低到高摞进栈,SP 往下移;ldmfd是反过来把栈里的数据原样弹回寄存器,SP 往回涨。看懂这一存一取,后面 HalPendSV 的"存旧取新"就懂了一半。
三、第③站:16 个寄存器地图
3.1 整体:先给整张地图
这是本篇最关键的一节------后续各篇讲的每一条汇编,都要靠这张地图看懂。
Cortex-M 有 16 个通用寄存器 + 若干特殊寄存器。先给整张地图,再分块讲:
| 寄存器 | 名字/别名 | 干什么 |
|---|---|---|
| R0~R3 | --- | 传参数、存返回值、算临时值 |
| R4~R11 | --- | 函数的"长期工作台" |
| R12 | IP | 临时寄存器 |
| R13 | SP(双份:MSP 主栈 / PSP 进程栈) | 栈指针。用哪份由 CONTROL.SPSEL 决定 |
| R14 | LR | 存返回地址;异常里存 EXC_RETURN 魔术值 |
| R15 | PC | 程序计数器,改它就是改执行流 |
| --- | xPSR | 标志位(N/Z/C/V)+ 当前异常号 + Thumb 位 |
| --- | CONTROL | bit1 SPSEL(选 MSP/PSP)、bit2 FPCA(FP 上下文活跃) |
| --- | PRIMASK | 1 = 全局关中断 |
| --- | MSP/PSP | 两个栈指针本体(读值用 mrs r0, psp) |
3.2 局部:三件重点记住
① R13 是双份的------同一个编号,里面是 MSP 或 PSP,由 CONTROL 寄存器选哪个。裸机用 MSP;RTOS 任务用 PSP,让每个任务有自己的栈。
② LR 不只是返回地址 ------异常发生时,硬件会往 LR 里塞一个魔术值 EXC_RETURN(第 2 篇详讲)。这是"bx lr 能触发异常返回"的关键。
③ 特殊寄存器只能用 mrs/msr 碰------这就是第 1 站说的"CPU 母语里专门碰特殊寄存器的两句话",C 够不着的根本原因。
【一句话】16 个通用里 R13 是双份栈指针(MSP/PSP 由 CONTROL 选),R14 LR 在异常里装魔术值,特殊寄存器只能用 mrs/msr 碰------这三件事是看懂后续汇编的钥匙。
四、AAPCS 分工:谁是公共工位,谁是私有笔记
光有寄存器地图还不够------还要知道哪些寄存器是"公共工位"(谁都能用,用完不管),哪些是"私有笔记"(用完必须恢复原样)。这条分界线叫 AAPCS。
4.1 整体:AAPCS 就是"谁该负责收拾"
AAPCS = A RM A rchitecture P rocedure C alling Standard,ARM 的函数调用标准。它把通用寄存器分成两派:
| 派别 | 成员 | 规矩 |
|---|---|---|
| 调用方保管(caller-saved) | R0-R3、R12、LR、PC、xPSR、s0-s15 | 调用别人的函数前,调用方自己把这些里有用的值备份好 |
| 被调方保管(callee-saved) | R4-R11、s16-s31 | 想用就先用,用完必须恢复原样 |
【类比·分蛋糕】caller-saved 像租来的工位------你(调用方)进来前要把自己东西存好,因为下一个进来的人(被调函数)有权随便用;callee-saved 像固定的办公桌------你可以随便用,但走时必须恢复原样,让下一个回来的人接着用。
4.2 局部:这条表就是上下文切换的全部逻辑
关键来了------异常(中断)本质上像一次"任务没主动发起的函数调用":
- 中断来得突然,硬件(CPU 自己)会自动把"调用方保管"那批寄存器压进当前栈
- 剩下的"被调方保管"那批寄存器,硬件不管------RTOS 的切换汇编干的活,就是把这批寄存器存进旧任务自己的栈、再从新任务的栈恢复出来
硬件帧 vs 软件帧的分界,就是 AAPCS 分工划的这条线。LiteOS-M 的 TaskContext 结构体就是按这条线画的------后面第 5 篇会看到,结构体的字段顺序与压栈顺序严丝合缝。

【一句话】硬件管"自动压栈"(caller-saved 那 8/26 字),软件管"补存 callee-saved"(R4R11、d8d15)。AAPCS 这条分界线,决定了硬件帧和软件帧各装什么。
五、小结:本篇你该带走的三句话
- 汇编是 CPU 的母语 :C 够不着的特殊寄存器(PSP/CONTROL/PRIMASK),必须用
mrs/msr读写------这是切换必须写汇编的物理根源。 - 栈是任务的私有笔记本 :RTOS 每个任务有自己的栈(PSP),切换时只换手里拿哪一本,A 的本子原封不动;栈向低地址生长,SP 永远指向最后写进去的位置,
stmfd存、ldmfd取,镜像对称。 - AAPCS 分工决定一切 :caller-saved(R0R3、R12、LR、PC、xPSR)由硬件自动压栈,callee-saved(R4R11、d8~d15)由软件手动保存------这条分界线就是
TaskContext结构体的骨架。
本篇只搭了三块地基------为什么必须写汇编、栈、寄存器地图与 AAPCS。下一篇讲看懂函数所需的指令、异常进出栈机制、和 los_dispatch.S 全景表。
下篇预告 :《LiteOS-M 切换汇编逐行图解(2):指令·异常机制·全景表》------讲清本文件用到的指令(只精讲 4 组核心:数据搬运/多寄存器存取/特殊寄存器/跳转,其他按需查表)、异常进出栈硬件帧与 EXC_RETURN 魔术值、以及
los_dispatch.S的 7 个常量 + 4 个函数全景表。