Operating Systems for Practical Programmers [4] – Into the Kernel

/ 0评 / 1

Where the main begins.

在上一节,我们进行了时钟的基本设置。现在我们要为驱动层和用户层做准备。这是一篇综述,具体的实现和问题将会在之后的章节一一展开。

系统计数器

我们最开始需要处理的部分就是系统计数器。尽管这个东西听起来有些奇怪,不过这个是我们之后进行任务调度时所不可或缺的一环。

在 ARM 中,有 SysTick 计时器可以作为系统计数器。从《编程手册》4.5 节中可以知道 SysTick 是一个 24 位的向下自重载计数器,根据设置可以选择每个 AHB 时钟或者每 8 个 AHB 时钟周期计一次数,当计数值下溢时触发一次中断并自动重载。

我们可以考虑 10us 的中断周期,并使用 AHB 时钟。同时我们需要为每 10us 计数。如果我们用 32 位无符号数计时的话大概可以连续运行 49 天。考虑到目前我们不需要考虑连续运行 10 天以及以上的应用程序,所以可以不用考虑计时器溢出的问题。

AHB 时钟在之前的设定为 168MHz. 要产生 1ms 周期的中断,计数值应该为 168000.

static volatile uint32_t time = 0;

void clock_setup() {
  SysTick_Config(168000u);
}

void SysTickHandler() {
  ++time;
}

注意到 volatile. 为了避免这个值被缓存到寄存器内,我们需要明确地指示编译器这个变量可能会在其他线程中被访问而不提前告知。不过 volatile 并不保证读写操作的原子性,也不保证缓存一致性,它仅明确告知编译器每次对其操作时都必须从内存中读取并写回内存,而不是优化到寄存器内。

内存管理

内存的分配

比较让人感到遗憾的是,Cortex-M 系列似乎没有内嵌的内存管理单元,这意味着内存分页和虚拟内存这一票好玩的东西都没戏。当然,可以通过软件模拟的方式实现内存分页和虚拟内存,但是这样造成的影响是十分显著的,以及会阻碍编译器帮我们收拾烂摊子

所以,我们就直接用现成的片内内存好了。

内存分配的一个非常简单的实现方法是链表追踪方法,即用一个小的内存分配子标记一块内存区域和下一个内存分配子。内存分配子同时也负责记录对应内存块是否在使用、使用者是谁等等相关信息。

A, C 为内存分配子;B, D 为内存块

使用链表的最大好处在于插入和删除都是简单的。

在分配内存时,我们可以沿着链表查找一块大小合适的且未被占用的内存块并使用。在释放内存时,我们只需要查找对应内存的分配子并标记释放即可。

当然,在最开始的状态下,整块内存都由一个分配子表示,此时进行分配时,我们可以将当前的分配子表示的内存大小缩小到合适的大小,再追加分配子表示余下的内存。当内存释放时,相邻的空闲分配子也按此方法进行合并。

D 内存块缩减,增加了 E, F

特权模式与非特权模式

Cortex-M4 的权限划分要更有趣一些,首先,内核有两种执行状态:中断处理模式和线程模式;中断处理模式始终在特权模式下运行,而线程模式则可以选择在特权模式或者非特权模式下运行。特权线程模式只能从中断处理模式进入,非特权线程模式则可以从两个特权模式中进入。

尽管按照规划,作为飞控我们并不需要特别为用户程序提供保护,但是有些时候用户程序可能会包含错误的代码从而更改我们不希望更改的值,进而发生非常有趣的难以排查的问题,所以对于系统内存,我们仍然需要进行保护。

但是对系统内存进行保护是一个很复杂的事情,一方面 STM32F4 只允许我们划分 8 块内存空间,这样的话就不可能提供任务级别的保护——至少是不可能在低开销的情况下进行内存保护,毕竟每次切换任务的时候都需要重新配置内存保护表,这属实麻烦;再者是对于全局变量的保护行为要更复杂一些。

那么,我们能否退而求其次——只保护操作系统内存呢?

不能。由于在内存分配子系统上的设计,操作系统内存也是非常松散地排列在整个可用内存空间内的。我们需要保护的无非就是任务调度的相关数据,而这些数据和应用程序数据也是放在一起的,它们也直接来自与 memory_allocate. 我们或许可以修复这个问题,比如创建 system_memory_allocate 函数从 SRAM 分配内存而不是从 CCMRAM 中分配内存,在其他配置下就是从一定的地址开始分配内存,进而实现操作系统内存的保护。

我们能做到的最好的程度就是禁止执行 CCMRAM 或者堆栈上的数据,即在 MPU 内设置对应区域的 XN 位,这样在发生意外跳转的时候就可以及时拦截。我们也可以禁止非特权任务直接访问硬件寄存器、内核寄存器和其他不存在的外设地址。

中断控制

从《参考手册》中我们可以知道所有中断都是在特权模式中进行的。这意味着内核需要接管所有的中断。

中断开关和临界区

我们首先需要做的事情就是获取开关中断的能力。CMSIS 库提供了一些函数,但是我们并不使用,而选择使用自己的函数。其实也不复杂,就两行而已:

void interrupt_disable() {
  __asm volatile ( "CPSID I" );
}

void interrupt_enable() {
  __asm volatile ( "CPSIE I" );
}

开关中断的主要目的是实现临界区。作为内核,我们将不可避免地使用一些全局变量。当对这些全局变量进行修改的时候,最好能保证内核不被其他事务打断导致关键变量修改失效,比如之前提到的系统计数器。

临界区的实现比中断的开关要更复杂一些,因为临界区有嵌套的情况。最简单的方案就是:在进入临界区时,给临界区计数变量 +1, 出临界区时,给临界区计数变量 -1. 尽管是一个比较朴素甚至危险的解决方案,但至少它能用:

static volatile uint32_t criticalCounter = 0;

void critical_enter() {
  interrupt_disable();
  ++criticalCounter;
}

void critical_exit() {
  if (--criticalCounter < 1) {
    interrupt_enable();
  }
}

我们也可以通过 CPSID E 实现关闭异常。但是关闭异常一方面是会关闭 SysTick 异常,从而导致实时性被破坏;另一方面是大多数异常发生的时候代表程序爆炸了,关闭异常会导致所有异常都升格到 HardFault 加重调试负担。

系统调用

STM32 的系统调用十分特殊:它提供了两个系统调用:SVC 中断和 PendSV 中断。

这两个中断的主要区别在于:PendSV 中断是可以挂起的。这为任务切换做好了准备:因为在任务切换期间,可能会有其他中断打断切换过程。如果我们在如 SysTick 等高优先级中断内进行任务切换,那么其他中断将会被打断,同时我们将直接进入被切换的任务。这会产生一个 UsageFault, 因为在有中断尚未结束的情况下就切入了线程模式。

PendSV 可以作为一个最低优先级中断挂起。当 SysTick 发生时,可以设置一个 PendSV 中断,在 SysTick 结束后让处理器自动进入被挂起的中断并进行任务切换。即使有其他中断发生,其他中断也会随着 SysTick 退出后处理,再回落到 PendSV 进行切换。

当然,用户程序向操作系统进行请求使用的是 SVC 中断而非 PendSV 中断。

消息

但是一般而言,我们需要在进程之间传递信息。一个非常传统的做法就是使用全局变量。全局变量虽然实现起来很容易,但是维护起来……最好的情况就是很头疼。或许我们可以使用消息队列改善这个情况。

但是消息队列并不是万能的。首先,在理想情况下,消息队列应该是具有无穷容量的、满足「至少到达一次」的。但是在单片机上,完全没有这种奢侈。我们实际将要实现的软件队列总是有穷的,且完全不能对消息的可靠到达作出保障。

听起来很糟糕,确实如此。但话虽这么说,大部分时间消息还是会到达接收方的。

消息系统有两种实现方案:发布-订阅模式和邮箱模式。发布-订阅模式实际上是一种回调机制,通过一个中心化的消息队列向各个订阅者发送消息。但是这样会有一个有趣的问题:难以保证回调函数运行的实时性。事实上消息队列本身就非常的不实时,考虑到它的典型用途在于保持最终一致性,所以无论用怎样聪明的办法,总是会撞到这样那样的问题:要么这个通知是阻塞运行的(破坏实时性),要么这个通知是异步运行的(破坏一致性)。

所以各位见到的大多数嵌入式实时操作系统都使用了邮箱。因为邮箱的一致性和实时性都比较容易保证——至少邮箱的操作可以是阻塞的。至于用户任务是否检查邮箱,那么就不是操作系统内核需要关心的问题了。

任务调度

合作和抢占

任务调度有两种方式:合作式调度和抢占式调度。合作调度的方式就是:任务主动向操作系统交出执行权;抢占调度的方式就是:所有任务竞争执行权,先到先得,且优先级高的任务可以打断优先级低的任务的执行。

合作调度实际上是协程 (coroutine) 的一个实现。所谓协程,就是能够在函数内部发生中断 (yield) 和继续 (resume) 的特殊函数。当发生中断后,函数的状态会被保存;在继续后,函数的状态会被恢复。一个协程可以多次通过中断的方式向调用者返回值。

我们也可以实现混合调度,或者说可合作的抢占调度。任务可以主动放弃执行权。

时间片

为了保证操作系统的实时性,我们希望操作系统能够保证任务的执行时间尽量稳定。一个保证执行稳定性的方法是使用时间片,即为每个任务分配一定的时间,并在给定时间耗尽后(或者任务主动放弃时间片时)调入下一个任务。这样我们可以不必等待一个任务完成就保存状态并调入其他任务。

时间片也可以是竞争的,高优先级的任务可以比低优先级的任务优先获得执行权。同时,也可以为不同的任务分配不同的时间片长度,让更耗时的任务获取更长的执行时间。

一个任务也可以要求定期执行(即时间片预约),结合优先级,可以创建需要固定时间内执行的关键任务。

发表评论

电子邮件地址不会被公开。 必填项已用*标注