Build Your Own RTOS Part1:理解M3架构的寄存器
Build Your Own RTOS 系列文章:
写在前面
懂一个东西的最好办法就是自己做一个。 刚好不管是单片机开发方面还是Linux方面,实时操作系统的原理都是非常重要的,于是我打算开启这个系列,用大约两个月的时间自己动手制作一个基于STM32F103C8T6(Cortex-M3,以后简称CM3)平台的RTOS。这个系列仅作为个人项目记录,只能作为参考,不能当做教程。本系列需要有微机原理、STM32开发和RTOS的前置知识,如果你还没有接触过,请先入门这几个领域后再阅读本系列。
如果你打算跟随本系列,需要了解以下内容:
- 作者使用的编译环境是Keil ARMCC V5.06、使用STM32CubeMX自动生成HAL库的Keil项目文件。
- 请一定要在网上下载《Cortex-M3权威指南》(后称《指南》)的pdf作为参考资料,它非常重要。本系列的绝大多数硬件知识是基于这本书整理而成。
- 请在https://github.com/SandOcean-ovo/Build-Your-Own-RTOS上阅读README.md,根据文档中的指令完成项目结构的配置。
本项目结构如下:
1 | Build-Your-Own-RTOS/ |
1 认识寄存器
CM3架构的MCU拥有通用寄存器 R0-R15 以及一些特殊功能寄存器。


1.1 通用寄存器(R0-R12)
这里简单提一下,根据AAPCS(ARM Architecture Procedure Call Standard,ARM过程调用标准),为了高效率的处理,将R0-R3四个寄存器用于函数传参。(例如C语言中调用add(a, b, c ,d),这里的a, b, c, d就是参数)
注意:在Thumb-2指令集中,不可以直接MOV一个32位的立即数到寄存器,因为指令长32位,光是一个立即数就把指令占满了,它就无法执行。但是对于某些特殊的立即数,比如小于8位的(0x000000XY),移位后的小于8位的(0x000XY000),模式复制(0xXYXYXYXY 、0x00XY00XY、0xXY00XY00),编译器会将其优化,于是就可以把32位的数塞进指令里。在这期及以后,你可能会看到我使用MOV将一个32位数移到寄存器里,但那些都是利用了ARM的这个编码特性,是合法的。但对于绝大多数的立即数,使用MOV是非法的,所以我们使用伪指令LDR {寄存器} =0x12345678,它会在内存的一个特定位置读到这个数据并存到寄存器中。
1.2 栈指针(Stack Point,R13)
这是我们设计RTOS的基础。(由于历史遗留问题,许多中文资料将 Stack Point 翻译成“堆栈指针”,这里纠正这个错误,使用正确的“栈指针”)
在 CM3 处理器内核中共有两个栈指针,于是也就支持两个栈。当引用 R13(或写作 SP)时,引用到的是当前正在使用的那一个,另一个必须用特殊的指令来访问(MRS,MSR指令)。这两个栈指针分别是:
- 主栈指针(MSP):这是默认的堆栈指针,它由 OS 内核、异常服务例程以及所有需要特权访问的应用程序代码来使用。
- 进程栈指针(PSP):用于常规的应用程序代码(不处于异常服用例程中时)。
注意: CM3内部确实有两个寄存器MSP和PSP,CPU会根据特殊寄存器CONTROL的状态,将其中一个映射(Banked)到R13上,这就是你必须用MRS、MSR指令访问另一个的原因。
两个栈指针带来什么好处?
- 如果只有一个栈指针,内核和用户程序就只能共用一个栈指针,虽然可以用,但做什么事都得战战兢兢地,只要做错一件事程序就极有可能跑飞。有了两个栈指针,内核和用户程序就内分开,即使用户程序跑崩了,也不至于把整个系统都搞坏,只要在内核把这个跑崩的用户程序“开除”就可以了。
- 操作系统的一个基本原理是“任务调度”,有两个栈指针,内核就不会干扰到上下文切换。
1.2.1 栈的操作模式:满减栈
ARM架构支持多种栈操作模式,但是CM3默认使用满减栈。
- “满”:SP指针总是指向最后一个被压入栈的数据(即栈顶是有数据的),而不是空的可用槽位。
- “减”:堆栈向低地址方向生长。
这意味着:
- 压栈(PUSH)时:SP 先自减(SP = SP - 4),然后将数据存入 SP 指向的地址。
- 出栈(POP)时:先读出 SP 指向的数据,然后 SP 自增(SP = SP + 4)。
这就是为什么在后面的实验中,你会看到随着数据的压入,SP 的地址值反而越来越小。
1.3 连接寄存器(Linking Register,R14)
LR 用于在调用子程序时存储返回地址。它主要解决的是程序在调用“函数”结束后,返回到哪里的问题。
例如使用汇编指令BL funtion(分支并链接子程序function)时,硬件会自动把下一个命令的地址填入LR,当子程序结束后(使用BX LR返回),通过阅读LR的值来返回原地址继续执行任务。
如果需要嵌套函数调用,则可以使用PUSH {LR}将LR的值压入堆栈,结束后使用POP {PC}再拿出来即可。
1.4 程序计数器(Program Counter,R15)
PC 是用于追踪程序流的指针,由于流水线的存在,它总是指向当前执行位置的“前方”。
比如有这样的程序:
1 | 0x1000: MOV R0, PC ; R0 = 0x1004 |
即:读PC的值会返回当前指令地址 +4。
由于指令集的原因,加载到 PC 的数值必须是奇数(即 LSB=1)。 通俗的讲,你不能向PC写入偶数,否则会进入异常。但是向PC写入奇数后,PC会自动将其-1。
1.5 特殊功能寄存器组
CM3的特殊功能寄存器包括:
- 程序状态寄存器组(xPSR)
- 中断屏蔽寄存器组(PRIMASK, FAULTMASK,以及 BASEPRI)
- 控制寄存器(CONTROL)
它们只能被特定的指令MRS/MSR访问,也没有与之相关联的访问地址。
1 | MRS <gp_reg>, <special_reg> ;读特殊功能寄存器的值到通用寄存器 |
1.5.1 程序状态寄存器组
程序状态寄存器在其内部又被分为三个子状态寄存器:
- 应用程序 PSR(APSR)
- 中断号 PSR(IPSR)
- 执行 PSR(EPSR)
所以,通过读取PSR的不同位来获取各项信息。

比较需要了解的就是,高位(Bit27 - Bit31)是APSR,用于数学运算后标志负数、零、借位等信息;低位(Bit0 - Bit8)是IPSR,存储了异常编号;中间位是EPSR,除了Bit[24]的T位外(我们以后会讲),通常不需要改动。
1.5.2 中断屏蔽寄存器组
用于控制异常的使能和除能。
- PRIMASK:一个单一比特的寄存器。在它被置1后,就关掉所有可屏蔽的异常,只剩下 NMI(不可屏蔽中断) 和 Hard Fault 可以响应。它的默认值是 0,表示没有关中断。
- FAULTMASK:同样是单一比特。被置1后,除了NMI外的异常均不被响应。它的默认值同样是 0。
- BASEPRI:这个寄存器最多有 9 位(由表达优先级的位数决定)。它定义了被屏蔽优先级的阈值。当它被设成某个值后,所有优先级号大于等于此值的中断都被关闭(优先级号越大,优先级越低)。但若被设成 0,则不关闭任何中断,0 也是默认值。
1.5.3 控制寄存器
控制寄存器有两个用途,其一用于定义特权级别,其二用于选择当前使用哪个堆栈指针。由两个比特来行使这两个职能。
- Bit0:决定当前特权级别,置0时为特权级(Privileged),置1时为用户级(User)。当进入Handler模式时,CPU会无视这个寄存器值,直接进入特权级。仅当在特权级下才有对该位操作的权限,所以一旦进入了用户级,唯一返回特权级的途径就是触发一个中断,再由服务例程改写该位。
- Bit1:决定栈指针功能的选择。置0时选择MSP(这也是复位后的默认值),置1时选择PSP。在线程或基础级,可以使用 PSP。在 Handler 模式下,只允许使用 MSP,所以此时不得往该位写 1。
2 操作模式和特权级别
同样是我们设计RTOS的基础。

2.1 操作模式
CM3有两个操作模式:线程(Thread)模式和处理者(Handler)模式。
线程模式用于运行普通应用程序的代码,可以使用特权级,也可以使用用户级。复位后,处理器默认进入线程模式
Handler模式用于运行异常服务例程的代码,包括中断服务程序。它总是特权级的。
2.2 特权级别
处理器支持特权级和用户级两种特权操作。这种分级提供了存储器访问的保护机制,防止普通用户程序意外或恶意地执行涉及系统要害的操作。
- 特权级:
- 特权级的程序可以访问所有范围的存储器(如果有存储器保护单元 MPU,则在 MPU 规定的禁地之外)。
- 可以执行所有指令。
- 处理器在复位后默认以特权级启动。
- 用户级:
- 一旦进入用户级,程序受到限制,无法访问系统控制空间(SCS),该空间包含配置寄存器组和调试组件的寄存器组。
- 用户级代码禁止访问和修改除APSR之外的特殊功能寄存器。如果尝试越权访问 SCS,将触发Fault异常。
- 用户级程序不能直接修改 CONTROL 寄存器以返回特权级。唯一返回特权级的途径是通过异常(例如执行系统调用指令 SVC),由异常服务例程接管并完成模式切换。
3 CM3中RTOS的上下文切换
这是操作系统的最底层逻辑之一。
3.1 上下文(Context)
实际上,上下文可以理解成所有16个寄存器+xPSR。如果在一个任务未完成时将它们先分别保存好,然后修改它们,让去完成另一个任务,之后再将它们移回来,CPU就会继续刚刚未完成的任务。
3.2 如何保存上下文
在CM3的RTOS里,保存R0-R15不是一次性完成的,而是分成了“自动”和“手动”两部分。RTOS 的任务切换通常发生在PendSV异常(这是 OS 专门用来切换任务的软中断)里。
3.2.1 硬件自动保存
当 PendSV 中断发生的一瞬间,CPU会自动把最紧急的东西扔进栈里。
它们是:xPSR, PC, LR, R12, R3, R2, R1, R0,这些是“易失性”的,必须马上保存。
1 | 高地址 +-------------------+ |
注意:虽然我们常说压栈顺序是 xPSR -> PC -> … -> R0,但由于是“满减栈”,R0最终会位于内存的最低地址(也就是当前的SP位置)。这一点在后续的内存取证实验中至关重要。
3.2.2 软件手动保存
也就是我们要做的操作,在PendSV中断服务函数里,将其余的R4-R11也保存在栈中。
3.3 核心:切换上下文
现在所有东西都存完了,我们就开始换“灵魂”(操作SP):
- 保存旧SP: 把当前(任务1)的PSP值,记录任务1的“任务控制块(TCB)”里。
- 加载新SP: 从任务2的TCB中,读出任务2上次停下来的PSP值。
- 写入SP: 把这个值塞进CPU的PSP寄存器。
接下来,软件(我们)再手动将任务2的R4-R11出栈,执行中断返回,硬件最后会自动将易失性寄存器出栈,一个上下文切换就做完了。
4 动手实操
出于篇幅考虑,本系列不打算过于详细的介绍汇编指令和C语言编程,但是对于特定的代码,会给出必要的注释和解释。
4.1 A Hello-World
先用一个简单的题目来熟悉一下汇编语言,并验证一下上面的关于AAPCS的知识。
题目: 在 main.c 文件中编写一个函数 __asm int asm_add(int a, int b),并在main函数中调用。进入Debug模式以查看各寄存器的值。
思考:LR 寄存器里的值指向哪里?执行完加法函数返回后,PC 的值变成了什么?
1 | __asm int asm_add(int a, int b) |


观察R0、R1寄存器的变化。
4.2 读取MSP/PSP
题目: 在main.c中编写两个函数__asm uint32_t get_msp_val()和__asm uint32_t get_psp_val(),将MSP/PSP的值移至R0并在main函数中调用。进入Debug模式,重点观察R0寄存器的值。
思考:系统刚复位并在main运行时,PSP 的值应该是多少?和你看到的一样吗?
1 | __asm uint32_t get_msp_val() |



4.3 手动压栈
简单模拟一下RTOS上下文切换的手动操作过程,将R4到R11压入当前栈(SP 指向的位置)。在main.c中编写一个函数__asm void save_context_test()来完成。
提示:先读出PSP的值,存到R0中,再将R4和R11压入R0所存的地址中。你可能会用到的指令有MRS和STMDB。(如果还不会用,可以查阅《指南》的第四章)
使用以下这个函数来伪造数据:
1 | __asm void setup_fake_environment(void) |
然后在main函数里依次调用setup_fake_environment和save_context_test,观察寄存器数据、内存里的数据。
1 | __asm void save_context_test() |


内存地址0x20001000前,整齐的躺着我们“伪造”的数据。 我们使用的STMDB是自减,天生带有PUSH的属性,故在这里是内存地址减少的存储。
5 总结
本次我们比较深入的理解了和RTOS架构紧密相关的寄存器和操作模式等,为我们理解并设计RTOS的任务切换打下了基础。
下一次我们将亲手触发中断,看看中断触发时到底会发生什么,以及我们要在PendSV_Handler中做些什么。
.jpg)