到目前为止,我们一直假设将所有进程的地址空间完整地加载到内存中。利用基址和界限寄存器,操作系统很容易将不同进程重定位到不同的物理内存区域。但是,对于这些内存区域,一件有趣的事是:栈和堆之间,有一大块“空闲”空间。
从图中可知,如果我们将整个地址空间放入物理内存,那么栈和堆之间的空间并没有被进程使用,却依然占用了实际的物理内存。因此,简单的通过基址寄存器和界限寄存器实现的虚拟内存很浪费。另外,如果剩余物理内存无法提供连续区域来放置完整的地址空间,进程便无法运行。这种基址加界限的方式看来并不像我们期望的那样灵活。因此:怎样支持大地址空间,同时栈和堆之间(可能)有大量空闲空间?在之前的例子里,地址空间非常小,所以这种浪费并不明显。但设想一个32位(4GB)的地址空间,通常的程序只会使用几兆的内存,但需要整个地址空间都放在内存中。
分段:泛化的基址/界限
为了解决这个问题,分段(segmentation)的概念应运而生。它在内存管理单元中引入不止一个基址和界限寄存器对,而是给地址空间内的每个逻辑段一对。一个段只是地址空间里的一个连续定长的区域,在典型的地址空间里有3个不同的段:代码、栈和堆。分段的机制使得操作系统能够将不同的段放到不同的物理内存区域,从而避免了虚拟地址空间中的未使用部分占用物理内存。
如上图所示,64KB的物理内存中放置了3个段(为操作系统保留16KB)。从图中可以看到,只有已用的内存才在物理内存中分配空间,因此可以容纳巨大的地址空间,其中包含大量未使用的地址空间(有时又称为稀疏地址空间,sparse address spaces)。在这种情况下,需要一组3对基址和界限寄存器。下表展示了上面的例子中的寄存器值,每个界限寄存器记录了一个段的大小。
段 | 基址 | 大小 |
---|---|---|
代码 | 32KB | 2KB |
堆 | 34KB | 3KB |
栈 | 28KB | 2KB |
假设现在要在代码段中引用虚拟地址100,内存管理单元将基址值加上偏移量(100)得到实际的物理地址:100 + 32KB = 32868。然后它会检查该地址是否在界限内(100小于2KB),发现是的,于是发起对物理地址32868的引用。来看一个堆中的地址,虚拟地址4200。我们首先应该先减去堆的偏移量,即该地址指的是这个段中的哪个字节。因为堆从虚拟地址4KB开始,4200的偏移量实际上是4200减去4096,即104,然后用这个偏移量加上基址寄存器中的物理地址,得到真正的物理地址34920。如果我们试图访问非法的地址,例如7KB,硬件会发现该地址越界,导致段异常或段错误。
段内偏移
硬件在地址转换时使用段寄存器,那么它是如何知道段地址以及段内偏移量的呢?一种常见的(显式)方式是用虚拟地址的开头几位来标识不同的段。在之前的例子中,我们有3个段,因此需要两位来标识。假设我们的虚拟地址有14位,它的前两位用来标识不同的段,剩下的12位用来标识段内偏移量。
在我们的例子中,如果前两位是00,硬件就知道这是属于代码段的地址,因此使用代码段的基址和界限来重定位到正确的物理地址。如果前两位是01,则是堆地址,对应地,使用堆的基址和界限。下面来看一个4200之上的堆虚拟地址,进行进制转换,确保弄清楚这些内容。虚拟地址4200的二进制形式如下:
从图中可以看到,前两位(01)告诉硬件我们引用哪个段。剩下的12位是段内偏移:0000 0110 1000(即十六进制0x068或十进制104)。因此,硬件就用前两位来决定使用哪个段寄存器,然后用后12位作为段内偏移。偏移量与基址寄存器相加,硬件就得到了最终的物理地址。请注意,偏移量也简化了对段边界的判断。我们只要检查偏移量是否小于界限,大于界限的为非法地址。因此,如果基址和界限放在数组中(每个段一项),为了获得需要的物理地址,硬件会做下面这样的事:
// get top 2 bits of 14-bit VA
Segment = (VirtualAddress & SEG_MASK) >> SEG_SHIFT
// now get offset
Offset = VirtualAddress & OFFSET_MASK
if (Offset >= Bounds[Segment])
RaiseException(PROTECTION_FAULT)
else
PhysAddr = Base[Segment] + Offset
Register = AccessMemory(PhysAddr)
在我们的例子中,SEG_MASK为0x3000,SEG_SHIFT为12,OFFSET_MASK为0xFFF。注意到,我们使用两位来区分段,但实际只有3个逻辑段(代码、堆、栈),有一个段的地址空间被浪费掉了。因此有些系统中会将堆和栈当作同一个段,这样只需要一位来做标识。
硬件还有其他方法来决定特定地址在哪个段。在隐式方式中,硬件通过地址产生的方式来确定段。例如,如果地址由程序计数器产生(即它是指令获取),那么地址在代码段;如果基于栈或基址指针,它一定在栈段;其他地址则在堆段。
栈
与代码段和堆段不同的是,栈反向增长。在我们的例子中,它始于物理地址28KB,增长回到26KB,相应虚拟地址从16KB到14KB。因此,地址转换必须与前二者有所不同。除了基址和界限外,硬件还需要知道段的增长方向(用一位区分,比如1代表自小而大增长,0反之)。在下表中,我们更新了硬件记录的视图。
段 | 基址 | 大小 | 是否自小而大增长 |
---|---|---|---|
代码 | 32KB | 2KB | 1 |
堆 | 34KB | 3KB | 1 |
栈 | 28KB | 2KB | 0 |
假设要访问虚拟地址15KB,它被映射到物理地址27KB。该虚拟地址的二进制形式是:11 1100 0000 0000(十六进制0x3C00)。硬件利用前两位(11)来指定栈段,然后我们要处理偏移量3KB。为了得到正确的反向偏移,我们必须从3KB中减去最大的段内偏移4KB(十六进制0xFFF),得到反向偏移量−1KB。只要用这个反向偏移量加上基址(28KB),就得到了正确的物理地址27KB。用户可以进行界限检查,确保反向偏移量的绝对值小于段的大小。
支持共享
有时候在地址空间之间共享某些内存段可以节省内存,提高效率。为了支持共享,需要一些额外的硬件支持,这就是保护位。我们为每个段增加了几个位,标识程序是否能够读写该段或执行其中的代码。通过将代码段标记为只读,同样的代码可以被多个进程共享,而不用担心破坏隔离。下表展示了硬件和操作系统需要记录的额外信息。可以看到,代码段的权限是可读和可执行,因此物理内存中的一个段可以映射到多个虚拟地址空间。
段 | 基址 | 大小 | 是否自小而大增长 | 保护 |
---|---|---|---|---|
代码 | 32KB | 2KB | 1 | 读——执行 |
堆 | 34KB | 2KB | 1 | 读——写 |
栈 | 28KB | 2KB | 0 | 读——写 |
有了保护位,前面描述的硬件算法也必须改变。除了检查虚拟地址是否越界,硬件还需要检查特定访问是否允许。如果用户进程试图写入只读段,或从非执行段执行指令,硬件会触发异常,让操作系统来处理出错进程。
粗粒度与细粒度的分段
到目前为止,我们的例子大多针对只有很少的几个段的系统(即代码、栈、堆)。我们可以认为这种分段是粗粒度的(coarse-grained),因为它将地址空间分成较大的、粗粒度的块。但是,一些早期系统更灵活,允许将地址空间划分为大量较小的段,这被称为细粒度(fine-grained)分段。支持许多段需要进一步的硬件支持,并在内存中保存某种段表(segment table)。这种段表通常支持创建非常多的段,因此系统使用段的方式,可以比之前讨论的方式更灵活。有了操作系统和硬件的支持,编译器可以将代码段和数据段划分为许多不同的部分。当时的考虑是,通过更细粒度的段,操作系统可以更好地了解哪些段在使用,哪些没有在使用,从而可以更高效地利用内存。
操作系统支持
使用分段技术能够大量地节省物理内存。然而,分段也带来了新的问题,即如何管理物理内存的空闲空间。新的地址空间被创建时,操作系统需要在物理内存中为它的段找到空间。之前,我们假设所有的地址空间大小相同,物理内存可以被认为是一些槽块,进程可以放进去。现在,每个进程都有一些段,每个段的大小也可能不同。
一般会遇到的问题是,物理内存很快充满了许多空闲空间的小洞,因而很难分配给新的段,或扩大已有的段。这种问题被称为外部碎片(external fragmentation),如下图(左边)所示。在这个例子中,一个进程需要分配一个20KB的段。当前有24KB空闲,但并不连续(是3个不相邻的块)。因此,操作系统无法满足这个20KB的请求。
该问题的一种解决方案是紧凑物理内存,重新安排原有的段。例如,操作系统先终止运行的进程,将它们的数据复制到连续的内存区域中去,改变它们的段寄存器中的值,指向新的物理地址,从而得到了足够大的连续空闲空间。这样做,操作系统能让新的内存分配请求成功。但是,内存紧凑成本很高,因为拷贝段是内存密集型的,一般会占用大量的处理器时间。上图(右边)是紧凑后的物理内存。
一种更简单的做法是利用空闲列表管理算法,试图保留大的内存块用于分配。相关的算法包括传统的最优匹配、最坏匹配、首次匹配等,我们将在第17章讨论该问题。