进程与进程相关的 API 一文中我们介绍了操作系统通过分时机制虚拟化 CPU,表面意义上实现了并发,但是其实现是存在一些问题的:
-
性能: 如何在不增加系统开销的情况下实现虚拟化?
-
控制: 如何在保持对 CPU 控制的同时有效地运行进程?
- 控制对于操作系统来说尤为重要,它意味着资源的将如何利用,如果操作系统无法做到绝对管控,那么就会出现进程无限期地 Run 下去,甚至随意访问不应该被允许访问的数据这样一些现象。
绝对管控的同时保持高性能是构建操作系统真正意义上的挑战。通常需要依赖硬件的支持。
为了实现我们的预期,构建操作系统的人提出了一种技术并称之为受限直接执行。直接执行,顾名思义,直接在 CPU 上运行程序。
所以,当操作系统要运行一段程序的时候,就会
- 在进程列表中创建一个新的进程;
- 为其分配一段内存;
- 将程序代码从磁盘加载到内存中;
- 通过 argc/argv 设置堆栈
- 清除寄存器
- 定位程序的入口(类似于 main())
- 跳到入口处并开始运行
- 释放进程内存
- 将该进程从进程列表中移除
这便是直接执行的过程,简单也存在很多问题
- 在程序运行过程中,操作系统怎么保证程序在管控范围内运行,简单来说,就是怎么知道这段程序是否做了我们不期望做的事
- 当我们运行一个进程时,操作系统怎么让它停下来并且切换到另一个线程继续运行,怎么实现我们期望的虚拟化 CPU
下面我会多方面去解释为什么在 "直接执行" 之后出现了 "受限直接执行",从而更好地理解虚拟化 CPU 到底需要什么?
因为程序本身直接在硬件 CPU 上运行,所有很直观的,直接执行具有高效的优势,可以很快实现用户的需求。但就像上面说的,如果在执行过程中我们希望做一些限制操作,例如发起资源访问,或者发起磁盘 I/O。
Q:
进程必须能够执行 I/O 和其他一些受限操作,但不能让进程完全管控系统。前面的挑战部分我们提到硬件的支持,那么操作系统和硬件如何协同工作才能做到这一点?
A:
-
最简单的方式就是:万能进程,让所有进程都可以执行 I/O 和任何受限操作,但是这就意味着没有权限可言。
-
现代操作系统引入新的处理器模式来解决这个问题。
- 用户模式: 在用户模式下运行的代码,其功能会受到限制。e.g.:用户态下进程无法发起 I/O 请求;
- 内核模式: 内核模式下,运行的代码权限不受限制,可以执行任何特权操作
Q:
在这种解决方案之下仍然存在着问题,如果用户进程需要执行一些特权操作,就不可行了。
A:
几乎所有的现代硬件都为用户模式下的程序提供了执行系统调用的能力,像 Atlas 一些老的机器上,系统调用针对关键功能向用户程序提供允许。大多数的操作系统会提供数百个调用。(可以参考 POSIX 标准)
-
执行一次系统调用,程序必须执行 trap 指令,跳转到内核并且同时将特权级别提升到内核模式,一旦进入内核,系统就可以执行所需的任何特权操作,从而为调用进程完成所需的工作。完成后,操作系统调用一个特殊的 return-from-trap指令,返回到调用用户程序,同时将特权级别降低到用户模式。
-
硬件在执行 trap 指令的时候需要注意保证有足够多的调用寄存器,因为当 return-from-trap 指令执行时,需要正确返回。像在 x86 上,处理器讲程序计数器、标志位、其他寄存器推入每个进程的内核堆栈,当 return-from-trap 指令执行的时候,需要将堆栈中的内容按照顺序弹出,并且恢复执行用户模式下的程序。
Q:
那么 trap 指令时如何知道哪些代码应该在操作系统中运行呢?
A:
显然调用进程无法指定要跳转到的地址。kernel 这个时候就起到了控制的作用。
kernel 通过在启动时设置 trap 表来实现,机器启动的时候,它以内核模式启动,根据需要自由配置机器硬件,操作系统做的第一件事便是告诉硬件当某些异常发生时要运行什么代码,硬件会记忆处理程序的位置,直到机器下次重新启动。
为了指定准确的系统调用,通常情况下会为每个系统调用分配一个系统调用编码。用户模式下的代码负责将所需要的系统调用编码放置在寄存器中或堆栈上的指定位置。操作系统在处理 trap 堆栈中的系统调用时会检查此编号,如果有效则执行相应的代码,这种间接级别是一种保护形式。用户代码不能指定要跳转的确切地址,而是通过编号请求特定的服务。
我们假设每个进程都有一个内核堆栈,当进入和离开内核时,寄存器(包括通用寄存器和程序计数器)在其中由硬件进行保存和恢复。
受限直接执行(LDE)协议有两个阶段,第一次在启动时,内核初始化 trap 表,CPU 记录位置以供后续使用。内核通过特权指令实现。
第二次在运行进程时,内核在使用 return-from-trap 指令开始执行进程执行设置例如分配内存等的一些事情时,会将 CPU 切换到用户模式下并开始运行该进程。当进程希望发出系统调用时,会通过 trap back 转回操作系统,操作系统处理完成再通过 trap 将控制权交给进程,然后进程完成任务,并从 main() 返回,通过到这儿就到了退出程序中,操作系统清理完毕。
在进程间切换应该很简单吧,操作系统只需要负责停止一个进程的运行并切换到另一个进程,如果你这么想就大错特错了。当一个进程正在 CPU 上运行,这就意味着操作系统并没有运行,如果操作操作系统没有运行,不在 CPU 上运行,它显然无法做任何操作。
那么操作系统如何获取对 CPU 的控制,以便可以在进程之间切换。
在一些古老的操作系统中是通过协作方式切换进程,操作系统信任系统进程的合理行为。
大多数进程通过进行系统调用来频繁地将 CPU 的控制权转交给操作系统,这种系统通常只包括一个显式 yield 系统调用,只负责将控制权转移到操作系统以便可以运行其他进程。应用程序在执行非法操作时也会将控制权转移给操作系统。在协作调度系统中,操作系统通过等待系统调用或某种非法操作的发生来重新获得对 CPU 的控制。
如果没有硬件的额外协助,当进程拒绝进行系统调用并将控制权转交给操作系统时,操作系统无法完成很多任务,在协作方式下,当一个进程陷入无限循环时,唯一的办法是求助于重启。
那么不使用协作方式,操作系统如何去获取对 CPU 的控制?而且操作系统如何保证流氓进程不接管机器呢?
答案就是定时器中断,定时器设备可以被编程从而每隔几毫秒产生一次中断,当中断引发时,当前运行的进程被暂停,并且操作系统中预先配置的中断护理程序运行。此时,操作系统获得了对 CPU 的控制,因此实现了停止当前的进程并启动另一个新的进程。
类似于系统调用,当定时器中断时,操作系统必须通知硬件运行哪个代码,所以在启动时操作系统正是如何做的,其次,在引导操作期间,一旦计时器开始,操作系统就处于安全状态,因为控制最终会返回给操作系统,操作系统可以自由地运行用户程序,定时器也可以被关闭。
当中断发生的时候,硬件要保存足够的中断发生时正在运行的程序的状态。以便后续执行返回指令的时候能够正确地恢复正在运行的程序。和显式系统调用很相似,不是吗🤔️。
既然操作系统已经重新获得控制权,无论是通过系统调用的协作方式还是定时器中断的方式。是继续运行当前正在运行的进程,还是切换到不同的进程这个决定交由 CPU 调度来决定。
如果决定进行切换,操作系统就会执行一段上下文切换的底层代码。通过这种方式,操作系统因此可以确保当最终执行 return-from-trap 指令时,系统能够正确地恢复另一个进程的执行。
上下文切换: 操作系统为当前正在执行的进程保存一些寄存器值到其内核堆栈中,并为即将执行的进程从其内核堆栈中恢复一些寄存器值。
为了保存当前运行进程的上下文,操作系统将执行一些底层汇编代码,来保存当前运行进程的通用寄存器 PC 和内核堆栈指针,然后恢复寄存器 PC,并切换到即将执行的进程的内核堆栈。通过切换堆栈,内核在被中断的进程的上下文中进入对代码的调用,并在即将执行的进程的上下文中返回。当操作系统最终执行一个从 trap 返回的指令时,即将执行的进程便成为了当前运行的进程,完成了上下文切换。
在此期间会发生两种类型的寄存器保存/恢复。
- 定时器中断发生时;在这种情况下,正在运行的进程的用户寄存器由硬件隐式保存,使用该进程的内核堆栈。
- 操作系统决定从 A 切换到 B 时;在这种情况下,内核寄存器由操作系统显式保存,但这次保存在进程的的内存中。
在系统调用期间如果发生了计时器中断,会发生什么呢?或者当处理一个中断而另一个中断发生的时候会发生什么呢?
这在内核中处理时非常难的,有两种方案,操作系统可能在中断处理期间禁用中断;当然后来构建操作系统的人开发了许多复杂的锁的方案保护对内部数据结构的并发访问。这部分后续内容中会持续补充。
在我们发现了虚拟化 CPU 存在的一些挑战之后,我们通过限制操作,进程切换,并发这样一些关键的底层机制来实现了真正意义上的 CPU 虚拟化。这便称作受限直接执行。只需要在 CPU 上运行您想要运行的程序,前提是需要确保设置硬件以限制进程在没有操作系统帮助的情况下可以找行的操作。