利用基址和界限寄存器将不同进程重定位到不同的物理内存区域存在一个问题:栈和堆之间,有一大块“空闲”空间。
分段:泛化的基址/界限
分段想法很简单:在 MMU 中引入不止一个基址和界限寄存器对,而是给地址空间内的每个逻辑段(segment)一对。一个段只是地址空间里的一个连续定长的区域,在典型的地址空间里有 3 个逻辑不同的段:代码、栈和堆。分段的机制使得操作系统能够将不同的段放到不同的物理内存区域,从而避免了虚拟地址空间中的未使用部分占用物理内存。
举个例子,将下图中的地址空间放入物理内存:
通过给每个段一对基址和界限寄存器,可以将每个段独立地放入物理内存。如下图所示,64KB 的物理内存中放置了 3 个段(为操作系统保留 16KB)。
已用的内存才在物理内存中分配空间,其中包含大量未使用的地址空间(也叫稀疏地址空间)
在这种情况下,需要一组 3 对基址和界限寄存器。如下表所示:
代码段放在物理地址 32KB,大小是 2KB。堆在 34KB,大小也是 2KB。
下面利用第一张图的地址空间举个地址转换例子。假设引用在代码段中的虚拟地址100,实际物理地址就是基址加偏移即32KB+100=32868,然后该地址在界限内(100小于2KB),就可以发起对物理地址32868的引用。
另一个例子是堆段中的虚拟地址4200,此时物理地址并不是4200+34KB(基址)。而是要减去堆的偏移量,看最上面那张图可以得到堆从地址4KB开始,则实际偏移量是4200-4KB=104,最终物理地址就是34KB+104=34920。
段错误指的是在支持分段的机器上发生了非法的内存访问。如果试图访问非法的地址,例如 7KB,它超出了堆的边界,可能会导致终止出错进程,即段异常(segmentation violation)或段错误(segmentation fault)。
我们引用哪个段
一种常见的方式,有时称为显式(explicit)方式,就是用虚拟地址的开头几位来标识不同的段。
如果我们用 14 位虚拟地址的前两位来标识代码,栈,堆三个段,那么虚拟地址如下所示:
前两位00代表代码段,01代表堆地址段。
再次看上面4200堆地址的例子。虚拟地址 4200 的二进制形式如下:
4200的二进制为01000001101000。前两位01告诉我们引用堆段,后面12位为段内偏移:0000 0110 1000(十进制104)。硬件用前两位来决定使用哪个段寄存器,将基址寄存器和段内偏移相加即为物理地址。我们只要检查偏移量是否小于界限,大于界限的为非法地址。
硬件还有其他方法来决定特定地址在哪个段。在隐式(implicit)方式中,硬件通过地址产生的方式来确定段。例如,如果地址由程序计数器产生(即它是指令获取),那么地址在代码段。如果基于栈或基址指针,它一定在栈段。其他地址则在堆段。
栈怎么办
在表 16.1 中,栈被重定位到物理地址 28KB,但它是反向增长的。除了基址和界限外,硬件还需要知道段的增长方向(用一位区分,比如 1 代表自小而大增长,0 反之)。在表 16.2 中,我们更新了硬件记录的视图:
下面来看一个栈虚拟地址的例子:假设要访问虚拟地址 15KB,它应该映射到物理地址 27KB。该虚拟地址的二进制形式是:11 1100 0000 0000(十六进制 0x3C00)。硬件利用前两位(11)来指定段,但然后我们要处理偏移量 3KB。为了得到正确的反向偏移,我们必须从 3KB 中减去最大的段地址:在这个例子中,段可以是 4KB(为什么是4KB:上面12位的段内偏移全为1时大小为4KB-1,则这里的段最大可以近似看成4KB),因此正确的偏移量是 3KB 减去 4KB,即−1KB。只要用这个反向偏移量(−1KB)加上基址(28KB),就得到了正确的物理地址 27KB。
支持共享
具体来说,要节省内存,有时候在地址空间之间共享(share)某些内存段是有用的。
为了支持共享,需要一些额外的硬件支持,这就是保护位(protection bit)。为每个段增加了几个位,标识程序是否能够读写该段,或执行其中的代码。通过将代码段标记为只读,同样的代码可以被多个进程共享,而不用担心破坏隔离。
下表展示了一个例子:
可以看到,代码段的权限是可读和可执行,因此物理内存中的一个段可以映射到多个虚拟地址空间。
有了保护位,前面描述的硬件算法也必须改变。除了检查虚拟地址是否越界,硬件还需要检查特定访问是否允许。
细粒度与粗粒度的分段
像很少的几个段的系统(即代码、栈、堆)这种分段是粗粒度的(coarse-grained)因为它将地址空间分成较大的、粗粒度的块。
一些早期系统更灵活,允许将地址空间划分为大量较小的段,这被称为细粒度(fine-grained)分段。
操作系统支持
分段的基本原理:系统运行时,地址空间中的不同段被重定位到物理内存中。与我们之前介绍的整个地址空间只有一个基址/界限寄存器对的方式相比,大量节省了物理内存。栈和堆之间没有使用的区域就不需要再分配物理内存,让我们能将更多地址空间放进物理内存。
分段也带来了一些新的问题。
- 操作系统在上下文切换时应该做什么?答:各个段寄存器中的内容必须保存和恢复。
- 管理物理内存的空闲空间。
- 物理内存很快充满了许多空闲空间的小洞,因而很难分配给新的段,或扩大已有的段,称为外部碎片(external fragmentation),如下图所示

在这个例子中,一个进程需要分配一个 20KB 的段。当前有 24KB 空闲,但并不连续(是3 个不相邻的块)。因此,操作系统无法满足这个 20KB 的请求。
该问题的一种解决方法是紧凑(compact)物理内存,重新安排原有的段,如下图所示
内存紧凑成本很高,因为拷贝段是内存密集型的,一般会占用大量的处理器时间。
另一种更简单的做法是利用空闲列表管理算法,试图保留大的内存块用于分配。
- 最优匹配(best-fit,从空闲链表中找最接近需要分配空间的空闲块返回)
- 最坏匹配(worst-fit)
- 首次匹配(first-fit)
- 像伙伴算法(buddy algorithm)
无论算法多么精妙,都无法完全消除外部碎片,只能试图减小它。
小结
分段解决了一些问题,帮助我们实现了更高效的虚拟内存。不只是动态重定位,通过避免地址空间的逻辑段之间的大量潜在的内存浪费,分段能更好地支持稀疏地址空间。它还很快,因为分段要求的算法很容易,很适合硬件完成,地址转换的开销极小。分段还有一个附加的好处:代码共享。如果代码放在独立的段中,这样的段就可能被多个运行的程序共享。
但我们已经知道,在内存中分配不同大小的段会导致一些问题,我们希望克服。首先,是我们上面讨论的外部碎片。由于段的大小不同,空闲内存被割裂成各种奇怪的大小,因此满足内存分配请求可能会很难。用户可以尝试采用聪明的算法,或定期紧凑内存,但问题很根本,难以避免。
第二个问题也许更重要,分段还是不足以支持更一般化的稀疏地址空间。例如,如果有一个很大但是稀疏的堆,都在一个逻辑段中,整个堆仍然必须完整地加载到内存中。换言之,如果使用地址空间的方式不能很好地匹配底层分段的设计目标,分段就不能很好地工作。