Xv6 book: Operating system organization

警告
本文最后更新于 2023-03-06,文中内容可能已过时。

Xv6 book 的第二章节

Operating system organization

操作系统的关键在于能够同时支持多个活动、在进程之间共享计算机资源以及隔离进程,也就是多路复用、隔离和交互。

Xx6 运行在多核的 RISC-V 微处理器上,有很多针对 RISC-V 的基地功能。Xv6 是用 “LP64"C (long 和指针是 64 位,int 是 32 位)编写的。

Xv6 是为 qemu 的 -machine virt 选项模拟支持的硬件编写的。

有一种操作系统的实现方式是:将第一节介绍的系统调用实现成一个库,应用程序与之链接。这种情况下,硬件设备信任应用程序,这不符合现在的安全需要。

为了实现隔离,就需要禁止应用程序直接访问敏感的硬件资源,而是将资源抽象为服务。比如程序使用 openwrite 等系统调用而不是直接访问磁盘。这为程序提供了方便,并允许操作系统作为接口的实现者管理磁盘。

隔离还需要应用程序和操作系统之间划一道线。如果程序出错,不能导致其他程序甚至操作系统出错,操作系统应该清理失败的程序并继续运行其他的程序。程序之间也应该有一道线以保证数据不会被修改。

CPU 为隔离提供硬件支持。例如 RISC-V 有三种 CPU 执行指令的模式:machinesupervisoruser。Machine 代表完全的权限。CPU 在机器模式下启动,这个模式主要用来配置计算机。Xv6 会在 machine 模式执行几行代码后切换到 supervisor 模式。

supervisor 模式允许 CPU 执行的特权指令:启动或禁用中断、读写保存页表地址的寄存器,等等。CPU 不会在 user 模式下执行特权指令,它会切换到supervisor 模式以便于让 supervisor 模式的代码可以终止程序。程序只能在用户空间执行 user 模式能执行的指令。在 supervisor 模式的软件可以在内核空间执行的指令比前者多一些特权指令。运行在内核空间的软件成为内核 (kernel)。

应用程序不能直接调用系统调用。CPU 提供了一个指令用于将 CPU 从 user 模式转成 supervisor 模式,并且在内核指定的入口点进入内核(RISC-V提供的指令是 ecall)。CPU 切换到 supervisor 模式后,内核就可以验证系统调用的参数(例如检查传递给系统调用的地址是否是应用程序内存的一部分),决定是否允许应用程序执行请求的操作并拒绝或执行(比如是否允许写入制定的文件)。内核控制转换到 supervisor 模式的入口点很重要。如果应用程序可以决定内核入口点,就可以跳过一些验证步骤。

操作系统的哪部分需要在 supervisor 模式下运行是一个关键问题。一种方法是让整个操作系统在内核中,所有的系统调用的实现都在 supervisor 模式下运行,这叫做宏内核。这种架构的优点在于,操作系统拿到了全部的硬件,操作系统的不同部分之间更容易协作;缺点在于操作系统不同部分之间的接口通常很复杂,更加容易出错(在 supervisor 模式下的错误经常导致内核出错从而导致计算机停止工作)。

为了减少内核出错的风险,操作系统设计人员可以尽量减少操作系统在 supervisor 模式下运行的代码,操作系统大部分处于 user 模式下。这种叫做微内核。

在微内核中,内核接口由几个底层函数组成,用于启动程序、发送信息和访问设备硬件等。这种架构使得内核也变得简单。

现实世界中,宏内核和微内核都很流行。许多 Unix 的内核都是宏内核(例如 Linux,尽管它有一些操作系统功能作为 user 模式下运行)。如 Minix、L4 和 QNX 操作系统都才需微内核架构并在嵌入式设备得到了广泛的应用。L4 的衍生 seL4 非常小,并且验证了它的内存安全性和其他安全属性。

Xv6 和大多数类 Unix 系统使用宏内核,因此 xv6 内核接口对应操作系统接口,内核实现了完整的操作系统。

Xv6 内核源码在 kernel 目录下,内部函数接口定义在 kernel/defs.h

文件描述
bio.c文件系统的磁盘缓存
console.c连接到用户的键盘和屏幕
entry.S第一个启动指令
exec.cexec() 系统调用
file.c文件描述符
fs.c文件系统
kalloc.c物理页分配
kernelvec.S处理内核 trap 和时钟中断
log.c文件系统日志记录和崩溃恢复
main.c引导时控制其他模块的初始化
pipe.c管道
plic.cRISC-V 中断控制器
printf.c向 console 格式化输出
proc.c进程和调度
sleeplock.c已有的 CPU 锁
spinlock.c未有的 CPU 锁
start.cmachine 模式的引导代码
string.cC 字符串和字节数组库
swtch.S线程切换
syscall.c向处理函数发送系统调用
sysfile.c文件相关的系统调用
sysproc.c线程相关的系统调用
trampoline.S用户和内核之间的切换
trap.c处理并返回来自 trap 和中断的 C 代码
uart.c串行端口控制台设备驱动程序
virtio_disk.c磁盘设备驱动程序
vm.c管理页表和地址空间

进程抽象可以防止一个进程破坏或监视另一个进程的内存、CPU、文件描述符等。它还可以防止进程破坏内核本身。内核实现进程的机制包括 user/supervisor模式、地址空间和线程的时间切片。

为了实现这种隔离,进程抽象给程序提供了一种假象——它独占机器。Xv6 使用页表(由硬件实现)给了每个进程自己的地址空间。RISC-V 将页表映射到物理地址。

Xv6 为每个进程维护了一个单独的页表用于定义进程的地址空间。进程地址空间包括在虚拟地址0开始的用户内存。首先是指令,然后是全局变量,然后是栈,最后是堆。有很多因素限制了进程地址空间的大小:RISC-V 指针是 64 位;在页表中查找虚拟地址时,硬件只使用低 39 位,而 xv6 只使用 39 位中的 38 位。因此最大的地址 MAXVA 被定义在 kerl/riscv.h 中,值是 $2^{38} - 1 =$ 0x33fffffffff。在地址空间的顶部,xv6 为 trampoline 留了一页,以及映射进程 trapframe 的页。Xv6 使用这两个页转换到内核并返回。Trampoline 页包含进出内核的代码,映射 trapframe 是保存和回护用户进程状态所必需的。

Xv6 内核为进程维护多个状态片段,它将这些片段收集在 proc 结构体中。使用 p->xxx 可以引用 proc 结构的元素,比如 p->pagetable 就是指向进程页表的指针。

每个进程都有一个线程去执行进程的指令。为了实现进程之间的切换,内核挂起当前正在运行的线程并恢复另一个进程的线程。线程的大部分状态(局部变量、函数调用返回地址)都存储在线程的栈上。每个进程都有两个栈:一个用户栈和一个内核栈(p->kstack)。当进程执行用户命令时,只有用户栈被使用,内核栈是空的。当进程进入内核(系统调用或中断)时,内核代码在进程的内核栈上执行,用户栈仍保存的数据但不会被主动调用。线程在主动使用用户栈和内核栈之间交替。内核栈是独立并受到保护,因此即便进程破坏了它的用户栈内核也可以运行。

进程可以通过执行 RISC-V 的 ecall 指令执行系统调用。这个指令提高硬件权限级别并将程序计数器改成内核定义的入口点。入口点的代码切换到内核栈执行实现系统调用的内核指令。系统调用完成时,内核切换到用户栈,并通过 sert 指令返回到用户空间,这将降低硬件特权级别,并在系统调用指令之后继续执行用户指令。

进程可以 block 以等待 I/O 完事。p->state 指明是否分配进程、准备运行、运行中、等待 I/O 还是退出。p->pagetable 以 RISC-V 预期的格式保存进程的页表。Xv6 使分页硬件执行进程时使用进程的 p->pagetable。进程的页表还用作分配用于存储进程内存的物理页的地址的记录。

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

Boot loader 会将 xv6 内核加载到物理地址为 0x80000000,不放在 0x0 的原因是 0x80000000 之前的地址存放 I/O 设备。

_entry 的指令设置了栈以便于让 xv6 能够给运行 C 代码。Xv6 在 start.c 中为初始栈 stack0 声明了空间。_entry 的代码加载地址 stack0 + 4096 的栈指针寄存器sp,这是栈顶,因为 RISC-V 的栈向下增长。内核有了栈,_entry 就开始调用 start 的 C 代码(start 函数)。

start 函数执行一些仅在 machine 模式下允许的配置,然后切换到 supervisor 模式。为了进入 supervisor 模式,RISC-V 提供了指令mret。这条指令通常用于从 supervisor 模式干到 machine 模式时返回到之前的模式。start 不会直接从这样的调用返回,而是设置一些东西:在寄存器mstatus将以前的权限设置为 supervisor,通过将 main 的地址写入寄存器 mpec 将返回地址设置为 main,将 0 写入页表寄存器stap禁用 supervisor 模式的虚拟地址转换,把所有的中断和异常委托给 supervisor 模式。

在进入 supervisor 模式之前,start 对时钟芯片编程以产生计时器中断。随着内存管理的出现,start 通过调用 mret 返回到 supervisor 模式。这将导致程序计数器改成 main。

main初始化几个设备和子系统之后,它调用userinit(kernel/proc.c)创建第一个进程。第一个进程执行用RISC-V汇编写的程序,该程序执行xv6的第一个系统调用。initcode.S(user/initcode.S)将exec系统调用的数字SYS_EXEC加载到寄存器a7中,然后调用ecall重新进入内核。

内核使用寄存器 a7 中的数字来调用所需的系统调用。系统调用表 (kernel/syscall.c) 将 SYS_EXEC 映射到内核调用的 sys_execexec 用一个新程序(本例是 /init)替换当前进程的内存和寄存器。

一旦内核执行完了 exec,它就会返回到 /init 进程的用户空间。init(user/init.c) 根据需求创建一个新的 console 文件,然后以文件描述符 0、1和 2 的形式打开它,然后启动一个 shell。

大多数操作系统都有进程的概念,并且大多数类似于 xv6 的。现代操作系统支持多线程,允许单个进程使用多个 CPU,这是 xv6 不具备的。