内存管理(一)

地址空间

操作系统需要提供一个易用(easy to use)的物理内存抽象。这个抽象叫作地址空间(address space),是运行的程序看到的系统中的内存。

一个进程的地址空间包含运行的程序的所有内存状态。比如:程序的代码(code,指令)必须在内存中,因此它们在地址空间里。

在程序运行时,地址空间有两个区域可能增长(或者收缩)。它们就是堆(在顶部)和栈(在底部)。堆在代码(1KB)之下开始并向下增长(当用户通过malloc()请求更多内存时),栈从16KB 开始并向上增长(当用户进行程序调用时)。

虚拟化内存

虚拟内存系统负责为程序提供一个巨大的、稀疏的、私有的地址空间的假象,其中保存了程序的所有指令和数据。操作系统在专门硬件的帮助下,通过每一个虚拟内存的索引,将其转换为物理地址,物理内存根据获得的物理地址但获取所需的信息。

当我们描述地址空间时,所描述的是操作系统提供给运行程序的抽象(abstract)。程序不在物理地址0~16KB 的内存中,而是加载在任意的物理地址。

当操作系统这样做时,我们说操作系统在虚拟化内存(virtualizing memory),因为运行的程序认为它被加载到特定地址的内存中,并且具有非常大的地址空间(例如32 位或64 位)。

地址转换

在实现虚拟内存时,我们将追求类似的战略,在实现高效和控制的同时,提供期望的虚拟化。高效决定了我们要利用硬件的支持,这在开始的时候非常初级(如使用一些寄存器),但会变得相当复杂(比如我们会讲到的TLB、页表等)。控制意味着操作系统要确保应用程序只能访问它自己的内存空间。

如何实现高效的内存虚拟化?如何提供应用程序所需的灵活性?如何保持控制应用程序可访问的内存位置,从而确保应用程序的内存访问受到合理的限制?如何高效地实现这一切?

我们利用了一种通用技术,有时被称为基于硬件的地址转换(hardware-based addresstranslation),简称为地址转换(address translation)。它可以看成是受限直接执行这种一般方法的补充。利用地址转换,硬件对每次内存访问进行处理(即指令获取、数据读取或写入),将指令中的虚拟(virtual)地址转换为数据实际存储的物理(physical)地址。因此,在每次内存引用时,硬件都会进行地址转换,将应用程序的内存引用重定位到内存中实际的位置。

  1. 动态(基于硬件)重定位

    将虚拟地址转换为物理地址,这正是所谓的地址转换(address translation)技术。也就是说,硬件取得进程认为它要访问的地址,将它转换成数据实际位于的物理地址。由于这种重定位是在运行时发生的,而且我们甚至可以在进程开始运行后改变其地址空间,这种技术一般被称为动态重定位。

    具体来说,每个CPU 需要两个硬件寄存器:基址(base)寄存器界限(bound)寄存器,有时称为限制(limit)寄存器。这组基址和界限寄存器,让我们能够将地址空间放在物理内存的任何位置,同时又能确保进程只能访问自己的地址空间。

    该进程产生的所有内存引用,都会被处理器通过以下方式转换为物理地址:
    physical address = virtual address + base

    这种基址寄存器配合界限寄存器的硬件结构是芯片中的(每个CPU 一对)。有时我们将CPU 的这个负责地址转换的部分统称为内存管理单元(Memory Management Unit,MMU)。

  2. 硬件上的支持

    操作系统在特权模式(privileged mode,或内核模式,kernel mode),可以访问整个机器资源。应用程序在用户模式(user mode)运行,只能做有限的操作。

    硬件应该提供一些特殊的指令,用于修改基址寄存器和界限寄存器,允许操作系统在切换进程时改变它们。这些指令是特权(privileged)指令,只有在内核模式下,才能修改这些寄存器。

    最后,在用户程序尝试非法访问内存(越界访问)时,CPU必须能够产生异常(exception)。在这种情况下,CPU 应该阻止用户程序的执行,并安排操作系统的“越界”异常处理程序(exception handler)去处理。操作系统的处理程序会做出正确的响应,比如在这种情况下终止进程。

    image-20230323092748131

  3. 操作系统的支持

    第一,在进程创建时,操作系统必须采取行动,为进程的地址空间找到内存空间。当新进程创建时,操
    作系统检索这个数据结构(常被称为空闲列表,free list),为新地址空间找到位置,并将其标记为已用。

    第二,在进程终止时(正常退出,或因行为不端被强制终止),操作系统也必须做一些工作,回收它的所有内存,给其他进程或者操作系统使用。在进程终止时,操作系统会将这些内存放回到空闲列表,并根据需要清除相关的数据结构。

    第三,在上下文切换时,操作系统也必须执行一些额外的操作。每个CPU 毕竟只有一个基址寄存器和一个界限寄存器,但对于每个运行的程序,它们的值都不同,因为每个程序被加载到内存中不同的物理地址。

    第四,操作系统必须提供异常处理程序(exception handler),或要一些调用的函数,像上面提到的那样。

    image-20230323092949390

分段

如果我们将整个地址空间放入物理内存,那么栈和堆之间的空间并没有被进程使用,却依然占用了实际的物理内存。因此,简单的通过基址寄存器和界限寄存器实现的虚拟内存很浪费。另外,如果剩余物理内存无法提供连续区域来放置完整的地址空间,进程便无法运行。

为了解决这个问题,分段(segmentation)的概念应运而生。在MMU 中引入不止一个基址和界限寄存器对,而是给地址空间内的每个逻辑段(segment)一对。

分段的机制使得操作系统能够将不同的段放到不同的物理内存区域,从而避免了虚拟地址空间中的未使用部分占用物理内存。
image-20230324092723779

共享问题

为了支持共享,需要一些额外的硬件支持,这就是保护位(protection bit)。基本为每个段增加了几个位,标识程序是否能够读写该段,或执行其中的代码。通过将代码段标记为只读,同样的代码可以被多个进程共享,而不用担心破坏隔离。虽然每个进程都认为自己独占这块内存,但操作系统秘密地共享了内存,进程不能修改这些内存,所以假象得以保持。

细粒度与粗粒度的分段

到目前为止,我们的例子大多针对只有很少的几个段的系统(即代码、栈、堆)。我们可以认为这种分段是粗粒度的(coarse-grained),因为它将地址空间分成较大的、粗粒度的块。但是,一些早期系统(如Multics[CV65, DD68])更灵活,允许将地址空间划分为大量较小的段,这被称为细粒度(fine-grained)分段。

支持许多段需要进一步的硬件支持,并在内存中保存某种段表(segment table)。这种段表通常支持创建非常多的段,因此系统使用段的方式,可以比之前讨论的方式更灵活。

出现的问题

  1. 第一个是老问题:操作系统在上下文切换时应该做什么?

    解答:各个段寄存器中的内容必须保存和恢复。显然,每个进程都有自己独立的虚拟地址空间,操作系统必须在进程运行前,确保这些寄存器被正确地赋值。

  2. 第二个问题是,物理内存很快充满了许多空闲空间的小洞,因而很难分配给新的段,或扩大已有的段。这种问题被称为外部碎片。

    解决方案:一种更简单的做法是利用空闲列表管理算法,试图保留大的内存块用于分配。

空闲空间管理

如果要管理的空闲空间由大小不同的单元构成,管理就变得困难(而且有趣)。这种情况出现在用户级的内存分配库(如malloc()和free()),或者操作系统用分段(segmentation)的方式实现虚拟内存。在这两种情况下,出现了外部碎片(external fragmentation)的问题:

  • 空闲空间被分割成不同大小的小块,成为碎片,后续的请求可能失败,因为没有一块足够大的连续空闲空间,即使这时总的空闲空间超出了请求的大小。

底层机制

分割与合并

  • 如果请求的空间大小小于某块空闲块。这种情况下,分配程序会执行所谓的分割(splitting)动作:它找到一块可以满足请求的空闲空间,将其分割,第一块返回给用户,第二块留在空闲列表中。

  • 在归还一块空闲内存时,仔细查看要归还的内存块的地址以及邻它的空闲空间块。如果新归还的空间与一个原有空闲块相邻(或两个,就像这个例子),就将它们合并为一个较大的空闲块。

追踪已分配空间的大小

你可能注意到,free(void *ptr)接口没有块大小的参数。对于给定的指针,内存分配库可以很快确定要释放空间的大小,从而将它放回空闲列表。要完成这个任务,大多数分配程序都会在头块(header)中保存一点额外的信息,它在内存中,通常就在返回的内存块之前。

获得头块的指针后,库可以很容易地确定幻数是否符合预期的值,作为正常性检查,并简单计算要释放的空间大小(即头块的大小加区域长度)

让堆增长

操作系统在执行sbrk 系统调用时,会找到空闲的物理内存页,将它们映射到请求进程的地址空间中去,并返回新的堆的末尾地址。

基本策略

最优匹配

最优匹配(best fit)策略非常简单:首先遍历整个空闲列表,找到和请求大小一样或更大的空闲块,然后返回这组候选者中最小的一块。这就是所谓的最优匹配(也可以称为最小匹配)。只需要遍历一次空闲列表,就足以找到正确的块并返回。

最差匹配

最差匹配(worst fit)方法与最优匹配相反,它尝试找最大的空闲块,分割并满足用户需求后,将剩余的块(很大)加入空闲列表。最差匹配尝试在空闲列表中保留较大的块,而不是向最优匹配那样可能剩下很多难以利用的小块。

首次匹配

首次匹配(first fit)策略就是找到第一个足够大的块,将请求的空间返回给用户。同样,剩余的空闲空间留给后续请求。

下次匹配

不同于首次匹配每次都从列表的开始查找,下次匹配(next fit)算法多维护一个指针,指向上一次查找结束的位置。

其他方式:

分离空闲列表

一直以来有一种很有趣的方式叫作分离空闲列表(segregated list)。基本想法很简单:如果某个应用程序经常申请一种(或几种)大小的内存空间,那就用一个独立的列表,只管理这样大小的对象。其他大小的请求都一给更通用的内存分配程序。

伙伴系统

因为合并对分配程序很关键,所以人们设计了一些方法,让合并变得简单,一个好例子就是二分伙伴分配程序(binary buddy allocator)。

在这种系统中,空闲空间首先从概念上被看成大小为2N 的大空间。当有一个内存分配请求时,空闲空间被递归地一分为二,直到刚好可以满足请求的大小(再一分为二就无法满足)。这时,请求的块被返回给用户。