一个操作系统必须满足三个要求:多路(分时)复用、隔离和交互。
(交互:如进程间通信)

本文主要介绍以宏内核为中心的主流设计,很多 Unix 操作系统都采用这种设计。

与微内核设计理念相对应的理念,这也是一个源自操作系统级别的概念。对于宏内核来说,整个操作系统就是一个整体,包括了进程管理、内存管理、文件系统等等(宏内核理念中,整个操作系统的所有组件,都属于内核)

2.1 Abstracting physical resources

每个应用程序可以根据自己的需要定制自己的库。应用程序可以直接与硬件资源进行交互,并以最适合应用程序的方式使用这些资源(例如,实现高性能)。一些用于嵌入式设备或实时系统的操作系统就是以这种方式组织的。

2.2 User mode, supervisor mode, and system calls

强隔离要求应用程序和操作系统之间有一个分界线。如果应用程序发生错误,我们不希望操作系统崩溃,也不希望其他应用程序崩溃。相反,操作系统应该能够清理崩溃的应用程序并继续运行其他应用程序。为了实现强隔离,操作系统必须安排应用程序不能修改(甚至不能读取)操作系统的数据结构和指令,应用程序不能访问其他进程的内存。

CPU 提供了强隔离的硬件支持。RISC-V 有三种模式,CPU 可以执行指令:机器模式、监督者(supervisor)模式和用户模式在机器模式下执行的指令具有完全的权限,一个 CPU 在机器模式下启动。机器模式主要用于配置计算机。Xv6 会在机器模式下执行几条指令,然后转为监督者模式。

在监督者(supervisor)模式下,CPU 被允许执行特权指令:例如,启用和禁用中断,读写保存页表地址的寄存器等
如果用户模式下的应用程序试图执行一条特权指令,CPU 不会执行该指令,而是切换到监督者模式,这样监督者模式的代码就可以终止应用程序,因为
它做了不该做的事情。

一个应用程序只能执行用户模式的指令(如数字相加等),被称为运行在用户空间,而处于监督者模式的软件也可以执行特权指令,被称为运行在内核空间。运行在内核空间(或监督者模式)的软件称为内核。

CPU提供了一个特殊的指令,可以将 CPU 从用户模式切换到监督模式,并在内核指定的入口处进入内核。(RISC-V 为此提供了 ecall 指令。)一旦 CPU 切换到监督者模式,内核就可以验证系统调用的参数,决定是否允许应用程序执行请求的操作,然后拒绝或执行该操作。

内核控制监督者模式的入口点是很重要的;如果应用程序可以决定内核的入口点,那么恶意应用程序就能够进入内核,例如,通过跳过参数验证而进入内核。

2.3 Kernel organization

一个关键的设计问题是操作系统的哪一部分应该在监督者模式下运行。

一种可能是整个操作系统驻留在内核中,这样所有系统调用的实现都在监督者模式下运行。这种组织方式称为宏内核在这种组织方式中,整个操作系统以全硬件权限运行。此外,操作系统的不同部分更容易合作。例如,一个操作系统可能有一个缓冲区,缓存文件系统和虚拟内存系统共享的数据。
宏内核组织方式的一个缺点是操作系统的不同部分之间的接口通常是复杂的(我们将在本文的其余部分看到),因此操作系统开发者很容易写 bug。xv6 和大多数 Unix 操作系统一样,是以宏内核的形式实现的。因此,xv6 内核接口与操作系统接口相对应,内核实现了完整的操作系统。

为了降低内核出错的风险,操作系统设计者可以尽量减少在监督者模式下运行的操作系统代码量,而在用户模式下执行操作系统的大部分代码。这种内核组织方式称为微内核
image.png
为了让应用程序与文件服务器进行交互,内核提供了一种进程间通信机制,用于从一个用户模式进程向另一个进程发送消息。例如,如果一个像 shell 这样的应用程序想要读写文件,它就会向文件服务器发送一个消息,并等待响应。
在微内核中,内核接口由一些低级函数组成,用于启动应用程序、发送消息、访问设备硬件等。这种组织方式使得内核相对简单,因为大部分操作系统驻留在用户级服务器中。

2.4 Code: xv6 organization

xv6 内核源码在 kernel/子目录下。按照模块化的概念,源码被分成了多个文件,图 2.2 列出了这些文件。模块间的接口在 defs.h(kernel/defs.h)中定义。
image.png

2.5 Process overview

xv6 中的隔离单位(和其他 Unix 操作系统一样)是一个进程。

内核用来实现进程的机制包括:用户/监督模式标志、地址空间和线程的时间切片(user/supervisor mode flag, address spaces, and time-slicing of threads)。

Xv6 使用页表(由硬件实现)给每个进程提供自己的地址空间。RISC-V 页表将虚拟地址(RISC-V 指令操作的地址)转换(或 “映射”)为物理地址(CPU 芯片发送到主存储器的地址)。
image.png
有一些因素限制了进程地址空间的最大长度:RISC-V 上的指针是 64 位宽;硬件在页表中查找虚拟地址时只使用低的 39 位;xv6 只使用 39 位中的 38 位。因此,最大地址是 238-1 = 0x3fffffffff,也就是 MAXVA(kernel/riscv.h:348)。
在地址空间的顶端,xv6 保留了一页,用于 trampoline 和映射进程trapframe 的页,以便切换到内核。

xv6 内核为每个进程维护了许多状态,它将这些状态放在 proc 结构体中(kernel/proc.h:86)。一个进程最重要的内核状态是它的页表、内核栈和运行状态。我们用 p->xxx 来表示 proc 结构的元素,例如,p->pagetable 是指向进程页表的指针。

每个进程都有一个执行线程(简称线程),执行进程的指令。
一个线程可以被暂停,然后再恢复。为了在进程之间透明地切换,内核会暂停当前运行的线程,并恢复另一个进程的线程。
线程的大部分状态(局部变量、函数调用返回地址)都存储在线程的栈中。
每个进程有两个栈:用户栈和内核栈(p->kstack)。当进程在执行用户指令时,只有它的用户栈在使用,而它的内核栈是空的。当进程进入内核时(为了系统调用或中断),内核代码在进程的内核栈上执行;当进程在内核中时,它的用户栈仍然包含保存的数据,但不被主动使用。进程的线程在用户栈和内核栈中交替执行。内核栈是独立的(并且受到保护,不受用户代码的影响),所以即使一个进程用户栈被破坏了,内核也可以执行。

一个进程可以通过执行 RISC-V ecall 指令进行系统调用。该指令提高硬件权限级别,并将程序计数器改变为内核定义的入口点。入口点的代码会切换到内核栈,并执行实现系统调用的内核指令。当系统调用完成后,内核切换回用户栈,并通过调用 sret 指令返回用户空间,降低硬件特权级别,恢复执行系统调用前的用户指令。进程的线程可以在内核中阻塞等待 I/O,当 I/O 完成后,再从离开的地方恢复。

p->state 表示进程是创建、就绪、运行、等待 I/O,还是退出。

p->pagetable 以 RISC-V 硬件需要的格式保存进程的页表,当进程在用户空间执行时,xv6 使分页硬件使用进程的 p->pagetable。进程的页表也会记录分配给该进程内存的物理页地址。

2.6 Code: starting xv6 and the first process

为了使 xv6 更加具体,我们将概述内核如何启动和运行第一个进程。

当 RISC-V 计算机开机时,它会初始化自己,并运行一个存储在只读存储器中的 boot loader。
Boot loader 将 xv6 内核加载到内存中。然后,在机器模式下,CPU 从 _entry(kernel/entry.S:6)开始执行 xv6。RISC-V 在禁用分页硬件的情况下启动:虚拟地址直接映射到物理地址。

loader 将 xv6 内核(也就是 _entry 开始)加载到物理地址 0x80000000 的内存中。之所以将内核放在 0x80000000 而不是 0x0,是因为地址范围 0x0-0x80000000 包含 I/O 设备。

_entry 处 的 指 令 设 置 了 一 个 栈 , 这 样 xv6 就 可 以 运 行 C 代 码 。 Xv6 在 文 件 start.c(kernel/start.c:11)中声明了初始栈的空间,即 stack0。在_entry 处的代码加载栈指针寄存器 sp,地址为 stack0+4096,也就是栈的顶部,因为 RISC-V 的栈是向下扩张的。现在内核就拥有了栈,_entry 调用 start(kernel/start.c:21),并执行其 C 代码。

函数 start 执行一些只有在机器模式下才允许的配置,然后切换到监督者模式。为了进入监督者模式,RISC-V 提供了指令 mret。
这条指令最常用来从上一次的调用中返回,上一次调用从监督者模式到机器模式。start 并不是从这样的调用中返回,而是把事情设置得像有过这样的调用一样:它在寄存器 mstatus 中把上一次的特权模式设置为特权者模式,它把 main 的地址写入寄存器 mepc 中,把返回地址设置为 main 函数的地址,在特权者模式中把 0 写入页表寄存器 satp 中,禁用虚拟地址转换,并把所有中断和异常委托给特权者模式。在进入特权者模式之前,start 还要执行一项任务:对时钟芯片进行编程以初始化定时器中断。在完成了这些基本管理后,start 通过调用 mret “返回” 到监督者模式。这将导致程序计数器变为 main(kernel/main.c:11)的地址。

在 main(kernel/main.c:11) 初 始 化 几 个 设 备 和 子 系 统 后 , 它 通 过 调 用 userinit(kernel/proc.c:212)来创建第一个进程。第一个进程执行一个用 RISC-V 汇编编写的小程序 initcode.S(user/initcode.S:1),它通过调用 exec 系统调用重新进入内核。正如我们在第一章中所看到的,exec 用一个新的程序(本例中是/init)替换当前进程的内存和寄存器。一旦内核完成 exec,它就会在/init 进程中返回到用户空间。init (user/init.c:15)在需要时会创建一个新的控制台设备文件,然后以文件描述符 0、1 和 2 的形式打开它。然后它在控制台上启动一个 shell。这样系统就启动了。

2.7

Security Model 用户代码和内核代码之间的区别有时是模糊的:一些有特权的用户级 进程可能提供基本的服务,并有效地成为操作系统的一部分,在一些操作系统中,有特权的 用户代码可以向内核插入新的代码(就像 Linux 的可加载内核模块一样)。

2.8 Real world

现代操作系统支持进程可以拥有多个线程,以允许一个进程利用多个 CPU。在一 个进程中支持多个线程涉及到不少xv6没有的机制,包括潜在的接口变化(如Linux的clone, fork 的变种),以控制线程共享进程的哪些方面。