Build Your Own RTOS 系列文章

  1. 当前阅读:理解M3架构
  2. 硬件自动压栈
  3. 初始化栈
  4. PendSV
  5. 时间片轮转
  6. 延时
  7. 信号量

写在前面

懂一个东西的最好办法就是自己做一个。 刚好不管是单片机开发方面还是Linux方面,实时操作系统的原理都是非常重要的,于是我打算开启这个系列,用大约两个月的时间自己动手制作一个基于STM32F103C8T6(Cortex-M3,以后简称CM3)平台的RTOS。这个系列仅作为个人项目记录,只能作为参考,不能当做教程。本系列需要有微机原理、STM32开发和RTOS的前置知识,如果你还没有接触过,请先入门这几个领域后再阅读本系列。

如果你打算跟随本系列,需要了解以下内容:

本项目结构如下:

1
2
3
4
5
6
7
8
9
Build-Your-Own-RTOS/
├── MDK-ARM/ # Keil MDK 工程文件
├── RTOS/ # RTOS 核心源码
│ ├── Inc/ # 内核头文件 (os_core.h 等)
│ ├── Src/ # 内核逻辑实现 (调度算法、时基管理)
│ └── Portable/ # 硬件移植层 (最核心的汇编代码在这里)
│ └── ARM_CM3/ # 针对 Cortex-M3 的 PendSV 实现与栈初始化
├── Core/ # 用户应用层 (main.c)
└── README.md # 项目说明文档

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),模式复制(0xXYXYXYXY0x00XY00XY0xXY00XY00),编译器会将其优化,于是就可以把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.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的特殊功能寄存器包括:

  1. 程序状态寄存器组(xPSR)
  2. 中断屏蔽寄存器组(PRIMASK, FAULTMASK,以及 BASEPRI)
  3. 控制寄存器(CONTROL)

它们只能被特定的指令MRS/MSR访问,也没有与之相关联的访问地址。

1
2
MRS <gp_reg>, <special_reg> ;读特殊功能寄存器的值到通用寄存器
MSR <special_reg>, <gp_reg> ;写通用寄存器的值到特殊功能寄存器

1.5.1 程序状态寄存器组

程序状态寄存器在其内部又被分为三个子状态寄存器:

  • 应用程序 PSR(APSR)
  • 中断号 PSR(IPSR)
  • 执行 PSR(EPSR)

所以,通过读取PSR的不同位来获取各项信息。

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 特权级别

处理器支持特权级用户级两种特权操作。这种分级提供了存储器访问的保护机制,防止普通用户程序意外或恶意地执行涉及系统要害的操作。

  1. 特权级:
    • 特权级的程序可以访问所有范围的存储器(如果有存储器保护单元 MPU,则在 MPU 规定的禁地之外)。
    • 可以执行所有指令。
    • 处理器在复位后默认以特权级启动。
  2. 用户级:
    • 一旦进入用户级,程序受到限制,无法访问系统控制空间(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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
高地址  +-------------------+
| xPSR |
+-------------------+
| PC | <-- 返回地址 (被中断的那条指令)
+-------------------+
| LR | <-- 函数返回
+-------------------+
| R12 |
+-------------------+
| R3 |
+-------------------+
| R2 |
+-------------------+
| R1 |
+-------------------+
| R0 | <-- 栈顶 (SP 指向这里)
低地址 +-------------------+

注意:虽然我们常说压栈顺序是 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 的值变成了什么?

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 的值应该是多少?和你看到的一样吗?

4.3 手动压栈

简单模拟一下RTOS上下文切换的手动操作过程,将R4到R11压入当前栈(SP 指向的位置)。在main.c中编写一个函数__asm void save_context_test()来完成。

提示:先读出PSP的值,存到R0中,再将R4和R11压入R0所存的地址中。你可能会用到的指令有MRSSTMDB。(如果还不会用,可以查阅《指南》的第四章)

使用以下这个函数来伪造数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
__asm void setup_fake_environment(void)
{
// 【造假数据】强行把 R4-R11 填满记号
MOV R4, #0x04040404
MOV R5, #0x05050505
MOV R6, #0x06060606
MOV R7, #0x07070707
MOV R8, #0x08080808
MOV R9, #0x09090909
MOV R10, #0x10101010
MOV R11, #0x11111111

LDR R0, =0x20001000
MSR PSP, R0

BX LR
}

然后在main函数里依次调用setup_fake_environmentsave_context_test,观察寄存器数据、内存里的数据。

5 总结

本次我们比较深入的理解了和RTOS架构紧密相关的寄存器和操作模式等,为我们理解并设计RTOS的任务切换打下了基础。

下一次我们将亲手触发中断,看看中断触发时到底会发生什么,以及我们要在PendSV_Handler中做些什么。