Skip to content

Latest commit

 

History

History
96 lines (48 loc) · 16 KB

第2章 操作系统组织.md

File metadata and controls

96 lines (48 loc) · 16 KB

操作系统的一个核心需求,是同时执行不同的活动。举个例子,我们可以使用第一章中提到的fork系统调用创建一个新进程。操作系统必须在不同的进程之间对计算机的资源进行时间共享。举例来说,即使有比物理CPU更多的进程在运行,操作系统也必须保证每个进程都有执行的机会。操作系统也必须在不同进程之间实施隔离。就是说,如果一个进程有bug或出现故障,它不应该影响到不依赖它的进程。然而,完全的隔离太强了,应该允许进程之间进行有意识的交互;管道是一个例子。总而言之,操作系统必须满足三大需求: 多任务,隔离以及交互。

本章概述了操作系统如何组织来满足这三个需求。事实证明,有很多方法可以做到这一点,但本文侧重于以单体内核为中心的主流设计,许多 Unix 操作系统都使用这种内核。 本章还概述了xv6进程,它是xv6中的隔离单元。此外还介绍了xv6启动时第一个进程的创建。

xv6运行于多核RISC-V微处理器,它的许多底层功能(比方说进程的实现)只适用于RISC-V。RISC-V是一种64位的CPU,xv6由“LP64”C写成。所谓“LP64”就是说,在这种C程序语言中,long与指针是64位,而int是32位。本书假设读者有一定的在某些架构上进行机器级别编程的经验,并将在RISC-V特有的内容出现时加以介绍。 “The RISC-V Reader: An Open Architecture Atlas”是RISC-V的一本有用的参考读物。The user-level ISA [2] 和the privileged architecture [1] 是官方的说明文档。

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

2.1 抽象物理资源

读者面对操作系统时,第一个可能想到的问题就是为什么要有一个操作系统?我们可以将图1.2中的系统调用实现在一个应用可以链接的库中。这样一来,每个应用甚至可以拥有按它自己的需求量身定做的库。应用可以直接与硬件资源交互,并采用对应用最好的方式(比方说达到高性能或可预计的性能)来使用硬件资源。一些嵌入式设备的操作系统或实时操作系统通过这样的方式组织。

库方案的缺点在于,如果有一个以上的应用同时运行,这些应用必须妥善地安排。举例来说,每个应用必须周期性地放弃CPU以便其他应用运行。如果所有应用之间相互信任且都没有bug,这种协作式的时间共享方案可能是OK的。然而更典型的场景是,应用之间无法相互信任,而且会有bug,所以我们通常需要比协作式方案提供更强的隔离。

为达到强隔离,以下的方法行之有效,禁止应用直接访问敏感的硬件资源,将资源抽象为服务。举例来说,Unix应用与存储仅仅通过文件系统的open,read,write和close系统调用来交互,而不是直接读写硬盘。这为应用提供了路径名的便利,也使得操作系统(接口的实现者)可以管理硬盘。即使不考虑隔离,有意交互(或者仅仅是希望互相之间不碍事)的程序也更容易发现,相比直接使用硬盘来说,文件系统是一种更方便的抽象。

相似的,Unix透明地在进程之间切换硬件CPU,在必要的时候保存和恢复寄存器状态,从而使得应用感觉不到时间共享。这种透明使得操作系统可以共享CPU,即使有些应用正在无限循环。

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

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

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

2.2 用户模式,管理员模式,系统调用

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

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

在管理者模式下,CPU被允许执行特权指令:例如允许和禁止中断,读写保存页表地址的寄存器。如果一个用户模式下的应用尝试执行特权指令,那CPU不会执行这个指令,它会切换到管理者模式下从而运行管理者模式下的代码,终止这个应用,因为这个应用做了它不该做的事情。第一章中的图1.1描述了这一组织。一个应用只能执行用户模式指令(例如增加数字等等),这被称为运行在用户空间。管理者模式下的软件能够执行特权指令,这被称为运行在内核空间。运行在内核空间(或管理者模式)的软件叫做内核。

想要调用内核函数(例如xv6中的read系统调用)必须切换到内核。CPU提供一个特殊的指令将CPU从用户模式切换到管理者模式,而且从内核指定的入口进入到内核。(RISC-V为这个目的提供了ecall指令。)一旦CPU切换到管理者模式,内核就能够验证系统调用的参数,决定是否允许应用执行请求的操作,然后拒绝或执行它。内核控制用户模式向管理者模式切换的入口这点非常重要;如果应用能够决定内核的入口,举例来说,一个恶意应用可以跳过参数验证进入内核。

2.3 内核组织

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

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

宏内核的缺点在于,操作系统中不同部分之间的接口通常比较复杂(正如本书其他部分所讲),因此操作系统开发者容易犯错。在宏内核中,错误是致命的,因为管理者模式下的错误往往能让系统崩掉。当系统崩溃时,计算机停止运行,所有应用也因此失败。计算机必须重启才能重新运转起来。

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

图2.1描述了微内核设计。图中文件系统作为一个用户进程运行。作为进程运行的OS服务被叫做服务器。为了允许进程与文件服务器交互,内核提供了一种进程间通信机制来让用户模式进程之间发送消息。举个例子,如果一个应用,比方说shell,想要读或写一个文件,就向文件服务器发送一个消息,然后等待回应。

在微内核中,内核接口由一些底层的函数包括启动应用,发送消息,访问硬件等组成。这种组织允许内核做得相对简单,因为操作系统的大部分都驻留在用户级别服务器中。

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

2.4 代码:xv6组织

xv6内核源码位于kernel目录下。源码大体按照模块划分为不同的文件;图2.2列出了这些文件。模块间接口定义在defs.h中。

2.5 进程概述

xv6的隔离单元是进程(其他Unix操作系统也一样)。进程的抽象阻止了进程破坏或者监听另一个进程的内存、CPU、文件描述符等资源。它也阻止了进程破坏内核本身,这样进程就无法破坏内核的隔离机制。内核必须小心谨慎地实现进程抽象,因为问题应用或恶意应用可能会欺骗内核或者硬件,做一些坏事(例如规避隔离)。内核用来实现进程的机制包括用户/管理者模式标志,地址空间,以及线程时间切片。

为了加强隔离,进程抽象欺骗程序,让它觉得拥有属于自己的私有机器。进程为程序提供了其他进程无法读写的私有内存系统,或者叫地址空间。进程也提供了看起来是程序自己的CPU来执行程序的命令。

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

xv6为每一个进程维护了一张单独的页表,这张页表定义了进程的地址空间。如图2.3,地址空间包括进程从虚拟地址0开始的的用户内存。内存中首先是指令,然后是全局变量,然后是栈,最后是进程可以按需求扩展的“堆”空间(用于malloc)。很多因素限制了进程地址空间的最大尺寸:RISC-V上的指针是64位;硬件仅使用低39位来从页表中查找虚拟地址;而xv6只使用了39位中的38位。因此,最大地址为2^38 - 1 = 0x3fffffffff,即MAXVA。在地址空间顶部xv6保留了两页,一页用于跳板页,另一页用于保存切换到内核的trapframe,trapframe的内容我们将在第四章解释。

xv6内核为每个进程维护了很多状态,这些状态都集中在struct proc中。进程最重要的内核状态是它的页表、内核栈以及运行状态。我们将使用记号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。进程的页表也作为其分配的物理页地址的记录,这些物理页保存了进程的内存。

2.6 代码:启动xv6与第一个进程

为了让xv6对读者来说更加具体,我们将讲述内核是怎样启动,并运行第一个进程的。后面的章节将会对这里涉及到的机制进行更详细的描述。

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

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

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

start函数执行了一些只允许在机器模式下进行的配置,然后切换到管理者模式。为了进入管理者模式,RISC-V提供了mret指令。这个指令通常用来从之前的管理者模式到机器模式的调用中返回。start并非从这样的调用中返回,而是做一些设置,假装之前有一个调用:它在mstatus寄存器中将之前的特权模式设置为管理者;在mepc寄存器中写入main的地址,从而将返回地址设置为main;在satp寄存器中写入0,来关闭管理者模式下虚拟地址转换,并且将所有中断和异常委派给管理者模式。

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

在main (kernel/main.c:11)初始化完一些设备和子系统之后,它调用userinit (kernel/proc.c:212),创建了第一个进程。第一个进程执行了一小段用RISC-V汇编语言编写的程序,initcode.S(user/initcode.S:1),通过调用exec系统调用回到内核。正如我们在第一章所看到的,exec使用一段新程序(在这里是/init)替换当前进程的内存和寄存器。当内核执行完exec,它返回到用户空间,/init进程中。如果需要,Init(user/init.c:15)创建一个新的控制台设备文件,然后将其打开为文件描述符0,1和2,接着在控制台启动一个shell。系统到这里就启动了。

2.7 真实世界

在真实世界中,我们既能找到宏内核也能找到微内核。很多Unix内核是宏内核。比方说linux,尽管有些OS功能运行在用户级别的服务器上(例如窗口系统)。有些内核例如L4,Minix,QNX组织成微内核与服务器,也广泛应用在嵌入式设备中。

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