对操作系统的一个关键要求是同时支持多个活动。例如,使用第1章中描述的系统调用接口,一个进程可以用fork启动新的进程。操作系统必须在这些进程中分时使用计算机资源。例如,即使进程的数量多于硬件CPU的数量,操作系统也必须确保所有的进程都有机会执行。操作系统还必须安排进程间的隔离。也就是说,如果一个进程有一个bug并出现故障,它不应该影响那些不依赖于有bug的进程的进程。然而,完全隔离并不可行,因为进程有可能有意地相互作用;管道就是一个例子。因此,操作系统必须满足三个要求:多路复用、隔离和交互。
本章概述了如何组织操作系统来实现这三个要求。事实证明,有许多方法可以做到这一点,但是本文主要关注的是以单片内核为中心的主流设计,许多Unix操作系统都使用这种内核。本章还概述了xv6进程(xv6中的隔离单元)以及xv6启动时第一个进程的创建。
Xv6运行在多核RISC-V微处理器,它的许多低级功能(例如,它的进程实现)是RISC-V特有的。RISC-V是64位CPU,xv6是用“LP64”C编写的,这意味着C编程语言中的long (L)和pointers (P)是64位,但int是32位。
在一台完整的计算机中,CPU被一系列硬件所包围,其中大部分是I/O接口。Xv6支持的硬件环境为qemu模拟的“-machine virt”。该模拟环境包括RAM、包含引导代码的ROM、到用户键盘/屏幕的串行连接以及用于存储的磁盘。
当遇到一个操作系统时,人们可能会问的第一个问题是为什么要有它?也就是说,可以实现图1.2中的系统调用作为应用程序链接的库。在这个计划中,每个应用程序甚至可以根据自己的需要定制自己的库。应用程序可以直接与硬件资源交互,并以最适合应用程序的方式使用这些资源(例如,实现高性能或可预测的性能)。一些用于嵌入式设备或实时系统的操作系统就是这样组织的。
这种库方法的缺点是,如果有多个应用程序在运行,这些应用程序必须表现良好。例如,每个应用程序必须定期放弃CPU,以便其他应用程序可以运行。如果所有的应用程序都相互信任并且没有错误,这样的协作式分时方案可能是可以的。对于应用程序来说,相互不信任和存在错误更为常见,因此人们通常希望比合作方案提供的隔离更强。为了实现强隔离,禁止应用程序直接访问敏感的硬件资源,而是将资源抽象为服务是很有帮助的。例如,Unix应用程序只能通过文件系统的open、read、write和close系统调用与存储交互,而不能直接读写磁盘。这为应用程序提供了路径名的便利,并允许操作系统(作为接口的实现者)管理磁盘。即使隔离不是一个问题,有意交互的程序(或者只是希望避开彼此的方式)很可能会发现文件系统是一个更方便的抽象而不是直接使用磁盘。
类似地,Unix透明地在进程间切换硬件CPU,根据需要保存和恢复寄存器状态,因此应用程序不必知道时间共享。这种透明性允许操作系统共享CPU,即使有些应用程序处于无限循环中。
作为另一个例子,Unix进程使用exec来构建它们的内存映像,而不是直接与物理内存交互。这允许操作系统决定在内存中的什么地方放置一个进程;如果内存紧张,操作系统甚至可能将进程的一些数据存储在磁盘上。Exec还为用户提供了方便的文件系统来存储可执行程序映像。
Unix进程之间许多形式的交互都是通过文件描述符进行的。文件描述符不仅抽象出许多细节(例如,管道或文件中的数据存储在哪里),还以简化交互的方式定义它们。例如,如果管道中的一个应用程序失败,内核会为管道中的下一个进程生成一个文件结束信号。 图1.2中的系统调用界面经过精心设计,为程序员提供了便利,同时也提供了高度隔离的可能性。Unix接口不是抽象资源的唯一方式,但它被证明是一种非常好的方式。
强隔离要求应用程序和操作系统之间有一个硬边界。如果应用程序出错,我们不希望操作系统失败或其他应用程序失败。相反,操作系统应该能够清理失败的应用程序,并继续运行其他应用程序。为了实现强隔离,操作系统必须安排应用程序不能修改(甚至不能读取)操作系统的数据结构和指令,并且应用程序不能访问其他进程的内存。
CPU为强隔离提供硬件支持。例如,RISC-V有三种CPU可以执行指令的模式:机器模式、管理模式和用户模式。在机器模式下执行的指令具有完全特权;CPU以机器模式启动。机器模式主要用于配置计算机。Xv6在机器模式下执行几行代码,然后切换到管理员模式。 在管理模式下,CPU被允许执行特权指令:例如,启用和禁用中断,读写保存页表地址的寄存器等。如果用户模式下的应用程序试图执行特权指令,那么CPU不会执行该指令,而是切换到管理模式,以便管理模式代码可以终止该应用程序,因为它做了不该做的事情。在第1章的图1.1中说明了这中组织方式。应用程序只能执行用户模式指令(例如,添加数字等)。)并被称为运行在用户空间,而管理模式下的软件也可以执行特权指令,并被称为运行在内核空间。运行在内核空间(或管理模式)的软件称为内核。
想要调用内核函数(例如xv6中的read系统调用)的应用程序必须转换到内核;应用程序不能直接调用内核函数。CPU提供了一个特殊的指令,将CPU从用户模式切换到管理模式,并在内核指定的入口点进入内核。(RISC-V为此提供了ecall指令。)一旦CPU切换到管理员模式,内核就可以验证系统调用的参数(例如,检查传递给系统调用的地址是否是应用程序存储器的一部分),决定是否允许应用程序执行所请求的操作(例如,检查是否允许应用程序写入指定的文件),然后拒绝或执行该操作。内核控制转换到管理模式的入口点是很重要的;例如,如果应用程序可以决定内核入口点,恶意应用程序就可以在跳过参数验证的地方进入内核。
一个关键的设计问题是操作系统的哪一部分应该在管理模式下运行。一种可能是,整个操作系统都驻留在内核中,因此所有系统调用的实现都在管理模式下运行。这种组织被称为宏内核。
在这个组织中,整个操作系统以完全的硬件特权运行。这种组织很方便,因为OS设计者不必决定操作系统的哪一部分不需要完全的硬件特权。此外,操作系统的不同部分更容易协作。例如,一个操作系统可能有一个可以由文件系统和虚拟内存系统共享的缓冲区缓存。 宏内核的一个缺点是操作系统不同部分之间的接口通常很复杂(正如我们将在本文的其余部分看到的那样),因此也确实如此
图2.1:带有文件系统服务器的微内核
操作系统开发人员很容易犯错误。在宏内核中,错误是致命的,因为管理模式中的错误通常会导致内核失败。如果内核失败,计算机停止工作,因此所有应用程序也会失败。计算机必须重新启动才能再次启动。 为了降低内核出错的风险,操作系统设计人员可以最大限度地减少在管理模式下运行的操作系统代码量,并在用户模式下执行大部分操作系统。这种内核组织被称为微内核。
图2.1 展示了这种微内核设计。在图2.1中,文件系统作为用户级进程运行。作为进程运行的操作系统服务称为服务器。为了允许应用程序与文件服务器交互,内核提供了一种进程间通信机制,将消息从一个用户模式进程发送到另一个用户模式进程。例如,如果像shell这样的应用程序想要读取或写入文件,它会向文件服务器发送一条消息,并等待响应。
在微内核中,内核接口由一些低级功能组成,用于启动应用程序、发送消息、访问设备硬件等。这种组织允许内核相对简单,因为大多数操作系统驻留在用户级服务器中。
在现实世界中,宏内核和微内核都很受欢迎。许多Unix内核都是宏内核的。例如,Linux具有宏内核,尽管一些OS功能作为用户级服务器运行(例如,窗口系统)。Linux为操作系统密集型应用程序提供了高性能,部分原因是内核的子系统可以紧密集成。
Minix、L4和QNX等操作系统被组织成一个带有服务器的微内核,并在嵌入式环境中得到了广泛的部署。L4的一个变体seL4足够小,已经过内存安全和其他安全属性的验证。
在操作系统的开发者中,对于哪种组织更好有很多争论,并且没有这样或那样的决定性证据。此外,这在很大程度上取决于“更好”的含义:更快的性能、更小的代码大小、内核的可靠性、整个操作系统的可靠性(包括用户级服务)等等。
还有比哪个组织的问题更重要的实际考虑。一些操作系统有一个微内核,但是由于性能原因,在内核空间中运行一些用户级服务。一些操作系统具有单片内核,因为这是它们开始的方式,并且没有什么动力转向纯微内核组织,因为新特性可能比重写现有操作系统以适应微内核设计更重要。
从这本书的角度来看,微内核和宏内核操作系统有许多共同的关键思想。它们实现系统调用,使用页表,处理中断,支持进程,它们使用锁进行并发控制,它们实现文件系统,等等。这本书关注这些核心思想。像大多数Unix操作系统一样,Xv6是作为一个整体内核实现的。因此,xv6内核接口对应于操作系统接口,内核实现完整的操作系统。由于xv6没有提供很多服务,所以它的内核比一些微内核要小,但是从概念上来说,xv6是宏内核的。
xv6内核源代码位于kernel/子目录中。源代码被分成多个文件,遵循模块化的粗略概念;图2.2 列出了这些文件。模块间接口定义在defs.h(kernel/defs.h)。
图2.3:进程虚拟地址空间的布局
xv6中的隔离单元(和其他Unix操作系统一样)是一个进程。进程抽象防止一个进程破坏或窥探另一个进程的内存、CPU、文件描述符等。它还防止进程破坏内核本身,这样进程就不会破坏内核的隔离机制。内核必须小心地实现进程抽象,因为有缺陷或恶意的应用程序可能会欺骗内核或硬件做一些坏事(例如,绕过隔离)。内核用来实现进程的机制包括用户/管理员模式标志、地址空间和线程的时间片。
为了帮助实施隔离,进程抽象为程序提供了一种错觉,即它拥有自己的私有机器。一个进程为一个程序提供一个私有的内存系统或地址空间,其他进程不能读写。一个进程也为程序提供了它自己的CPU来执行程序的指令。
Xv6使用页表(由硬件实现)为每个进程提供自己的地址空间。RISC-V页表将虚拟地址(RISC-V指令处理的地址)转换(或“映射”)为物理地址(CPU芯片发送到主存储器的地址)。
Xv6为每个进程维护一个单独的页表,该表定义了该进程的地址空间。如图2.3所示, 地址空间包括从虚拟地址零开始的进程的用户内存。首先是指令,其次是全局变量,然后是堆栈,最后是一个“堆”区域(对于malloc),进程可以根据需要扩展它。有许多因素限制了进程地址空间的最大大小:RISC-V上的指针是64位宽;当在页表中查找虚拟地址时,硬件只使用低39位;xv6只使用了这39位中的38位。因此,最大地址为2^38-1 = 0x3fffffffff,即MAXVA(kernel/riscv.h:363)。在地址空间的顶部,xv6为trampoline保留了一个页面,并为映射进程的trapframe保留了一个页面。Xv6使用这两个页面过渡到内核并返回;trampoline页面包含了进入和退出内核的代码,映射trapframe对于保存/恢复用户进程的状态是必要的,我们将在第4章解释。
xv6内核为每个进程维护许多状态,并将其收集到一个struct proc中(kernel/proc.h:86)。一个进程最重要的内核状态是它的页表、内核堆栈和运行状态。我们将使用符号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。
为了使xv6更具体,我们将概述内核如何启动和运行第一个进程。后续章节将更详细地描述本概述中出现的机制。
当RISC-V计算机通电时,它会初始化自己并运行存储在只读存储器中的bootloader。bootloader将xv6内核加载到内存中。然后,在机器模式下,CPU从_entry(kernel/entry.S:7)开始执行xv6。RISC-V启动时禁用分页硬件:虚拟地址直接映射到物理地址。
bootloader将xv6内核加载到内存中的物理地址0x80000000处。它将内核放在0x80000000而不是0x0的原因是因为地址范围0x0:0x80000000包含I/O设备。
在_entry处的指令建立了一个栈,这样xv6就可以运行C代码了。Xv6在文件start.c(kernel/start.c:11)中声明了初始堆栈stack0的空间。 _entry处的代码在堆栈顶部加载堆栈指针寄存器sp,其地址为stack0+4096,因为RISC-V上的堆栈向下增长。既然内核有了一个栈,那么_entry将在start(kernel/start.c:21)中调用C代码。
函数start执行一些仅在机器模式下允许的配置,然后切换到管理员模式。为了进入管理模式,RISC-V提供指令mret。该指令最常用于从先前的管理模式调用返回到机器模式。start不会从这样的调用中返回,而是将事情设置为好像已经发生过一样:它在寄存器mstatus中将先前的特权模式设置为supervisor,通过将main的地址写入寄存器mepc将返回地址设置为main,通过将0写入页表寄存器satp来禁用supervisor模式下的虚拟地址转换,并将所有中断和异常委托给supervisor模式。
在进入管理模式之前,start还要执行一项任务:它对时钟芯片进行编程,以产生定时器中断。这种内务处理完成后,通过调用mret开始“返回”到超级visor模式。这导致程序计数器变为main(kernel/main.c:11)。在main之后(kernel/main.c:11)初始化几个设备和子系统,它通过调用userinit创建第一个进程(内核/处理器c:226)。第一个进程执行一个用RISC-V汇编编写的小程序,make在xv6中进行第一次系统调用。initcode。S(用户/初始化代码。学生3)加载exec系统调用SYS_EXEC的编号(kernel/syscall.h:8),放入寄存器a7,然后调用ecall重新进入内核。
内核在syscall中使用寄存器a7中的数字(kernel/syscall.c:133)调用所需的系统调用。系统调用表(kernel/syscall.c:108)将SYS_EXEC映射到内核调用的sys_exec。正如我们在第二章看到的1, exec用一个新程序(在本例中是/init)替换当前进程的内存和寄存器。
一旦内核完成了exec,它就在/init进程中返回到用户空间。初始化(user/init.c:15)如果需要,创建新的控制台设备文件,然后将其作为文件描述符0、1和2打开。然后它在控制台上启动一个shell。系统启动了。
您可能想知道操作系统是如何处理错误或恶意代码的。因为对付恶意比处理意外错误更难,所以有理由将这个主题视为与安全相关。以下是操作系统设计中典型安全假设和目标的高级视图。
操作系统必须假设一个进程的用户级代码会尽力破坏内核或其他进程。用户代码可能试图取消引用其允许的地址空间之外的指针;它可能试图执行任何RISC-V指令,甚至那些不是为用户代码设计的指令;它可以尝试读取和写入任何RISC-V控制寄存器;它可能试图直接访问设备硬件;它可能会将聪明的值传递给系统调用,试图欺骗内核崩溃或做一些愚蠢的事情。内核的目标是限制每个用户进程,使其只能读/写/执行自己的用户内存,使用32个通用RISC-V寄存器,并以系统调用允许的方式影响内核和其他进程。内核必须阻止任何其他动作。这通常是内核设计中的一个绝对要求。
对内核自身代码的期望完全不同。内核代码被认为是由善意且细心的程序员编写的。内核代码应该是无bug的,当然也不包含任何恶意代码。这个假设影响了我们分析内核代码的方式。例如,有许多内部内核函数(例如自旋锁),如果内核代码不正确地使用它们,就会导致严重的问题。当检查任何一段特定的内核代码时,我们都希望确信它的行为是正确的。然而,我们假设内核代码总体上是正确编写的,并且遵循了关于使用内核自身函数和数据结构的所有规则。在硬件层面,RISC-V的CPU、RAM、磁盘等。假定按照文档中的广告运行,没有硬件错误。
当然,在现实生活中,事情没有这么简单。通过消耗受内核保护的资源——磁盘空间、CPU时间、进程表槽等,很难防止聪明的用户代码使系统不可用(或导致系统崩溃)。通常不可能写出无错误的代码或设计无错误的硬件;如果恶意用户代码的作者知道内核或硬件的缺陷,他们就会利用这些缺陷。值得在内核中设计安全措施来防止它可能存在的错误:断言、类型检查、堆栈保护页等。最后,用户代码和内核代码之间的区别有时是模糊的:一些特权用户级进程可以提供必要的服务,并有效地成为操作系统的一部分,而在一些操作系统中,特权用户代码可以将新代码插入内核(就像Linux的可加载内核模块一样)。
大多数操作系统都采用了进程概念,并且大多数进程看起来与xv6的相似。然而,现代操作系统支持一个进程中有几个线程,以允许单个进程利用多个CPU。在一个进程中支持多线程涉及到xv6没有的很多机制,包括潜在的接口变化(例如,Linux的克隆,fork的变体),以控制线程共享进程的哪些方面。
向xv6添加一个系统调用,返回可用的空闲内存量。