第 6 章 机制: 受限制的直接执行

Mark Walen大约 23 分钟

思维导图

受限制的直接执行
受限制的直接执行

引言

为了虚拟化 CPU,操作系统需要以某种方式让许多任务共享物理 CPU,让这些任务看起来像是同时运行的。基本思想很简单:给每个进程分配一个时间片,一个进程运行一段时间,如果时间片用完了或者提前结束,则切换到其他进程继续运行。通过时分的方式共享 CPU, 实现 CPU 虚拟化。

实现这样的虚拟化机制存在着一些挑战:

  1. 性能:如何在不增加系统开销的情况下实现虚拟化?

  2. CPU 控制权:如何高效地运行进程,同时保留操作系统对 CPU 的控制?

    控制权对于操作系统尤为重要,因为操作系统负责资源管理。如果操作系统失去了 CPU 的控制权,一个进程可以简单地无限制运行并接管机器,或访问没有权限的信息。

在保持控制权的同时获得高性能,这是构建操作系统的主要挑战之一。

6.1 基础技术:受限制的直接执行

为了让程序跑的尽可能的快,OS 开发者想出了一种称为 “限制直接执行技术”。直接执行 的思路是简单的:让程序直接在 CPU 上运行。操作系统启动一个程序的过程:首先在进程列表中创建一个进程条目[1],然后为这个新创建的进程分配内存,将用户程序代码加载到内存(新进程的内存)中。定位跳转到程序入口[2] (在 C 语言中,程序的入口是 main() 函数),将 CPU 控制权转交给用户程序,这时用户程序开始运行,直到运行至程序出口或者遇到异常时,将 CPU 的控制权交还给操作系统。

虽然直接执行技术的实现听起来很简单,但是使用这种技术去实现 CPU 虚拟化会存在一些问题。

  • 如果我们只运行一个程序,操作系统如何确保高效地运行这个程序的同时,让它不做任何我们不想让它做的事?
  • 当我们运行一个进程时,操作系统如何让它停止运行并切换到另一个进程,从而实现时分共享 CPU,达到虚拟化 CPU 的目标?

在开发这些技术时,我们会明白标题中的“受限”部分来自哪里。如果运行一个程序而不对它施加任何限制,操作系统将无法控制任何事情,这样的操作系统“仅仅是一个库”而已。

6.2 问题 1:受限制的操作

直接执行的一个明显优势是快。程序直接在硬件 CPU 上运行,因此执行速度与预期的一样快。但是,在 CPU 上运行会带来一个问题:如果进程希望执行某种受限制的操作(如向磁盘发起 I/O 请求或者获得更多系统资源(如 CPU 或内存),该怎么办?

  • 一种方法就是:让进程做任何它想做的事。但是,这样做会导致我们无法构建许多想要的系统。例如,当我们想构建一个有权限的文件系统时,就不能简单地让任何用户进程向磁盘发出 I/O。如果这么做,一个进程就可以对整个磁盘进行读写,这样所有的保护都会失效。

  • 因此,我们采用的方法是引入一种新的 CPU 模式open in new window:用户模式[3]。CPU 模式分为两类:

    • 用户模式:在用户态下运行的代码会受到限制。例如,在用户模式下运行时,进程不能发起 I/O 请求和执行特权指令。这样做会导致处理器抛出异常,操作系统可能会终止进程;

    • 内核模式[4]:操作系统(或内核)就以这种模式运行。运行在该模式的代码,可以执行任何操作,包括特权操作,比如发起 I/O 请求和执行各种受限制的指令。

但是,我们仍然面临着一个挑战——如果用户希望执行某种特权操作(如从磁盘中读取数据),应该怎么做?为了实现这一点,几乎所有的现代硬件都提供了用户程序执行系统调用[5]的能力。系统调用允许内核小心地向用户程序暴露某些关键功能,例如访问文件系统、创建和销毁进程、与其他进程通信,以及分配更多内存。

如果程序要执行系统调用,必须执行一条特殊的指令: trap 指令 [6]。这条指令跳转到内核的同时会将权限级别提升到内核模式;一旦进入内核,系统就可以执行所需的特权操作(如果被允许),从而完成调用进程所需的工作。完成后,操作系统调用一个特殊的 return-from-trap 指令[7],返回到调用的用户程序,同时将权限级别降回用户模式。

在 trap 指令时,硬件要确保有足够的堆栈空间保存的调用者寄存器,以便在操作系统发出 return-from-trap 指令时能够正确返回到用户模式。例如,在 x86 架构上,处理器会将程序计数器、标志寄存器和一些其他寄存器推送到每个进程的内核栈上;return-from-trap 指令会从 SP (堆栈指针)寄存器中弹出这些值,并继续执行用户模式程序。

在这个讨论中,还有一个重要的细节没有提到:陷阱如何知道在操作系统内部运行哪个代码?

在启动时,内核通过设置陷阱表来实现这一点。当计算机启动时,操作系统以特权(内核)模式启动,因此可以自由配置机器硬件。因此,操作系统所做的第一件事情之一是告诉硬件在发生某些异常事件时运行哪些代码。例如,当硬盘中断发生时,当键盘中断发生时,或当程序进行系统调用时应该运行哪些代码?操作系统通常使用某种特殊指令来通知硬件这些陷阱处理程序(trap handler)的位置。硬件会记住这些处理程序的位置,直到下次机器重新启动,因此硬件知道在系统调用和其他异常事件发生时要执行什么代码(即要跳转到哪个代码)。

为了指定确切的系统调用,通常为每个系统调用分配一个系统调用号。因此,用户代码负责将所需的系统调用号放入一个寄存器或指定的堆栈位置;当操作系统在陷阱处理程序内处理系统调用时,它会检查这个号码,确保它是有效的,如果是有效的,则执行相应的代码。这种间接级别作为一种保护机制;用户代码不能指定确切地址进行跳转,而是必须通过系统调用号请求特定的服务。

受限制的直接执行(LDE)协议中有两个阶段。

  • 在第一个阶段(在引导时),内核初始化陷阱表,并且 CPU 会记住它的位置以供后续使用。内核通过一条特权指令来完成这个操作。
  • 在第二阶段(运行进程时),内核在开始执行进程之前设置了一些东西(例如,在进程列表上分配一个节点,分配内存);然后使用从陷阱返回指令来启动进程的执行;这将把CPU切换到用户模式并开始运行进程。
LDE Protocol
图 6.2.1 受限制的执行协议

6.3 问题 2:切换进程

直接执行的下一个问题是 如何在不同进程之间进行切换。切换进程应该很简单,对吧?操作系统只需决定停止一个进程并启动另一个进程。有什么大不了的?但实际上,这有点棘手:具体来说,如果一个进程正在 CPU 上运行,这就意味着操作系统没有在运行,那操作系统怎么能执行任何操作呢?显然,如果操作系统没有在运行,那么它就没有 CPU 的控制权,也就无法采取任何行动。因此,我们来到了问题的核心。

6.3.1 一种合作性方法:等待系统调用

过去一些系统采取的一种方法(例如,早期的 Macintosh 操作系统或旧的 Xerox Alto 系统)被称为合作性方法。在这种方式中,操作系统相信系统的进程会表现得合理。那些运行时间过长的进程被认为会定期放弃 CPU,以便操作系统可以决定运行其他任务。

因此,你可能会问,在这个乌托邦般的世界中,友好的进程如何放弃 CPU?

  • 事实证明,大多数进程通过频繁地执行系统调用来将 CPU 的控制权转移给操作系统,例如,打开文件并读取它,或向另一台机器发送消息,或创建一个新进程。像这样的系统通常包括一个显式的 yield 系统调用,它只做一件事:把控制权传递给操作系统,以便操作系统可以运行其他进程。

  • 应用程序在执行非法操作时也会将控制权转交给操作系统。例如,如果一个应用程序进行除零操作,或试图访问它不能访问的内存,它将生成一个陷阱给操作系统。然后,操作系统将再次控制CPU(并可能终止有问题的进程)。

因此,在一个合作调度系统中,操作系统通过等待系统调用或某种非法操作来重新获得 CPU 的控制权

你可能还会想:这种被动的方法不是不太理想吗?例如,如果一个进程(无论是恶意的还是充满漏洞的)陷入无限循环,永远不进行系统调用怎么办?

6.3.2 一种非合作性方法:操作系统接管控制

事实证明,如果没有硬件的帮助,当一个进程拒绝进行系统调用(或执行非法操作)并因此不将控制权交还给操作系统时,操作系统几乎无法做任何事情。实际上,在合作性方法中,当一个进程陷入无限循环时,你唯一的补救措施就是回到计算机系统中解决所有问题的古老方法:重新启动机器。因此,我们再次遇到了我们通常的问题,即如何获取CPU 的控制权的子问题。

关键问题:如何在没有合作的情况下获取控制权

即使进程不合作,操作系统如何获取 CPU 的控制权?操作系统可以采取什么措施来确保恶意进程不会接管机器?

答案其实很简单,很多年前构建计算机系统的人们已经发现了这个答案:定时器中断[8] (SysTick Timer Interrupt)。可以编程设置一个定时器设备,使其每隔一定毫秒数引发一个中断;当中断发生时,当前运行的进程被暂停,操作系统中预先配置的中断处理程序运行。此时,操作系统已经重新获得了 CPU 的控制权,因此可以为所欲为:停止当前进程,并启动另一个进程。

正如我们之前在系统调用中讨论过的那样,操作系统必须在定时器中断发生时告知硬件要运行的代码;因此,在引导时,操作系统确实是这么做的。其次,在启动期间,操作系统必须启动定时器,这当然是一个特权操作。一旦定时器启动,操作系统就可以确保最终将控制权归还给它,因此操作系统可以自由地运行用户程序。定时器也可以被关闭(同样是一个特权操作),我们将在更详细地理解并发性时讨论这一点。

需要注意的是,在中断发生时,硬件有一些职责,特别是中断发生时,保存正在运行的程序的状态,以便后续的 return from trap 指令能够正确地恢复运行的程序。这一系列操作与硬件在显式系统调用陷入内核时的行为非常相似,因此各种寄存器会被保存(例如,保存到内核栈上),并且可以 return from trap 指令轻松恢复。

处理应用程序不当行为

操作系统通常必须处理行为不当的进程,这些进程无论是出于设计(恶意)还是偶然(错误)的原因,试图执行不应该执行的操作。在现代系统中,操作系统尝试处理这种不当行为的方式是简单地终止违规者。一次违规就立刻出局!也许这有些严厉,但当你试图非法访问内存或执行非法指令时,操作系统还能做什么呢?

6.4.3 保存和恢复上下文

现在,操作系统已经重新获得了控制权,无论是通过系统调用合作方式还是通过定时器中断更强制的方式,都必须做出一个决定:是继续运行当前正在运行的进程,还是切换到另一个进程。这个决定是由调度程序做出,它是操作系统的一部分;我们将在接下来的几章中详细讨论调度策略。

如果决定切换,那么操作系统将执行上下文切换。上下文切换在概念上很简单:操作系统只需保存当前正在执行的进程的一些寄存器值(例如,保存到其内核栈上)并恢复即将执行的进程的一些寄存器值(从其内核栈中)。通过这样做,操作系统确保当最终执行 return from trap 指令时,不会返回到原本正在运行的进程,而是执行另一个进程。

为了保存当前正在运行的进程的上下文,操作系统将执行一些低级汇编代码来保存当前正在运行进程的通用寄存器、PC 和内核栈指针,然后恢复这些寄存器,并切换到即将执行的进程的内核栈。通过切换堆栈,内核以一个进程的上下文(被中断的进程)的背景进入到切换代码的调用中,并在另一个进程的上下文(即将执行的进程)中返回。然后,当操作系统最终 return from trap 指令时,即将执行的进程成为当前正在运行的进程。至此上下文切换完成。

整个过程的时间线如图 6.3 所示。在这个示例中,进程 A 正在运行,然后被定时器中断中断。硬件将其寄存器保存到其内核栈上,并进入内核(切换到内核模式)。在定时器中断处理程序中,操作系统决定从运行进程 A 切换到进程 B。此时,它调用 switch() 例程,谨慎地保存当前寄存器值(到进程 A 的进程结构中),恢复进程 B 的寄存器值(从其进程结构中的条目),然后切换上下文,具体来说,通过将栈指针更改为使用 B 的内核栈(而不是 A 的)。最后,操作系统从陷阱中返回,恢复 B 的寄存器并开始运行它。

需要注意的是,在这个协议过程中会发生两种类型的寄存器保存/恢复。第一种是在定时器中断发生时;在这种情况下,运行进程的用户寄存器由硬件隐式保存,使用该进程的内核栈。第二种是当操作系统决定从 A 切换到 B 时;在这种情况下,内核寄存器由软件(即操作系统)显式保存到进程的进程结构中的内存中。后一种操作将系统从运行中看起来就像刚从 A 中陷入内核到刚从 B 中陷入内核一样。

6.4 担心并发吗?

一些细心和深思熟虑的读者可能会思考:“嗯...如果在系统调用期间发生了定时器中断会发生什么?”或者“当你正在处理一个中断时,又发生了另一个中断怎么办?内核中处理这种情况不会很困难吗?”这些是很好的问题,看来你们确实有希望!

答案是,操作系统确实需要关心的是,如果在中断或陷阱处理过程中发生了另一个中断会发生什么。事实上,这正是本书的第二部分,关于并发的主题;我们将推迟详细讨论到那时。

为了引起你的兴趣,我们只简要概述一些操作系统处理这些棘手情况的基础知识。操作系统可能会在中断处理期间禁用中断;这样做可以确保在处理一个中断时,不会传递其他中断给CPU。当然,操作系统在这样做时必须小心;禁用中断的时间过长可能会导致中断丢失,这在技术术语上是不好的。

操作系统还开发了许多复杂的锁机制,以保护对内部数据结构的并发访问。这使得多个活动可以同时在内核中进行,特别是在多处理器系统上非常有用。然而,正如我们将在本书关于并发的下一部分中看到的那样,这种锁可能会很复杂,并导致各种有趣且难以找到的错误。

6.5 小结

我们已经描述了一些实现CPU虚拟化的关键底层机制,这些技术集合起来被称为受限制的直接执行。基本思想很简单:只需在CPU上运行你想运行的程序,但首先确保设置硬件以限制进程在没有操作系统协助的情况下所能做的事情。

这种一般性方法在现实生活中也有应用。例如,那些有孩子或者至少听说过孩子的人可能对婴儿房的防护概念很熟悉:锁住储存危险物品的柜子,遮盖电源插座。当房间经过这样的准备后,你可以让你的宝宝自由活动,安心地知道房间中最危险的部分已经受到限制。

类似地,操作系统首先通过(在启动时)设置陷阱处理程序和启动中断定时器,然后只在受限模式下运行进程,来“婴儿房”化CPU。通过这样做,操作系统可以相当确信进程可以高效运行,只需要在执行特权操作时或当它们占用CPU时间过长需要被切换出去时才需要操作系统的干预。

因此,我们已经具备了虚拟化CPU的基本机制。但是一个重要的问题尚未得到回答:在特定时刻应该运行哪个进程?这个问题必须由调度程序来回答,因此它是我们研究的下一个主题。

旁注:关键的CPU虚拟化术语(机制)

• CPU应该支持至少两种执行模式:受限用户模式和特权(非受限)内核模式。

• 典型的用户应用程序在用户模式下运行,并使用系统调用陷入内核以请求操作系统服务。

• 陷入指令会小心保存寄存器状态,将硬件状态更改为内核模式,并跳转到OS中的预定目的地:陷阱表。

• 当操作系统完成对系统调用的服务时,它通过另一个特殊的返回自陷入指令返回到用户程序,该指令会降低特权并将控制权返回到陷入OS之后的指令。

• 陷阱表必须在操作系统在启动时设置,并确保它们不能被用户程序轻易修改。所有这些都是有限的直接执行协议的一部分,该协议可以高效地运行程序,但不会丧失操作系统的控制权。

• 一旦程序正在运行,操作系统必须使用硬件机制来确保用户程序不会无限期运行,即定时器中断。这种方法是一种非合作的CPU调度方法。

• 有时操作系统在定时器中断或系统调用期间可能希望从当前进程切换到另一个进程,这是一种称为上下文切换的低级技术。


脚注信息来源:维基百科


  1. 进程条目:指的是操作系统中的数据结构或记录,用于表示和管理一个正在运行或即将运行的进程。这个记录通常包括有关进程的各种信息,如进程标识符、进程状态、内存分配信息等,以便操作系统能够有效地管理和控制各个进程的执行。 ↩︎

  2. 程序入口: 也称为“入口点”或“主入口点”,是计算机程序代码中的特定位置,程序从这个位置开始执行。它是程序执行的起点,通常是计算机在运行程序时执行的第一条指令和程序访问命令行参数的地方。 ↩︎

  3. 用户模式: CPU的用户模式通常是内核模式下可用功能的一个子集,但在某些情况下,例如硬件模拟非本地体系结构时,它们可能与标准内核模式下可用的功能明显不同。一些CPU体系结构支持多个用户模式,通常具有权限的层次结构。这些体系结构通常被称为基于环的安全性(ring-based security),其中权限的层次结构类似于一组同心环,内核模式位于中心。Multics硬件是环安全性的第一个重要实现,但许多其他硬件平台也沿着类似的路线设计,包括Intel 80286受保护模式和IA-64等,尽管在这些情况下使用不同的名称来描述。 ↩︎

  4. 内核模式: 在内核模式下,CPU可以执行其体系结构允许的任何操作;可以执行任何指令,启动任何I/O操作,访问内存的任何区域等等。而在其他CPU模式下,硬件会对CPU操作施加一定的限制。通常情况下,某些指令不被允许执行(特别是那些可能改变机器全局状态的指令,包括I/O操作在内),某些内存区域不可访问等等。 ↩︎

  5. 系统调用: 指运行在用户模式下的程序向操作系统内核请求需要更高权限运行的服务。这可能包括与硬件相关的服务(例如,访问硬盘驱动器或访问设备的摄像头),创建和执行新进程,以及与内核服务(如进程调度)通信。系统调用提供了进程与操作系统之间的重要接口。 ↩︎

  6. 陷入指令: 也称为陷阱或软件中断,是计算机体系结构中的一种机制,允许程序在执行过程中故意引发异常或中断。陷阱用于各种目的,包括系统调用错误处理调试。当执行陷阱指令时,它会导致 CPU 从用户模式切换到内核模式,将控制权转移到操作系统或软件提供的特定处理程序例程。 ↩︎

  7. 从陷阱返回指令: 通常称为"陷阱返回"或"从中断返回",是一种指令,用于退出陷阱、中断或异常处理程序的执行,并将控制返回到最初触发陷阱的程序点。在x86和x86-64体系结构中,常用的用于从中断或异常处理程序返回的指令是iret(中断返回)指令;在ARM体系结构中,通常使用带有适当寄存器集的ldm(加载多个)指令来恢复保存的状态;在RISC-V体系结构中,使用 sret(监督员返回)指令来从在监督员模式下执行的陷阱处理程序返回。 ↩︎

  8. 中断open in new window: 。有时也称为陷阱(trap),是向处理器请求打断当前正在执行的代码的一种请求,以便及时处理事件(如果允许)。如果请求被接受,处理器将暂停其当前的活动,保存其状态,并执行一个称为中断处理程序open in new window(或中断服务例程,ISR)的函数来处理该事件。这种中断通常是临时的,在中断处理程序完成后,允许软件恢复正常活动,尽管中断也可能表示致命错误。 ↩︎