在学习本节时,请读者思考以下问题:
- 为什么要进行内存管理?
- 多级页表解决了什么问题?又会带来什么问题?
在学习经典的内存管理方法之前,也建议读者先尝试独立思考,提出自己的内存管理设想,并在后续学习中与经典方案进行比较。本节内容循序渐进,后一种方法通常旨在克服前一种方法的不足。希望读者勤于思考,深入比较各类方法的异同,并着重掌握页式管理。
3.1.1 内存管理的基本原理和要求
内存管理是操作系统中最核心、也最复杂的机制之一。尽管现代计算机的主存容量持续增长,但仍无法同时容纳所有用户进程及其所需的程序与数据。因此,操作系统必须对有限的物理内存进行合理划分,并实施高效的动态分配策略。所谓内存管理(Memory Management),正是指操作系统对内存空间的组织、分配、回收以及地址映射等一系列操作。
在多道程序环境下,有效的内存管理尤为重要。它不仅提升了内存的利用率,简化了用户对存储资源的使用,还能在逻辑上扩展内存空间。具体而言,内存管理的主要功能包括:
内存空间的分配与回收。操作系统需实时跟踪内存使用状态,记录空闲区域,为新创建的进程分配所需空间,并在进程终止后及时回收其所占用的内存。地址转换。用户程序使用的是逻辑地址(也称虚拟地址),而实际访问内存时必须使用物理地址。操作系统需提供机制,将逻辑地址动态转换为对应的物理地址。内存空间的扩充。通过虚拟存储技术,使进程能够使用远大于物理内存容量的地址空间。内存共享。允许多个进程安全地访问同一块物理内存区域,常用于共享库或进程间通信。存储保护。确保各进程只能访问自身被授权的内存区域,防止相互干扰或非法破坏。
上述功能的实现依赖于内存管理的基本原理,其中逻辑地址与物理地址的区别尤为关键。此处仅作初步介绍,详细机制将在后续结合具体管理方法深入展开。
1. 程序的链接与装入
创建进程时,首先需要将程序和数据装入内存。将用户源程序转换为可在内存中执行的程序,通常需经过以下三个步骤:
编译。由编译程序将用户源代码翻译成若干目标模块。链接。由链接程序将这些目标模块及其所需的库函数合并,形成一个完整的装入模块。装入(加载)。由装入程序将该装入模块载入内存,并启动其执行。
2. 逻辑地址与物理地址
编译后的每个目标模块通常从0号单元开始编址,这种地址称为该模块的相对地址(或逻辑地址)。链接程序将多个目标模块合并为一个完整的可执行程序时,依次将各模块的相对地址拼接成一个统一的、从0开始编址的逻辑地址空间(或虚拟地址空间)。以32位系统为例,该逻辑地址空间的范围为 。进程运行时所使用的地址均为逻辑地址;用户程序和程序员只需关注逻辑地址,而底层的内存管理机制对其完全透明。不同进程可以拥有相同的逻辑地址,因为它们各自拥有独立的逻辑地址空间,这些地址会被映射到主存的不同位置。
物理地址空间是指主存中所有物理存储单元的集合,它是地址转换的最终目标。无论进程执行指令还是访问数据,最终都必须通过物理地址从主存中读取或写入信息。在早期系统中,程序装入内存时需将逻辑地址转换为物理地址,这一过程称为地址重定位;而在现代支持虚拟内存的系统中,该转换由内存管理单元(MMU)在硬件层面于运行时自动完成。
具体而言,现代操作系统为每个进程维护一张页表,用于记录逻辑页到物理页框的映射关系。当进程访问某个逻辑地址时,MMU会依据当前进程的页表,将其动态转换为相应的物理地址。整个过程对用户程序完全透明,构成了虚拟内存系统的基础。
3. 程序的链接
链接程序的作用是将编译生成的目标模块及其所需的库函数合并,形成一个完整的装入模块,以便后续装入内存并执行。根据链接发生的时机不同,可分为以下三种方式。
(1) 静态链接
在程序运行之前,将各目标模块及其所需的库函数链接成一个完整的装入模块(可执行文件),此后不再拆分,这种链接方式称为静态链接。链接过程中需完成两项关键操作。
地址调整。各目标模块在编译后均以0为起始地址,链接时需根据其在装入模块中的实际位置,将内部地址统一加上相应的偏移量。例如,若模块A的长度为 ,且链接后模块B紧邻在模块A之后存放,则原本从0开始编址的模块B在合并后的装入模块中将从地址 处开始,其内部所有地址均需加上偏移量 。外部符号解析。将模块间引用的外部符号(如函数名)替换为确定的地址。若模块A中包含语句CALL B,其中B为指向模块B入口的外部调用符号,则链接程序会将其解析为模块B在装入模块中的起始地址 ,并将CALL B中的符号B替换为地址 。
(2) 装入时动态链接
装入时动态链接是指:将用户源程序编译后生成的一组目标模块,在装入内存时采用边装入边链接的方式。具体而言,当装入某个目标模块时,若遇到对外部模块的调用,则装入程序会立即查找并装入相应的外部目标模块,并替换其中的外部符号。其优点如下。
便于修改和更新。静态链接需将所有目标模块预先装配成一个完整的装入模块,若其中任一模块需要修改或更新,则必须重新生成整个装入模块。若采用装入时动态链接方式,则各目标模块独立存放,只需替换被修改的模块,无须重建整个程序。便于实现对目标模块的共享。在静态链接方式下,每个应用程序都包含其所依赖目标模块的完整副本,无法实现共享。而在装入时动态链接方式下,操作系统可将同一个目标模块链接到多个应用程序中,从而实现多个程序对该目标模块的共享。
(3) 运行时动态链接
运行时动态链接是对装入时动态链接的改进,在程序执行过程中,仅当需要调用某个尚未装入内存的目标模块时,操作系统才动态加载并链接该模块。具体而言,系统会查找该模块,将其装入内存,并完成符号解析,使其可被当前进程调用;而程序运行中未使用的模块,则既不会被装入内存,也不会进行链接。其优点是既能加快程序的装入过程,又可节省内存空间。
4. 程序的装入
将一个装入模块载入内存时,主要有以下三种方式。
(1) 绝对装入
绝对装入仅适用于单道程序环境。由于内存中始终只有一个程序,其驻留位置在编译前即可确定,因此编译程序可以直接生成使用绝对地址的目标代码;装入程序只需按这些地址将程序和数据载入内存。此时,程序中的逻辑地址与实际物理地址完全一致,无须任何修改。
程序中通常使用的是符号地址(如变量名、函数名),由编译器或汇编器在编译或汇编阶段将其转换为绝对地址;当然,绝对地址也可由程序员直接指定,但这种方式缺乏灵活性。
(2) 可重定位装入
在多道程序环境下,编译程序无法预知目标模块在内存中的具体位置,因此经编译和链接生成的装入模块通常从地址0开始编址,程序中的地址均为相对于模块起始位置的逻辑地址。此时应采用可重定位装入方式,它可根据内存的具体情况,将装入模块装入内存的适当位置。
装入时,系统根据内存当前的空闲情况,为该模块分配一块连续的内存区域,并将整个模块载入其中。随后,一次性将所有逻辑地址修改为对应的物理地址。这一地址转换过程称为重定位。由于转换在装入时完成,且在程序运行期间不再改变,故称静态重定位。
进程必须在装入前一次性获得其所需的全部内存空间;若内存不足,则无法装入。一旦装入,进程在整个运行期间既不能移动位置,也不能动态申请额外内存,限制了内存管理的灵活性。
(3) 动态运行时装入
为克服静态重定位的局限,支持程序在内存中的移动,需采用动态重定位方式。装入程序将装入模块载入内存后,并不立即转换地址,而是将地址转换推迟到指令实际执行时进行。为此,系统借助一个重定位寄存器,用于存放该模块在内存中的起始物理地址。程序中保留的仍是逻辑地址,而实际物理地址则由硬件在运行时动态计算得出。
动态重定位的优点:支持程序在内存中移动,便于操作系统进行紧凑,从而有效减少外部碎片;提高内存分配的灵活性,允许在运行期间根据需要动态调整进程的内存布局。
5. 内存保护
为确保各进程拥有独立的内存空间,内存保护机制需在内存分配前防止用户进程破坏操作系统,同时避免用户进程相互干扰。常用方法主要有以下两种。
- 在CPU中设置一对
上、下限寄存器,分别存放用户进程在内存中的下限和上限地址,每当CPU访问一个地址时,硬件自动将其与这两个寄存器的值进行比较,以判断是否越界。 - 采用
重定位寄存器(也称基地址寄存器)和界地址寄存器(也称限长寄存器)实现越界检查。重定位寄存器存放进程在内存中的起始物理地址,界地址寄存器存放进程地址空间的长度。内存管理部件首先将逻辑地址与界地址寄存器的值进行比较,若未越界,则将逻辑地址加上重定位寄存器的值,得到物理地址,并据此访存。
重定位寄存器与界地址寄存器的作用不同:重定位寄存器用于“加”,即将逻辑地址加上其值,得到物理地址;界地址寄存器用于“比”,即将逻辑地址与其值比较,判断是否越界。
重定位寄存器与界地址寄存器的加载必须通过特权指令完成,仅操作系统内核具备此权限。这一机制可确保寄存器内容只能由内核修改,用户程序无法篡改,从而有效保障内存的安全。
6. 内存共享
并非所有进程的内存空间都适合共享,只有只读区域才可以被共享。可重入代码(也称纯代码)是一种允许多个进程同时访问但不允许被任何进程修改的代码。在实际执行时,每个进程需配备独立的私有数据区,用于存放运行过程中可能修改的数据;程序仅对私有数据区进行写操作,而共享的代码段始终保持不变。例如,考虑一个可同时容纳40个用户的多用户系统,所有用户同时运行同一个文本编辑程序。该程序包含160KB的代码区和40KB的数据区。若不共享代码,则系统共需 的内存;若代码为可共享代码,则无论采用分页系统还是分段系统,整个系统只需保留一份副本,此时所需内存仅为 。
在分页系统中,假设页面大小为4KB,则代码区占用40个页面,数据区占用10个页面。为实现代码共享,每个进程的页表中需设置40个页表项,均指向同一组共享代码页的物理页框;此外,每个进程还需为其私有数据区建立10个页表项,指向各自的数据页。
在分段系统中,由于以段为单位进行管理,只需为共享代码段设置一个段表项,记录共享代码段的起始地址和段长(160KB)。每个进程的段表中包含对该共享段的引用。由此可见,分页与分段均可有效支持内存共享,区别在于共享的粒度和管理机制。
此外,在第2章中介绍过基于共享内存的进程通信机制,由操作系统提供同步与互斥支持。本章后续还将介绍另一种内存共享的实现方式——内存映射文件。
7. 内存分配与回收
操作系统的演进持续推动内存管理的发展。随着系统从单道向多道程序发展,单一连续分配逐渐被固定分区分配所取代。然而,固定分区难以适应作业大小的动态变化,因此又被动态分区分配所替代。为进一步提升内存利用率,连续分配方式最终让位于离散分配方式(如页式存储管理)。此外,为满足用户在编程和使用上的更高需求(如支持程序的逻辑结构、实现段级共享与保护等),分段存储管理应运而生,而这些功能恰是其他分配方式难以有效支持的。
3.1.2 连续分配管理方式
连续分配方式是指为一个用户程序分配一个连续的内存空间。例如,若某用户程序需要100MB的内存,则系统便会在内存中为其分配一块连续的100MB区域。
连续分配方式主要包括单一连续分配、固定分区分配和动态分区分配。
1. 单一连续分配
在单一连续分配方式中,内存被划分为系统区和用户区。系统区仅供操作系统使用,通常位于低地址部分;用户区则仅允许一道用户程序运行,即该程序独占整个用户区。
这种方式的优点是结构简单、无外部碎片,且无须内存保护机制,因为内存中始终只有一道程序。缺点是仅适用于单用户、单任务的操作系统,存在内部碎片,且内存利用率极低。
2. 固定分区分配
固定分区分配是最简单的一种多道程序存储管理方式。它将用户内存空间划分为若干大小固定的分区,每个分区仅能容纳一道作业。当有空闲分区时,系统可从外存的后备作业队列中选择一个合适大小的作业装入该分区,并反复执行这一过程。在划分分区时,有两种方法:
分区大小相等。程序过小会造成空间浪费,过大则无法装入,缺乏灵活性。分区大小不等。划分为多个较小分区、适量中等分区和少量大分区,以提高适应性。
为便于内存的分配与回收,系统维护一张分区使用表,通常按分区大小排序。各表项包含对应分区的始址、大小及状态(是否已分配)。分配时,系统检索该表,寻找一个满足作业大小要求且尚未分配的分区,将其分配给待装入程序,并将对应表项的状态置为“已分配”:若找不到合适分区,则拒绝分配。回收时,只需将对应表项的状态置为“未分配”即可。
该方式存在两个问题:
- 若作业大于所有分区,则无法装入;
- 若作业小于分区大小,则仍需占用整个分区,造成内部未被利用的空间,即
内部碎片。
固定分区方式虽无外部碎片,但因每个分区仅能由一个作业独占,无法支持多个进程共享同一内存区域,故内存利用率较低。
3. 动态分区分配
(1) 动态分区分配的基本原理
动态分区分配也称可变分区分配,是指在进程装入内存时,按其实际需求动态分配一块大小恰好匹配的连续内存空间,因此系统中分区的数量和大小随进程的装入与释放而动态变化。
系统拥有64MB内存空间,其中低8MB固定分配给操作系统,其余为用户可用区域。初始时装入前三个进程后,仅剩4MB空闲,不足以容纳进程4。为腾出空间,操作系统换出进程2,并换入较小的进程4;因其所需内存更少,释放出一个6MB的空闲块。随后,当需要重新换入进程2时,因剩余空间仍不足,操作系统又换出进程1,再换入进程2。
动态分区分配在初期运行良好,但随着进程频繁装入与释放,内存中会逐渐积累大量分散的小空闲块,导致可用内存总量虽足,却难以满足较大进程的需求,内存利用率随之下降。这些散布在已分配分区之间、无法利用的小空闲块称为外部碎片,与固定分区中因分区内部未用完而产生的内部碎片形成鲜明对比。外部碎片可通过紧凑技术缓解:操作系统周期性地移动进程,将所有空闲块合并为一个连续区域;然而,该操作要求系统支持动态重定位,且开销较大。其原理类似于 Windows 系统中的磁盘碎片整理,但后者作用于外存文件系统,而前者作用于主存。
在动态分区分配中,系统维护一张空闲分区链(表),通常按起始地址排序。分配内存时,系统检索该链,寻找一个满足请求大小的空闲分区;若其尺寸大于所需,则从中分割出请求大小的空间分配给进程,剩余部分(如果不小于最小分配单位)仍保留在空闲分区链中。回收内存时,系统根据回收分区的起始地址,在空闲分区链中确定插入位置,并依据相邻情况执行合并操作,具体可分为四种情形:
回收区与前一空闲分区相邻,两者合并,更新前一分区的大小;回收区与后一空闲分区相邻,两者合并,更新后一分区的起始地址和大小;回收区与前后两个空闲分区均相邻,三者合并,更新前一分区的大小,并删除后一分区的表项;回收区不与任何空闲分区相邻,为其新建一个表项,记录起始地址和大小,并插入空闲分区链。
上述三种连续分配方式有一个共同特点:用户程序在主存中均以连续方式存放。
(2) 基于顺序搜索的分配算法
将作业装入主存时,需依据特定的分配算法从空闲分区链(表)中选出一个大小合适的分区分配给该作业。根据分区检索方式的不同,动态分区分配算法可分为顺序分配算法和索引分配算法。其中,顺序分配算法通过依次遍历空闲分区链,查找第一个满足作业大小要求的分区。
常见的顺序分配算法有以下四种。
- 首次适应(First Fit)算法。空闲分区按地址递增次序排列。分配时从链首开始顺序查找,找到第一个满足请求的空闲分区,从中划出所需空间分配给作业,余下部分仍留在链中。该算法优先利用低地址分区,从而保留高地址的大空闲区,有利于后续大作业装入;但低地址容易积累小碎片,且每次分配均需从链首开始查找,增加了查找开销。
- 邻近适应(Next Fit)算法。也称
循环首次适应算法,由首次适应算法改进而来。不同之处在于:分配时从上次查找结束的位置开始继续循环搜索,而非每次都从链首开始。该算法减少了对低地址区域的重复扫描,但往往导致大空闲区在内存中分布不均,难以有效支持后续大作业的装入,因此其整体性能通常不如首次适应算法。 - 最佳适应(Best Fit)算法。空闲分区按容量递增次序排列。分配时,顺序查找第一个能满足作业大小的空闲分区(最小的可用分区)进行分配。尽管名为“最佳”,该算法虽能尽量保留较大的空闲分区,但每次分配都会在原分区中留下极小的剩余块。随着时间推移,这些小块难以被利用,产生大量外部碎片,实际性能通常较差。
- 最坏适应(Worst Fit)算法。空闲分区按容量递减次序排列。分配时,选择第一个满足要求的空闲分区(最大的可用分区),从中分割出所需空间。与最佳适应算法相反,该算法试图通过避免生成小碎片来提升内存利用率,但由于总是切割最大的空闲区,系统很快难以满足大作业的内存需求,因此性能同样不佳。
综合来看,首次适应算法在实现开销、碎片控制与大作业支持之间取得了较好的平衡:其查找过程简单高效,回收分区时无须对空闲链重新排序,且能有效保留高地址的大空闲区。
(3) 基于索引搜索的分配算法
当系统规模较大时,空闲分区链可能很长,采用顺序搜索算法效率较低。为此,大中型系统常采用基于索引的分配算法,索引分配算法的思想是:根据空闲分区的大小进行分类,对每一类大小相同的空闲分区建立独立的空闲分区链,并通过一张索引表统一管理这些链。分配时,依据请求大小在索引表中定位对应链表,获取其头指针,从而快速取得一个空闲分区。
常见的索引分配算法有以下三种。
- 快速适应(Quick Fit)算法。根据系统中进程常用的空间大小,预先将空闲分区划分为若干固定尺寸的类别。分配过程分为两步:
- 根据作业长度,在索引表中找到能容纳它的最小类别链表;
- 从该链表头部取下第一个空闲分区进行分配。优点是查找效率高、不会产生内存碎片;缺点是回收时难以有效合并分区,算法比较复杂,开销较大。
- 伙伴系统(Buddy System)。规定所有分区大小均为 ( 为正整数)。当需要为进程分配大小为 的内存时(满足 ),首先在大小为 的空闲分区链中查找。若存在,则直接分配;否则,依次在大小为 、……的链中查找,直至找到一个可用分区。随后,将该分区不断二分,直至得到所需大小的块,其余部分按大小加入对应的空闲链。在伙伴系统中,每个大小为 的分区都有一个唯一的
伙伴分区:与它大小相同、地址相邻(首尾相接)的另一个分区。回收时,若某空闲分区与其伙伴也为空闲,则合并为一个大小为 的分区,并继续向上尝试合并,直至无法合并为止。 - 哈希算法。以空闲分区大小为关键字,建立哈希函数,构建一张哈希表,每个表项记录对应空闲分区链的头指针。分配时,根据所需分区大小,通过哈希函数计算出其在哈希表中的位置,从而快速获取相应的空闲分区链。
在连续分配方式中,即使系统拥有超过1GB的空闲内存,只要其中不存在连续的1GB空间,需要该大小内存的作业便无法装入运行,这正是外部碎片导致的问题。为了克服这一限制,操作系统引入了非连续分配方式:将进程的地址空间划分为多个单元,并允许这些单元分散地装入内存中互不相邻的区域,从而彻底摆脱对外部连续空间的依赖。该机制能有效利用零散的空闲内存,显著提升内存利用率;但与此同时,系统必须额外维护各逻辑单元与其物理位置之间的映射关系,因而带来了一定的存储开销。根据所划分单元的大小是否固定,非连续分配方式可分为分页存储管理与分段存储管理。进一步,若作业运行前需要一次性装入全部单元,则称为基本分页或基本分段;若支持按需动态装入,则相应地称为请求分页与请求分段。
