Chapter 2 Operating system organization
一个操作系统的关键特征是支持多个任务。例如,用fork系统调用接口创建新的进程,这些进程分时共享计算资源。尽管进程数量比硬件CPU数量多,操作系统一定可以确保所有进程都有机会执行。另一个特征是进程的隔离性(isolation),如果一个进程有一个bug或故障,不会影响到其他无关进程。然而完全的隔离性太强大,对进程来说需要主动交互协作。操作系统必须具备的三个要求:并发,隔离,交互。
有很多方法可以实现这三个特征,本文关注被许多Unix操作系统使用的以宏内核为中心的主流设计。在xv6中,隔离性是以进程为单位的。当xv6启动时,第一个进程被创建。
xv6运行在多核RISC-V微处理器,底层功能(进程实现)针对的是RISC-V。RISC-V是一个64位CPU,xv6用"LP64"C语言开发,L(long)和P(pointers)在C语言中是64位,int是32位。
xv6的硬件支持是由quem的machine virt模拟的,模拟包括RAM,存储boot code的ROM和与用户外设的连接以及提供存储的硬盘。
抽象的物理资源
操作系统为什么存在?
实现上个章节所讲的所有系统调用,作为应用程序可以link的library。这样每个应用程序甚至可以有独立的满足自己需求的library,应用程序可以直接和硬件资源打交道并且以最好的方式利用硬件资源达到好的性能。一些嵌入式操作系统和实时操作系统是这样设计的。
library方法的缺点:多个应用程序运行,每个程序必须“well-behaved”。如:每个应用程序必须周期性的放弃CPU从而保证程序交替执行。如果所有的应用程序彼此信任且没有bugs,这种协作式的分时共享模式是OK的。但事实是应用程序彼此不信任且有bugs,强隔离性比协作模式更重要。
禁止应用程序直接访问敏感的硬件资源而使用服务提供的抽象资源对实现强隔离性很有用。例如,Unix应用通过文件系统的open,read,write,close系统调用和存储交互而无需直接读写硬盘。操作系统直接管理硬盘,应用程序用方便的文件名进行操作。即使不考虑隔离性,有意交互的应用程序也需要找到一个更方便的文件系统而不是直接操作硬盘。
同样,Unix在进程间透明地切换CPU资源,保存和恢复必要的寄存器状态,所以应用程序无需感知分时共享。这种透明性(应用程序不感知)使得尽管应用程序是无限循环的,操作系统也可以分享CPUs。
另一个例子:Unix进程使用exec构建它们的内存镜像(exec是内存的抽象),而不是直接和物理内存交互。由操作系统决定将进程放在内存的何处,如果内存空间紧张,操作系统可以决定将进程的一些数据放在外存。exec也提供给用户方便的文件系统来存储可执行镜像。
Unix进程的许多交互形式通过文件描述符,文件描述符不仅抽象了许多细节(管道中的数据在哪,文件存储在哪,由OS决定文件到硬盘的映射),也简化了交互方式。例如,如果一个pipeline中的应用失败了,内核产生一个end-of-file的信号给pipeline的下一个进程。
上个章节设计的系统调用接口不仅方便了开发者,也增强了隔离性。Unix接口不仅是抽象资源的方法,也被证明是一个好的方案。
user mode, supervisor mode 和 系统调用
强隔离性要求应用程序和操作系统之间有明确的分界,如果应用程序故障,不能影响操作系统或其他应用程序。相反,操作系统应该能清除故障程序,正常运行其他程序。实现强隔离性:操作系统必须使应用程序不能修改甚至访问操作系统的数据结构和指令,应用程序不能访问其他进程的内存。
CPUs对强隔离性提供硬件支持。例如,RISC-V中CPU能以三种mode执行指令:machine mode,supervisor mode,user mode。
machine mode下的指令执行拥有所有特权;一个CPU在machine mode下开启。machine mode主要为了计算机的配置。xv6在machine mode下运行很少的代码,就切换为supervisor mode。
在supervisor mode下CPU可以执行特权指令(privileged instructions):如开、关中断,读、写存储有页表地址的寄存器等。在user mode下应用程序尝试执行特权指令,则CPU不会执行该指令,而是切换到supervisor mode下终止该程序,因为该程序尝试执行它不该执行的指令。一个应用程序只能执行user mode指令,即运行在用户空间(user space),然而supervisor mode下的软件能执行特权指令,即运行在内核空间(kernel space)。运行在kernel space下的软件称为内核。
一个想调用内核函数的应用程序必须陷入内核,一个应用程序不可能直接调用内核函数。CPUs提供了一条特殊的指令将CPU从user mode切换到supervisor mode并在内核指定的entry point进入内核(RISC-V提供了ecall指令)。一旦CPU切换到supervisor mode,内核就能检验确认系统调用的参数(检查传给系统调用的地址是否是应用程序内存的一部分),决定应用程序是否被允许执行请求的操作(如检查应用程序是否被允许写特定的文件),最终决定是否执行。内核控制陷入supervisor mode的entry point很重要:如果应用程序可以决定内核的entry point,一个恶意应用就能在参数检验被忽略的point处进入内核。
内核组织
一个关键的设计问题是:操作系统的哪部分需要运行在supervisor mode。一个方案是全部嵌入内核,所有系统调用的实现都运行在supervisor mode。这种设计是宏内核(monolithic kernel)。
这种组织方式下,整个操作系统具有完全的硬件特权,好处是很方便,因为OS的设计者不需要决定哪部分不需要特权。操作系统的不同模块协作简单。如:一个操作系统有一块缓冲cache,文件系统和虚拟内存系统可以共享。
宏内核设计的缺点是操作系统不同模块的接口是复杂的,对于系统开发者来说很容易犯错。而宏内核中一个错误是致命的,因为supervisor mode下的错误通常引起系统故障,计算机停止工作,所有的应用程序也故障,计算机只能重启。
为了降低内核错误的风险,OS设计者要简化运行在supervisor mode下的内核代码,大多数代码执行在user mode下,这种设计是微内核(microkernel)。
微内核设计中,文件系统作为用户级进程运行。操作系统服务以进程运行称为servers。为了应用程序和文件服务交互,内核提供IPC机制为user-mode进程通信。如shell应用想读、写一个文件,它发一个消息给file server然后等待回应。
在微内核中,内核接口由一些底层函数组成,如启动应用程序,发送消息,访问硬件设备等。这种组织方式使内核相对简单,操作系统的大部分模块以用户级servers运行。
现实中,两种设计都很流行。许多Unix内核是宏内核,如Linux,尽管Linux的一些系统模块(如windowning system)以用户级servers运行。Linux给OS-intensive应用(如数据中心)带来高性能,部分原因是内核子系统能紧密整合。
如Minix,L4和QNX都是微内核操作系统,主要用在嵌入式环境。L4的变体seL4,非常小,使内存安全和其他安全属性得到了验证。
关于两种设计的优略,需要思考不同的方向:更高的性能,更小的内核代码,内核的可靠性,整个系统的可靠性等。
实际考虑因素可能比怎么组织这个问题更重要。一些操作系统是微内核,但是为了性能将一些用户级服务运行在kernel space。一些操作系统是宏内核,因为项目启动时这样组织的,没有动机转成微内核,因为新特性开发可能比将操作系统重构为微内核更重要。
本书角度看,两种组织有许多共同点:实现了系统调用,使用页表,中断处理,支持进程,使用锁去并发控制,实现文件系统等。
xv6作为宏内核实现,和大多数Unix操作系统一样。因此,xv6内核接口和操作系统接口一致,内核实现了整个操作系统。因为xv6没有提供许多服务,它的内核比许多微内核还小,但从概念上理解,xv6是宏内核。
Code:xv6组织
File | Description |
---|---|
bio.c | 文件系统的硬盘块cache |
console.c | 连接用户键盘和屏幕 |
entry.S | boot指令 |
exec.c | exec()系统调用 |
file.c | 支持文件描述符 |
fs.c | 文件系统 |
kalloc.c | 物理页分配 |
kernelvec.S | 处理来自内核的traps,始终中断 |
log.c | 文件系统的日志和异常恢复 |
main.c | 控制其他模块在boot后的初始化 |
pipe.c | 管道 |
plic.c | RISC-V中断控制 |
printf.c | 格式化输出到console |
proc.c | 进程和调度 |
sleeplock.c | 阻塞CPU的锁 |
spinlock.c | 非阻塞CPU的锁 |
start.c | machina-mode的boot代码 |
string.c | C字符串和字节数组library |
swtch.S | 线程切换 |
syscall.c | 系统调用处理函数 |
sysfile.c | 文件相关系统调用 |
sysproc.c | 进程相关系统调用 |
trampoline.S | user和kernel间切换的汇编代码 |
trap.c | 从traps和中断处理和返回的C代码 |
uart.c | 串行端口console设备驱动 |
virtio_disk.c | 硬盘设备驱动 |
vm.c | 管理页表和地址空间 |
进程概述
xv6中隔离性是以进程为单位的。进程抽象防止一个进程破坏或查看其他进程的内存,CPU,文件描述符等,也防止进程破坏内核,所以一个进程不可能破坏内核的隔离机制。内核必须小心的实现进程抽象,因为一个有bug的或者恶意的应用程序可能利用内核或硬件做破坏。内核实现进程的机制包括:user/supervisor mode标志,地址空间,线程的时间片。
为了加强隔离性,进程抽象使得程序以为自己占用了独立的机器:进程使得程序好像拥有其他进程不能读写的独立的内存系统或地址空间;好像程序独占CPU执行指令。
xv6使用页表保证每个进程只使用自己的地址空间。RISC-V页表映射一个虚拟地址(RISC-V指令操作的地址)到一个物理地址(CPU chip传送给主存的地址)。
---------------
MAXVA--->| trampoline |
----------------
| trapframe |
----------------
| |
| |
| |
| |
| heap |
| |
| |
| |
| |
-----------------
| userstack |
----------------
| user test |
| and data |
| |
0---> ----------------
xv6为每个进程维护一个单独的页表,定义进程的地址空间。一个地址空间包括从逻辑地址0开始的user memory。首先是指令,然后是全局变量,然后是栈,最后是进程可以按需扩展的堆空间(malloc)。限制进程最大地址空间的原因:RISC-V的指针是64位宽,在页表中查询虚拟地址空间时只使用低39位,xv6只使用其中的38位。因此最大的地址空间是\(2^{38} - 1 = 0x3fffffffff\),定义在MAXVA
(kernel/riscv.h)。在地址空间的顶部,xv6为trampoline保存了一页,为映射进程的trapframe保存了一页。xv6使用这两页陷入内核和返回:trampoline页包含陷入内核和退出内核的代码,映射trapframe对于保存和恢复用户进程的状态是必须的(Chapter4详解)。
xv6进程为每个进程维护许多状态项,整体定义在struct proc
中(kernel/proc.h)。进程最重要的内核状态项是**页表,内核栈,和运行的状态*。使用p->xxx
指向proc structure的状态项,p->pagetable
是指向进程页表的指针。
每个进程有一个执行线程(thread)执行进程的指令,一个thread能被暂停,后面又恢复运行。为了在进程间透明的切换,内核暂停当前运行的thread,恢复执行其他进程的thread。thread的许多状态(本地变量,函数调用返回地址)存储在线程栈中。每个进程有两个栈,一个用户栈,一个内核栈(p->kstack)。当进程执行用户指令时,只有用户栈使用,内核栈为空。当进程进入内核(系统调用或中断),内核代码执行在进程的内核栈。当进程在内核中,它的用户栈含有被保存的数据,但没有被使用。一个进程的thread交替使用用户栈和内核栈。内核栈是独立的(和用户代码隔离),所以尽管进程破坏了用户栈,内核仍可以执行。
进程通过RISC-V的ecall
指令进行系统调用。这个指令提升硬件特权级别,改变PC寄存器到内核定义的entry point。entry point的代码切换到内核栈,执行实现系统调用的内核指令。当系统调用完成时,内核切换到用户栈,通过调用sret
指令返回用户空间,降低硬件特权级别,系统调用指令执行后恢复执行用户指令。一个进程的thread因I/O而阻塞在内核,当I/O完成后恢复执行。
p->state
(UNUSED, USED, SLEEPING, RUNNABLE, RUNNING, ZOMBIE)指示进程是否被分配,准备运行,等待I/O,暂停。p->pagetable
指向进程的页表,匹配RISC-V要求的格式。当进程在用户空间执行时,xv6使用进程的p->pagetable
指向分页硬件。进程的页表也记录进程内存对应的物理页地址。
总之,进程结合了两种设计思想:地址空间让进程好像独占内存;thread让进程好像独占CPU。xv6中,一个进程由一个地址空间和一个thread组成。真实操作系统进程可能有多个thread利用多个CPUs。
Code:xv6的启动,第一个进程和系统调用
概述xv6如何启动并运行第一个进程,描述更多的关于进程概述中的机制的细节。
当RISC-V计算机上电,它初始化并运行存储在ROM中的boot loader。boot loader将xv6的内核导入内存。然后,在machine-mode下,CPU从_entry(kernel/entry.S:7)开始执行xv6。RISC-V启动时关闭分页硬件,虚拟内存可以直接映射到物理内存。
loader将xv6内核装载到物理地址为0x80000000
的内存,内核装载到0x80000000
而不是0x0
的原因是:0x0:0x80000000
含有I/O设备。
在_entry
的指令为了可以运行C代码设置了一个栈。xv6为初始化栈在start.c
(kernel/start.c:11)声明了空间,_entry
的代码将栈指针sp(stack pointer register)指向地址stack0+4096,这是栈顶,因为RISC-V的栈是向下增长的。现在内核有一个栈,_entry
调用start
处的C代码(kernel/start.c:21)。
(初始化内核栈时,为每个CPU(hart)分配4KB的栈(16字节对齐),由mhartid(hardware thread ID,hartware thread表示一个硬件线程,可以看作一个独立的处理器,也有可能是一个处理器单元被多个硬件线程复用)寄存器确定)。
start
函数在machine-mode下做了一些配置,然后切换到supervisor-mode。为了进入supervisor mode,RISC-V提供了mret
指令,用于返回在machine-mode下的trap。start
没有从这个调用返回,相反做了些设置:在mstatus寄存器中设置前特权级别(Previous Privilege mode,MPP)为supervisor;通过将main的地址写到寄存器mepc(Machine Exception Program Counter)设置返回地址是main,(当执行mret时,程序从发生异常的指令处恢复执行);通过写0到页表寄存器satp关闭虚拟地址映射;将所有中断和异常委托给supervisor mode。
进入supervisor mode前,start
还需要:为了产生时钟中断需要对定时器编程(体现了C语言对硬件的控制力)。最后start
通过调用mret
返回supervisor mode,这使得PC指向main(kernel/main.c:11)。
main初始化一些设备和子系统后,通过调用userinit
(kernel/proc.c:266)创建第一个进程。第一个进程执行了用RISC-V汇编写的一小段代码,完成了xv6中的第一个系统调用(第一个系统调用执行的是exec(init,_)
)。initcode.S(user/initcode.S:3)将SYS_EXEC装入寄存器a7调用exec系统调用,然后调用ecall重新进入内核。
内核在sysycall
(kernel/syscall.c:133)中使用寄存器a7中的数字调用指定的系统调用。系统调用表(kernel/syscall.c:108)将SYS_EXEC映射到sys_exec供内核调用。exec将当前进程的内存和寄存器用新的程序(/init)替换。
一旦内核完成了exec,返回用户空间的/init
进程。Init
(user/init.c:15)创建了一个新的console设备文件,将文件描述符0,1,2指向它。在console中起一个shell,系统启动。
安全模型
处理恶意bugs比处理意外bugs更难。下面是一些操作系统设计的关于典型安全假设和目标的高级观点。
操作系统必须认为一个进程的用户级代码会全力破坏内核和其他进程。用户代码可能解引用一个指向非法地址空间的指针;可能执行包括用户代码不能执行的任何RISC-V指令;可能读写任何RISC-V控制寄存器;可能直接访问设备硬件;可能传递一个特意设计的值给系统调用使内核崩溃或者做些不合适的事情。 内核的目标是限制每个用户进程使它所做的一切都是对它自己的用户内存进行读、写、执行,使用32位通用RISC-V寄存器,按照系统调用所允许的方式影响内核和其他进程。内核必须防止其他任何行为。这通常是内核设计的绝对要求。
对内核的代码期望完全不同。内核的代码被认为是bug-free的,确定不包含任何恶意的代码。这个假设影响着我们如何分析内核代码。例如,有许多内部的内核函数,如果内核代码使用不当将会造成一系列问题(如spin locks)。检查任何内核代码时,都要使自己相信他们是正确的。假设内核代码通常是正确的,并遵循所有内核自身函数和数据结构的使用规则。在硬件层面,假设RISC-V CPU,RAM,硬盘等都没有硬件bugs且都按照文档所规定运行。
当然,真实世界不会如此简单。很难防止有意的用户代码通过消耗内核保护的资源(硬盘空间,CPU时间,进程表空间等)使系统无法使用或者panic。写bug-free的代码或设计bug-free的硬件几乎不可能;如果恶意代码的开发者了解内核或者硬件的bug,他们会利用这些bugs。需要设计保护措施防止出现bugs的可能:断言、类型检查、栈保护页等。最后,用户代码和内核代码的区分有时不明显:一些有特权的用户级进程可能提供基本的服务并成为操作系统的一部分,一些操作系统中有特权的用户代码可以导入新的代码到内核(Linux的loadable kernel modules,LKM在内核运行时被从用户空间装入内核)。
Real world
许多操作系统都采用了进程概念,和xv6相似。然而,现代操作系统支持一个进程中有许多线程,使得一个进程可以使用多个CPUs。在一个进程中支持多个线程涉及到很多xv6没有的机制,以及一些接口的修改(如Linux的clone,是fork的变体),能控制进程中的线程在哪些方面共享。