CPU和内存的虚拟化

为什么要虚拟化 CPU 和内存

虚拟化内存使应用编程变得简单，操作系统给程序提供了一种假象，程序仿佛拥有一个很大的独立地址空间，而不需要考虑实际小而拥挤的物理内存是如何分配的。其次可以保护其他程序的内存不被恶意程序修改。

虚拟化 CPU 使得操作系统可以使用调度算法，保证在多程序执行时，CPU 不会被某一个程序单独占用。

虚拟内存的 3 个目标：透明，效率和保护。

僵尸状态

一个进程可以处于已退出但尚未清理的最终（final）状态（在基于 UNIX 的系统中，这称为僵尸状态）。这个最终状态非常有用，因为它允许其他进程（通常是创建进程的父进程）检查进的返回代码，并查看刚刚完成的进程是否成功执行（通常，在基于 UNIX 的系统中，程成功完成任务时返回零，否则返回非零）。

shell 的调用过程，fork，exec

shell 也是一个用户程序，它首先显示一个提示符（prompt)，然后等待用户输入。你可以向它输入一个命令（一个可执行程序的名称及需要的参数)，大多数情况下，shell 可以在文件系统中找到这个可执行程序，调用 fork () 创建新进程，并调用 exec () 的某个变体来执行这个可执行程序，调用 wait () 等待该命令完成。子进程执行结束后，shell 从 wait () 返回并再次输出一个提示符，等待用户输入下一条命令。

forkO 和 execO 的分离，让 shell 可以方便地实现很多有用的功能。比如：

Copy
wc p3.c > newfile.txt

在上面的例子中，wc 的输出结果被重定向（redirect) 到文件 newfile.bxt 中（通过 newfile.at 之前的大于号来指明重定向)。shell 实现结果重定向的方式也很简单，当完成子进程的创建后，shell 在调用 exec () 之前先关闭了标准输出（slandard output)，打开了文件 newfile.txt。这样，即将运行的程序 wc 的输出结果就被发送到该文件，而不是打印在屏幕上。

CPU 虚拟化的关键底层机制，受限直接执行

系统调用的过程，trap 指令，陷阱表

要执行系统调用，程序必须执行特殊的陷阱（trap）指令。该指令同时跳入内核并将特权级别提升到内核模式。一旦进入内核，系统就可以执行任何需要的特权操作（如果允许）, 从而为调用进程执行所需的工作。完成后，操作系统调用一个特殊的从陷阱返回 (return-from-trap）指令，如你期望的那样，该指令返回到发起调用的用户程序中，同时将特权级别降低，回到用户模式。

还有一个重要的细节没讨论：陷阱 trap 如何知道在 OS 内运行哪些代码？

显然，发起调用的过程不能指定要跳转到的地址（就像你在进行过程调用时一样），这样做让程序可以跳转到内核中的任意位置，这显然是一个糟糕的主意。实际上，这种能力很可能让一个狡猾的程序员令内核运行任意代码序列。因此内核必须谨慎地控制在陷阱上执行的代码。

内核通过在启动时设置陷阱表（trap table）来实现。当机器启动时，它在特权（内核）模式下执行，因此可以根据需要自由配置机器硬件。操作系统做的第一件事，就是告诉硬件在发生某些异常事件时要运行哪些代码。

例如，当发生硬盘中断，发生键盘中断或程序进行系统调用时，应该运行哪些代码？操作系统通常通过某种特殊的指令，通知硬件这些陷阱处理程序的位置。一旦硬件被通知，它就会记住这些处理程序的位置，直到下一次重新启动机器，并且硬件知道在发生系统调用和其他异常事件时要做什么（即跳转到哪段代码）。

最后再插一句：能够执行指令来告诉硬件陷阱表的位置是一个非常强大的功能。因此，你可能已经猜到，这也是一项特权（privileged）操作。如果你试图在用户模式下执行这个指令，硬件不会允许。

协作调度系统中

协作调度系统中，OS 通过等待系统调用，或某种非法操作发生，从而重新获得 CPU 的控制权

非协作调度系统中

非协作调度系统中，OS 通过等待时钟中断，系统调用，或某种非法操作发生，从而重新获得 CPU 的控制权，由操作系统的调度算法决定接下来运行什么程序。

虚拟内存

基于硬件的动态重定位

每个 CPU 需要两个寄存器，基址寄存器和界限寄存器。编写和编译程序时，假设地址空间从 0 开始，当程序真正执行时，操作系统会决定其在物理内存中的实际加载地址，并将起始地址记录在基址寄存器中。当进程运行时，该进程产生的所有内存引用，都会被硬件处理为物理地址 = 虚拟地址 + 基址。这种方法存在内存碎片(已分配的空间程序内部却未使用)。

分段，泛化的基址 / 界限

典型的地址空间有 3 个不同的逻辑段，代码，栈和堆，将这 3 个部分分成 3 个段放在物理内存中，每个段中有一组基址 / 界限寄存器，硬件在转换虚拟地址时使用段寄存器。

例如一个段寄存器前 2 位标识虚拟地址所属的段，剩余 12 位表示段内偏移量，此时物理地址 = 偏移量 + 段对应的基址，界限寄存器的值用于检验是否发生段溢出。每个段中除了基址 / 界限寄存器外，硬件还需要知道段增长的方向 (比如栈可能是反向增长)。

分段带来的问题是，物理内存很快充满了许多空闲空间的小洞，因而很难分配给新的段，或扩大已有的段。这种问题被称为外部碎片(external fragmentation)。

分页

分页，即将一个进程的地址空间分割成固定大小的单元，每个单元称为一页。相应的，把物理内存分割成页帧，每个这样的页帧包含一个虚拟内存页。

为了记录地址空间的每个虚拟页放在物理内存中的位置，操作系统通常为每个进程保存一个数据结构，称为页表 (page table)。

页表的主要作用是为地址空间的每个虚拟页面保存地址转换，从而让我们知道每个页在物理内存中的位置。页表是每个进程一个 (per-process) 的数据结构。

为了转换该过程生成的虚拟地址，我们必须首先将它分成两个组件：虚拟页面号（virtual page number，VPN）和页内的偏移量（offset）。假设进程的虚拟地址空间是 64 字节，我们的虚拟地址总共需要 6 位（$2^6=64$）。若页大小设置为 16 字节，则页内的偏移量需要占 4 位 ($2^4=16$)，剩余的两位划分给 VPN ($2^2$ 可以表示 4 页，$4*16=64$) 。

在进行地址转换时，操作系统查询页表将 VPN 转换为 PFN (物理页号)，偏移量保持不变。

页表就是一种数据结构，用于将虚拟地址映射到物理地址，因此，任何数据结构都可以采用。最简单的形式称为线性页表（linear page table)，就是一个数组。操作系统通过虚拟页号（VPN）检素该数组，并在该索引处查找页表项 (PTE)，以便找到期望的物理帧号（PFN）。

页表项 PTE 中有有效位，保护位，存在位等等，当然还有最重要的物理帧号 (PFN)。

分页的问题

线性页表占用的内存很大，因为不论虚拟页 VPN 是否被映射到物理地址都在页表中存储了。

此外，对于每个内存引用（无论是取指令还是显式加载或存储)，分页都需要我们执行一个额外的内在引用，以便首先从页表中获取地址转换。额外的内存引用开销很大，在这种情况下，可能会使进程减慢两倍或更多。

所以，有两个必须解决的实际问题。如果不仔细设计硬件和软件，页表会导致系统运行速度过慢，并占用太多内存。

快速地址转换 - TLB

TLB (Translation Look-aside Buffer) 是基于硬件的缓存，可以解决地址转换慢的问题，硬件转换虚拟地址时首先查看 TLB 是否命中，未命中再去访问内存中的页表。

TLB 中包含的虚拟到物理的地址映射只对当前进程有效，对其他进程是没有意义的。所以在发生进程上下文切换时，硬件或操作系统（或二者）必须注意确保即将运行的进程不要误读了之前进程的地址映射。

简单的清空 TLB。

在 TLB 中添加一个地址空间标识符 (标识属于哪个进程)。

多级页表

页表占用的内存很大，可以用一种简单的方法减小页表大小，即使用更大的页 (VPN 数量变少，页内偏移量增大)，但更大的页意味着会有较多的内存碎片。另一个个办法是使用分段和分页杂交的方式。

我们说页表是一个数据结构，我们完全可以使用哈希表或者树的方式来存储有效的的页表项 (PTE)。当然使用二叉树的代价比较大，在查找，过程中会多次加载内存。

现代操作系统常使用多级页表，可以看成是一种简单的哈希表 (事实上线性表数组也是一种哈希表)，将普通的线性页表分成页大小的单元，然后将这些单元放进一个新的线性表，取名为页目录。这是一种以时间换空间的方法，若使用二级页表，当 TLB 未命中时，需要先从内存查询页目录，再从页目录对应的线性表查询需要的 PTE。得到 PTE 共加载内存 2 次，通过 PTE 得到物理地址后再访问数据还需要加载一次内存，成本相比于简单线性表更高，好在我们还有 TLB。