Xv6 book: Operating system organization
Xv6 book 的第二章节
Operating system organization
操作系统的关键在于能够同时支持多个活动、在进程之间共享计算机资源以及隔离进程,也就是多路复用、隔离和交互。
Xx6 运行在多核的 RISC-V 微处理器上,有很多针对 RISC-V 的基地功能。Xv6 是用 “LP64"C (long
和指针是 64 位,int
是 32 位)编写的。
Xv6 是为 qemu 的 -machine virt
选项模拟支持的硬件编写的。
硬件的抽象
有一种操作系统的实现方式是:将第一节介绍的系统调用实现成一个库,应用程序与之链接。这种情况下,硬件设备信任应用程序,这不符合现在的安全需要。
为了实现隔离,就需要禁止应用程序直接访问敏感的硬件资源,而是将资源抽象为服务。比如程序使用 open
、write
等系统调用而不是直接访问磁盘。这为程序提供了方便,并允许操作系统作为接口的实现者管理磁盘。
user、supervisor 和 system call
隔离还需要应用程序和操作系统之间划一道线。如果程序出错,不能导致其他程序甚至操作系统出错,操作系统应该清理失败的程序并继续运行其他的程序。程序之间也应该有一道线以保证数据不会被修改。
CPU 为隔离提供硬件支持。例如 RISC-V 有三种 CPU 执行指令的模式:machine、supervisor 和 user。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 架构
Xv6 内核源码在 kernel 目录下,内部函数接口定义在 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 | 向 console 格式化输出 |
proc.c | 进程和调度 |
sleeplock.c | 已有的 CPU 锁 |
spinlock.c | 未有的 CPU 锁 |
start.c | machine 模式的引导代码 |
string.c | C 字符串和字节数组库 |
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
。进程的页表还用作分配用于存储进程内存的物理页的地址的记录。
启动 xv6,第一个进程和系统调用
当 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_exec
。exec
用一个新程序(本例是 /init
)替换当前进程的内存和寄存器。
一旦内核执行完了 exec
,它就会返回到 /init
进程的用户空间。init
(user/init.c) 根据需求创建一个新的 console 文件,然后以文件描述符 0、1和 2 的形式打开它,然后启动一个 shell。
现实状况
大多数操作系统都有进程的概念,并且大多数类似于 xv6 的。现代操作系统支持多线程,允许单个进程使用多个 CPU,这是 xv6 不具备的。