为什么要虚拟化 CPU 和内存#
虚拟化内存使应用编程变得简单,操作系统给程序提供了一种假象,程序仿佛拥有一个很大的独立地址空间,而不需要考虑实际小而拥挤的物理内存是如何分配的。其次可以保护其他程序的内存不被恶意程序修改。
虚拟化 CPU 使得操作系统可以使用调度算法,保证在多程序执行时,CPU 不会被某一个程序单独占用。
虚拟内存的 3 个目标:透明,效率和保护。
僵尸状态#
一个进程可以处于已退出但尚未清理的最终(final)状态(在基于 UNIX 的系统中,这称为僵尸状态)。这个最终状态非常有用,因为它允许其他进程(通常是创建进程的父进程)检查进的返回代码,并查看刚刚完成的进程是否成功执行(通常,在基于 UNIX 的系统中,程成功完成任务时返回零,否则返回非零)。
shell 的调用过程,fork,exec#
shell 也是一个用户程序,它首先显示一个提示符(prompt),然后等待用户输入。你可以向它输入一个命令(一个可执行程序的名称及需要的参数),大多数情况下,shell 可以在文件系统中找到这个可执行程序,调用 fork () 创建新进程,并调用 exec () 的某个变体来执行这个可执行程序,调用 wait () 等待该命令完成。子进程执行结束后,shell 从 wait () 返回并再次输出一个提示符,等待用户输入下一条命令。
forkO 和 execO 的分离,让 shell 可以方便地实现很多有用的功能。比如:
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。