B.5 保护与虚拟存储器实例
多道程序设计(multiprogramming)让多个并发运行的程序共享一台计算机,也给程序之间的保护与共享提出了新要求。这些要求与今天计算机中的虚拟存储器紧密相连,因此本节将一并讨论,并介绍两个虚拟存储器实例。
多道程序设计引出了进程(process)的概念。打个比方,进程是程序呼吸的空气和生存的空间:它包含正在运行的程序,以及继续运行所需的全部状态。分时(time-sharing)是多道程序设计的一种变体,让多个交互用户同时共享处理器和存储器,给每个用户一种独占计算机的感觉。因此,系统必须随时能够从一个进程切换到另一个进程。这种切换称为进程切换(process switch)或上下文切换(context switch)。
无论一个进程从头到尾连续执行,还是反复被中断并与其他进程交替执行,它都必须正确运行。维持进程正确行为的责任由计算机设计者与操作系统设计者共同承担:前者必须保证进程状态中属于处理器的部分能够保存和恢复;后者必须保证各进程的计算互不干扰。
要保护一个进程的状态不被另一个进程破坏,最稳妥的方式是把当前信息复制到磁盘。但是,这会让一次进程切换耗时数秒,对分时环境而言实在太慢。
操作系统通过划分主存,让多个进程的状态同时保留在内存中,从而解决这一问题。这样的划分意味着操作系统设计者需要计算机设计者提供保护支持,确保一个进程不能修改另一个进程的状态。除了保护,计算机还要允许进程间共享代码和数据,以实现进程通信,或通过减少相同信息的副本数来节省存储空间。
进程保护
让每个进程使用自己的页表,并使各页表指向不同的内存页,就能把进程相互隔离。显然,必须禁止用户程序修改自己的页表,否则保护机制就会被绕过。
保护可以进一步加强,程度取决于计算机设计者或购买者的担忧。向处理器的保护结构中加入保护环(ring),可以把存储访问保护从用户态、内核态这两级扩展到更多级。与绝密、机密、秘密、公开这样的军事密级系统相似,同心环式的安全级别允许最受信任的程序访问一切,次一级程序访问除最内层以外的所有内容,以此类推。“平民”程序最不受信任,因此访问范围也最有限。还可以限制哪些存储区域能够包含代码,即执行保护(execute protection),甚至限制各级之间的入口点。本节稍后将介绍使用保护环的 Intel 80x86 保护结构。在实践中,保护环是否优于简单的用户态和内核态系统,尚不明确。
当设计者的担忧升级为惶恐时,这些简单的保护环可能还不够。要限制程序在最受保护区域内享有的自由,就需要新的分类体系。这里不再用军事模型,而用钥匙和锁作比喻:程序没有钥匙,就不能“开锁”访问数据。要使这些钥匙——即 capability——发挥作用,硬件与操作系统必须能够明确地把它们从一个程序传给另一个程序,同时禁止程序自行伪造。如果要把检查钥匙的耗时控制得很低,就需要大量硬件支持。
多年来,80x86 体系结构尝试过其中若干方案。由于向后兼容一直是其指导原则之一,最新版本也包含了它在虚拟存储器方面的全部尝试。下面考察两种选择:先介绍较早的分段地址空间,再介绍较新的平坦 64 位地址空间。
分段虚拟存储器实例:Intel Pentium 的保护机制
一个人所设计的第二个系统,是最危险的系统。……一般的倾向是过度设计第二个系统,把设计第一个系统时谨慎搁置的各种想法和修饰全部加进去。
——F. P. Brooks, Jr.,《人月神话》(1975)
最初的 8086 用段来寻址,却没有提供虚拟存储器或保护支持。段有基址寄存器,但没有界限寄存器,也没有访问检查;装入段寄存器之前,对应的段必须已经在物理存储器中。Intel 在 8086 的后继处理器上明显加强了虚拟存储器和保护支持,并扩展了少数字段以支持更大的地址。这套保护方案相当复杂,许多细节经过精心设计,试图避免安全漏洞。我们将它称为 IA-32。接下来的几页只概述 Intel 的部分保护措施;如果你觉得读起来吃力,不妨想象一下实现它们有多困难!
第一项增强是把传统的两级保护模型加倍:IA-32 有四个保护级。最内层的 0 级对应传统内核态,最外层的 3 级权限最低。IA-32 为每个级别设置独立的栈,以防止级别之间出现安全漏洞。它还有类似传统页表的数据结构,其中包含段的物理地址,以及需要对转换后地址执行的一系列检查。
Intel 的设计者并未止步于此。IA-32 对地址空间进行划分,让操作系统与用户都能够访问整个空间。用户可以调用这个空间中的操作系统例程,甚至向其传递参数,同时仍保持完整的保护。安全地完成这种调用并非易事,因为操作系统栈与用户栈并不相同。
此外,IA-32 允许操作系统为传给被调用例程的参数维持相应的保护级别。它禁止用户进程请求操作系统间接访问用户自身无权访问的内容,从而封堵这种潜在的保护漏洞。(这样的安全漏洞称为特洛伊木马,Trojan horse。)
Intel 设计者遵循的原则是:在支持共享和保护的同时,尽量减少对操作系统的信任。举一个受保护共享的例子:工资程序既要签发支票,又要更新本年度累计工资与福利支付总额。因此,我们希望程序能够读取工资和本年度累计信息、修改累计信息,却不能修改工资本身。稍后将看到支持这种功能的机制。本小节的其余部分将考察 IA-32 保护的全貌及其动机。
加入界限检查与存储器映射
增强 Intel 处理器的第一步,是让分段寻址除了提供基址,还能够检查界限。IA-32 的段寄存器不再包含基址,而是包含指向一种虚拟存储器数据结构的索引;这种结构称为描述符表(descriptor table),承担传统页表的角色。IA-32 中相当于页表项的是段描述符(segment descriptor),其中包含一些与 PTE 类似的字段:
- 存在位(present bit):相当于 PTE 的有效位,表示这是一个有效的地址转换。
- 基址字段(base field):相当于页框地址,包含段内第一个字节的物理地址。
- 访问位(access bit):类似某些体系结构中的引用位或使用位,可辅助替换算法。
- 属性字段(attributes field):指定使用该段的操作所允许的类型和保护级别。
此外,还有分页系统中没有的界限字段(limit field),规定段内有效偏移的上界。图 B.26 展示了 IA-32 段描述符的几个实例。
除了这种分段寻址,IA-32 还提供可选的分页系统。32 位地址的高位部分选择段描述符,中间部分作为索引,访问该描述符选定的页表。下面讨论不依赖分页的保护系统。1

图中文字与字段:
| 图中项目 | 中文含义及原图标注 |
|---|---|
| Attributes、G/D、Base、Limit | 属性 8 位;G/D 等字段 4 位;基址 32 位;界限原图标为 24 位 |
| Code segment | 代码段:Present(存在)、DPL(描述符特权级)、11、Conforming(一致)、Readable(可读)、Accessed(已访问) |
| Data segment | 数据段:Present(存在)、DPL、10、Expand down(向下扩展)、Writable(可写)、Accessed(已访问) |
| 调用门的主字段 | Attributes(属性,8 位)、Word count(字计数,8 位)、Destination selector(目标选择子,16 位)、Destination offset(目标偏移,16 位) |
| Call gate | 调用门:Present(存在)、DPL、0、00100 |
加入共享与保护
为实现受保护的共享,地址空间的一半由所有进程共享,另一半由每个进程独占,分别称为全局地址空间(global address space)和局部地址空间(local address space)。两部分各有一个相应命名的描述符表:指向共享段的描述符放在全局描述符表中,私有段的描述符放在局部描述符表中。
程序把表内索引和一个表示选用哪张表的位装入 IA-32 段寄存器。系统依据描述符的属性检查操作;只要偏移小于界限字段,就把处理器给出的偏移与描述符中的基址相加,得到物理地址。每个段描述符还有一个独立的 2 位字段,规定该段的合法访问级别。只有程序试图使用描述符中保护级编号更小的段时,才会发生违规。
现在可以说明,如何让上述工资程序更新本年度累计信息,却不能修改工资。首先,向它提供一个指向相关信息、但可写位清零的描述符:它能读数据,却不能写。然后提供一个受信任的程序,它只负责写入本年度累计信息;为它配置一个可写位置位的描述符(图 B.26)。工资程序使用 Conforming 位置位的代码段描述符调用受信任的代码。按照这里的说明,被调用程序采用被调用代码的特权级,而非调用者的特权级。1 因此,工资程序能够读取工资,也能调用受信任程序更新本年度累计总额,却不能修改工资本身。若系统中存在特洛伊木马,要使其奏效,就必须把它放进那个唯一职责是更新累计信息的受信任程序中。这种保护方式的理由是:限制漏洞可能出现的范围,可以提高安全性。
加入从用户到操作系统的安全调用:门与参数保护级的继承
允许用户跳转到操作系统内部,是相当大胆的一步。那么,在不信任操作系统或其他任何代码的前提下,硬件设计者如何增加系统安全的把握?IA-32 的做法是限制用户进入代码的位置,安全地把参数放到正确的栈上,并确保用户参数不会获得被调用代码的保护级别。
为了限制进入其他代码的入口,IA-32 提供一种特殊的段描述符,称为调用门(call gate),由属性字段中的一个位识别。与其他描述符不同,调用门给出内存中某个对象的完整物理地址,忽略处理器提供的偏移。1 如前所述,它的目的在于防止用户任意跳入受保护或特权更高的代码段。在工资程序的例子中,这意味着工资程序只能从正确的边界进入受信任代码。
只有施加这种限制,一致代码段才能按预期工作。
如果调用者与被调用者“相互怀疑”,谁也不信任谁,该怎么办?答案在图 B.26 最下方描述符的字计数字段中。当调用指令使用调用门描述符时,硬件会按照描述符指定的字数,把内容从局部栈复制到该段保护级所对应的栈中。用户因此可以先把参数压入局部栈,再由硬件安全地转移到正确的栈上。通过调用门返回时,会从两个栈中弹出参数,并把返回值复制到适当的栈。请注意,这个模型与目前通过寄存器传参的做法不兼容。
这一方案仍然留下了潜在漏洞:操作系统可能使用用户作为参数传入的地址,却按操作系统的安全级别而非用户级别访问。IA-32 的解决办法是在每个处理器段寄存器中专设 2 位,表示请求特权级(requested protection level)。操作系统例程被调用时,可以执行一条指令,把所有地址参数中的这 2 位设为调用用户的保护级。这样,地址参数装入段寄存器时,请求特权级就会设为正确值。随后,IA-32 硬件用请求特权级防止不当访问:系统例程不能利用这些参数,访问特权高于所请求级别的段。
分页虚拟存储器实例:64 位 Opteron 存储管理
AMD 工程师发现,前面复杂的保护模型很少得到使用。实际普遍采用的是 80386 引入的平坦 32 位地址空间:把所有段寄存器的基址都设为 0。因此,AMD 在 64 位模式下取消了多段,假定段基址为 0,并忽略界限字段。原书在此列出的页大小是 4 KiB、2 MiB 和 4 MiB。2
AMD64 体系结构把 64 位虚拟地址映射为 52 位物理地址;具体实现也可以采用更少的位数,以简化硬件。例如,Opteron 使用 48 位虚拟地址和 40 位物理地址。AMD64 要求虚拟地址高 16 位只是低 48 位的符号扩展,并将这种形式称为 canonical form(规范形式)。
64 位地址空间所需的页表大小十分惊人,因此 AMD64 用多级层次页表映射地址空间,将页表规模控制在合理范围内。级数取决于虚拟地址空间的大小。图 B.27 展示了 Opteron 的 48 位虚拟地址如何经过四级转换。

图中文字:Page-map L4 base addr (CR3)=四级页映射表基址(CR3);Page-map L4 table=四级页映射表;Page-mp entry=页映射表项;Page-directory pointer table=页目录指针表;Page-dir-ptr entry=页目录指针表项;Page-directory table=页目录表;Page-dir entry=页目录项;Page table=页表;Page-table entry=页表项;Page offset=页内偏移;Physical address=物理地址;Physical page frame number=物理页框号;Main memory=主存。虚拟地址位 63–48 为全 0 或全 1 的符号扩展;47–39、38–30、29–21、20–12 分别索引四级页映射表、页目录指针表、页目录表、页表;11–0 是页内偏移。
各级页表的偏移来自四个 9 位字段。地址转换先把第一个偏移加到四级页映射表基址寄存器上,再从所得位置读取主存,取得下一级页表的基址。接着,把下一个地址偏移加到刚取得的基址上,再次访问主存,确定第三级页表的基址;随后继续以相同方式进行。最后一个地址字段加到最终的页表基址上,再按所得地址读取主存,才终于取得被引用页面的物理地址。把它与 12 位页内偏移拼接,就得到完整物理地址。注意,Opteron 体系结构中的一张页表可以放在一个 4 KiB 页面中。
Opteron 在各级页表中都使用 64 位表项。原书接着说明:前 12 位保留以供将来使用,接下来的 52 位包含物理页框号,最后 12 位提供保护与使用信息。3 各级字段略有不同,基本字段包括:
- 存在(Presence):表示页面在内存中。
- 读/写(Read/write):表示页面是只读还是可读写。
- 用户/管理(User/supervisor):表示用户是否能访问该页面,还是仅限较高的三个特权级访问。
- 脏(Dirty):表示页面是否已被修改。
- 已访问(Accessed):表示自上次清零以来,页面是否被读过或写过。
- 页大小(Page size):原书说明,表示最后一级对应 4 KiB 页还是 4 MiB 页;若为后者,Opteron 只需三级而非四级页表。2
- 禁止执行(No execute):80386 的保护机制中没有这个位;增设它是为了禁止在某些页面中执行代码。
- 页面级缓存禁用(Page level cache disable):表示页面是否可缓存。
- 页面级直写(Page level write through):表示数据缓存对该页面采用写回还是直写。
由于 Opteron 在 TLB 未命中时通常要经过四级表,因此有另外三个可能检查保护限制的位置。原书说明,Opteron 只遵循最底层 PTE 的保护信息,对其他级只检查有效位是否置位。2
表项长 8 字节,每张页表含 512 个表项,页面为 4 KiB,因此一张页表恰好占一页。四个级别字段各为 9 位,页内偏移为 12 位,所以还剩下 位,需要作符号扩展以满足规范地址的要求。
前面解释了合法地址的转换过程,那么,如何防止用户建立非法地址映射并借此捣乱?页表自身受到保护,不允许用户程序写入。因此,用户可以尝试任何虚拟地址,但操作系统通过控制页表项,就控制了实际能够访问的物理存储器。若要让进程共享内存,只需让各自地址空间中的页表项指向同一个物理页。
Opteron 使用四个 TLB 来减少地址转换时间:两个用于指令访问,两个用于数据访问。与多级缓存类似,它用容量更大的两个 L2 TLB 来减少 TLB 未命中,一个服务指令,另一个服务数据。图 B.28 介绍了这些 TLB 的参数。
小结:32 位 Intel Pentium 与 64 位 AMD Opteron 的保护机制
Opteron 的存储管理代表了当今多数桌面和服务器计算机的做法:依赖页面级地址转换和操作系统的正确运行,为共享同一台计算机的多个进程提供安全保障。虽然这里把两者作为不同方案介绍,Intel 实际上已经追随 AMD,接纳了 AMD64 体系结构。因此,AMD 和 Intel 都支持 80x86 的 64 位扩展;同时,为了兼容,也都支持复杂的分段保护方案。
| 参数 | 说明 |
|---|---|
| 块大小 | 1 个 PTE(8 字节) |
| L1 命中时间 | 1 个时钟周期 |
| L2 命中时间 | 7 个时钟周期 |
| L1 TLB 容量 | 指令与数据 TLB 相同:每个 40 个 PTE,其中 32 个用于 4 KiB 页,8 个用于 2 MiB 或 4 MiB 页 |
| L2 TLB 容量 | 指令与数据 TLB 相同:512 个 PTE,均用于 4 KiB 页 |
| 块替换 | LRU |
| 写策略 | 不适用 |
| L1 块放置 | 全相联 |
| L2 块放置 | 四路组相联 |
如果你觉得分段保护模型比 AMD64 模型更难实现,那是因为事实确实如此。工程师付出的努力尤其令人沮丧,因为很少有客户使用这套复杂的保护机制。此外,这个保护模型与类 UNIX 系统的简单分页保护不相匹配,意味着只有专门为这种计算机编写操作系统的人才会使用它,而这种情况尚未出现。
译注:图 B.26 的段描述符字段宽度原标为 8、4、32、24 位,合计 68 位,另一个调用门示意合计 48 位;这些是原书图文,不能把该示意直接当作准确的描述符二进制编码。原文关于“32 位地址高位选择段描述符”、Conforming 采用被调用者特权级,以及调用门为“完整物理地址”的表述也需结合具体模式的处理器体系结构手册核查。 ↩︎ ↩︎ ↩︎
译注:原文在介绍 64 位模式时也引用了其他模式的分页参数。实际编程或硬件实现须区分运行模式,并核对相应处理器手册。 ↩︎ ↩︎ ↩︎
译注:原书这里明确给出“64 位表项”,随后却列出 位,算术上不一致。译文忠实记录这处源文错误,未把字段宽度猜改成某个值。 ↩︎