xv6:一个简单的unix-like操作系统译文(二)

发布于 作者: Russ Cox, Frans Kaashoek, Robert Morris

第二章 操作系统的组织结构

操作系统的一个关键需求是能够同时支持多个活动。例如,用户可以通过第 1 章介绍的 forkexec 系统调用同时启动一个编译器和一个文本编辑器作为不同的进程。操作系统必须在这些进程之间共享(时间复用)硬件资源,例如 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 中,应用程序只能通过文件系统提供的 openreadwriteclose 等系统调用来访问存储设备,而不能直接读写磁盘。这种方式不仅为程序员提供了路径名等便利抽象,也使操作系统能够统一管理磁盘资源。

即使不考虑安全隔离,仅从程序协作的角度看,这种抽象也是有益的。即便多个程序只是希望“互不干扰”,文件系统也比直接操作磁盘更方便、更安全。

类似地,Unix 会在多个进程之间透明地切换 CPU:在切换时保存和恢复寄存器状态,使得应用程序无需感知时间片调度的存在。正是这种透明性,使得即使某些应用陷入无限循环,操作系统仍然能够让其他应用继续运行。

再例如,Unix 进程通过 exec 来构建自己的内存映像,而不是直接操作物理内存。这使得操作系统可以自由决定进程在内存中的放置位置;在内存紧张时,甚至可以将部分进程数据暂存到磁盘上。exec 同时也利用文件系统来存储可执行程序,为用户提供了极大的便利。

在 Unix 中,进程之间的许多交互都通过 文件描述符(file descriptor) 完成。文件描述符不仅抽象了底层细节(例如数据究竟存储在管道中还是磁盘文件中),而且还简化了进程间的协作。例如,当管道中的某个进程退出或崩溃时,内核会自动为下游进程生成一个“文件结束(EOF)”信号。

图 1.2 中展示的系统调用接口正是经过精心设计的,它在程序员易用性强隔离性之间取得了平衡。Unix 的接口并非抽象资源的唯一方式,但事实证明,它是一种非常成功的设计。

图 2.1:带有文件系统服务器的微内核

xv6-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:进程的虚拟地址布局

xv6-2-2

五、进程概览(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.Skernel/start.ckernel/main.c 以及 user/init.c

当 RISC-V 计算机上电时,它会首先进行初始化,并运行存储在只读存储器(ROM)中的引导加载程序(boot loader)。该引导加载程序会将 xv6 内核拷贝到物理地址 0x80000000 处。之所以选择这个地址而不是 0x0,是因为地址范围 0x00x80000000 被 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 的一种变体,用于控制线程之间共享哪些资源。