MIT6.S081 Chap2:操作系统组织

337 阅读18分钟

操作系统组织

对操作系统的一个关键要求,是同时执行不同的活动。比如通过系统调用接口 fork来启动新的进程。操作系统必须在不同的进程之间对计算机的资源进行分时共享。即使进程的数量多于CPU的数量,操作系统也必须确保所有的进程都有机会执行。

操作系统还必须安排进程间的隔离。即如果一个进程有bug或出现故障,它不应该影响那些不依赖于它的进程。 然而,完全的隔离太强了,应该允许进程之间进行有意识的交互。管道就是一个例子。

综上,操作系统必须满足三个条件:多路复用,隔离以及交互

本章讲的就是如何组织操作系统来实现这三个要求。有许多方法可以做到这一点,但是本文侧重于以宏内核为中心的主流设计,许多Unix操作系统都使用这种内核。除此之外,本章还概述了xv6进程,它是xv6中的隔离单元,以及xv6启动时第一个进程的创建。

xv6运行于多核RISC-V微处理器,它的许多底层功能(比如说进程的实现)只适用于RISC-V。RISC-V是一种64位的CPU,xv6是用"LP64"C编写的,在这种C语言中,long与指针是64位,而int是32位。

在完整的计算机中,CPU总是被支持它的硬件围绕,大部分硬件以I/O接口的形式出现。qemu通过-machine virt选项模拟了外围硬件,xv6就是为这种硬件编写的。硬件包括RAM,包含boot代码的ROM,到用户键盘/屏幕的串行连接以及用于存储的磁盘。

1.抽象物理资源

第一次面对操作系统时,想到的问题可能是为什么要有一个操作系统?
image-20220420155001499

我们可以将上图中的系统调用实现在一个应用可以链接的库中,这样一来,每个应用程序甚至可以根据自己的需要定制自己的库。应用可以直接与硬件资源交互,并以最适合应用程序的方式使用这些资源(例如实现高性能)。一些嵌入式设备的操作系统或实时操作系统通过这样的方式组织。

库方案的缺点在于,如果有多个应用程序同时运行,这些应用程序必须妥善安排。比如,每个应用必须周期性地放弃CPU以便其他应用运行。如果所有应用之间相互信任并且都没有bug,那么这种协作式的分时方案是可以的。然而,对于应用程序来说,相互不信任和存在错误是更为常见的。所以,我们通常需要比协作式方案提供更强的隔离

为了达到强隔离,可以采用以下的方法:禁止应用直接访问敏感的硬件资源将资源抽象为服务。比如,Unix应用程序只能通过文件系统的openreadwriteclose系统调用与存储交互,而不是直接读写磁盘。这为应用程序提供了路径名的便利,也使得操作系统(作为接口的实现者)可以管理磁盘。即使不考虑隔离,有意交互的程序也会发现,相比直接使用硬盘来说,文件系统是一种更方便的抽象。 类似的,Unix透明地在进程之间切换硬件CPU,在必要的时候保存和恢复寄存器状态,从而使得应用程序感受不到时间的共享。这种透明性允许操作系统共享CPU,即使有些应用正在无限循环。

另一个例子是Unix进程使用exec来建立它们的内存映像,而不是直接与物理内存交互,这让操作系统来决定将进程放在内存中的哪个位置,如果内存紧张,操作系统甚至可能将进程的部分数据存储在硬盘上。exec也为用户提供了在文件系统中存储可执行程序映像的便利。

Unix进程之间的很多交互形式通过文件描述符实现。文件描述符不仅抽象了许多细节(例如,管道或文件中的数据存储在哪里),也定义了一种简化交互的方法。比如,如果管道一端的应用执行失败,内核会向管道的另一端的进程发送一个EOF信号。

上图中的系统调用接口经过了精心设计,既能够为程序员提供便利,也能够提供强隔离的可能性。Unix接口并不是抽象资源的唯一方式,但它被证明是一种很好的方式。

2.用户模式,管理员模式,系统调用

强隔离需要在应用程序与操作系统之间划分清晰的边界。如果一个应用出现错误,我们不希望操作系统或其他应用因此出现问题。相反,操作系统应该能够清理掉出错的应用,继续运行别的应用。为实现强隔离,操作系统必须保证,应用不能修改(甚至不能读取)操作系统的数据结构和指令,而且,应用也不能访问其他进程的内存

CPU为强隔离提供了硬件支持。举例来说,RISC-V中,CPU可以在三种模式执行指令:机器模式,管理者模式,用户模式。机器模式下执行的指令拥有全部特权;CPU以机器模式启动。机器模式主要用于配置计算机,xv6在机器模式下执行几行后,就切换到管理者模式。

在管理者模式下,CPU被允许执行特权指令,例如允许和禁止中断,读写保存页表地址的寄存器。如果一个用户模式下的应用尝试执行特权指令,那么CPU不会执行这个指令,它会切换到管理者模式,以便管理者模式代码可以终止该应用程序,因为这个应用做了它不该做的事情。

image-20220420165324635 上图描述了这一组织。一个应用只能执行用户模式指令(例如增加数字等),这被称为运行在用户空间。管理者模式下的软件能够执行特权指令,这被称为运行在内核空间。运行在内核空间(或管理者模式)的软件叫做内核

想要调用内核函数(例如xv6中的read系统调用)必须切换到内核。应用程序不能直接调用内核函数。CPU提供了一个特殊的指令将CPU从用户模式切换到管理者模式,并在内核指定的入口进入内核(RISC-V为这个目的提供了ecall指令)。一旦CPU切换到管理者模式,内核就能够验证系统调用的参数(例如,检查传递给系统调用的地址是否是应用程序存储器的一部分),决定是否允许应用程序执行请求的操作(例如,检查是否允许应用程序写入指定的文件),然后拒绝或执行它。

内核控制用户模式向管理者模式切换的入口这点非常重要:如果应用能够决定内核的入口,那么一个恶意应用程序可以在跳过参数验证的地方进入内核

3. 内核组织

一个关键的问题是操作系统的哪部分应该运行在管理者模式下。一种可能是整个操作系统都放在内核中,这样所有系统调用的实现都在管理者模式下运行。这种组织称为宏内核

在这种组织中,整个操作系统的运行都拥有全部硬件特权。这非常方便,因为OS的设计者不需要考虑操作系统的哪一部分不需要全部的硬件特权。此外,操作系统的不同部分之间合作起来也更容易。比如,一个操作系统可能有一个缓冲区,可以由文件系统和虚拟内存系统共享

宏内核的缺点在于,操作系统中不同部分之间的接口通常比较复杂,因此操作系统开发人员容易犯错。在宏内核中,错误是致命的,因为管理者模式下的错误往往能让系统崩掉,当系统崩掉时,计算机停止运行,因此所有应用程序也会失败。计算机必须重新启动。

为了降低内核出错的风险,OS设计者们可以最小化管理者模式下运行的代码数量,并在用户模式下执行操作系统的大部分。这种内核组织叫做微内核

image-20220420214143284

上图描述了微内核设计。图中,文件系统作为一个用户进程运行。作为进程运行的OS服务被叫做服务器。为了允许进程与文件服务器交互,内核提供了一种进程间通信机制让用户模式进程之间发送消息。比如,像shell这样的应用程序,想要读或写一个文件,就向文件服务器发送一个消息,然后等待回应。 在微内核中,内核接口由一些底层的函数包括启动应用程序,发送消息,访问硬件等组成。这种组织允许内核做得相对简单,因为操作系统的大部分都驻留在用户级别的服务器中。

微内核和宏内核操作系统有许多共同的思想。它们实现系统调用,使用页表,处理中断,支持进程,使用锁进行并发控制,实现文件系统,等等。

跟大部分Unix操作系统一样,xv6实现为宏内核。因此xv6内核接口对应于操作系统接口,而且内核实现了完整的操作系统。因为xv6提供的服务不多,其内核比许多微内核都小,但它仍属于宏内核的范畴。

4. 代码:xv6组织

xv6内核源代码位于kernel目录下。源码大体按照模块划分为不同的文件:

image-20220421160814989

模块间接口定义在kernel/defs.h中。

5. 进程概述

xv6的隔离单元是进程(和其他Unix操作系统一样)。进程的抽象阻止了进程破坏或者窥探另一个进程的内存、CPU、文件描述符等资源。它也阻止了进程破坏内核本身,这样进程就无法破坏内核的隔离机制。内核必须小心地实现进程抽象,因为恶意应用可能会欺骗内核或硬件,做一些坏事(比如说规避隔离)。内核用来实现进程的机制包括用户/管理者模式标志,地址空间,以及线程时间片。 为了加强隔离,进程抽象为程序提供了一种错觉,即让它觉得拥有属于自己的私有机器。进程为程序提供了其他进程无法读写的私有内存系统,或者叫地址空间。进程也提供了看起来是程序自己的CPU来执行程序的命令。

xv6使用页表(硬件实现)来为每个进程提供自己的地址空间。RISC-V页表将虚拟地址(RISC-V指令操作的地址)翻译(或映射)成物理地址(CPU芯片向主内存发送的地址)

xv6为每一个进程维护了一张单独的页表,这张页表定义了进程的地址空间。地址空间如下图:

image-20220421173100287

地址空间包括进程从虚拟地址0开始的用户内存。内存中首先是指令,然后是全局变量,然后是栈,最后是进程可以按需求扩展的堆空间(malloc)。很多因素限制了进程地址空间的最大尺寸:RISC-V上的指针是64位,硬件仅使用低39位来从页表中查找虚拟地址,而xv6只使用了39位中的38位。因此,最大地址为2381=0x3fffffffff2^{38}-1 = 0x3fffffffff,即MAXVA。在地址空间的顶部,xv6保留了两页,一页用于跳板页trampolinetrampoline页面包含了进入和退出内核的代码。另一页trapframe,对于保存/恢复用户进程的状态是必要的。 xv6使用这两个页面过渡到内核和返回。 具体的将在Chap4解释。

xv6内核为每个进程维护了很多状态,这些状态都集中在struct proc(kernel/proc.h)中。进程最重要的内核状态是它的页表、内核栈以及运行状态。 下文通过记号p->xxx来表示proc结构体中的元素,比如p->pagetable是指向该进程页表的指针。

每个进程都有一个执行线程(简称线程),执行进程的指令。线程可以被挂起,后面进行恢复。为了透明地在进程间进行切换,内核挂起当前运行的线程,恢复其他进程的线程。线程中的大部分状态(局部变量、函数调用返回地址)存放在线程栈中。每个进程有两个栈:一个用户栈和一个内核栈(p->kstack)。当进程在执行用户指令时,只有它的用户栈在使用,内核栈是空的。当进程进入到内核时(系统调用或者中断),内核代码在进程的内核栈上执行。当进程运行在内核时,它的用户栈仍然包含保存的数据,但并未被使用。进程的线程在用户栈和内核栈之间来回切换。内核栈是独立的(并且不受用户代码影响),因此即使进程破坏了它的用户栈,内核也可以继续执行

进程可以通过执行RISC-V ecall指令发起系统调用。这个指令提升硬件特权级别将程序计数器设置为内核定义的入口点。入口处的代码切换到内核栈,并且执行实现系统调用的内核指令。当系统调用结束,内核切换回用户栈,并且通过调用sret指令返回用户空间。这个指令降低硬件特权级别,并在系统调用指令结束后,立即恢复执行用户指令。进程的线程可以在内核中阻塞,以等待I/O,并在I/O完成后恢复到它之前离开的位置。

p->state表示进程是否被分配内存,是否准备就绪,是否在运行,等待I/O,还是正在退出。

p->pagetable以RISC-V硬件期望的格式保存了进程的页表。在用户空间执行进程时,xv6让分页硬件使用进程的p->pagetable。进程的页表也作为其分配的物理页地址的记录,这些物理页保存了进程的内存。

总之,一个进程捆绑了两个设计思想:通过地址空间,给一个进程拥有全部内存的假象;通过一个线程,给一个进程独占CPU的假象。在xv6中,一个进程由一个地址空间和一个线程组成。在真实的操作系统中,一个进程可能有多个线程来利用多个CPU。

6. 代码:启动xv6,第一个进程和系统调用

本节概述内核如何启动和运行第一个进程

当RISC-V计算机通电时,它将初始化自己,然后运行存储在只读内存上的 boot loader。boot loader 将 xv6 内核加载到内存中。然后,CPU在机器模式下,从_entry(kernel/entry.S)开始执行xv6。RISC-V启动时,禁用分页硬件:虚拟地址直接映射到物理地址上

加载程序(loader)将xv6内核加载到物理地址0x80000000的内存上。之所以将内核放置在0x80000000而不是0x0,是因为0x00x80000000这段地址包含了I/O设备。

_entry中的指令创建了一个栈以便xv6运行C代码。xv6在start.c(kernel/start.c)中声明了初始栈即stack0的空间。_entry中的代码将地址stack0 + 4096加载进栈指针寄存器 sp 中,这个地址是栈的顶部,因为RISC-V中的栈向下生长。现在内核有了一个栈,_entry可以执行start中的C代码了。

start函数执行了一些只允许在机器模式下进行的配置,然后切换到管理者模式。为了进入管理者模式,RISC-V提供了mret指令。这个指令通常用来从之前的管理者模式到机器模式的调用中返回(从机器模式返回到管理者模式)。但是strat并非从这样的调用中返回,而是进行一些设置,假装之前有这样一个调用(这个调用是从管理者模式到机器模式的)。

start会进行一些设置:在mstatus寄存器中将之前的特权模式设置为管理者模式;在mepc寄存器中写入main的地址,从而将返回地址设置为main;通过将0写入页表寄存器satp来禁用管理者模式下的虚拟地址转换,并且将所有中断和异常委派给管理者模式。

在跳转到管理者模式之前,start还执行了另外一个任务:它对时钟芯片进行编程,以产生定时器中断。等所有这些任务都完成之后,start调用mret来"返回"到管理者模式。这使得程序计数器变更为main(kernel/main.c)

main先对一些设备和子系统进行初始化。然后通过调用userinit(kernel/proc.c)来创建第一个进程。第一个进程执行了一个用RISC-V汇编编写的小程序,在xv6中进行第一次系统调用。initcode.S(user/initcode.S)将exec系统调用的编号SYS_EXEC(kernel/syscall.h)加载到寄存器a7中,然后调用ecall重新进入内核。

内核使用寄存器a7中的数字来调用所需的系统调用。系统调用表(kernel/syscall.c)将SYS_EXEC映射到sys_exec,由内核调用。由Chap1,我们知道,exec用一个新的程序(这里是/init)替换了当前进程的内存和寄存器。

一旦内核完成了exec,它就在/init进程中返回到用户空间。如果需要,Init(user/init.c)创建一个新的控制台设备文件,然后将其作为文件描述符0,1,2打开。接着,在控制台启动一个shell,系统到这里就启动了。

7. 安全模式

对付恶意操作远比处理意外错误更困难。

操作系统必须假设一个进程的用户级代码会尽力破坏内核或其他进程。用户代码可能试图解引用其允许的地址空间之外的指针;它可能试图执行任何RISC-V指令,甚至那些不是为用户代码设计的指令;它可以尝试读取和写入任何RISC-V控制寄存器;它可能试图直接访问设备硬件。内核的目标是限制每个用户进程,使其只能读/写/执行自己的用户内存,使用32位通用RISC-V寄存器,并以系统调用允许的方式影响内核和其他进程。内核必须阻止任何其他动作。这通常是内核设计中的一个绝对要求。

对内核自身的代码的期望则完全不同。我们假设内核代码总体上是正确编写的,并且遵循了关于使用内核自身函数和数据结构的所有规则。在硬件层面,RISC-V的CPU、RAM、磁盘等,假定按照文档运行,没有硬件错误。当然,在现实世界,事情没有那么简单。如果恶意用户代码的作者知道内核或硬件的缺陷,他们就会利用这些缺陷。

所以,应该在内核中设计安全措施来防止它可能存在的错误:断言、类型检查、堆栈保护页等。

8. Real world

大多数操作系统都采用了进程的概念,并且大多数进程看起来与xv6的差不多。然而,在现代操作系统中,一个进程支持多个线程,从而允许一个单独的进程能够利用多个CPU。在一个进程中支持多线程,需要一些xv6所没有的机制,来控制进程中的哪些元素可以在线程间共享。其中一个机制是隐式的接口转换(例如 Linux的 clone 和 几种 fork)