第二章 操作系统的组织形式

作者: 橡树人 | 来源:发表于2020-11-09 07:09 被阅读0次

    操作系统的一个关键要求是要一次支持多个活动。比如,使用系统调用fork,一个进程可以启动新进程。

    操作系统必须在这些进程间分时共享计算机资源。比如,即使系统中的进程数比硬件CPU多,则操作系统也必须保证所有进程都有执行的机会。

    操作系统必须安排进程之间的隔离。即, 如果一个进程里有错误,并出现故障,则不应影响不依赖该进程的那些进程。但是,彻底地隔离又太强了,因为进程间可能存在交互,比如管道。

    因此,操作系统必须满足三个要求:多路复用隔离交互

    本章概述了如何组织操作系统来实现前述3个要求。

    虽然已证明有很多方式来实现这3个要求,但是本文重点关注有关整体内核的主流设计,比如许多Unix操作系统使用的就是这种设计。

    本章也概述了xv6进程,其中进程是xv6系统的隔离单元。
    本章也概述了当xv6启动时,第一个进程是如何创建的。

    xv6在一台多核的RISC-V的微处理器上运行,许多xv6的底层功能都是特定于RISC-V的。RISC-V是一个64位的CPU,xv6是用LP64C编写的,表示在C语言里的long和pointer是64位的,而不是32位。

    本书假定读者了解在某种体系架构上的一些机器级编程,并会介绍RISC-V特定的思想。有关RISC-V的有用的参考是《The RISC-V Reader: An Open Architecture Atlas》。

    在一台完整的电脑上的CPU是被支持硬件包围着的,其中的大部分都是I/O接口。通过qemu的-machine virt选项,可为支持硬件编写xv6。这里的支持硬件包括RAM、一个包含启动代码的ROM、用户键盘/屏幕的串行连接、一个存储用的磁盘。

    2.1节 对物理资源进行抽象

    为什么会有操作系统?

    如果没有操作系统,则可以通过类库的形式来实现系统调用,应用通过链接到类库的方式来使用类库。
    使用类库的方式实现系统调用,每个应用甚应用至可以根据自身需要量身定制自己的类库。
    使用类库的方式来实现系统调用,应用能直接跟硬件资源交互,以对应用最优的方式来使用硬件资源。
    一些嵌入式设备或者实时系统采用这种组织形式。
    类库方法的缺点是:如果有多个应用在运行,则这些应用必须有良好的行为,比如每个应用必须周期性地放弃CPU,以便其他应用可以运行。如果所有的应用都彼此信任,且没有错误,则这种协作式分时共享策略是可行的。由于对于应用来说,更常见的是彼此不信任,或者有错误。所以我们想要的隔离度要比协作式策略更强。

    换句话说,协作式策略需要的隔离是弱隔离。

    禁止应用直接访问敏感的硬件资源,转而将资源抽象为服务,对实现强隔离是有帮助的。
    比如,UNIX应用只能通过文件系统的openreadwriteclose等系统调用来跟存储交互,而不是直接读写磁盘。UNIX的文件系统为应用提供了路径名,且允许操作系统来管理磁盘。

    即使不关心隔离特性,那些存在交互或者只希望彼此保持隔离的程序也可能会发现:文件系统是一个比直接使用磁盘更方便的抽象。

    类似地,通过按需保存和恢复寄存器状态,UNIX透明地在多个进程间切换硬件CPU,使得应用自身意识不到分时共享。这种透明性允许:即使有应用陷入死循环,则操作系统仍可以共享CPU。

    比如,UNIX进程使用exec来构建它的内存映像,而不是直接跟物理内存交互。这样就允许:操作系统决定进程在内存中的放置位置。如果内存不够用的话,则操作系统甚至会将进程的数据保存在磁盘上。除此之外,exec为用户提供了方便的文件系统来存储可执行程序映像。

    UNIX进程间的许多进程的交互方式都是通过文件描述符发生的。文件描述符不仅抽象了许多细节(比如数据是存储在管道里还是文件里等),而且还是以简化交互的形式定义的。比如,如果在管道中的一个应用失败了,则内核会为管道中的下一个进程生成一个end-of-file的信号。

    为了既为程序员提供方便,也提供强隔离,则在图1.3中的系统调用接口是经过仔细设计的。

    虽然UNIX接口不是唯一地抽象资源方式,但已被证明是一个很好的设计。

    2.2节 用户模式、内核模式及系统调用

    强隔离要求应用和操作系统之间有一个明确的边界。如果应用出错了,我们不想让操作系统也崩溃,或者让其他应用崩溃。

    操作系统应该能清理出错的应用,继续运行其他应用。

    为了达到强隔离,则操作系统必须安排好使得应用不能修改操作系统的数据结构和指令,不能访问其他进程的内存。

    CPU为强隔离提供硬件上的支持,比如,RISC-V有3种CPU执行指令的模式:机器模式,超级用户模式,用户模式。
    在机器模式下执行指令具有完整的特权。机器模式主要用于配置计算机。
    xv6在机器模式下执行若干行指令,然后切换到超级用户模式。

    在超级用户模式下,允许CPU执行特权指令,比如使中断生效和失效、读写持有页表地址的寄存器等。
    如果处于用户模式的应用尝试去执行一个特权指令,则CPU不会执行该指令,而是切换到超级用户模式,使得超级用户代码能终止该应用,因为应用做了本不应该它做的事。
    第一章中的图1.1说明了这种组织方式。

    应用只能执行用户模式的指令(比如,将数字相加等),称该软件在用户控件运行。

    在超级用户模式下的软件也能执行特权指令,称该软件在内核空间运行。
    在内核空间中运行的软件成为内核。

    想调用内核功能(比如xv6中的read系统调用)的的应用必须要切换到内核。
    CPU提供了一个特定指令(RISC-V提供了ecall指令),该指令的功能是从用户模式切换到超级用户模式的特定指令,进入内核的指定位置。
    一旦CPU切换到了超级用户模式,则内核就能验证该系统调用的参数,决定是否允许应用执行请求操作,拒绝执行或者执行。

    重要的是内核控制着切换到超级用户模式的入口点。如果应用能决定内核的进入点,则一个恶意的应用就能在一个跳过验证参数的点进入内核。

    第2.3节 内核的组织结构

    一个很关键的设计问题:操作系统的哪些部分应该以超级用户模式运行?

    思路1:整体内核monolithic kernel

    整个操作系统都驻留在内核,使得所有的系统调用都是在超级用户模式下运行。

    在整体内核中,整个操作系统运行时都具有完整的硬件特权。

    优点:

    这样的组织很方便,因为

    • 操作系统的设计者不用判断操作系统的哪一部分不需要完整的特权。
    • 操作系统的各个部分之间协作起来会更容易,比如操作系统可能有一个被文件系统和虚拟内存系统共用的缓冲区缓存。

    缺点:

    操作系统的不同部分之间的接口通常会很复杂,从而导致操作系统的开发者很容易就犯错。

    在整体内核中,出一个错就是致命的,因为在超级用户模式下的一个错误通常会导致内核崩溃。如果内核崩溃了,则计算机将停止工作,进而所有应用都失败了。计算机必须重新启动了。

    思路2:微内核microkernel

    为了降低内核中出错的风险,操作系统的设计者要

    • 最小化能以超级用户模式运行的操作系统代码的数量;
    • 以用户模式执行操作系统剩余的大部分代码;

    图2.1说明了这种微内核设计。
    作为进程运行的OS服务称为服务器,文件系统作为一个用户级别的进程在运行。
    为了允许应用跟文件服务器交互,内核提供了进程间通讯机制来从一个处于用户模式的进程向另一个发送消息。
    比如,如果shell想读写一个文件,则shell进程就会给文件服务器发送一个消息,并等待响应。
    在微内核中,内核接口由若干个底层功能构成,比如开始一个应用、发送消息、访问硬件设备等。
    这种组织形式允许内核可以相对地小,大部分操作系统驻留作为用户级别的服务器。

    跟大部分Unix操作系统一样,xv6采用微内核的方式来实现。因此,xv6的内核接口对应的是操作系统接口,内核实现了完整的操作系统。
    由于xv6没有提供许多服务,因此跟一些微内核相比,xv6的内核相对较小,但是从概念上讲,xv6是整体内核。

    第2.4节 xv6的代码组织结构

    xv6的内核源代码是在kernel子目录下。遵循粗略的模块化概念,它被分成许多文件,如图2.2所示。

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

    第2.5节 进程简介

    xv6里的一个隔离单元就是一个进程。

    进程抽象可以防止一个进程破坏或者监视另一个进程的内存、CPU、文件描述符等。
    进程抽象还可以防止一个进程破坏内核,以便一个进程不能颠覆内核的隔离机制。

    内核必须仔细地实现隔离机制,因为一个出错的或者恶意的应用会诱使操作系统做一些不好的事情,比如绕过隔离等。

    内核用来实现进程的机制包括用户/超级用户模式标记、地址空间、线程的时间分片等。

    为了有助于加强隔离度,进程抽象给程序提供了一种拥有自己专属机器的错觉。

    进程不仅给程序提供了不能被其他进程读写的私有内存空间,即地址空间。
    进程而且给程序提供了看起来似乎是自己的CPU来执行程序指令。

    xv6使用页表(通过硬件来实现)来给予每个进程自己的地址空间。
    RISC-V页表将虚拟地址(RISC-V指令能操作的地址)映射到物理地址(CPU芯片发送给主内存的地址)。

    xv6为每个进程维护一张单独的页表,该页表定义了进程的地址空间。
    如图2.3所示,一个地址空间包括进程的用户内存(从0开始):

    • 指令
    • 全局变量
    • trampoline
    • trapframe

    有若干个限制进程地址空间大小的因素:在RISC-V上的指针是64位的;硬件仅使用低39位去页表中查询虚拟地址;xv6仅使用这39位中的38位。

    因此,最大的地址是2^{38}-1=0x3fffffffff,即MAXVA(定义可见kernel/risv.h)。

    在地址空间的顶部,xv6预留了一页用于trampoline,一页用于映射进程的trapframe来切换到内核。

    这里留了两个问题:

    • 什么是trampoline?
    • 什么是trapframe?

    xv6内核为每个进程维护了许多状态片段,且将这些状态收集在了结构体struct proc(定义见kernel/proc.h)中。

    进程最重要的内核状态片段就是页表、内核栈、运行状态。

    每个进程有一个执行线程来执行该进程的指令。
    一个线程能被挂起,然后恢复。

    为了在进程间透明地切换,内核会挂起当前运行线程,恢复另一个进程的线程。

    线程的许多状态是保存在线程栈上的,比如局部变量、函数调用的返回地址等。

    每个进程有两个栈:用户栈和内核栈(p->kstack)。
    当进程在执行用户指令时,只有用户栈在使用,内核栈是空的。
    当进程进入内核,可能是系统调用或者中断,内核代码在内核栈上执行。注意,当一个进程处于内核中时,用户栈中仍保存有数据,只是没被激活使用而已。
    一个进程的线程可交替使用用户栈和内核栈。
    内核栈是跟用户代码隔离开的,以便及时用户栈被破坏了,内核仍可以执行。

    一个进程通过执行RISC-v的ecall指令来进行系统调用。

    • 该指令提升硬件特权等级,修改程序计数器到某个内核定义的入口点。内核入口点的代码切换到一个内核栈,执行实现了该系统调用的内核指令。

    当系统调用完成时,内核通过调用sret指令来切换回用户栈,和返回到用户空间。该指令降低硬件特权等级,恢复执行紧邻系统调用指令的那条用户指令。

    一个进程的线程可以在内核中因为等待I/O而阻塞,当I/O完成时,该线程就在挂起的地方开始执行。

    p->kstack:进程的内核栈。

    p->state表示进程的状态,包括已分配、待运行、运行中、等待I/O、退出等。

    p->pagetable持有的是进程的页表,格式要满足RISC-V硬件的要求。
    xv6使得:在用户空间中执行进程时,分页硬件使用进程的p->pagetable
    进程的页表也记录用于存储进程内存的物理页的地址。

    相关文章

      网友评论

        本文标题:第二章 操作系统的组织形式

        本文链接:https://www.haomeiwen.com/subject/dxokbktx.html