我们将在本书中回答一个核心问题:操作系统如何将资源虚拟化?这是关键问题。 为什么操作系统这样做? 这不是主要问题,因为答案应该很明显:它让系统更易于使用。 因此我们关注如何虚拟化:操作系统通过哪些机制和策略来实现虚拟化?操作系统如何有效地实现虚拟化?需要哪些硬件支持?
虽然只有少量的物理的CPU可用,但是操作系统如何提供几乎有无数个CPU可用的假象? 操作系统通过虚拟化CPU提供这种假象。通过让一个进程只运行一个时间片,然后切换到其他进程,操作系统提供了存在多个虚拟CPU的假象。这就是时分共享(time sharing)CPU技术,允许用户如愿运行多个并发进程。潜在的开销就是性能损失,因为如果CPU必须共享,每个进程的运行就会慢一点。
机制是一些低级方法或协议,实现了所需的功能。 策略是在操作系统内做出某种决定的算法。
在许多操作系统中,一个通用的设计范式是将高级策略与低级机制分可。你可以将机制堪称系统的"如何(how)"问题功能答案。操作系统如何执行上下文切换?策略为"哪个(which)"问题提供答案。 例如,操作系统现在应该运行哪个进程? ++将两者分开可以轻松改变策略,而不必重新考虑机制,因此这是一种模块化(modularity)的形式,一种通用的软件设计原则++
系统系统的基本的抽象 — 进程。 进程就是运行中的程序。
操作系统如何启动并运行一个程序?进程创建实际如何进行?
操作系统运行程序必须做的第一件事是将代码和所有静态数据(例如初始化变量)加载到内存中,加载到进程的地址空间中。 将代码和静态数据加载到内存后,操作系统在运行此进程之间还需:为程序的运行时栈(run-time stack或stack)分配一些内存,C程序使用栈存放局部变量、函数参数和返回地址。 操作系统也可能为程序的堆(heap)分配一些内存。在C程序中,堆用于显式请求的动态分配数据。malloc()/free();数据结构(如链表、散列表、树和其他有趣的数据结构)需要堆。起初堆很小,随着程序运行,通过malloc()库API请求共呢个多内存。 操作系统还将执行一些其他初始化任务,特别是与输入/输出(I/O)相关的任务。例如UNIX系统中,默认情况下每个进程都有3个打开的文件描述符,用于标准输入、输出和错误。
通过上述工作,OS终于为程序执行搭好了舞台。然后执行最后一项任务:启动程序,在入口处运行,即main()。通过跳转到main()例程,OS将CPU的控制权转移到创建的进程中,从而程序开始执行。
操作系统应该提供怎样的进程来创建及控制接口?如何设计这些接口才能既方便又实用?
fork() 子进程并不是完全拷贝了父进程。具体来说,虽然它拥有自己的地址空间(即拥有自己的私有内存)、寄存器、程序计数器等,但是它从fork()返回的值是不同的。父进程获得的返回值是新创建子进程的PID,而子进程获得的返回值是0. CPU调度程序(scheduler)决定了某个时刻哪个进程被执行,由于CPU调度程序非常复杂,所以我们不能假设哪个进程会先运行。 事实表明,这种不确定性(non-determinism)会导致一些很有趣的问题,特别是在多线程程序(multi-threaded program)。wait() 用于父进程等待子进程执行完毕。exec() 这个系统调用可以让子进程执行与父进程不同的程序。调用fork(),这只是在你想运行相同程序的拷贝时有用。给定可执行程序的名称(如wc)及需要的参数后,exec()会从可执行程序中加载代码和静态数据,并用它覆写自己的代码端(以及静态数据),堆、栈及其他内存也会被重新初始化。然后操作系统就执行该程序,将参数从高argv传递给该进程。因此,它并没有创建新进程,而是直接将当前运行的程序替换为不同的运行程序。对exec()的成功调用永远不会返回。
分离fork()及exec()的作用 给shell在fork之后exec之前运行代码的机会,这些代码可以在运行新程序前改变环境,从而让一系列有趣的功能很容易实现。
在构建虚拟化时存在的挑战
如何在不增加系统开销的情况下实现虚拟化?有效地运行如何有效地运行进程,同时保留对CPU的控制? 控制权对于操作系统尤为重要,因为操作系统负责资源管理。如果没有控制权,一个进程可以简单地无限制运行并接管机器,或访问没有权限的信息。操作系统必须以高性能的方式虚拟化CPU,同时保持对系统的控制。为此,需要硬件和操作系统支持。操作系统通常会明智地利用硬件支持,以便高效地实现其工作。
直接执行:只需直接在CPU上运行程序即可,当OS希望启动程序运行时,它会在进程列表中为其创建一个进程条目,为其分配一些内存,将程序代码(从磁盘)加载到内存中,找到入口点,跳转到哪里,并开始运行用户代码,并在稍后回到内核。
问题1:如果我们只运行一个程序,操作系统怎么能确保程序不做任何我们不希望它做的事,同时仍然高效地运行它? 增加受限制操作
一个进程必须能够执行I/O和其他一些受限制的操作,又不能让进程完全控制系统。操作系统和硬件如何写作实现这一点?
硬件通过提供不同的执行模式来协助操作系统。在用户模式(user mode)下,应用程序不能完全访问硬件资源。在内核模式(kernel mode)下,操作系统可以访问机器的全部资源。还提供了陷入(trap)内核和从陷阱返回(return-from-trap)到用户模式程序的特别说明,以及一些指令,让操作系统高速硬件陷阱表(trap table)在内存中的位置。
执行陷阱时,硬件需要小心,因为它必须确保存储足够的调用者寄存器,以便在操作系统发出从陷阱返回指令时能够正确返回。
你可能知道,为什么对系统调用的调用(如open() read())看起来完全就像C中的典型过程调用。也就是说,如果它看起来像一个过程调用,系统如何知道这是一个系统调用,并做所有正确的事情?原因很简单:它是一个过程调用,但隐藏在过程调用内部的是著名的陷阱指令。更具体地说,当你调用open()时,你正在执行对C库的过程调用。其中,无论时对于open()还是提供的其他系统调用,库都使用与内核一致的调用约定来将参数放在众所周知的位置(例如,在栈中或特定的寄存器中),将系统调用号也放入一个众所周知的位置(同样,放在栈或寄存器中),然后执行上述的陷阱指令。库中陷阱之后的代码准备好返回值,并将控制权返回给发出系统调用的程序。因此,C库中进行系统调用的部分是用于汇编手工编码的,因为它们需要仔细遵守约定,以便正确处理参数和返回值,以及执行硬件特定的陷阱指令。现在你知道为什么你自己不必写汇编来陷入操作系统了,因为有人已经为你写了这些汇编。
显然,发起调用的过程不能指定要跳转到的地址(就像你在进行过程调用时一样),这样做让程序可以跳转到内核中的任意位置,这显然是一个糟糕的主意。 内核通过在启动时设置陷阱表(trap table)来实现。当机器启动时,它在特权(内核)模式下执行,因此可以根据需要自由配置机器硬件。操作系统做的第一件事,就是告诉硬件在发生某些异常事件时要运行哪些代码。例如,当发生硬盘终端,发生键盘中断或程序进行系统调用时,应该运行哪些代码?操作系统通常通过某种特殊的指令,通知硬件这些陷阱处理程序的位置。
问题2:当我们运行一个进程时,操作系统如何让它停下来并却换到另一个进程,从而实现虚拟化CPU所需的时分共享? 在进程之间切换
操作系统如何获得CPU的控制权(regain control),以便它可以在进程之间切换?
协作方式:等待系统调用 过去某些系统采用的一种方式,在这种风格下,操作系统相信系统的进程会合理运行。运行事件过长的进程被假定会定期放弃CPU,以便操作系统可以决定运行其他任务。 大多数进程通过进行系统调用,将CPU的控制权转移给操作系统。提示:处理应用程序的不正当行为 操作系统通常必须处理不正当行为,这些程序通过设计(恶意)或不小心(错误),尝试做某些不应该做的事情。在现代系统中,操作系统试图处理这种不当行为的方式是简单地终止犯罪者。 如果应用程序执行了某些非法操作,也会将控制转移给操作系统(如除0操作)。 这种方式存在问题:如果某个进程进入无限循环,并且从不进行系统调用,会发生什么情况?
非协作方式:操作系统进行控制 事实证明,没有硬件的额外帮助,如果进程拒绝进行系统调用(也不出错),从而控制权将无法交还给操作系统,那么操作系统无法做任何事情。只能重启计算机。即使进程不协作,操作系统如何获得CPU的控制权?操作系统可以做什么来确保流氓进程不会占用机器? 答案:时钟中断 时钟设备可以编程为每隔几毫秒产生一次中断。产生中断时,当前正在运行的进程停止,操作系统中预先配置的中断处理程序会运行。此时,操作系统重新获得CPU的控制权。
请注意,在此协议中,有两种类型的寄存器保存/恢复。第一种是发生时钟中断的时候。在这种情况下,运行进程的用户寄存器由硬件隐式保存,使用该进程的内核栈。第二种是当操作系统决定从A切换到B。这种情况下,内核寄存器被软件(即OS)明确地保存,但这次被存储在该进程的进程结构的内从中。后一个操作让系统从好像刚刚由A陷入内核,变成好像刚刚由B陷入内核。