第二章 操作系统的组织结构
操作系统的一个关键需求是能够同时支持多个活动。例如,用户可以通过第 1 章介绍的 fork 和 exec 系统调用同时启动一个编译器和一个文本编辑器作为不同的进程。操作系统必须在这些进程之间共享(时间复用)硬件资源,例如 CPU 和内存。同时,操作系统还必须在进程之间提供隔离性:如果某个进程由于 bug 发生异常,不应影响到其他无关的进程。
然而,完全的隔离又是不现实的,因为进程之间有时需要有意地进行交互;例如管道(pipeline)就是一种典型的进程间协作方式。因此,一个操作系统必须同时满足以下三个基本需求:
- 多路复用(Multiplexing)
- 隔离(Isolation)
- 交互(Interaction)
本章将概述操作系统是如何组织起来以实现这三个目标的。实际上,实现这些目标的方法有很多种,但本书主要聚焦于一种主流设计——以单体内核(monolithic kernel)为中心的架构,这种设计被许多 Unix 操作系统所采用。本章还将介绍 xv6 中的进程概念,它是 xv6 中实现隔离的基本单位。
xv6 运行在一个多核(multi-core)的 RISC-V 微处理器之上,其许多底层功能(例如进程的实现)都与 RISC-V 架构密切相关。RISC-V 是一种 64 位架构,而 xv6 使用的是 LP64 模型 的 C 语言:其中 long 类型(L)和指针(P)是 64 位的,而 int 仍然是 32 位的。本书假设读者已经在某种体系结构上进行过一定程度的底层编程,并将在需要时介绍与 RISC-V 相关的具体概念。
一台完整计算机中的 CPU 周围还连接着大量支持硬件,主要以 I/O 接口的形式存在。xv6 是为 qemu 模拟器中 -machine virt 选项所提供的硬件环境编写的。该环境包括:
- 内存(RAM)
- 包含启动代码的只读存储器(ROM)
- 与用户键盘和屏幕相连的串行接口
- 用于存储的数据磁盘
注: 多核(multi-core) 指的是多个 CPU 共享同一内存、并行执行、但每个 CPU 都有自己独立寄存器集合的系统。本文有时会使用“多处理器(multiprocessor)”一词作为多核的同义词,但在更严格的意义上,多处理器也可以指由多个独立处理器芯片组成的系统。
一、抽象物理资源(Abstracting Physical Resources)
当人们第一次接触操作系统时,可能会提出一个问题:为什么需要操作系统? 换句话说,是否可以直接把图 1.2 中的系统调用实现为一个库,让应用程序直接链接使用?
在这种设计中,每个应用甚至可以拥有一个为其量身定制的库。应用程序可以直接与硬件资源交互,并以最适合自身需求的方式使用这些资源(例如,为了获得更高或更可预测的性能)。一些嵌入式系统或实时系统的操作系统,确实就是以这种方式组织的。
然而,这种“库式”方案的缺点在于:当系统中运行不止一个应用时,应用必须表现得非常“守规矩”。例如,每个应用都必须主动、定期地让出 CPU,以便其他应用能够运行。这种协作式(cooperative)的时间共享机制,只有在所有应用彼此信任、且都没有 bug 的情况下才是可行的。
但在现实中,应用程序往往既不可信,也难免存在错误。因此,人们通常希望获得比协作式方案更强的隔离性(isolation)。
为了实现强隔离,一个常见做法是:禁止应用程序直接访问敏感的硬件资源,而是将这些资源抽象为服务。
例如,在 Unix 中,应用程序只能通过文件系统提供的 open、read、write、close 等系统调用来访问存储设备,而不能直接读写磁盘。这种方式不仅为程序员提供了路径名等便利抽象,也使操作系统能够统一管理磁盘资源。
即使不考虑安全隔离,仅从程序协作的角度看,这种抽象也是有益的。即便多个程序只是希望“互不干扰”,文件系统也比直接操作磁盘更方便、更安全。
类似地,Unix 会在多个进程之间透明地切换 CPU:在切换时保存和恢复寄存器状态,使得应用程序无需感知时间片调度的存在。正是这种透明性,使得即使某些应用陷入无限循环,操作系统仍然能够让其他应用继续运行。
再例如,Unix 进程通过 exec 来构建自己的内存映像,而不是直接操作物理内存。这使得操作系统可以自由决定进程在内存中的放置位置;在内存紧张时,甚至可以将部分进程数据暂存到磁盘上。exec 同时也利用文件系统来存储可执行程序,为用户提供了极大的便利。
在 Unix 中,进程之间的许多交互都通过 文件描述符(file descriptor) 完成。文件描述符不仅抽象了底层细节(例如数据究竟存储在管道中还是磁盘文件中),而且还简化了进程间的协作。例如,当管道中的某个进程退出或崩溃时,内核会自动为下游进程生成一个“文件结束(EOF)”信号。
图 1.2 中展示的系统调用接口正是经过精心设计的,它在程序员易用性与强隔离性之间取得了平衡。Unix 的接口并非抽象资源的唯一方式,但事实证明,它是一种非常成功的设计。
图 2.1:带有文件系统服务器的微内核

二、用户态、监督态与系统调用(User Mode, Supervisor Mode, and System Calls)
强隔离(strong isolation)要求在应用程序与操作系统之间建立明确而坚固的边界。应用程序不应被允许干扰操作系统或其他程序的运行,即使该应用存在 bug,甚至是恶意的。
为了实现这种强隔离,操作系统必须确保:
- 应用程序不能修改(甚至不能读取)操作系统的数据结构和指令;
- 应用程序不能访问其他进程的内存。
为此,CPU 在硬件层面提供了对隔离的支持。例如,RISC-V 定义了 三种特权级别(privilege levels),用于限制代码能够执行的操作:
- 机器态(Machine mode)
- 监督态(Supervisor mode)
- 用户态(User mode)
在机器态下执行的指令拥有最高权限。CPU 启动时处于机器态,该模式主要用于系统启动和初始化硬件。xv6 在启动阶段短暂运行于机器态,随后切换到监督态。
在 监督态(supervisor mode) 下,CPU 允许执行特权指令,例如:
- 启用或关闭中断;
- 读写页表寄存器;
- 配置内存管理单元等。
如果用户态的应用程序尝试执行一条特权指令,CPU 不会执行该指令,而是触发一个 trap(陷入),跳转到监督态中预先定义的处理代码,通常会导致该应用被终止。第 1 章中的图 1.1 展示了这种结构。
因此:
- 应用程序只能执行用户态指令(例如普通的算术运算),处于 用户空间(user space);
- 运行在监督态的软件可以执行特权指令,处于 内核空间(kernel space);
- 运行在内核空间的软件称为 内核(kernel)。
应用程序通过 系统调用(system calls) 与内核进行交互,例如 read。应用程序不能直接调用内核函数,也不能直接访问内核内存。
在 RISC-V 中,系统调用通过 ecall 指令实现。该指令会将 CPU 从用户态切换到监督态,并跳转到内核指定的入口地址。一旦进入监督态,内核就可以:
- 校验系统调用参数是否合法(例如,检查传入的地址是否属于该进程的地址空间);
- 判断应用是否有权限执行该操作(例如,是否有权限写某个文件);
- 决定拒绝请求或执行该请求。
非常关键的一点是:进入监督态的入口必须由内核控制。如果应用程序可以自行指定跳转到内核的任意位置,那么恶意程序就可能绕过安全检查,例如直接跳过参数校验代码,从而破坏系统安全。
三、内核的组织方式(Kernel Organization)
一个关键的设计问题是:操作系统的哪些部分应该运行在监督态(supervisor mode)?
一种可能的设计是:整个操作系统都运行在内核中,也就是说,所有系统调用的实现都在监督态执行。这种结构称为 单体内核(monolithic kernel)。
在单体内核结构中,整个操作系统是一个运行在监督态的单一程序。这种组织方式的一个优点是:操作系统设计者不需要区分哪些代码需要特权、哪些不需要。并且,由于系统的不同部分都属于同一个程序,它们之间的协作非常方便。例如,单体内核可以让文件系统和虚拟内存系统共享同一个磁盘块缓存。
然而,这种结构的缺点是:随着功能的增加,内核往往会变得非常庞大和复杂,以至于没有任何一个开发者能够完全理解所有模块之间的交互关系。这种复杂性极易引入 bug。而内核中的 bug 往往后果严重——可能导致整个系统崩溃、多个应用程序同时失效,甚至引发严重的安全漏洞。
微内核(Microkernel)
微内核的目标是减少内核中的错误。其核心思想是:尽可能减少在内核态运行的代码量,让内核本身保持极小、易于理解和验证。大部分操作系统功能都运行在用户态的服务器进程中。
例如,在微内核设计中,文件系统不再是内核的一部分,而是作为一个用户态的服务器进程运行。
图 2.1 展示了这种微内核结构:文件系统作为用户态服务存在。为了让应用程序与文件服务器交互,内核提供了一种 进程间通信(IPC)机制,使得一个用户进程可以向另一个用户进程发送消息。
举例来说,当 shell 需要读写文件时,它会向文件服务器发送一条消息,并等待服务器返回结果。
在微内核中,内核接口通常只包含少量基础功能,例如:
- 启动进程
- 进程间消息传递
- 访问设备硬件
这种设计使得内核本身相对简单,因为绝大多数操作系统功能都位于用户态服务器中。
单体内核 vs 微内核
在现实世界中,这两种结构都被广泛采用:
- 许多 Unix 系统采用 单体内核。例如 Linux 就是单体内核,尽管它的一些 OS 功能(如窗口系统)运行在用户态。Linux 能为操作系统密集型应用提供很高的性能,部分原因正是其内核子系统之间可以紧密集成。
- 一些操作系统(如 Minix、L4、QNX)采用 微内核结构,在嵌入式领域得到了广泛应用。其中,L4 的一个变种 seL4 足够小,已经被形式化验证,证明其具备内存安全性等安全属性。
关于哪种设计更优,操作系统领域长期存在争论,但并没有定论。这取决于“更好”的定义——是更高性能、更小代码规模、更强内核可靠性,还是整体系统(包括用户态服务)的可靠性。
此外,还有一些现实因素会影响选择。例如:
- 有些系统采用微内核结构,但为了性能,仍然将部分用户态服务放回内核态运行;
- 有些系统历史上采用了单体内核结构,后续维护成本高,缺乏将其重构为微内核的动力。
xv6 的定位
从本书的视角来看,微内核和单体内核在许多关键思想上是共通的: 它们都实现系统调用、使用页表、处理中断、支持进程、实现文件系统等。
xv6 采用的是单体内核结构,与大多数 Unix 操作系统类似。因此,xv6 的内核接口也就是整个操作系统的接口。虽然 xv6 的功能相对精简,但在架构上它依然是一个典型的单体内核。
图 2.2:xv6 内核源代码结构说明
| 模块 | 文件 | 功能 |
|---|---|---|
| 启动 | entry.S |
最早的启动指令 |
| 启动 | main.c |
控制其他模块初始化 |
| 启动 | start.c |
早期 machine mode 启动代码 |
| 进程 | exec.c |
exec() 系统调用 |
| 进程 | proc.c |
进程与调度 |
| 进程 | swtch.S |
线程切换 |
| 陷阱 | kernelvec.S |
内核态 trap 处理 |
| 陷阱 | trampoline.S |
用户态 trap 处理 |
| 陷阱 | trap.c |
trap 与中断的 C 实现 |
| 系统调用 | syscall.c |
系统调用分发 |
| 内存 | vm.c |
页表与地址空间管理 |
| 内存 | kalloc.c |
物理页分配器 |
| 设备 | console.c |
键盘与屏幕 |
| 设备 | plic.c |
RISC-V 中断控制器 |
| 设备 | uart.c |
串口驱动 |
| 设备 | virtio_disk.c |
磁盘驱动 |
| 文件系统 | bio.c |
磁盘块缓存 |
| 文件系统 | file.c |
文件描述符 |
| 文件系统 | fs.c |
文件系统核心 |
| 文件系统 | log.c |
日志与崩溃恢复 |
| 文件系统 | sysfile.c |
文件相关系统调用 |
| 文件系统 | pipe.c |
管道 |
| 杂项 | sleeplock.c |
会睡眠的锁 |
| 杂项 | spinlock.c |
自旋锁 |
| 杂项 | string.c |
C 字符串与内存函数 |
四、代码结构:xv6 的组织方式(Code: xv6 organization)
xv6 内核的源代码位于 kernel/ 子目录中。图 2.2 列出了这些文件,并按照内核的主要职责进行了分类,包括:系统启动(booting)、进程的创建与控制、陷阱处理(中断与系统调用)、内存分配与虚拟地址配置、设备控制以及文件系统管理。
图 2.3 展示了一个进程的虚拟地址空间布局。
0
user text
and data
user stack
heap
MAXVA
trampoline
trapframe
图 2.3:进程的虚拟地址布局

五、进程概览(Process Overview)
在 xv6(以及其他 Unix 操作系统)中, 进程(process) 是实现隔离的基本单位。进程抽象可以防止一个进程破坏或窥探另一个进程的内存、CPU、文件描述符等资源,同时也防止进程破坏内核本身,从而绕过系统的隔离机制。由于应用程序可能存在缺陷甚至是恶意的,内核必须非常谨慎地实现进程抽象,以防止被欺骗或被诱导执行危险操作(例如破坏隔离机制)。
用于实现进程的机制包括:用户态 / 监督态的区分、地址空间,以及线程的时间片调度。
为了加强隔离性,进程抽象向程序提供了一种“私有机器”的假象。每个进程都仿佛拥有独立的内存系统(地址空间),其他进程无法读取或写入该空间;同时,每个进程也仿佛拥有独立的 CPU 来执行自己的指令。
xv6 使用页表(由硬件实现)为每个进程提供独立的地址空间。RISC-V 页表负责将虚拟地址(RISC-V 指令所使用的地址)映射为物理地址(CPU 实际访问主存的地址)。
xv6 为每个进程维护一份独立的页表,用以定义该进程的地址空间。如图 2.3 所示,一个地址空间从虚拟地址 0 开始,依次包括:程序指令、全局变量、用户栈,以及用于动态分配(malloc)的堆空间。进程地址空间的最大大小受到多种因素限制:RISC-V 的指针宽度为 64 位,但硬件在页表查找时只使用低 39 位,而 xv6 只使用其中的 38 位。因此,最大虚拟地址为
2^38 - 1 = 0x3fffffffff,即 MAXVA。
在地址空间的最高端,xv6 放置了一个 trampoline 页面(4096 字节)和一个 trapframe 页面。xv6 使用这两个页面在用户态与内核态之间切换:trampoline 页面包含进入和离开内核的代码,而 trapframe 用于保存进程的用户寄存器(第 4 章将详细介绍)。
xv6 内核为每个进程维护大量状态信息,这些信息被集中存放在一个 struct proc 结构体中(定义在第 2034 行左右)。一个进程最重要的内核状态包括:它的页表、内核栈,以及运行状态。本文中使用 p->xxx 来表示 proc 结构体中的字段,例如 p->pagetable 表示该进程的页表指针。
此时建议读者阅读 kernel/proc.h,其中定义了 struct proc。理解 xv6 源代码比阅读本书更重要;当代码不易理解时,再回到书中查阅说明。某些代码在初读时可能难以理解,但随着阅读的深入会逐渐清晰。鼓励读者自行探索并修改代码。
每个进程都有一个执行线程(thread),用于保存执行所需的状态。在线程运行时,它可能正在某个 CPU 上执行,也可能处于暂停状态(尚未执行,但将来可以继续)。当内核需要在不同进程之间切换 CPU 时,会保存当前进程线程的状态,并恢复另一个进程线程的状态。线程的大部分状态(如局部变量、函数调用返回地址等)都保存在其栈中。
每个进程有两个栈:
- 用户栈(user stack)
- 内核栈(kernel stack,
p->kstack)
当进程执行用户态指令时,仅使用用户栈,内核栈为空。当进程因系统调用或中断进入内核时,内核代码在该进程的内核栈上运行;此时用户栈中的内容仍然存在,但不会被使用。线程在用户栈与内核栈之间交替执行。内核栈与用户代码隔离,使得即使用户栈被破坏,内核仍能安全运行。
进程的用户代码可以通过执行 RISC-V 的 ecall 指令发起系统调用。该指令会切换到监督态,并将程序计数器跳转到内核指定的入口地址。入口处的代码会切换到进程的内核栈,并执行实现系统调用的内核代码。系统调用完成后,内核通过执行 sret 指令返回用户态,从系统调用之后的位置继续执行。进程在内核中还可能因等待 I/O 而阻塞,待 I/O 完成后再继续执行。
p->state 表示进程当前的状态,例如:已分配、就绪、正在运行、等待 I/O 或正在退出。
p->pagetable 保存了进程的页表,其格式符合 RISC-V 的硬件要求。xv6 在执行用户态代码时,会让分页硬件使用该页表。页表同时记录了为进程分配的物理内存页。
总而言之,一个进程结合了两个核心抽象:
- 地址空间:为进程提供“私有内存”的假象;
- 线程:为进程提供“私有 CPU”的假象。
在 xv6 中,一个进程由一个地址空间和一个线程组成。而在现实操作系统中,一个进程通常可以包含多个线程,以充分利用多核 CPU。
六、代码:xv6 的启动、第一个进程与系统调用
为了更具体地理解 xv6,本节将概述内核是如何启动并运行第一个进程的。后续章节将更详细地介绍在这一过程中出现的各种机制。请阅读以下文件:kernel/entry.S、kernel/start.c、kernel/main.c 以及 user/init.c。
当 RISC-V 计算机上电时,它会首先进行初始化,并运行存储在只读存储器(ROM)中的引导加载程序(boot loader)。该引导加载程序会将 xv6 内核拷贝到物理地址 0x80000000 处。之所以选择这个地址而不是 0x0,是因为地址范围 0x0 到 0x80000000 被 I/O 设备占用。
随后,引导加载程序跳转到 xv6 的入口点 _entry(1006)。此时,RISC-V 处理器的分页硬件尚未启用:虚拟地址直接映射到物理地址。_entry 中的指令会先建立一个栈,使 xv6 能够运行 C 代码。xv6 在 start.c(1060)中为该栈分配了空间,命名为 stack0。_entry 将栈指针寄存器 sp 设置为 stack0 + 4096,即栈顶位置,因为 RISC-V 的栈是向下增长的。至此,内核拥有了一个可用的栈,随后 _entry 调用 C 代码中的 start(1064)。
start 函数执行一些只能在机器态(machine mode)下完成的初始化工作,其中最关键的是配置时钟中断。之后,start 使用 RISC-V 的 mret 指令切换到监督态(supervisor mode),并跳转到 main(1160)。在执行 mret 之前,需要完成一些准备工作:
- 在寄存器
mstatus中将前一特权级设置为监督态; - 将
main的地址写入寄存器mepc,作为返回地址; - 将页表寄存器
satp置为 0,以在监督态下关闭虚拟地址转换; - 将所有中断和异常委托给监督态处理。
当执行到 main(1160)后,内核会初始化多个设备和子系统,然后通过调用 userinit(2327)创建第一个进程。所有新创建的进程最初都会在内核中的 forkret(2653)函数开始执行。对于第一个进程,forkret 会调用 kexec 来加载用户程序 /init。
在调用 kexec 之后,forkret 返回到用户空间,开始执行 /init 进程。init(7764)首先在需要时创建一个新的控制台设备文件,然后将其打开为文件描述符 0、1 和 2。接着,它在控制台上启动一个 shell。至此,整个系统完成启动。
七、安全模型(Security Model)
你可能会好奇,操作系统是如何应对存在缺陷或恶意行为的代码的。由于防御恶意行为比应对意外错误更加困难,因此在操作系统设计中,通常优先考虑如何抵御恶意行为。下面是对操作系统安全假设和目标的一个高层次概述。
操作系统必须假设:用户态程序会竭尽所能地破坏内核或其他进程。用户程序可能会尝试访问其地址空间之外的内存;可能试图执行不允许在用户态执行的指令;可能试图读写 RISC-V 的控制寄存器;可能试图直接访问设备硬件;还可能向系统调用传递精心构造的参数,以诱使内核崩溃或执行不恰当的行为。
内核的目标是将每个用户进程限制在一个受控范围内,使其只能访问自己的用户内存,只能使用 32 个通用寄存器,并且只能通过系统调用所允许的方式影响内核或其他进程。除此之外的一切行为都必须被禁止。这些通常是内核设计中不可妥协的要求。
对内核自身代码的假设则不同。内核代码被假定是由善意且谨慎的程序员编写的,不包含恶意逻辑,并且是无 bug 的。这个假设影响了我们分析内核代码的方式。例如,内核中存在许多内部函数(如自旋锁相关函数),如果被错误使用将会导致严重问题;但我们假设内核会正确地使用这些函数。在硬件层面,也假设 RISC-V CPU、内存、磁盘等硬件按其文档所描述的方式正确工作,不存在硬件缺陷。
现实世界并非如此简单。完全防止恶意用户程序通过消耗内核资源(如磁盘空间、CPU 时间、进程表项等)使系统不可用,几乎是不可能的。同时,也几乎不可能写出完全没有漏洞的内核代码,硬件设计中也难免存在缺陷。如果恶意用户了解内核或硬件中的漏洞,就可能加以利用。即便在成熟且广泛使用的系统中(如 Linux),人们仍然会不断发现新的安全漏洞。此外,用户态与内核态之间的界限在实践中也并非总是清晰:某些特权用户态进程提供了关键系统服务,几乎等同于操作系统的一部分;而在某些系统中,特权用户程序甚至可以向内核动态加载代码(例如 Linux 的可加载内核模块和 eBPF)。
作为对内核错误的一种部分防御机制,xv6 在代码中加入了一些一致性检查和不可恢复错误的检测机制,并在发现问题时调用 panic()。该函数会打印错误信息并终止系统运行。虽然系统崩溃并非理想结果,但在内核状态已经不一致时,停止执行比继续运行更加安全。通常,当内核由于错误引用内存或破坏内部数据结构而触发 panic 时,开发者需要定位并修复相应的内核缺陷。
八、现实世界(Real World)
大多数操作系统都采用了“进程”这一抽象概念,并且其实现方式与 xv6 大体相似。然而,现代操作系统通常支持在一个进程中包含多个线程,从而使单个进程能够利用多核 CPU 的并行能力。
支持多线程需要相当复杂的机制,而 xv6 并未实现这些机制。这通常还需要对接口进行扩展,例如 Linux 的 clone 系统调用,它是 fork 的一种变体,用于控制线程之间共享哪些资源。