机制-受限直接执行
为了虚拟化 CPU,操作系统需要以某种方式让许多任务共享物理 CPU,让它们看起来像是在同时运行。基本思想很简单:运行一个进程一段时间,然后运行另一个进程,如此轮换。通过这种方式分时共享 CPU,就实现了虚拟化。
然而,在构建这样的虚拟化机制时存在一些挑战。第一个是性能:如何在不增加系统开销的情况下实现虚拟化?第二个是控制权:如何有效的运行进程,同时保留对 CPU 的控制?控制权对于操作系统尤为重要,因为操作系统负责管理资源。如果没有控制权,一个进程可以简单的无限制运行并接管机器,或者访问原本没有权限的信息。因此,在保持控制权的同时获得高性能,是构建操作系统的主要挑战之一。
关键问题:如果高效可控的虚拟化 CPU 操作系统必须以高性能的方式虚拟化 CPU,同时保持对系统的控制。为此,需要硬件和操作系统的支持。操作系统通常会明智的利用硬件的支持,以便高效的实现其工作。
基本技巧:受限直接执行
为了使程序尽可能快的执行,操作系统开发人员想出了一种技术——我们称之为受限的直接执行。这个概念的“直接执行”部分很简单:只需直接在 CPU 上运行程序即可。因此,当 OS 希望启动程序运行时,它会在进程列表中为其创建一个进程条目,为其分配一些内存,将程序代码从磁盘加载到内存中,找到入口点并跳转到那里,然后开始执行用户的代码。下表展示了这种基本的直接执行协议(没有任何限制),使用正常的调用并返回跳转到程序的 mian,并在稍后回到内核。
操作系统 | 程序 |
---|---|
在进程列表上创建条目 为程序分配内存 将程序加载到内存中 根据 argc/argv 设置程序栈 | |
清除寄存器 执行 call main 方法 | |
执行 main | |
释放进程的内存 将进程从进程列表中清除 |
听起来很简单,但是这种方法在我们虚拟化 CPU 时产生了一些问题。第一个问题很简单:如果我们只运行一个程序,操作系统怎么能确保程序不做任何我们不希望它做的事情,同时仍然能够高效执行?第二个问题:当我们运行一个进程时,操作系统如何让它停下来并切换到另一个进程,从而实现虚拟化 CPU 所需要的分时共享?
下面在回到这些问题时,我们将更好的了解虚拟化 CPU 需要什么。在开发这些技术时,我们还会看到标题中的“受限”部分来自哪里。如果对运行程序没有限制,操作系统无法控制任何事情,因此会成为“仅仅是一个库”——对应有抱负的操作系统而言,这真是令人悲伤的事情!
问题 1:受限制的操作
直接执行的明显优势是快速。该程序直接在硬件 CPU 上执行,因此执行速度与预期的一样快。但是,在 CPU 上运行会带来一个问题——如果进程希望执行某种受限操作,比如向磁盘发起 IO 请求或获得更多系统资源,该怎么办?
提示:采用受保护的控制权转移 硬件通过提供不同的执行模式来协助操作系统。在用户模式下,应用程序不能完全访问硬件资源。在内核模式下,操作系统可以访问机器的全部资源。还提供了陷入内核和从陷阱返回到用户模式程序的特别说明,以及一些指令,让操作系统告诉硬件陷阱表在内存中为位置。
对于 IO 和其他相关操作,一种方法就是让所有进程做所有它想做的事情。但是,这样做导致无法构建许多我们想要的系统。例如,如果我们希望构建一个在授予文件访问权限前检查权限的文件系统,就不能简单的让任何用户进程都可以向磁盘发起 IO 请求。如果这样做,一个进程就可以读写整个磁盘,这样所有的保护都将失效。
因此,我们采用的方法是引入一种新的处理器模式,称为用户模式。在用户模式下运行的代码会收到限制。比如,在用户模式下运行时,进程不能发出 IO 请求。这样做会导致处理器引发异常,操作系统可能会终止进程。
与用户模式不同的内核模式,操作系统就可以以这种模式运行。在该模式下,运行的代码可以做它喜欢的事情,包括特权操作,如果发出 IO 请求或执行所有类型的受限指令。
但是,我们仍然面临一个挑战——如果用户希望执行某种特权操作,应该怎么办?为了实现这一点,几乎所有的现代硬件都提供了用户程序执行系统调用的能力。系统调用是在 Atlas 等古老机器上开创的,它允许内核小心的向用户程序暴露某些关键功能,例如访问文件系统、创建和销毁进程、与其他进程通信,以及分配更多内存。大多数操作系统能提供几百个调用。早期的 UNIX 系统公开了更加简洁的子集,大约 20 个调用。
要执行系统调用,程序必须执行特殊的陷阱指令。该指令同时跳入内核并将特权界别提升到内核模式。一旦进入内核,系统即可以执行任何允许的特权操作,从而为调用进程执行需要的工作。完成后,操作系统调用一个特殊的从陷阱返回的指令,如你期望的那样,该指令返回到发起调用的用户程序中,同时将特权级别降低,回到用户模式。
执行陷阱时,硬件需要小心,因为它必须确保存储足够的调用者寄存器,以便在操作系统发出从陷阱返回的指令时能够正确返回。比如在 X86 上,处理器会将程序计数器、标志和其他一些寄存器推送到每个进程的内核栈上。从陷阱返回将从栈弹出这些值,并恢复执行用户模式程序。其他硬件系统使用不同的约定,但基本概念在各个平台上是类似的。
补充:为什么系统调用看起来像是过程调用? 你可能想知道,为什么系统调用(open/read)看起来完全像是 C 中典型的过程调用。也就是说,如果它看起来像是一个过程调用,系统又是如何知道它是一个系统调用呢,并做出正确的响应?原因很简单:它是一个过程调用,但隐藏在过程调用内部的是著名的陷阱指令。 更具体的说,当你调用 open 时,你正在执行对 C 库的过程调用。其中,无论是对于 open 还是提供的其他系统调用,库都使用与内核一致的调用约定来将参数放在众所周知的位置(如栈中或特定的寄存器中),将系统调用号也放在一个众所周知的位置,然后再执行上述陷阱指令。 在库中,陷阱之后的代码准备好返回值,并将控制权返回给发出系统调用的程序。因此,C 库中进行系统调用的部分是用汇编手工编码的,因为它需要仔细遵循约定,以便正确的处理参数和返回值,以及执行硬件特定的陷阱指令。 现在你知道为什么你自己不必编写汇编来陷入操作系统内核了吧,因为已经有人完成了这些工作。
还有一个重要的细节没有讨论:陷阱如何知道在 OS 内执行哪些代码?显然,发起调用的过程不能指定要跳转到的地址(就像你在进行过程调用时一样),这样做让程序可以跳转到内核中的任意位置,这显然是一个糟糕的主意。因此内核必须谨慎的控制在陷阱中执行的代码。
内核通过在启动时设置陷阱表来实现。当机器启动时,它在特权(内核)模式下执行,因此可以根据需要自由配置机器硬件。操作系统做的第一件事就是告诉硬件在发生某些异常事件时要运行哪些代码。比如,当发生磁盘中断、发生键盘中断或程序进行系统调用时,应该运行哪些代码。操作系统通常通过某种特殊的指令,通知硬件这些陷阱处理程序的位置。一旦硬件被通知,它就会记住这些处理程序的位置,直到下一次重启机器,并且硬件知道在发生系统调用和其他异常事件时要做什么(即跳转到那段代码)。
最后,能够执行指令来告诉硬件陷阱表的位置是一个非常强大的功能。因此你可能已经猜到,这也是一项特权操作。如果你试图在用户模式下执行这些指令,硬件是不会允许的。如果你可以自己设置陷阱表,你可以对系统做些什么呢?你能接管机器码?
时间线总结了该协议。我们假设每个进程都有一个内核栈,在进入内核和离开内核时,寄存器分别被保存和恢复。
下表是“受限直接运行协议”:
操作系统@启动(内核模式) | 硬件 | |
---|---|---|
初始化陷阱表 | ||
记住系统调用处理程序的地址 | ||
操作系统@运行(内核模式) | 硬件 | 程序(应用模式) |
在进程列表上创建条目 为程序分配内存 将程序加载到内存中 根据 argv 设置程序栈 用寄存器/程序计数器填充内核栈 从陷阱返回 | ||
从内核栈恢复寄存器 转向用户模式 调到 main | ||
运行 main …… 执行系统调用 陷入操作系统 | ||
将寄存器保存到内核栈 转向内核模式 调到陷阱处理程序 | ||
处理陷阱 完成系统调用指定的工作 从陷阱返回 | ||
从内核栈恢复寄存器 转向用户模式 调到陷阱之后的程序计数器 | ||
从 main 返回 陷入(通过 exit) | ||
释放进程的内存 将进程从进程列表中清除 |
LDE 协议有两个阶段。第一个阶段(在系统引导时),内核初始化陷阱表,并且 CPU 记住他的位置以供随后使用。内核通过特权指令来执行此操作。第二个阶段(运行进程时),在使用从陷阱返回指令开始执行进程之前,内核设置了一些内容。这会将 CPU 切换到用户模式并开始运行该程序。当进程希望发出系统调用时,它会重新陷入操作系统,然后再次通过从陷阱返回,将控制权还给进程。该进程然后完成它的工作,并从 main 返回。这通常会返回到一些存根代码,它将正确退出该程序。此时,OS 清理干净,任务完成。
问题 2:进程间切换
直接执行的下一个问题是进程间切换。实际上这个问题很棘手,特别是,如果一个进程在 CPU 上运行,这就意味着这时操作系统没有运行。如果操作系统没有处于运行状态,它又怎么做事情?虽然这听起来几乎是哲学,但这是真正的问题——如果操作系统没有在 CPU 上运行,那么显然操作系统没有办法采取行动。因此,我们遇到了关键问题。
协作方式:等待系统调用
过去某些系统采用的一种方式成为“协作方式”。在这种形式下,操作系统会相信系统中的进程会合理运行。运行时间过长的进程被假定会定期放弃 CPU,以便操作系统可以决定运行其他任务。
因此你可能会问,在这个虚拟的世界中,一个友好的进程如何放弃 CPU?事实证明,大多数进程通过执行调用,将 CPU 的控制权转移给操作系统,比如打开文件并随后读取文件,或者向另一台机器发送消息或创建新的进程。像这样的系统通常包括一个显式的 yield 系统调用,它什么也不干,只是将控制权交给操作系统,以便操作系统可以运行其他进程。
如果应用程序执行了某些非法操作,也会将控制权转移给操作系统。比如,如果应用程序已 0 为除数,或者尝试访问无法访问的内存,就会陷入操作系统。操作系统将再次控制 CPU,并可能终止违规进程。
因此,在协作调度系统中,OS 通过等待系统调用,或某种非法操作发生,从而重新获得 CPU 的控制权。你也许会想到,这种被动方式是不是不太理想?比如,如果某个进程进入无线循环并且从不进行系统调用,或发生什么情况?那么操作系统又能做什么?
非协作方式:操作系统进行控制
事实证明,没有硬件的额外帮助,如果进程拒绝执行系统调用而且不出错,从而将控制权交还给操作系统,那么操作系统无法做什么事情。事实上,在协作方式中,当进程陷入无限循环时,唯一的办法就是使用古来的解决方案来处理计算机系统中的所有问题——重新启动计算机。因此,我们又遇到了请求获得 CPU 控制权的一个子问题。
关键问题:如何在没有协作的情况下获得控制权? 即使进程不协作,操作系统如何获得 CPU 控制权?操作系统如何保证流氓进程不会占有机器?
答案很简单,很多年前构建计算机系统的人都发现了:时钟中断。时钟设备可以变成为每隔几毫秒产生一次中断。产生中断时,当前正在运行的进程停止,操作系统中预先配置的中断处理程序会运行。这时,操作系统重新获得了 CPU 的控制权,因此可以做它想做的事情:停止当前进程,并启动另一个进程。
提示:利用时钟中断重新获得控制权 即使进程以非协作的方式运行,添加时钟中断也让操作系统能够在 CPU 上重新运行。因此,该硬件功能对于操作系统维持机器的控制权至关重要。
首先,正如我们之前讨论过的系统调用一样,操作系统必须通知硬件哪些代码需要在发生中断时运行。因此,在启动时,操作系统就是这样做的。其次,在启动过程中,操作系统也必须启动时钟,这当然是一项特权操作。一旦时钟开始运行,操作系统就感到安全了,因为控制权最终会归还给他,因此操作系统可以自由运行用户程序。时钟也可以关闭,在稍后对并发的讲解中我们会进行讨论。
请注意,硬件在发生中断时有一定的责任,尤其是在发生中断时,要为正在运行的程序保存足够的状态,以便随后从陷阱返回指令能够正确的恢复该程序。这一组操作与硬件在显式系统调用陷入内核时的行为非常相似,其中各种寄存器因此会被保存,因此可以很容易的从陷阱返回指令恢复。
保存和恢复上下文
既然操作系统已经重新获得了控制权,无论是通过系统调用协作,还是通过时钟中断强制执行,都必须决定:是继续运行当前正在运行的进程,还是切换到另一个进程。这个决定是由调度程序做出的,它是操作系统的一部分。我们将在接下来的几章中详细讨论调度策略。
如果决定进行切换,OS 就会执行一些底层代码,即所谓的上下文切换。上下文切换在概念上很简单:操作系统要做的就是为当前正在执行的进程保存一些寄存器的值,并未即将执行的进程恢复一些寄存器的值。这样一来,操作系统就可以确保最后执行从陷阱返回指令时,不是返回到之前运行的进程,而是继续执行另一个进程。
为了保存当前正在运行的进程的上下文,操作系统会执行一些底层汇编代码,来保存通用寄存器、程序计数器,以及当前正在运行的进程的内核栈指针,然后恢复寄存器、程序计数器,并切换内核栈,供即将运行的进程使用。通过切换栈,内核在进入切换代码调用时,是一个(被中断的)进程的上下文,返回时,是另一个(即将执行的)进程的上下文。当操作系统最终执行从陷阱返回指令时,即将执行的进程编程了当前运行的进程。至此,一次上下文切换过程完成。
操作系统@启动(内核模式) | 硬件 | |
---|---|---|
初始化陷阱表 | ||
记住以下地址: 系统调用处理程序 时钟处理程序 | ||
启动时钟中断 | ||
启动时钟 每个 N 毫秒中断 CPU | ||
操作系统@运行(内核模式) | 硬件 | 程序(应用模式) |
进程 A… | ||
时钟中断 将寄存器(A)保存到内核栈(A) 转向内核模式 跳转陷阱处理程序 | ||
处理陷阱 调用 switch 例程 将寄存器(A)保存到进程结构(A) 将进程结构(B)恢复到寄存器(B) 从陷阱返回(进入 B) | ||
从内核栈(B)恢复到寄存器(B) 转向用户模式 调到 B 的程序计数器 | ||
进程 B… |
上表展示了完整的时间线。在这个例子中,进程 A 正在运行,然后被时钟中断。硬件(在内核栈中)保存其寄存器,并进入内核栈(切换到内核模式)。在时钟中断处理程序中,操作系统决定从正在运行的进程 A 切换到进程 B。此时,它调用 switch 例程,该例程仔细保存当前寄存器的值(保存到 A 的进程结构),恢复寄存器进程 B(从 B 的进程结构),然后切换上下文,具体来说是通过改变栈指针来使用 B 的内核栈(而不再是 A)。最后,操作系统从陷阱返回,恢复 B 的寄存器并开始运行 B。
请注意,在此协议中,有两种类型的寄存器保存和恢复。第一种是发生时钟中断的时候,在这种情况下,运行进程的用户寄存器由硬件隐式保存,使用的是该进程的内核栈。第二种是当操作系统决定从 A 切换到 B。在这种情况下,内核寄存器被软件(即 OS)明确的保存,但这次被存储在该进程的进程结构对应的内存中。后一个操作让系统从好像刚刚 A 陷入内核,编程好像刚刚从 B 陷入内核。
为了更好的理解如何实现这种切换,下面的代码展示了具体的实现过程。context 结构的 old 和 new 分别在老的和新的进程的进程结构中。
1 # void swtch(struct context **old, struct context *new);
2 #
3 # Save current register context in old
4 # and then load register context from new.
5 .globl swtch
6 swtch:
7 # Save old registers
8 movl 4(%esp), %eax # put old ptr into eax
9 popl 0(%eax) # save the old IP
10 movl %esp, 4(%eax) # and stack
11 movl %ebx, 8(%eax) # and other registers
12 movl %ecx, 12(%eax)
13 movl %edx, 16(%eax)
14 movl %esi, 20(%eax)
15 movl %edi, 24(%eax)
16 movl %ebp, 28(%eax)
17
18 # Load new registers
19 movl 4(%esp), %eax # put new ptr into eax
20 movl 28(%eax), %ebp # restore other registers
21 movl 24(%eax), %edi
22 movl 20(%eax), %esi
23 movl 16(%eax), %edx
24 movl 12(%eax), %ecx
25 movl 8(%eax), %ebx
26 movl 4(%eax), %esp # stack is switched here
27 pushl 0(%eax) # return addr put in place
28 ret # finally return into new ctxt
并发问题
作为细心的读者,你们可能会想到:在系统调用期间发生时钟中断会发生什么?或者,在处理一个中断时又发生了另一个中断,这时会发生什么?这不会让内核难以处理吗?
如果在中断或陷阱处理过程中发生另一个中断,那么操作系统确实需要关心发生了什么。实际上,这正是本书第二部分关于并发的主题。
补充:上下文切换的耗时 一个自然的问题是:上下文切换需要多久,或者系统调用需要多久。 随着时间的推移,结果有了很大的提高,大致跟上了处理器你的性能提升。比如,1996 年在 200-MHz P6 CPU 上运行 Linux 1.3.37,系统调用花费了 4us,上下文切换的时间大致为 6us。现代操作系统的性能几乎可以提高一个数量级,在具有 2GHz 或 3GHz 处理器的系统上的性能可以达到亚微秒级。 应该注意的是,并非所有的操作系统都会跟踪 CPU 的性能。正如 Ousterhout 所说,许多操作系统的操作都是内存密集型的,而随着时间的推移,但随着时间的推移,内存带宽并没有像处理器速度那样显著提高。因此,根据你的工作负载,购买最新的、性能好的处理器可能不会像你期望的那样加速操作系统。
为了让你开开胃,我们只是简单介绍了操作系统如何处理这些棘手的情况。操作系统可能简单的决定,在中断处理期间禁止中断。这样做可以确保在处理一个中断时,不会将其他中断交给 CPU。当然,操作系统这样做必须小心。禁用中断时间过长则可能导致中断丢失,这在技术上是不好的。
操作系统还开发了许多复杂的加锁方案,以保护对内部数据结构的并发访问。这使得多个活动可以同时在内核中进行,特别适用于多处理器。我们将在后续章节中看到,这种锁可能会变得复杂,并导致各种有趣且难发现的错误。
小结
我们已经描述了一些实现 CPU 虚拟化的关键底层机制,并将其统称为受限直接执行。基本思路很简单:就让你想运行的程序在 CPU 上运行,但首先要确保设置好硬件,以便在没有操作系统帮助的情况下限制进程可以执行的操作。
这种一般方法在现实生活中也适用。比如,有些孩子会熟悉宝宝防护房间的概念——锁好包含危险物品的柜子,并掩盖电源插座。当这些都准备妥当时,你可以让宝宝自由行动,确保房间最危险的方面受到限制。
提示:重新启动是有用的 之前我们指出,在协作式抢占时,无限循环的唯一解决方案是重启机器。虽然你可能嘲笑这种粗暴的做法,但研究表明,重启(软件)可能构建强大系统的一个非常有用的工具。 具体来说,重新启动很有用,因为它让软件回到已知的状态,很可能是经过更多测试的状态。重新启动还可以回收旧的或泄露的资源,否则这些资源可能很难处理。最后,重启很容易自动化。由于所有这些原因,在大规模互联网服务中,系统管理软件定期重启一些机器,重置它们并因此获得以上好处,这并不少见。
通过类似的方式,OS 首先在启动时设置陷阱处理程序并启动中断时钟,然后仅在受限模式下运行进程,以此为 CPU 提供宝宝防护。这样做,操作系统能确信进程可以高效运行,只在执行特权操作,或者让它们独占 CPU 时间过长并因此需要切换时,才需要操作系统干预。
至此,我们有了虚拟化 CPU 的基本机制。但一个主要问题还没有答案:在特定时间,我们应该运行哪个进程?调度程序可以解答这个问题。
Feedback
Was this page helpful?
Glad to hear it! Please tell us how we can improve.
Sorry to hear that. Please tell us how we can improve.