文章目录
操作系统第8章 内存管理
8.1 背景
一个典型指令执行周期,首先从内存中读取指令。接着该指令被解码,且可能需要从内存中读取操作数。在指令对操作数执行后,其结果可能被存回到内存。内存单元只看到地址流,而并不知道这些地址是如何产生的(由指令计数器、索引、间接寻址、实地址等)或它们是什么地址(指令或数据)。相应地,可以忽略内存地址是如何由程序产生的,而只是对由运行中的程序产生的内存地址感兴趣。
8.1.1 基本硬件
CPU 所能直接访问的存储器只有内存和处理器内的寄存器。机器指令可以用内存地址作为参数,而不能用磁盘地址作为参数。因此,执行指令以及指令使用的数据必须在这些直接可访问的存储设备上。如果数据不在内存中,那么在 CPU使用前必须先把数据移到内存中。
除了保证访问物理内存的相对速度之外,还要确保操作系统不被用户进程所访问,以及确保用户进程不被其他用户进程访问。这种保护可通过硬件来实现。硬件实现有许多方法,将在本章后面讨论。这里,只简述一种可能方案。
首先需要确保每个进程都有独立的内存空间。为此,需要确定进程可访问的合法地址的范围,并确保进程只访问其合法地址。如图 8.1 所示,通过两个寄存器即基地址寄存器和界限地址寄存器,可以实现这种保护。
**基地址寄存器(base register)含有最小的合法物理内存地址,而界限地址寄存器(limit register)**决定了范围的大小。例如,如果基地址寄存器为300040,而界限寄存器为120900,那么程序可以合法访问从 300040到 420940(含)的所有地址。
内存空间保护的实现,是通过CPU 硬件对用户模式所产生的每一个地址与寄存器的地址进行比较来完成的。如用户模式下执行的程序试图访问操作系统内存或其他用户内存,则会陷入到操作系统,并作为致命错误处理(图8.2)。这种方案防止用户程序(有意或无意地)修改操作系统或其他用户的代码或数据结构。
只有操作系统可以通过特殊的特权指令来加载基地址寄存器和界限地址寄存器。由于特权指令只可在内核模式下执行,而只有操作系统在内核模式下执行,所以只有操作系统可以加载基地址寄存器和界限地址寄存器。这种方案允许操作系统修改这两个寄存器的值,而不允许用户程序修改它们。
操作系统在内核模式下执行,可以无限制地访问操作系统和用户的内存。因此操作系统可以将用户程序装入用户内存,在出错时输出这些程序,访问并修改系统调用的参数等。
8.1.2 地址绑定
通常,程序以二进制可执行文件的形式存储在磁盘上。为了执行,程序被调入内存并放在进程空间内。根据所使用的内存管理方案,进程在执行时可以在磁盘和内存之间移动。在磁盘上等待调入内存以便执行的进程形成输入队列(input queue)。
通常的步骤是从输入队列中选取一个进程并装入内存。进程在执行时。 会访问内存中的指令和数据。最后,进程终止,其地址空间将被释放。
许多系统允许用户进程放在物理内存的任意位置。因此,虽然计算机的地址空间从00000开始,但用户进程的开始地址不必也是00000。在绝大多数情况下,用户程序在执行前,需要经过好几个步骤,其中有的是可选的(参见图8.3)。在这些步骤中,地址可能有不同的表示形式。源程序中的地址通常是用符号来表示的(如 count)。编译器通常将这些符号地址绑定(bind)在可重定位的地址(如"从本模块开始的第 14字节")。链接程序或加载程序再将这些可重定位的地址绑定成绝对地址(如 74014)。每次绑定都是从一个地址空间到另一个地址空间的映射。
通常,将指令与数据绑定到内存地址有以下几种情况∶
- 编译时(compile time):如果在编译时就知道进程将在内存中的驻留地址,那么就可以生成绝对代码(absolute code)。例如,如果事先就知道用户进程驻留在内存地址R处,那么所生成的编译代码就可用从该位置开始向后扩展。如果将来开始地址发生变化,那么就必须重新编译代码。
- 加载时(load time):如果在编译时不知道进程将驻留在内存的什么地方,那么编译器就必须生成可重定位代码(relocatable code)。对于这种情况,最后绑定会延迟到加载时才进行。如果开始地址发送变化,只需要重新加载用户代码以引入改变值。
- 执行时(execution time):如果进程在执行时可以从一个内存段转移到另一个内存段,那么绑定必须延迟到执行时才进行。采用这种方案需要特定的硬件。绝大多数通用计算机操作系统采用这种方法。
本章的主要部分将描述如何在计算机系统中有效地实现这些绑定,并将讨论合适的硬件支持。
8.1.3 逻辑地址空间与物理地址空间
CPU所生成的地址通常称为逻辑地址(logical address),而内存单元所看到的地址通常称为物理地址(physical address)。
编译和加载时的地址绑定方法生成相同的逻辑地址和物理地址。但是执行时的地址绑定方案导致不同的逻辑地址和物理地址。对于这种情况,通常称逻辑地址为虚拟地址(virtual address)。在本书中,对逻辑地址和虚拟地址不作区分。
由程序所生成的所有逻辑地址的集合称为逻辑地址空间(logical address space),与这些逻辑地址相对应的所有物理地址的集合称为物理地址空间(physical address space)。
运行时从虚拟地址到物理地址的映射是由被称为内存管理单元(memory-management unit,MMU)的硬件设备来完成的。
有许多可选择的方法来完成这种映射。其中一种简单的MMU方案是对8.1.1小节所描述的基地址寄存器方案的推广。基地址寄存器在这里称为重定位寄存器(relocation register)。用户进程所生成的地址在送交内存之前,都将加上重定位寄存器的值(如图 8.4所示)。例如,如果基地址为 14000,那么用户对位置0的访问将动态地重定位为位置14000 对地址346 的访问将映射为位置14346。用户程序决不会看到真正的物理地址。
8.1.4 动态加载
为了获得更好的内存空间使用率,可以使用动态加载(dynamic loading)。采用动态加载时,一个子程序只有在调用时才被加载。所有子程序都以可重定位的形式保存在磁盘上。
主程序装入内存并执行。当一个子程序需要调用另一个子程序时,调用子程序首先检查另一个子程序是否已加载。如果没有, 可重定位的链接程序将用来加载所需要的子程序,并更新程序的地址表以反映这一变化。接着,控制传递给新加载的子程序。
动态加载的优点是不用的子程序决不会被加载。如果大多数代码需要用来处理异常情况,如错误处理,那么这种方法特别有用。对于这种情况,虽然总体上程序比较大,但是所使用的部分(即加载的部分)可能小很多。
动态加载不需要操作系统提供特别的支持。利用这种方法来设计程序主要是用户的责任。不过,操作系统可以帮助程序员,如提供子程序库以实现动态加载。
8.1.5 动态链接与共享库
动态链接库(dynamically linked library)。有的操作系统只支持静态链接(static linking),此时系统语言库的处理与其他目标模块一样,由加载程序合并到二进制程序镜像中。动态链接的概念与动态加载相似。只是这里不是将加载延迟到运行时,而是将链接延迟到运行时。
如果有动态链接,二进制镜像中对每个库程序的引用都有一个存根(stub)。存根是一小段代码,用来指出如何定位适当的内存驻留库程序,或如果该程序不在内存时应如何装入库。当执行存根时,它首先检查所需子程序是否已在内存中。如果不在,就将子程序装入内存。不管如何,存根会用子程序地址来替换自己,并开始执行子程序。因此,下次再执行该子程序代码时,就可以直接进行,而不会因动态链接产生任何开销。
动态链接也可用于库更新(如修改漏洞)。一个库可以被新的版本所替代,且使用该库的所有程序会自动使用新的版本。没有动态链接,所有这些程序必须重新链接以便访问新的库。为了不使程序错用新的、不兼容版本的库,程序和库将包括版本信息。多个版本的库可以都装入内存,程序将通过版本信息来确定使用哪个库副本。
因此,只有用新库编译的程序才会受新库的不兼容变化影响。在新程序装入之前所链接的其他程序可以继续使用老库。这种系统也称为共享库。
与动态加载不同,动态链接通常需要操作系统的帮助。如果内存中进程是彼此保护的,那么只有操作系统才可以检查所需子程序是否在其他进程内存空间内,或是允许多个进程访问同一内存地址。
8.2 交换
进程需要在内存中以便执行。不过,进程可以暂时从内存中交换(swap)到备份存储(backing store)上,当需要再次执行时再调回到内存中。中。例如,假如有一个 CPU调度算法采用轮转法的多道程序环境,当时间片用完,内存管理器开始将刚刚执行过的进程换出,将另一进程换入到刚刚释放的内存空间中(图8.5)。同时,CPU调度器可以将时间片分配给其他已在内存中的进程。当每个进程用完时间片,它将与另一进程进行交换。在理想情况下,内存管理器可以以足够快的速度交换进程,以便当 CPU调度器需要调度 CPU 时,总有进程在内存内可以执行。
这种交换策略的变种被用在基于优先级的调度算法中。如果有一个更高优先级的进程且需要服务,内存管理器可以交换出低优先级的进程,以便可以装入和执行更高优先级的进程。当更高优先级的进程执行完后,低优先级进程可以交换回内存以继续执行。这种交换有时称为滚出(roll out)和滚入(roll in)。
通常,一个交换出的进程需要交换回它原来所占有的内存空间。这一限制是由地址绑定方式决定的。如果绑定是在汇编时或加载时所定的,那么就不可以移动到不同的位置。如果绑定在运行时才确定,由于物理地址是在运行时才确定的,那么进程可以移到不同的地址空间。
交换需要备份存储。备份存储通常是快速磁盘。这必须足够大,以便容纳所有用户的内存镜像副本,它也必须提供对这些内存镜像的直接访问。
当 CPU 调度程序决定执行进程时,它调用调度程序。调度程序检查队列中的下一进程是否在内存中。如果不在内存中目没有空闲内存空间,调度程序将一个已在内存中的进程交换出去,并换入所需要的进程。然后,它重新装载寄存器,并将控制转交给所选择的进程。
交换系统的上下文切换时间比较长。为了明确上下文切换时间的概念,假设用户进程的大小为 10 MB 且备份存储是传输速度为 40 MBps 的标准硬盘。10 MB 进程传入或传出内存的时间为:
10000
K
B
/
40000
K
B
p
s
=
250
m
s
10000KB/40000KBps = 250ms
10000KB/40000KBps=250ms
假定无须磁头寻址且平均延迟为8ms,交换时间为258 ms。由于需要换出和换入,因此总的交换时间约为516ms。
为了有效使用 CPU,需要使每个进程的执行时间比交换时间长。例如,对于轮转法CPU 调度算法,时间片应比0.516s要大。
交换也受其他因素所限制。如果要换出进程,那么必须确保该进程完全处于空闲状态。尤其值得关注的是待处理 I/O。假如要换出一个进程以释放内存,而该进程正在等待I/O操作。如果I/O异步访问用户内存的 I/O缓冲区,那么该进程就不能被换出。假定由于设备忙,I/O操作在排队等待。如果换出进程 P1而换入进程 P2,那么 I/O操作可能试图使用现在已属于进程P2的内存。对这个问题有两种解决方法,一是不能换出有待处理 I/O的进程,二是 I/O 操作的执行只能使用操作系统缓冲区。仅当换入进程后,才执行操作系统缓冲与进程内存之间的数据转移。
8.3 连续内存分配
内存必须容纳操作系统和各种用户进程,因此应该尽可能有效地分配内存的各个部分。本节将介绍一种常用方法——连续内存分配。
内存通常分为两个区域:一个用于驻留操作系统,另一个用于用户进程。操作系统可以位于低内存,也可位于高内存。影响这一决定的主要因素是中断向量的位置。由于中断向量通常位于低内存,因此程序员通常将操作系统也放在低内存。
通常需要将多个进程同时放在内存中,因此需要考虑如何为输入队列中需要调入内存的进程分配内存空间。采用**连续内存分配(contiguous memory allocation)**时,每个进程位于一个连续的内存区域。
8.3.1 内存映射和保护
在讨论内存分配前,必须先讨论内存映射与保护问题。通过采用重定位寄存器(已在8.1.3小节讨论)和界限地址寄存器(已在8.1.1小节讨论),可以实现这种保护。重定位寄存器含有最小的物理地址值∶ 界限地址寄存器含有逻辑地址的范围值(例如,重定位=100040,界限=74600)。有了重定位寄存器和界限地址寄存器,每个逻辑地址必须小于界限地址寄存器。MMU动态地将逻辑地址加上重定位寄存器的值后映射成物理地址。映射后的物理地址再送交内存单元。
当 CPU调度器选择一个进程来执行时,作为上下文切换工作的一部分,调度程序会用正确的值来初始化重定位寄存器和界限地址寄存器。
重定位寄存器机制为允许操作系统动态改变提供了一个有效方法。许多情况都需要这一灵活性。例如,操作系统的驱动程序需要代码和缓冲空间。如果某驱动程序(或其他操作系统服务)不常使用,可以不必在内存中保留该代码和数据,这部分空间可以用于其他目的。这类代码有时称为暂时(transient)操作系统代码;它们根据需要调入或调出。因此,使用这种代码可以在程序执行时动态改变操作系统的大小。
8.3.2 内存分配(可变分区)
在可变分区(variable-partition)方案里,,操作系统有一个表,用于记录哪些内存可用和哪些内存已被占用。一开始,所有内存都可用于用户进程,因此可以作为一大块可用内存,称为孔(hole)。当有新进程需要内存时,为该进程查找足够大的孔。如果找到,可以从该孔为该进程分配所需的内存,孔内未分配的内存可以下次再用。
在任意时候,有一组可用孔(块)大小列表和输入队列。操作系统根据调度算法来对输入队列进行排序。内存不断地分配给进程,直到下一个进程的内存需求不能满足为止,这时没有足够大的可用孔来装入进程。操作系统可以等到有足够大的空间,或者往下扫描输入队列以确定是否有其他内存需求较小的进程可以被满足。
通常,一组不同大小的孔分散在内存中。当新进程需要内存时,系统为该进程查找足够大的孔。如果孔太大,那么就分为两块∶一块分配给新进程,另一块还回到孔集合。当进程终止时,它将释放其内存,该内存将还给孔集合。如果新孔与其他孔相邻,那么将这些孔合并成大孔。这时,系统可以检查是否有进程在等待内存空间,新合并的内存空间是否满足等待进程。
从一组可用孔中选择一个空闲孔的最为常用方法有首次适应(first-fit)、最佳适应(best-fit)、最差适应(worst-fit)。
首次适应
分配第一个足够大的孔。查找可以从头开始,也可以从上次首次适应结束时开始。一旦找到足够大的空闲孔,就可以停止。
最佳适应
分配最小的足够大的孔。必须查找整个列表,除非列表按大小排序。这种方法可以产生最小剩余孔。
最差适应
分配最大的孔。同样,必须查找整个列表,除非列表按大小排序。这种方法可以产生最大剩余孔,该孔可能比最佳适应方法产生的较小剩余孔更为有用。
模拟结果显示首次适应和最佳适应方法在执行时间和利用空间方面都好于最差适应方法。首次适应和最佳适应方法在利用空间方面难分伯仲,但是首次适应方法要更快些。
8.3.3 碎片
首次适应方法和最佳适应方法算法都有外部碎片问题(external fragmentation)。随着进程装入和移出内存,空闲内存空间被分为小片段。当所有总的可用内存之和可以满足请求,但并不连续时,这就出现了外部碎片问题,该问题可能很严重。
根据内存空间总的大小和平均进程大小的不同,外部碎片的重要程度也不同。例如,对采用首次适应方法的统计说明,不管使用什么优化,假定有N个可分配块,那么可能有0.5N个块为外部碎片。即 1/3的内存可能不能使用。这一特性称为50%则。
内存碎片可以是内部的,也可以是外部的。设想有一个18464 B大小的孔,并采用多分区分配方案。假如有一个进程需要 18462B。如果只准确分配所要求的块,那么还剩下一个2B的孔。维护这一小孔的开销要比孔本身大得多。因此,通常将内存以固定大小的块为单元(而不是字节)来分配。采用这种方案,进程所分配的内存可能比所要的要大。这两个数字之差称为内部碎片,这部分内存在分区内,但又不能使用。
一种解决外部碎片问题的方法是紧缩(compaction)。紧缩的目的是移动内存内容,以便所有空闲空间合并成一整块。但紧缩并非总是可能的。如果重定位是静态的,并且在汇编时或装入时进行的,那么就不能紧缩。紧缩仅在重定位是动态并在运行时可采用。
另一种可能解决外部碎片问题的方法是允许物理地址空间为非连续,这样只要有物理内存就可为进程分配。这种方案有两种互补的实现技术∶分页(见8.4节)和分段(见8.6节)。这种两种技术也可合并(见8.7节)。
8.4 分页
分页(paging)内存管理方案允许进程的物理地址空间可以是非连续的。分页避免了将不同大小的内存块匹配到交换空间上这样的麻烦,前面所述内存管理方案都有这个问题。当位于内存中的代码或数据需要换出时,必须先在备份存储上找到空间,这时问题就产生了。备份存储也有前面所述与内存相关的碎片问题,只不过访问更慢,因此不适宜采用合并。各种形式的分页由于其优越性,因此通常为绝大多数操作系统所采用。
传统上,分页支持一直是由硬件来处理的。然而,最近的设计是通过将硬件和操作系统相配合来实现分页(尤其是在64 位微处理器上)。
8.4.1 基本方法
实现分页的基本方法涉及将物理内存分为固定大小的块,称为帧(frame),而将逻辑内存也分为同样大小的块,称为页(page)。当需要执行进程时,其页从备份存储中调入到可用的内存帧中。备份存储也分为固定大小的块,其大小与内存帧一样。
分页硬件支持如图8.7所示。由 CPU生成的每个地址分为两个部分∶页号(p)和页偏移(d)。页号作为页表中的索引。页表包含每页所在物理内存的基地址,这些基地址与页偏移的组合就形成了物理地址,就可送交物理单元。
页大小(与帧大小一样)是由硬件来决定的。页的大小通常为2的幂,根据计算机结构的不同,其每页大小从512B~16 MB 不等。选择页的大小为2的幂可以方便地将逻辑地址转换为页号和页偏移。如果逻辑地址空间为 2 m 2^m 2m,且页大小为 2 n 2^n 2n单元(字节或字),那么逻辑地址的高 m-n 位表示页号,而低 n 位表示页偏移。
分页也是一种动态重定位。每个逻辑地址由分页硬件绑定为一定的物理地址。采用分页类似于使用一组基(重定位)地址寄存器,每个基地址对应着一个内存帧。
采用分页技术不会产生外部碎片∶每个帧都可以分配给需要它的进程。不过,分页有内部碎片。
如果进程大小与页大小无关,那么可以推测每个进程平均可能有半页的内部碎片。这一结构意味着小一点的页可能好些。不过,页表中的每项也有一定的开销,该开销随着页的增大而降低。故页也并不是越小越好的。
每个页表的条目通常为 4B,不过这是可变的。一个 32 位的条目可以指向 2 32 2^{32} 232个物理帧中的任一个。如果帧为4KB,那么具有4B条目的系统可以访问 2 44 2^{44} 244B大小(或16TB)的物理内存。
分页的一个重要特点是用户视角的内存和实际的物理内存的分离。用户程序将内存作为一整块来处理,而且它只包括这一个进程。事实上,一个用户程序与其他程序一起,分布在物理内存上。
用户视角的内存和实际的物理内存的差异是通过地址转换硬件协调的。逻辑地址转变成物理地址。这种映射是用户所不知道的,但是受操作系统所控制。
由于操作系统管理物理内存,它必须知道物理内存的分配细节:哪些帧已占用,哪些帧可用,总共有多少帧,等等。这些信息通常保存在称为帧表的数据结构中。在帧表(frametable)中,每个条目对应着一个帧,以表示该帧是空闲还是已占用,如果占用,是被哪个(或哪些)进程的哪个页所占用。
操作系统为每个进程维护一个页表的副本,就如同它需要维护指令计数器和寄存器的内容一样。当操作系统必须手工将逻辑地址映射成物理地址时,这个副本可用来将逻辑地址转换为物理地址。当个进程可分配到 CPU时,CPU调度程序可以根据该副本来定义硬件页表。因此,分页增加了切换时间。
8.4.2 硬件支持
每个操作系统都有自己的方法来保存页表。绝大多数都为每个进程分配一个页表。页表的指针与其他寄存器的值(如指令计数器)一起存入进程控制块中。
页表的硬件实现有多种方法。最为简单的一种方法是将页表作为一组专用寄存器(register)来实现。。CPU调度程序在装入其他寄存器时,也需要装入这些寄存器。装入或修改页表寄存器的指令是特权级的,因此只有操作系统才可以修改内存映射图。DEC PDP-11 就是这种类型的结构。它的地址有16位,而页面大小为8KB,因此页表有8个条目可放在快速寄存器中。
如果页表比较小(例如 256 个条目),那么页表使用寄存器还是比较合理的。但是,绝大多数当代计算机都允许页表非常大(如1百万个条目)。对于这些机器,采用快速寄存器来实现页表就不可行了。因而需要将页表放在内存中,并将**页表基寄存器(page-table base register,PTBR)**指向页表。改变页表只需要改变这一寄存器就可以,这也大大降低了切换时间。
采用这种方法的问题是访问用户内存位置需要一些时间。如果要访问位置 i,那么必须先用PTBR 中的值再加上页号 i ,来查找页表。这一任务需要内存访问。根据所得的帧号,再加上页偏移,就得到了真实物理地址。接着就可以访问内存中所需的位置。采用这种方案,访问一个字节需要两次内存访问(一次用于页表条目,一次用于字节)。这样,内存访问的速度就减半。在绝大多数情况下,这种延迟是无法忍受的,还不如采用交换机制。
对这一问题的标准解决方案是采用小但专用且快速的硬件缓冲,这种缓冲称为转换表缓冲区(translation look-aside buffer,TLB)。B)。TLB是关联的快速内存。TLB条目由两部分组成∶键(标签)和值。当关联内存根据给定值查找时,它会同时与所有键进行比较。如果找到条目,那么就得到相应的值域。这种查找方式比较快,不过硬件也比较昂贵。通常,TLB的条目数并不多,通常在64~1 024 之间。
TLB与页表一起按如下方法使用∶TLB只包括页表中的一小部分条目。当CPU产生逻辑地址后,其页号提交给TLB。如果找到页号,那么也就得到了帧号,并可用来访问内存。整个任务与不采用内存映射相比,其时间增加不会超过 10%。
如果页码不在 TLB中(称为TLB失效),那么就需要访问页表。当得到帧号后,就可以用它来访问内存(如图8.11所示)。同时,将页号和帧号增加到TLB中,这样下次再用时就可很快查找到。如果 TLB中的条目已满,那么操作系统会选择一个来替换。替换策略有很多,从最近最少使用替换(LRU)到随机替换等。另外,有的 TLB允许有些条目固定下来,也就是说它们不会从TLB中被替换。通常内核代码的条目是固定下来的。
页号在 TLB中被查找到的百分比称为命中率。假如查找TLB需要 20 ns,访问内存需要 100 ns,如果访问
位于 TLB中的页号,那么采用内存映射访问需要120 ns。如果不能在 TLB中找到,那么必须先访问位于内存中的页表以得到帧号(100ns),并进而访问内存中的所需字节(100 ns),这总共要花费 220ns。
8.4.3 保护
在分页环境下,内存保护是通过与每个帧相关联的保护位来实现的。通常,这些位保存在页表中。
可以用一个位来定义一个页是可读写还是只读的。每次地址引用都要通过页表来查找正确的帧码,在计算物理地址的同时,可以通过检查保护位来验证有没有对只读页进行写操作。对只读页进行写操作会向操作系统产生硬件陷阱(或内存保护冲突)。
可以很容易地扩展这一方法以提供更细致的保护。可以创建硬件以提供只读、读写、只执行保护。或者,通过为每种访问情况提供独立保护位,实现这些访问的各种组合,非法访问会被操作系统捕捉到。
还有一个位通常与页表中的每一条目相关联∶有效-无效位。当该位为有效时,表示相关的页在进程的逻辑地址空间内,因此是合法(或有效)的页。当该位为无效时,表示相关的页不在进程的逻辑地址空间内。通过使用有效-无效位可以捕捉到非法地址。操作系统通过对该位的设置可以允许或不允许对某页的访问。
例如,对于14位地址空间(0~16 383)的系统,有一个程序,其有效地址空间为0~10468。如果页的大小为 2KB,那么得到如图 8.12所示的页表。页 0、1、2、3、4和 5的地址可以通过页表正常映射。然而,如果试图产生页6、7中的地址,就会发现有效-无效位为无效,这样操作系统就会捕捉到这一非法操作(无效地址引用)。
一个进程很少会使用系统所有的地址空间。事实上,许多进程只使用一小部分可用的地址空间。对这些情况,如果为地址范围内的所有页都在页表中建立一个条目,这将是非常浪费的。表中的绝大多数并不会被使用,却占用可用的地址空间。有些系统提供硬件如页表长度寄存器(page-table length register,PTLR)来表示页表的大小,该寄存器的值可用于检查每个逻辑地址以验证其是否位于进程的有效范围内。如果检测无法通过,会被操作系统捕提到。
8.4.4 共享页
分页的优点之一在于可以共享公共代码,这种考虑对分时环境特别重要。如果代码是可重入代码(reentrant code,或称为纯代码),则可以共享。
可重入代码是不能自我修改的代码,它从不会在执行期间改变。因此,两个或更多的进程可以在相同的时间执行相同的代码。每个进程都有它自己的寄存器副本和数据存储,以控制进程执行的数据。当然,两个不同的进程的数据也将不同。
要共享,代码必须能重入。共享代码的只读特点不能只通过正确代码来保证。而需要操作系统来强制实现。
8.5 页表结构
8.5.1 层次页表
绝大多数现代计算机系统支持大逻辑地址空间( 2 32 2^{32} 232~ 2 64 2^{64} 264)。在这种情况下,页表本身可以非常大。例如,设想一下具有 32位逻辑地址空间的计算机系统。如果系统的页大小为4 KB( 2 12 2^{12} 212),那么一个页表可以包含1 百万个条目( 2 32 / 2 12 2^{32}/2^{12} 232/212)。假设每个条目有4B,那么每个进程需要 4 MB物理地址空间来存储页表本身。显然,我们并不可能在内存中连续地分配这个页表。这个问题的一个简单解决方法是将页表划分为更小部分。划分有许多方法。
一种方法是使用两级分页算法,就是将页表再分页(见图8.14)。仍以之前一个4 KB页大小的32位系统为例。一个逻辑地址被分为 20位的页码和12位的页偏移。因为要对页表进行再分页,所以该页号可分为10位的(页表的)页码和10位的(页表的)页偏移。这样,一个逻辑地址就分为如下形式∶
其中,p1是用来访问外部页表的索引,而 p2是外部页表的页偏移。采用这种结构的地址转换方法如图 8.15 所示。由于地址转换由外向内,这种方案也称为向前映射页表( forward-mapped page table)。
对于 64 位的逻辑地址空间的系统,两级分页方案就不再适合。为了说明这一点,假设系统的页大小为 4 KB( 2 12 2^{12} 212)。这时,页表可由 2 52 2^{52} 252 条目组成。如果使用两级分页方案,那么内部页表可方便地定为一页长,或包括 2 10 2^{10} 210 个4B的条目。地址形式如下所示∶
外部页表有 2 42 2^{42} 242 个条目,或 2 44 2^{44} 244 B。避免这样一个大表的显而易见的方法是将外部页表再进一步细分。这种方法也可用于32位处理器来增加灵活性和有效性。
然而,对于64位体系结构,层次页表通常并不适合。例如,64位的UIltraSPARC体系结构可采用7级分页,这可以说是一个有效转换逻辑地址的内存访问的极限。
8.5.2 哈希页表
处理超过32位地址空间的常用方法是使用哈希页表(hashed page table),并以虚拟页码作为哈希值。哈希页表的每一条目都包括一个链表的元素,链表用于连接发生碰撞的元素。每个元素有3个域∶(1)虚拟页码,(2)所映射的帧号,(3)指向链表中下一个元素的指针。
该算法按如下方式工作∶虚拟地址中的虚拟页号转换到哈希表中,用虚拟页号与链表中的每一个元素的第一个域相比较。如果匹配,那么相应的帧号(第二个域)就用来形成物理地址;如果不匹配,那么就对链表中的下一个节点进行比较,以寻找一个匹配的页号。该方案如图8.16所示。
人们提出了这种方法的一个变种,它比较适合64位的地址空间。**群集页表(clustered page table)**类似于哈希页表,不过这种哈希页表的每一条目不只包括一页信息,而是包括多页(例如 16页)。因此,一个页表条目可以存储多个物理页帧的映射。群集页表对于稀疏地址空间特别有用,稀疏地址空间中的地址引用不连续,且分散在整个地址空间。
8.5.3 反向页表
通常,每个进程都有一个相关页表。该进程所使用的每个页都在页表中有一项(或者每个虚拟地址都有一项,不管后者是否有效)。这种方法的缺点之一是每个页表可能有很多项。这些表可能消耗大量物理内存,却仅用来跟踪物理内存是如何使用的。
为了解决这个问题,可以使用反向页表(inverted page table)。反向页表对于每个真正的内存页或帧才有一个条目。每个条目包含保存在真正内存位置的页的虚拟地址以及拥有该页的进程的信息。
整个系统只有一个页表,对每个物理内存的页只有一条相应的条目。图8.17说明了反向页表的操作,将它与图8.7的标准页表操作相比较。因为系统只有一个页表,而有多个地址空间映射物理内存,所以反向页表的条目中通常需要一个地址空间标识符(即ASID,可用于唯一标识进程,并为进程提供地址空间保护。当TLB试图解析虚拟页号时,它确保当前运行进程的ASID与虚拟页相关的ASID相匹配。如果不匹配,那么就作为TLB失效。),以确保一个特定进程的一个逻辑页可以映射到相应的物理帧。
为了说明这种方法,这里描述一种简化的反向页表实现,IBM RT 就使用这种方法。系统的每个虚拟地址有一个三元组:
<
p
r
o
c
e
s
s
−
i
d
,
p
a
g
e
−
n
u
m
b
e
r
,
o
f
f
s
e
t
>
<process-id, page-number, offset>
<process−id,page−number,offset>
每个反向页表的条目为一对<process-id,page-number>,其中 process-id用来作为地址空间的标识符。当需要内存引用时,由<process-id, page-number>组成的虚拟地址部分送交内存子系统。通过查找反向页表来寻找匹配。如果匹配找到,例如条目 i,那么就产生了物理地址<i,offset>。如果没有匹配,那么就是试图进行非法地址访问。
虽然这种方案减少了存储每个页表所需要的内存空间,但是当引用页时,它增加了查找页表所需要的时间。由于反向页表按物理地址排序,而查找是根据虚拟地址,因此可能需要查找整个表来寻求匹配。这种查找会花费很长时间。
为了解决这一问题,可以使用哈希页表来将查找限制在一个或少数几个页表条目。当然,每次访问哈希页表也为整个过程增加了一次内存引用,因此一次虚拟地址引用至少需要两个内存读; 一个查找哈希页表条目,另一个查找页表。为了改善性能,可以在访问哈希页表时先查找 TLB。
采用反向页表的系统在实现共享内存时存在困难。共享内存通常作为被映射到一个物理地址的多虚拟地址(其中每一个进程共享内存)来实现。这种标准的方法不能用到反向页表,因为此时每个物理页只有一个虚拟页条目,一个物理页不可能有两个(或更多)的共享虚拟地址。解决该问题的一个简单方法是允许页表仅包含一个虚拟地址到共享物理地址的映射,这意味着对未被映射的虚拟地址的引用将导致页错误。
8.6 分段
8.6.1 基本方法
用户是否会将内存看做是一个线性字节数组,有的包含指令而其他的包含数据?绝大多数人会说不。用户通常愿意将内存看做是一组不同长度的段的集合,这些段之间并没有一定的顺序。
分段((segmentation)就是支持这种用户视角的内存管理方案。逻辑地址空间是由一组段组成的。每个段都有名称和长度。地址指定了段名称和段内偏移。因此用户通过两个量来指定地址∶段名称和偏移(请将这一方案与分页相比较。在分页中,用户只指定一个地址,该地址通过硬件分为页码和偏移,对于这些,程序员是看不见的)。
为实现简单起见,段是编号的,是通过段号而不是段名来引用的。因此,逻辑地址由有序对组成∶
<
s
e
g
m
e
n
t
−
n
u
m
b
e
r
,
o
f
f
s
e
t
>
<segment-number, offset>
<segment−number,offset>
8.6.2 硬件
虽然用户现在能够通过二维地址来引用程序中的对象,但是实际物理内存仍然是一维序列的字节。因此,必须定义一个实现方式,以便将二维的用户定义地址映射为一维物理地址。这个地址是通过段表(segment table)来实现的。段表的每个条目都有段基地址和段界限。段基地址包含该段在内存中的开始物理地址,而段界限指定该段的长度。
段表的使用如图 8.19 所示。一个逻辑地址由两部分组成;段号s和段内的偏移 d。段号用做段表的索引,逻辑地址的偏移 d 应位于0和段界限之间。如果不是这样,会陷入到操作系统中(逻辑地址试图访问段的外面)。如果偏移 d 合法,那么就与段基地址相加而得到所需字节在物理内存的地址。因此段表是一组基地址和界限寄存器对。
段的长度是不固定的,因此会产生外部碎片问题。[TOC]