0%

操作系统虚拟化技术的机制——受限直接执行

基本技巧:受限直接执行

截屏2021-07-14 上午11.54.34

上述方式产生的问题:

  • 操作系统如何确保程序不做任何我们不希望它做的事,同时仍然高效地运行它。
  • 当运行一个进程时,操作系统如何让它停下来并切换到另一个进程。

问题1:受限制的操作

硬件通过提供不同的执行模式来协助操作系统。例如:

  1. 在用户模式下,应用程序不能完全访问硬件资源,不能发出 I/O 请求,这样做会导致处理器引发异常,操作系统可能会终止进程。
  2. 在内核模式下,操作系统可以访问机器的全部资源

那么当用户模式下进程想要执行某些特权操作该怎么办?

为了实现这一点,几乎所有的现代硬件都提供了用户程序执行系统调用的能力,它允许内核小心地向用户程序暴露某些关键功能,例如访问文件系统,创建和销毁进程,与其他进程通信,以及分配更大的内存。

要执行系统调用,程序必须执行特殊的陷阱(trap)指令,该指令同时调入内核并将特权级别提升到内核模式。进入内核后,就可以执行某些特权操作。执行完成后,该指令返回到发起调用的用户程序中,同时将特权级别降低,回到用户模式。

执行陷阱(trap)指令时,硬件需要确保存储足够的调用者寄存器,以便在操作系统发出从陷阱返回指令时能够正确返回。例如:在 x86 上,处理器会将程序计数器,标志和其他一些寄存器推送到每个进程的内核栈(kernel stack)上。从陷阱返回将从内核栈弹出这些值,并恢复执行用户模式程序。

问题:陷阱如何知道在 os 内运行哪些代码?显然,发起系统调用的过程不能指定要跳转的地址。内核通过在启动时设置陷阱表(trap table) 来实现。当机器启动时,它在特权(内核)模式下执行,因此可以根据需要自由配置机器硬件。操作系统做的第一件事,就是告诉硬件在发生某些异常事件时需要运行哪些代码。例如:当发生硬盘中断,发生键盘中断或程序进行系统调用时,应该执行哪些代码。当进程进行系统调用时,使用与内核一致的系统调用约定来将参数放在一个众所周知的位置(例如,在栈中或特定的寄存器中),将系统调用号也放入一个众所周知的位置(同样,放在栈或寄存器中),然后执行陷阱(trap)指令。库中陷阱之后的的代码准备好返回值,并将控制权返回给发出系统调用的程序。

能够执行指令来告诉硬件陷阱表的位置是一个非常强大的功能,它是一项特权操作,用户模式下执行这个指令会产生异常。

截屏2021-07-14 下午1.08.14

LDE 协议有 2 个阶段:

第 1 阶段:系统引导时,内核初始化陷阱表,并且 CPU 记住它的位置以供随后使用。

第 2 阶段:(1) 程序运行时,在使用从陷阱指令返回指令开始执行进程之前,内核设置一些内容(例如:分配内存),然后将 CPU 切换到用户模式并开始运行该进程。当进程发出系统调用时,它会重新陷入操作系统,然后再次通过从陷阱返回,将控制权还给进程。(2) 当该进程完成它的工作,并从 main() 返回。这通常会返回到一些存根代码,它将正确退出该程序(例如,通过 exit() 系统调用,这将陷入 OS 中)。最后,OS 清理干净,任务完成了。

问题2:在进程之间切换

关键问题?

操作系统如何重新获得 CPU 的控制权(regain control),以便它可以在进程之间切换?

协作方式:等待系统调用。这种方式相信进程会合理利用 CPU,并在适当时候将控制权移交给操作系统。但也可能存在恶意程序或者程序出现错误而导致无限循环。

非协作方式:操作系统进行控制

如何在没有协作的情况下获得控制权?答案很简单,许多年前构建计算机系统的许多人都发现了:时钟中断(timer interrupt)。时钟设备可以编程为每隔几毫秒产生一次中断。产生中断时,当前正在运行的进程停止,操作系统中预先配置的中断处理处理程序(interrupt handler) 会运行。此时,操作系统重新获得 CPU 的控制权,因此可以做它想做的事:停止当前进程,并启动另一个进程。

操作系统必须通知硬件哪些代码在发生时钟中断时运行。当然,硬件在发生中断时有一定责任,要为正在运行的程序保存足够的状态,以便随后从陷阱返回指令能够正确恢复正在运行的程序,以便随后从陷阱返回指令能够正确恢复正在运行的程序。

保存和恢复上下文

操作系统获得控制权,需要决定是继续运行当前正在运行的进程,还是切换到另一个进程。这个决策由 调用程序(scheduler) 做出的,它是操作系统的一部分。

如果决定进行切换,OS 就会执行一些底层代码,即所谓的上下文切换(context switch)。上下文切换在概念上很简单:操作系统要做的就是为当前正在执行的进程保存一些寄存器的值到它的内核栈,并为即将执行的进程恢复一些寄存器的值(从它的内核栈)。这样一来,操作系统就可以确保最后执行从陷阱返回指令时,不是返回到之前运行的进程,而是继续执行另一个进程。

为了保存当前正在执行的进程的上下文,操作系统会执行一些底层汇编代码,来保存通用寄存器,程序计数器,以及当前正在运行的进程的内核栈指针,然后恢复寄存器,程序计数器,并切换内核栈,供即将运行的进程使用。通过切换栈,内核在进入切换代码调用时,是一个进程(被中断的进程)的上下文,在返回时,是另一个进程(即将执行的进程)的上下文。当操作系统最终执行从陷阱返回指令时,即将执行的进程变成了当前运行的进程。至此上下文切换完毕。

截屏2021-07-14 下午5.55.02

在此协议中,有两种类型的寄存器保存/恢复。第 1 次是发生在时钟中断的时候。在这种情况下,运行进程的用户寄存器由硬件隐式保存,使用该进程的内核栈。第 2 种是当操作系统决定从 A 切换到 B。在这种情况下,内核寄存器被软件(即 OS)明确保存,但这次被存储在该进程的进程结构的内存中。后一个操作让系统从好像刚刚由 A 陷入内核,变为好像刚刚由 B 陷入内核。。

下列代码是 xv6 的上下文切换代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# Context switch
#
# void swtch(struct context **old, struct context *new);
#
# Save the current registers on the stack, creating
# a struct context, and save its address in *old.
# Switch stacks to new and pop previously-saved registers.

.globl swtch
swtch:
movl 4(%esp), %eax # eax 为 old
movl 8(%esp), %edx # edx 为 new

# Save old callee-saved registers
pushl %ebp
pushl %ebx
pushl %esi
pushl %edi

# Switch stacks
movl %esp, (%eax) # 保存当前进程的栈基址。将当前栈基址保存到 old 指针的值
movl %edx, %esp # 切换栈基址。将 new 保存到 esp

# Load new callee-saved registers
popl %edi
popl %esi
popl %ebx
popl %ebp
ret

上述功能实现交换两个进程栈基址的交换。

担心并发吗

如果在中断或陷阱处理过程中发生另一个中断,那么操作系统确实需要关心发生了什么。具体内容将在第 2 部分并发讲解。

小节

重新启动很有用,因为它让软件回到已知状态,很可能是回到经过更多测试的状态。重新启动还可以回收旧或泄露的资源(例如内存),否则这些资源可能很难处理。