操作系统的一个重要需求就是同时支持多个活动。例如,使用第一章介绍的系统调用
fork,一个进程可以开启一个新进程。操作系统必须在这些进程之间分时共享(time-share)计算机资源。又例如,即使进程数量比 CPU 的数量多,操作系统也必须保证所有进程都有机会执行。操作系统还必须负责资源间的隔离(isolation)。也就是说,如果一个进程有 bug 并发生了故障,其他不依赖该bug进程的进程不应受到影响。完全的隔离是过于严苛的,因为应该保证进程间交互的可行性,管道(pipe)就是一个例子。因此,操作系统需要实现这三个需求:多路复用(multiplexing),隔离(isolation)以及交互(interaction)。本章提供了操作系统实现上述三个需求的蓝图。事实证明有很多方法实现这些需求,但是本文着重介绍主流的宏内核(monolithic kernel)方法,这被用于很多 unix 操作系统。本章也简要介绍了 xv6的进程,进程是 xv6 中实现隔离的基本单元。同时介绍了 xv6 启动时创建的第一个进程。
xv6 运行在一个多核的 RISC-V 微处理器上,因此许多特性适合RSIC-V底层相关的。 RISC-V 是一个 64 位的 CPU,xv6 使用LP64 C 语言编写,L 指代 Long,P 指代 Pointer,64 位语言(但是 int 是 32 位的)。更多 RISC-V 的内容可以查阅 The RISC-V Reader: An Open Architecture Atlas。
计算机中的 CPU 需要外围硬件配合。xv6 使用 qemu 模拟的虚拟硬件,包括 RAM,包含启动代码的 ROM,链接键盘/屏幕的串行接口以及磁盘存储。
2.1 抽象物理资源
一个常问的问题可能是,为什么操作系统要包含这些内容?我们不可以像第一章里提供的系统调用表格一样,把这些实现为一套库吗?如果使用这种方式,那么每个应用都需要它自己的一套调用库,并且程序会直接与硬件资源打交道。一些嵌入式操作系统可能会以这种形式组织。
这种方式的另一个缺点是,如果同时有多个程序在执行,那么这些程序必须是运行良好且正常的。例如,一个程序需要周期性地释放 CPU 以便其他程序可以执行。如果各个程序之间可以相互信任并且每个程序都是没有 bug 的,那么这种合作式的分时共享机制或许是可行的。但是通常情况下,程序之间不会相互信任,也不能保证没有 bug,因此产生了更高层级隔离的需求。
为了实现更好的隔离,禁止程序直接访问敏感的硬件硬件资源是很好思路,我们可以把资源抽象成服务(services)。举个例子,Unix 程序使用系统调用 open, read, write, close 访问存储,而不是直接读写磁盘。
类似的,Unix 必要时会在进程间透明地切换 CPU,保存和恢复寄存器状态,程序本身不会意识到分时共享的存在。这种机制允许操作系统在进程之间共享 CPU,即使有的程序中有无尽的循环。
另一个例子是,Unix 调用 exec 构建内存镜像,而不是直接操作物理内存。这允许操作系统该决定将进程在内存中的位置。如果内存紧张,操作系统甚至可以将一些进程数据放在磁盘上。exec 也为用户提供了保存可执行程序镜像的文件系统。
Unix 的很多类型的进程间交互通过文件描述符完成。这不仅因为文件描述符抽象隐藏了很多细节,也因为这简化了交互方式的定义。例如,管道中如果一个程序失败,内核会为管道中的下一个进程生成一个文件结束信号。
2.2 用户模式,特权模式和系统调用
强大的隔离机制要求程序和操作系统之间有明显的界限。我们不希望一个程序出错会导致操作系统或其他程序也运行失败。因此,操作系统需要确保一个程序不能修改(甚至读取)操作系统的数据结构、指令等,并且一个程序也不能访问替他进程的内存。
CPU 为强隔离提供了硬件支持。RISC-V 有三种模式:机器模式(machine mode),特权模式(supervisor mode)以及用户模式(user mode)。
在机器模式运行的代码拥有所有特权,CPU 在机器模式中启动。机器模式主要用来配置计算机。xv6 一开始在机器模式执行少量的代码,之后就会进入特权模式。
在特权模式下可以执行特权指令:例如,使能/禁止中断,读取存有页表地址的寄存器等。如果运行在用户模式的程序试图执行特权指令,CPU 不会执行这些指令,而是首先切换到特权模式,特权模式下可以终止这个程序,因为它做了不该做的事情。在用户空间(user space)执行的程序只能执行用户模式下的指令,在内核空间(kernel space)执行的程序可以执行特权指令。
一个用户程序欲执行内核的系统调用,需要切换到内核模式。所有 CPU 都提供了这种切换指令,比如,RISC-V 使用 ecall 指令来实现此目的。一旦 CPU 切换到特权模式,内核会检查系统调用的参数,决定用户程序是否允许执行请求的操作,然后拒绝或执行。很重要的一点是,内核控制着切换到特权模式的入口点。为什么不是用户程序自己负责这个入口点呢?因为如果用户程序可以,那么恶意软件同样也可以,这是很危险的!
2.3 内核组织架构
操作系统设计的一个关键问题就是:让操作系统的哪些部分运行在特权模式。
宏内核的设计是把整个操作系统都放在内核里。这样的内核组织方式使得操作系统拥有完整的硬件权限,这有利于操作系统的不同部分间相互协同,同时也不用纠结把操作系统的哪些部分放在特权模式了。比如,操作系统的一份缓存可以被文件系统和虚拟内存系统共享。
但这也使操作系统的不同部分之间的接口变得复杂,从而出容易出现错误。宏内核中的错误是致命的,因为特权模式下的一个错误往往会导致整个内核崩溃,进一步导致系统中所有的用户程序崩溃。此时,只能重启系统了。
为了减少内核产生错误的风险,仅让少量的操作系统代码运行在特权模式,而让大部分的操作系统都运行在用户模式,这样的内核组织方式叫微内核。

上如阐释了微内核的设计方式。图中,文件系统作为用户级进程运行。以进程形式运行的 OS 被称为服务器(server)。为了允许应用程序与文件服务器交互,内核提供了一种进程间通信机制,将消息从一个用户模式进程发送到另一个用户模式进程。例如,如果像shell这样的应用程序想要读取或写入一个文件,它会向文件服务器发送一条消息并等待响应。
在微内核里,操作系统的大量功能都以服务的形式运行于用户态,其它程序以进程间通信的方式使用操作系统提供的服务。这样内核接口就仅包含少量的低级别的功能,诸如启动程序、发送消息、访问硬件等。
像大多数 Unix 系统一样,xv6 实现为宏内核结构。因此,xv6 内核对口对应于操作系统接口,内核就是整个操作系统。由于 xv6 提供的服务不多,因此它比某些微内核还要小,但从概念上,xv6 是宏内核。
2.4 代码:xv6 组织架构
xv6内核的源文件在kernel子目录下。以模块化的粗略概念组织在一起,各模块之间的接口定义在defs.h。
| 文件名 | 描述 |
|---|---|
| bio.c | 文件系统的硬盘块缓存 |
| console.c | 连接到用户的键盘和屏幕 |
| entry.S | 最初的启动指令 |
| exec.c | exec系统调用 |
| file.c | 对文件描述符的支持 |
| fs.c | 文件系统 |
| kalloc.c | 物理页分配 |
| kernelvec.S | 管理来自内核的trap和计时器中断 |
| log.c | 文件系统日志和崩溃恢复 |
| main.c | 在启动的时候控制其它模块的初始化 |
| pipe.c | 管道 |
| plic.c | RISC-V的中断控制器 |
| printf.c | 格式化输出到控制台 |
| proc.c | 进程与调度 |
| sleeplock.c | 让出CPU的锁 |
| spinlock.c | 不让出CPU的锁 |
| start.c | 初始机器模式下的启动代码 |
| string.c | C字符串和字节数组(byte-array)的库 |
| swtch.S | 线程切换 |
| syscall.c | 把系统调用对应到处理函数 |
| sysfile.c | 与文件相关的系统调用 |
| sysproc.c | 与进程相关的系统调用 |
| trampoline.S | 用户与内核切换的汇编代码 |
| trap.c | 管理traps和中断 |
| uart.c | 串口设备的驱动 |
| virtio_disk.c | 磁盘设备的驱动 |
| vm.c | 管理页表和地址空间 |
2.5 进程概述
像其它Unix操作系统一样,xv6把进程作为隔离单元。进程的抽象阻止了一个进程破坏或者监视其它进程的内存,CPU,文件描述符等。这种抽象也阻止了一个进程破坏内核本身。因此,一个进程不可能违反内核的隔离机制。内核必须小心地实现进程抽象,否则一个带有bug或者恶意代码的程序可能会欺骗内核或代码从而执行一些恶意操作。内核实现进程所用到的机制包括用户模式/特权模式的标志(flag),地址空间,线程的时间片。
为了强制隔离,进程抽象使程序看上去拥有自己的CPU和内存,就好像自己独占 CPU。xv6使用页表(page table)[页表由硬件实现]来实现进程各自的地址空间。RISC-V 页表将一个虚拟地址翻译到物理地址。
每个进程都有自己的页表,这使得它们的物理内存互不重合。进程的虚拟地址空间从零开始,首先是指令,接下来是全局变量,然后是栈,最后是堆区(用于进程扩展内存 malloc)**。有一些限制进程地址空间的因素:RISC-V 的指针是64 位的,但是只用低 39 位来在页表中寻址虚拟地址,并且 xv6 只使用了这 39 位中的 38 位。因此,地址最大为 $2^{38}-1=0xfffffffff$,即 MAXVA (kernel/riscv.h:348)。最顶端的两个页是保留的,上面是trampoline,下面则映射的是切换到内核的trapframe。**

xv6用struct proc记录每个进程的状态(kernel/proc.h:86)。对一个进程最重要的状态有页表,内核栈和运行状态。使用箭头操作符引用 proc 结构体的元素,比如 p->pagetable是一个指向进程页表的指针。
每个进程都有一个运行着的线程来执行进程的指令。线程可以挂起和恢复。不同进程之间的切换,就是内核挂起当前运行着的线程并恢复其它进程的线程。线程的很多状态(本地变量,函数调用的返回地址等)都保存在线程栈中。每个进程都有两个栈:用户栈和内核栈(p -> kstack)。
- ①当进程执行用户指令的时候仅使用它的用户栈,它的内核栈是空的。
- ②当进程进入内核(通过系统调用或中断),内核代码使用进程的内核栈,这时进程的用户栈还保存了数据,只是不被使用了。内核栈是独立的,即使进程破坏了它的用户栈内核也可以继续执行。
进程使用RISC-V 的 ecall 指令来做出一个系统调用。这个指令提升特权级并把程序计数器(Program Counter, PC)切换到内核定义的入口上。入口处的代码做的事就是切换到内核栈并执行对应的系统调用实现。当系统调用完成后,内核切换回用户栈并通过 sret 指令返回用户空间,sret 指令会降低权限级别并恢复执行系统调用后面的用户指令。比如,一个进程的线程可以阻塞内核来等待 I/O,当 I/O 结束后恢复执行。
p -> state 指示进程是否被分配(allocated),准备执行(ready to run),正在执行(running),等待 I/O (waiting for I/O)或者退出(exit)。
p -> pagetable 保存进程的页表指针。当在用户空间执行一个进程时,xv6 会让硬件使用对应进程的 p -> pagetable。一个进程的页表也可以作为存储进程内存而分配的物理页地址的记录。
2.6 代码:xv6 的启动以及第一个进程
为了使 xv6 更加具体,我们加下来简要介绍内核如何启动以及运行第一个进程。
RISC-V 计算机上电后先完成初始化,然后运行ROM里的bootloader,bootloader会把xv6内核载入内存。然后在机器模式下,CPU从xv6内核的_entry (kernel/entry.S:6)开始执行。RISC-V 启动时是禁用分页硬件(paging hardware)的,此时虚地址被直接映射到物理地址。
内核被加载到物理地址的 0x80000000 处,之所以不加载到0x0处是因为在0x0:0x80000000之间包含了I/O设备。
_entry处的指令建立了一个栈,以便 xv6 可以执行 C 代码。xv6 在 start.c(kernel.start.c:11) 声明了一个初始的栈空间 stack0。_entry 中的指令加载地址stack0 + 4096 到栈指针寄存器 sp,这指示栈顶,因为 xv6 中的栈是向下生长的。现在内核有一个栈了,_entry 之后跳转到 start (kernel.start.c:21) 执行 C 代码。
start标记的作用是进行一些配置,这些配置只能在机器模式下来做,然后就切换到特权模式。为了进入特权模式,RISC-V 提供了 mret 指令。这条指令常用来从特权模式到机器模式的调用中返回。start 不是从这个调用返回,而是直接设置一些必要的操作,就好像是从 mret 返回的一样:
- 在寄存器
mstatus中设置为 supervisor 模式 - 设置返回地址为
main的地址 (将main地址写入mepc寄存器) - 禁用虚地址映射(在页表寄存器
satp中写入 0) - 并向特权模式(s mode)委托所有的中断和异常
在切换到 s mode 之前, start还要做一件事:为时钟芯片编程以产生定时中断。然后通过 mret返回到 supervisor 模式,这会导致 PC 变为 main (kernel/main.c:11)。
main函数首先是初始化了一些设备和子系统,然后调用userinit来创建第一个进程(kernel/proc.c:212)。第一个进程执行了一个使用 RISC-V 汇编编写的小程序initcode.S(user/initcode.S:1),通过系统调用exec 重新进入内核,这将会把当前进程的内存和寄存器替换成一个新的程序:/init。一旦内核完成 exec ,就会返回到/init进程的用户空间。/init(kernel/init.c:15)创建了一个控制台设备文件,并作为文件描述符0,1,2来打开它。然后在无限循环中,启动shell并处理僵尸进程。系统就这样启动了。
2.7 Real world
In the real world, one can find both monolithic kernels and microkernels. Many Unix kernels are monolithic. For example, Linux has a monolithic kernel, although some OS functions run as user- level servers (e.g., the windowing system). Kernels such as L4, Minix, and QNX are organized as a microkernel with servers, and have seen wide deployment in embedded settings.
Most operating systems have adopted the process concept, and most processes look similar to xv6’s. Modern operating systems, however, support several threads within a process, to allow a single process to exploit multiple CPUs. Supporting multiple threads in a process involves quite a
bit of machinery that xv6 doesn’t have, including potential interface changes (e.g., Linux’s clone,
a variant of fork), to control which aspects of a process threads share.