背景
CPU所能直接访问的存储器只有内存和处理器内的寄存器。机器指令可以用内存地址作为参数,而不能用磁盘地制作参数,因此,执行指令以及指令使用的数据必须在这些直接可访问的存储设备上。如果数据不在内存中,那么在CPU使用前必须先把数据移到内存中。对于寄存器中的内容,绝大多数CPU可以在一个时钟周期内解析并执行一个或多个指令。而对于内存(其访问通过内存总线上的事务进行),就不行了。完成内存访问可能需要多个CPU时钟周期,由于没有数据以便完成正在执行的指令,CPU通常需要暂停(stall)。由于内存访问频繁,这种情况是难以忍受的。解决方法是在CPU与内存之间,增加快速内存。这种协调速度差异的内存缓存区,称为高速缓存(cache)。
除了保证访问物理内存的相对速度之外,还要确保操作系统不被用户进程所访问,以及确保用户进程不被其他用户进程访问。这种保护可通过硬件来实现,其中一种可能的方案是采用基地址寄存器和界限地址寄存器的硬件地址保护。基址寄存器(base register)含有最小的合法物理内存地址,而界限寄存器(limit register)决定了范围的大小。内存空间保护的实现,是通过CPU硬件对用户模式所产生的每一个地址与寄存器的地址进行比较来完成的。如用户模式下执行的程序试图访问操作系统内存或其他用户内存,则会陷入到操作系统,并作为致命错误处理。只有操作系统可以通过特殊的特权指令来加载基地址寄存器和界限寄存器。由于特权指令只可在内核模式下执行,而只有操作系统在内核模式下执行,所以只有操作系统可以加载基地址寄存器和界限地址寄存器。这种方案只允许操作系统修改这两个寄存器的值,而不允许用户程序修改它们。
地址绑定,通常,程序以二进制可执行文件的形式存储在磁盘上。为了执行,程序被调入内存并放在进程空间内。根据所使用的内存管理方案,进程在执行时可以在磁盘和内存之间移动。在磁盘上等待调入内存以便执行的进程形成了输入队列。通常的步骤是从输入队列中选取一个进程并装入内存。进程在执行时,会访问内存中的指令和数据。最后,进程终止,其地址空间将被释放。在绝大多数情况下,用户程序在执行前,需要经过好几个步骤,其中有的是可选的。在这些步骤中,地址可能有不同的表示形式。源程序中的地址通常是用符号来表示的(如count),编译器通常将这些符号地址绑定在可重定位的地址(如“从本模块开始的14个字节”),链接程序或加载程序再将这些可重定位的地址绑定成绝对地址。每次绑定都是从一个地址空间到另一个地址空间的映射。
通常,将指令与数据绑定到内存地址可以在以下步骤的任何一部中执行:
编译时(compile time)。如果在编译时就知道进程将在内存中的驻留地址,那么就可以生成绝对代码(absolute code)。MS-DOS的.COM格式程序就是在编译时绑定成绝对代码。如果将来开始地址发生变化,那么就必须重新编译代码。
加载时(load time)。如果在编译时并不知道进程将驻留在内存的什么地方,那么编译器就必须生成可重定位代码(relocatable code)。对于这种情况,最后绑定会延迟到加载时才进行。如果开始地址发生变化,只需重新加载用户代码以引入改变值。
执行时(execution time)。如果进程在执行时可以从一个内存段移到另一个内存段,那么绑定必须延迟到执行时才进行。采用这种方案需要特定的硬件,绝大多数通用计算机操作系统采用这种方法。
逻辑地址空间与物理地址空间
CPU所生成的地址通常称为逻辑地址(logical address),而内存单元所拥有的地址(即加载到内存地址寄存器(memory-address register)中的地址)通常称为物理地址(physical address)。
编译和加载时的地址绑定方法生成相同逻辑地址和物理地址。但是,执行时的地址绑定方案导致不同的逻辑地址和物理地址。对于这种情况,通常称逻辑地址为虚拟地址(virtual address)。以后讨论中,对逻辑地址和虚拟地址不加区分,由程序所生成的所有逻辑地址的集合称为逻辑地址空间(logical address space),与这些逻辑地址相对应的所有物理地址的集合称为物理地址空间(physical address space)训醒是从虚拟地址到物理地址的映射是由被称为内存管理单元(memory-management unit,MMU)的硬件设备来完成的。
动态加载
上述讨论中,一个进程的整个程序和数据必须处于物理内存中,以便执行。因此进程的大小受物理内存大小的限制。为了获得更好的内存空间实用率,可以使用动态加载(dynamic loading)。采用动态加载时,一个子程序只有在调用时才被加载,所有子程序都以可重定位的形式保存在磁盘上。这程序装入内存并执行,当一个子程序需要调用另一个子程序时,调用子程序首先检查另一个子程序是否已加载,并更新程序的地址表以反映这一变化,接着,控制传递给新加载的子程序。
动态加载的优点是不用的子程序绝不会被加载。如果大多数代码需要用来处理异常情况,如错误处理,那么这种方法特别有用。
动态链接与共享库
动态链接的概念与动态加载想死。只是这里不是将加载延迟到运行时,而是将链接延迟到运行时。这一特点通常用于系统库,如语言子程序。没有这一点,系统上的所有程序都需要一个语言库的副本(或至少那些引用语言库的程序),这样浪费了磁盘空间和内存空间。
如果有动态链接,二进制镜像中对每个库程序的引用都有一个存根(stub)。存根是一小段代码,用来指出如何定位适当的内存驻留库程序,或如果该程序不再内存时应如何装入库。当执行存根时,它首先检查所需子程序是否已在内存中。如果不在,就将子程序装入内存。不管如何,存根会用子程序地址来替换自己,并开始执行子程序。因此,下次再执行该子程序代码时,就可以直接进行,而不会因动态链接产生任何开销。采用这种方案,使用语言库的所有进程只需要一个库代码副本就可以了。
为了不是程序错用新的、不兼容版本的库,程序和库会包括版本信息。多个版本的库可以都装入内存,程序将通过版本信息来确定使用哪个库副本。不太重要的改动保持了同样的版本号,而重要改动增加版本号。因此,只有用新库编译的程序才会受新库的不兼容变化影响。在新程序装入之前所链接的其他程序可以继续使用老库。这种系统也称为共享库(shared library)。
与动态加载不同,动态链接通常需要操作系统的帮助。如果内存中近程是彼此保护的,那么只有操作系统才可以检查所需子程序是否在其他进程内存空间内,或是允许多个进程访问同一内存地址。
连续内存分配
内存必须容纳操作系统和各种用户进程,因此应该尽可能有效地分配内存的各个部分。一种常用的方法就是连续内存分配。内存通常分为两个区域:一个用于驻留操作系统,另一个用于用户进程。操作系统可以放在低内存,也可放在高内存。影响这一决定的主要因素是中断向量的位置。由于中断向量通常位于低内存,因此程序员通常将操作系统也放在低内存。对于用户进程,采用连续内存分配(contiguous memory allocation)时,每个进程位于一个连续的内存区域。
最为简单的内存分配方法之一就是将内存分为多个固定大小的分区(partition)。每个分区只能容纳一进程,因此,多道程序的程度会受分区数所限制。如果使用这种多分区方法(multiple-partition method),当一个分区空闲时,可以从输入队列中选择一个进程,已调入到空闲分区。当进程终止时,其分区可以被其他进程所使用。这种方式主要用于批处理环境。
在可变分区(variable-partition)方案里,操作系统有一个表,用于记录那些内存可用和那些内存已被占用。一开始,所有内存都可用于用户进程,因此可以作为一大块可用内存,称为孔(hole)。一开始,所有内存都可用于用户进程,因此可以作为一大块可用内存,称为孔(hole)。当有新进程需要内存时,为该进程查找足够大的孔,如果找到,可以从该孔为该进程分配所需的内存,孔内未分配的内存可以下次再用。在任意时候,有一组可用孔(块)大小列表和输入队列。操作系统根据调度算法来对输入队列进行排序。内存不断地分配给进程,直到下一个进程的内存需求不能满足为止,这时没有足够大的可用孔来装入进程。操作系统可以等到有足够大的空间,或者往下扫描输入队列以确定是否有其他内存需求较小的进程可以被满足。
通常,一组不同大小的孔分散在内存中。当新进程需要内存时,系统为该进程查找足够大的孔。如果孔太大,那么就分为两块:一块分配给新进程,另一块还回到孔集合。当进程终止时,它将释放其内存,该内存将还给孔集合。如果新孔与其他孔相邻,那么将这些孔合并成大孔。这时,系统可以检查是否有进程在等待内存空间,新合并的内存空间是否满足等待进程。
这种方式是通用动态存储分配问题(dynamic storage allocation problem)的一种情况(根据一组空闲孔来分配大小为n的请求)。最为常用的方法有首次适应(first-fit)、最佳适应(best-fit)、最差适应(worst-fit)。首次适应,分配第一个足够大的孔;最佳适应,分配最小的足够大的孔;最差适应,分配最大的孔。模拟结果显式首次适应和最佳适应方法在执行时间和利用空间方面都好于最差适应;首次适应和最佳适应方法在利用空间方面难分伯仲,但是首次适应方法更快些。
首次适应和最佳适应内存分配算法都有外部碎片问题(external fragmentation)。随着进城装入和移出内存,空闲内存空间被分为小片段。当所有总的可用内存之和可以满足请求但并不连续时,这就出现了外部碎片问题。根据内存空间总的大小和平均进程大小的不同,外部碎片或许次要或许重要。
内存碎片可以是内部的,也可以是外部的设想有一个18464B大小的孔,并采用多分区分配方案,假如有一个进程需要18462B。如果只准确分配所要求的块,那么还剩下一个2B的孔。维护这一小孔的开销要比孔本身大很多。因此,通常将内存以固定大小的块为单元(而不是字节)来分配。采用这种方案,进程所分配的内存可能比所需要的要大。这两个数字之差称为内部碎片(internal fragmentation),这部分内存存在分区内,但又不能使用。
一种解决外部碎片问题的方法是紧缩(compaction)。紧缩的目的是移动内存内容,以便所有空闲空间合并成一整块。但紧缩并非总是可能的,紧缩仅在重定位是动态并在运行时才可采用。如果地址可动态重定位,可以首先移动程序和数据,然后再根据新基地制的值来改变基地址寄存器。最简单的合并算法是简单地将所有进程移到内存的一端,而将所有的孔移到内存的另一端以生成一个大的空闲块。这种方案开销较大。
另一种可能解决外部碎片问题的方法是允许物理地址空间为非连续,这样只要有物理内存就可为进城分配。这种方案有两种互补的实现技术:分页和分段。这两种技术也可合并。