内存管理中的覆盖

2025年4月29日 | 阅读 6 分钟

在计算机早期,对程序员施加的主要限制之一是计算机内存的大小。如果程序大于可用内存,则无法加载,这严重限制了程序的大小。固定分区的主要问题在于,进程的大小必须受到分区最大尺寸的限制,这意味着一个进程永远无法跨越另一个进程。

显而易见的解决方案是增加可用内存量,但这会显著增加计算机系统的成本。为了解决这个问题,早期人们使用了一种称为覆盖(Overlays)的解决方案。

覆盖的概念是,当一个进程运行时,它不会同时使用完整的程序。它只会使用其中的一部分。然后覆盖的概念是,你需要的任何部分,就加载它,一旦该部分完成,就卸载它,这意味着将其拉回并获取你所需的新部分并运行它。

正式来说,“将一部分程序代码或其他数据传输到内部内存的过程,替换已存储的内容”。

有时,与最大分区的尺寸相比,程序的大小甚至更大。在这种情况下,你应该使用覆盖。

Overlays in Memory Management

因此,覆盖是一种在程序大于物理内存大小时运行该程序的技术,其方法是在任何给定时间只保留所需的指令和数据。将程序划分为模块,以便并非所有模块都需要同时驻留在内存中。在内存管理中,覆盖按以下步骤工作,例如:

  1. 程序员将程序划分为许多逻辑部分。
  2. 程序的一小部分必须始终保留在内存中,但其余部分(或覆盖)仅在需要时加载。
  3. 覆盖的使用使程序员能够编写比物理内存大得多的程序,尽管内存使用取决于程序员而不是操作系统。

覆盖示例

覆盖的最佳例子是汇编器。考虑汇编器有两个传递(passes),两个传递意味着在任何时候它只做一件事,要么是第一个传递,要么是第二个传递。这意味着它会先完成第一个传递,然后再完成第二个传递。假设可用主内存大小为 150KB,总代码大小为 200KB。

由于总代码大小为 200KB,主内存大小为 150KB,不可能同时使用两个传递。因此,在这种情况下,我们应该使用覆盖技术。

  • 根据覆盖的概念,只会使用一个传递,并且两个传递都始终需要符号表和公共例程。
  • 如果覆盖驱动程序为 10KB,则需要最小分区大小是多少?
  • 第一个传递所需的总内存 = (70KB + 30KB + 20KB + 10KB) = 130KB。
  • 第二个传递所需的总内存 = (80KB + 30KB + 20KB + 10KB) = 140KB。
  • 因此,如果我们有一个最小 140KB 的分区大小,我们可以非常轻松地运行此代码。

覆盖驱动程序(Overlays driver): 用户负责处理覆盖。操作系统不提供任何支持。这意味着用户甚至应该编写第一个传递中需要哪些部分的代码,一旦第一个传递完成,用户就应该编写代码来移除第一个传递并加载第二个传递。用户在此过程中承担的责任称为覆盖驱动程序。覆盖驱动程序将帮助我们移出和移入代码的各个部分。

覆盖的使用

构建覆盖程序涉及手动将程序划分为称为覆盖(overlays)的自包含的目标代码块,并以树形结构布局。兄弟(Sibling)段,即同一深度级别的段,共享相同的内存,称为覆盖目标区域(destination regions)。覆盖管理器,无论是操作系统的一部分还是覆盖程序的一部分,在需要时都将所需的覆盖从外部内存加载到其目标区域。链接器通常支持覆盖。

例如,程序的覆盖树如下所示:

Overlays in Memory Management

使用覆盖的概念,我们不必将整个程序都放在主内存中。我们只需要在那个时刻需要的部分。我们需要的要么是 Root-A-D 部分,要么是 Root-A-E 部分,要么是 Root-B-F 部分,要么是 Root-C-G 部分。

Root+A+D = 2KB + 4KB + 6KB = 12KB

Root+A+E = 2KB + 4KB + 8KB = 14KB

Root+B+F = 2KB + 6KB + 2KB = 10KB

Root+C+G = 2KB + 8KB + 4KB = 14KB

因此,如果我们有一个 14KB 的分区大小,我们就可以运行其中任何一个。

覆盖如何工作

假设你有一台计算机,其指令地址空间只有 64KB,但它有比其他人可以访问的更多的内存,例如特殊指令、段寄存器或内存管理硬件。假设你想让一个大于 64KB 的程序在这套系统上运行。

一种解决方案是识别程序中相对独立且无需直接相互调用的模块(覆盖)。将覆盖与主程序分开,并将它们的机器代码存放在更大的内存中。将主程序放在指令内存中,但至少留出足够的空间来容纳最大的覆盖。

要调用位于覆盖中的函数,你必须首先将该覆盖的机器代码从大内存复制到指令内存中为其预留的空间,然后跳转到那里的入口点。

Overlays in Memory Management

上图显示了一个具有独立数据和指令地址空间的系统。程序将其代码从较大的地址空间复制到指令地址空间以映射覆盖。由于此处显示的覆盖都使用相同的映射地址,因此一次只能映射一个。

加载到指令内存中并准备使用的覆盖称为映射(mapped)覆盖。它的映射地址(mapped address)是它在指令内存中的地址。不存在(或仅部分存在)于指令内存中的覆盖称为未映射(unmapped)覆盖,它的加载地址(load address)是它在较大内存中的地址。映射地址也称为虚拟内存地址(virtual memory address,VMA),加载地址也称为加载内存地址(load memory address,LMA)

不幸的是,覆盖并不是一种完全透明的程序适应有限指令内存的方法。它们引入了一套新的全局约束:

  • 在调用或返回到覆盖中的函数之前,你的程序必须确保该覆盖已被映射。否则,调用或返回会将控制转移到正确的地址,但转移到了错误的覆盖,你的程序很可能会崩溃。
  • 如果映射覆盖的过程在你的系统中开销很大,你将需要仔细选择你的覆盖,以最大程度地减少它们对程序性能的影响。
  • 你加载到系统上的可执行文件必须包含每个覆盖的指令,这些指令出现在覆盖的加载地址,而不是其映射地址。但是,每个覆盖的指令都必须经过重定位,并且其符号定义要假定覆盖位于其映射地址。你可以使用 GNU 链接器脚本来为程序的各个部分指定不同的加载和重定位地址。
  • 将可执行文件加载到你的系统上的过程必须将它们的内容加载到较大的地址空间以及指令和数据空间中。

覆盖的优点

内存管理中的覆盖具有以下优点,例如:

  • 减少内存需求。
  • 减少时间需求。

覆盖的缺点

覆盖也有一些缺点,例如:

  • 程序员必须指定覆盖映射。
  • 程序员必须了解内存需求。
  • 重叠的模块必须完全不相交。
  • 覆盖结构的编程设计复杂,并非在所有情况下都可行。