计算机系统的主要目的是执行程序。在执行程序及其访问数据应该至少有部分在内存里。
为了提高CPU的利用率和响应用户的速度,通用计算机在内存里必须保留多个进程。
为了实现性能的改进, 应将多个进程保存在内存中;也就是说,必须共享内存。
内存管理算法有很多:从原始的裸机方法,到分页和分段的方法,每种方法都有各自的优点和缺点。
为特定系统选择内存管理方法取决于很多因素,特别是系统的硬件设计。
1.1 背景
内存由一个很大的字节数组来组成,每个字节都有各自的地址(即内存地址)。
CPU根据程序计数器的值从内存中提取指令,这些指令可能引起对特定内存地址的额外加载与存储。
1.1.1 基本硬件
首先需要确保每个进程都有一个单独的内存空间。单独的进程内存空间可以保护进程而不相互影响。为了分开内存空间,需要能够确定一个进程可以访问的合法地址的范围,且确保该进程只能访问这个合法地址。
通过两个寄存器,通常为基地址和界限地址。基地址寄存器含有最小的合法的物理内存地址,而界限地址寄存器指定了范围的大小。
基地址寄存器和界限地址寄存器定义逻辑地址空间如下图所示。
内存空间保护的实现是通过CPU硬件对在用户模式下产生的地址与寄存器的地址进行比较来完成的。当在用户模式下执行的程序试图访问操作系统内存或其他用户内存时,会陷入操作系统,而操作系统则将它作为致命错误来处理:
这种方案防止用户程序无意或故意修改操作系统或其他用户的代码或数据结构。只有操作系统可以通过特殊的特权指令,才能加载基地址寄存器和界限地址寄存器。
由于特权指令只能在内核模式下执行,而只有操作系统才能在内核模式下执行,所以只有操作系统可以加载基地址寄存器和界限地址寄存器。这种方案允许操作系统修改这两个寄存器的值,而不允许用户程序修改它们。
在内核模式下执行的操作系统可以无限制地访问操作系统及用户的内存。这项规定允许操作系统:加载用户程序到用户内存,转储出现错误的程序,访问和修改系统调用的参数,执行用户内存的I/O,以及提供许多其他服务等。
例如,多任务系统的操作系统在进行上下文切换时,应将一个进程的寄存器的状态存到内存,再从内存中调入下个进程的上下文到寄存器。
1.1.2 地址绑定
通常,程序作为二进制的可执行文件,存放在磁盘上。为了执行,程序应被调入内存,并放在进程中。根据采用的内存管理,进程在执行时可以在磁盘和内存之间移动。在磁盘上等待调到内存以便执行的进程形成了输入队列(input queue) 。
正常的单任务处理过程是:从输入队列中选取一个进程并加载到内存;进程在执行时,会访问内存的指令和数据;最后,进程终止时,它的内存空间将会释放。
大多数系统允许用户进程放在物理内存中的任意位置。因此,虽然计算机的地址空间从00000开始,但用户进程的开始地址不必也是00000。
链接程序或加载程序再将这些可重定位的地址绑定到绝对地址。每次绑定都是从一个地址空间到另一个地址空间的映射。
通常,指令和数据绑定到存储器地址可在沿途的任何一步中进行:
- 编译时(compile time) :如果在编译时就已知道进程将在内存中的驻留地址,那么就可以生成绝对代码(absolute code) 。例如,如果事先就知道用户进程驻留在内存地址R处,那么生成的编译代码就可以从该位置开始并向后延伸。如果将来开始地址发生变化,那么就有必要重新编译代码。
- 加载时(load time) :如果在编译时并不知道进程将驻留在何处, 那么编译器就应生成可重定位代码(relocatable code) 。对这种情况, 最后绑定会延迟到加载时才进行。如果开始地址发生变化,那么只需重新加载用户代码以合并更改的值。
- 执行时(runtime time) :如果进程在执行时可以从一个内存段移到另一个内存段, 那么绑定应延迟到执行时才进行。采用这种方案需要特定硬件才行。大多数的通用计算机操作系统采用这种方法。
1.1.3 逻辑地址空间与物理地址空间
CPU生成的地址通常称为逻辑地址,而内存单元看到的地址(即加载到内存地址寄存器的地址)通常称为物理地址。编译时和加载时的地址绑定方法生成相同的逻辑地址和物理地址。
然而,执行时的地址绑定方案生成不同的逻辑地址和物理地址。在这种情况下,我们通常称逻辑地址为虚拟地址(virtual address) 。程序所生成的所有逻辑地址的集合称为逻辑地址空间(logical address space) , 这些逻辑地址对应的所有物理地址的集合称为物理地址空间(physical address space) 。因此, 对于执行时地址绑定方案, 逻辑地址空间与物理地址空间是不同的。
从虚拟地址到物理地址的运行时映射是由内存管理单元(Memory-Management Unit,MMU) 的硬件设备来完成。
1.1.4 动态加载
在迄今为止的讨论中,一个进程的整个程序和所有数据都应在物理内存中,以便执行。因此,进程的大小受限于内存的大小。为了获得更好的内存空间利用率,可以使用动态加载(dynamic loading) 。采用动态加载时, 一个程序只有在调用时才会加载。所有程序都以可重定位加载格式保存在磁盘上。主程序被加载到内存,并执行。当一个程序需要调用另一个程序时,调用程序首先检查另一个程序是否已加载。如果没有,可重定位链接程序会加载所需的程序到内存,并更新程序的地址表以反映这一变化。接着,控制传递给新加载的程序。
动态加载的优点是,只有一个程序被需要时,它才会被加载。这种情况下,虽然整个程序可能很大,但是所用的部分可能很小。
1.2 交换
进程必须在内存中以便执行。不过,进程可以暂时从内存交换(swap) 到备份存储(backing store) , 当再次执行时再调回到内存中。交换有可能让所有进程的总的物理地址空间超过真实系统的物理地址空间,从而增加了系统的多道程序程度。
1.3 连续内存分配
内存应容纳操作系统和各种用户进程,因此应该尽可能有效地分配内存。一种早期方法:连续内存分配。
内存通常分为两个区域:一个用于驻留操作系统,另一个用于用户进程。操作系统可以放在低内存,也可放在高内存。影响这一决定的主要因素是中断向量的位置。由于中断向量通常位于低内存,因此程序员通常将操作系统也放在低内存。
通常,我们需要将多个进程同时放在内存中。因此我们需要考虑,如何为输入队列中需要调入内存的进程分配内存空间。在采用连续内存分配时,每个进程位于一个连续的内存区域,与包含下一个进程的内存相连。
1.3.1内存保护
防止进程访问不属于它的内存。
1.3.2 内存分配
最为简单的内存分配方法之一,就是将内存分为多个固定大小的分区(partition) 。每个分区可以只包含一个进程。因此, 多道程序的程度受限于分区数。
如果使用这种多分区方法,那么当一个分区空闲时, 可以从输入队列中选择一个进程,以调入空闲分区。当该进程终止时,它的分区可以用于其他进程。这种方法现在已不再使用。
下面所描述的方法是固定分区方案的推广(称为MVT) , 它主要用于批处理环境。
可变分区: 操作系统有一个表,用于记录哪些内存可用和哪些内存已用。
所有内存都可以用于用户进程,因此可以作为一大块的可用内存,称为孔(hole)。内存有一个集合,以包含各种大小的孔。
从一组可用孔中选择一个空闲孔的最常用方法包括:首次适应(first-bit)、最优适应(best-bit)及最差适应(worst-fit)。
- 首次适应: 分配首个足够大的孔。查找可以从头开始,也可以从上次首次适应结束时开始。一旦找到足够大的空闲孔,就可以停止。
- 最优适应: 分配最小的足够大的孔。应查找整个列表,除非列表按大小排序。这种方法可以产生最小剩余孔。
- 最差适应: 分配最大的孔。同样,应该查找整个列表,除非列表按大小排序。这种方法可以产生最大剩余孔。
模拟结果显示,首次适应和最优适应在执行时间和利用空间方面都好于最差适应。首次适应和最优适应在利用空间方面难分伯仲,但是首次适应要更快些。
1.3.3 内存碎片
用于内存分配的首次适应和最优适应算法都有外部碎片(external fragmentation) 的问题。
随着进程加载到内存和从内存退出,空闲内存空间被分为小的片段。当总的可用内存之和可以满足请求但并不连续时,这就出现了外部碎片问题:存储被分成了大量的小孔。
这个问题可能很严重。在最坏情况下,每两个进程之间就有空闲(或浪费的)块。如果这些内存是一整块,那么可能可以再运行多个进程。
选择首次适应或者最优适应,可能会影响碎片的数量。(对一些系统来说,首次适应更好;对另一些系统,最优适应更好)。
另一因素是从空闲块的哪端开始分配。(哪个是剩余的块,是上面的还是下面的?)不管使用哪种算法,外部碎片始终是个问题。
内存碎片可以是内部的,也可以是外部的。假设有一个18464字节大小的孔,并采用多分区分配方案。假设有一个进程需要18462字节。如果只能分配所要求的块,那么还剩下一个2字节的孔。维护这一小孔的开销要比孔本身大很多。
因此,通常按固定大小的块为单位(而不是字节)来分配内存。采用这种方案,进程所分配的内存可能比所需的要大。这两个数字之差称为内部碎片(internal fragmentation) , 这部分内存在分区内部, 但又不能用。
外部碎片问题的一种解决方法是紧缩(compaction) 。它的目的是移动内存内容, 以便将所有空闲空间合并成一整块。然而,紧缩并非总是可能的。如果重定位是静态的,并且在汇编时或加载时进行的,那么就不能紧缩。只有重定位是动态的,并且在运行时进行的,才可采用紧缩。如果地址被动态重定位,可以首先移动程序和数据,然后再根据新基地址的值来改变基地址寄存器。如果能采用紧缩,那么还要评估开销。最简单的合并算法是简单地将所有进程移到内存的一端,而将所有的孔移到内存的另一端,从而生成一个大的空闲块。这种方案比较昂贵。
外部碎片化问题的另一个可能的解决方案是:允许进程的逻辑地址空间是不连续的;这样,只要有物理内存可用,就允许为进程分配内存。
有两种互补的技术可以实现这个解决方案:分段和分页,这两个技术也可以组合起来。
1.4 分段
分段(segmentation) 就是支持这种用户视图的内存管理方案。逻辑地址空间是由一组段构成。每个段都有名称和长度。地址指定了段名称和段内偏移。因此用户通过两个量来指定地址:段名称和段偏移。
为了实现简单起见,段是编号的,是通过段号而不是段名称来引用。因此,逻辑地址由有序对(two tuple) 组成:
<段号,偏移>
1.5 分页
分段允许进程的物理地址空间是非连续的。分页是提供这种优势的另一种内存管理方案。
然而,分页避免了外部碎片和紧缩,而分段不可以。分页也避免了将不同大小的内存块匹配到交换空间的麻烦问题。在分页引入之前采用的内存管理方案都有这个问题。
这个问题出现的原因是:当位于内存的代码和数据段需要换出时,应在备份存储上找到空间。备份存储也有同样的与内存相关的碎片问题,但是访问更慢,因此紧缩是不可能的。
由于比早期方法更加优越,各种形式的分页为大多数操作系统采用,包括大型机的和智能手机的操作系统。实现分页需要操作系统和计算机硬件的协作。
采用分页方案不会产生外部碎片:每个空闲帧都可以分配给需要它的进程。不过,分页有内部碎片。
分页的优点之一是可以共享公共代码。对于分时环境,这种考虑特别重要。
1.6 页表结构
组织页表的一些最常用技术,包括分层分页、哈希页表和倒置页表。
1.6.1 分层分页
大多数现代计算机系统支持大逻辑地址空间(
2
32
2^{32}
232~
2
64
2^{64}
264)。在这种情况下,页表本身可以非常大。
例如,假设具有32位逻辑地址空间的一个计算机系统。如果系统的页大小为4KB(
2
12
2^{12}
212),那么页表可以多达100万的条目(
2
32
2^{32}
232/
2
12
2^{12}
212)。假设每个条目有4字节,那么每个进程需要4MB物理地址空间来存储页表本身。显然,我们并不想在内存中连续地分配这个页表。这个问题的一个简单解决方法是将页表划分为更小的块,完成这种划分有多个方法。一种方法是使用两层分页算法,就是将页表再分页:
1.6.2 哈希页表
处理大于32位地址空间的常用方法是使用哈希页表(hashed page table) , 采用虚拟页码作为哈希值。哈希页表的每一个条目都包括一个链表,该链表的元素哈希到同一位置(该链表用来解决处理碰撞)。每个元素由三个字段组成:1)虚拟页码,2)映射的帧码,3)指向链表内下一个元素的指针。
该算法工作如下:虚拟地址的虚拟页码哈希到哈希表,用虚拟页码与链表内的第一个元素的第一个字段相比较。如果匹配,那么相应的帧码(第二个字段)就用来形成物理地址;如果不匹配,那么与链表内的后续节点的第一个字段进行比较,以查找匹配的页码。
1.6.3 倒置页表
通常,每个进程都有一个关联的页表。该进程所使用的每个页都在页表中有一项(或者每个虚拟页都有一项,不管后者是否有效)。这种表示方式比较自然,因为进程是通过虚拟地址来引用页的。操作系统应将这种引用转换成物理内存的地址。
由于页表是按虚拟地址排序的,操作系统可计算出所对应条目在页表中的位置,可以直接使用该值。这种方法的缺点之一是,每个页表可能包含数以百万计的条目。这些表可能需要大量的物理内存,以跟踪其他物理内存是如何使用的。
为了解决这个问题, 我们可以使用倒置页表(inverted page table) 。对于每个真正的内存页或帧,倒置页表才有一个条目。每个条目包含保存在真正内存位置上的页的虚拟地址以及拥有该页进程的信息。
因此,整个系统只有一个页表,并且每个物理内存的页只有一条相应的条目。下图显示了倒置页表的工作原理;由于一个倒置页表通常包含多个不同的映射物理内存的地址空间,通常要求它的每个条目保存一个地址空间标识符。地址空间标识符的保存确保了,具体进程的每个逻辑页可映射到相应的物理帧。
系统内的每个虚拟地址为一个三元组:〈进程id,页码,偏移〉。
每个倒置页表条目为二元组〈进程id,页码〉,这里进程id用来作为地址空间的标识符。当发生内存引用时,由〈进程id,页码〉组成的虚拟地址被提交到内存子系统。然后,搜索倒置页表来寻找匹配。如果找到匹配条目,如条目i,则生成物理地址〈i,偏移〉。如果找不到匹配,则为非法地址访问。
缺点: 1. 虽然减少了存储每个页表所需的内存空间,但增加了由于引用页而查找页表所需的时间。
2. 采用倒置页表的系统在实现共享内存的时候会有困难。