• 20135328陈都信息安全系统设计基础第十五周学习总结


    一、学习目标

    1. 理解虚拟存储器的概念和作用
    2. 理解地址翻译的概念
    3. 理解存储器映射
    4. 掌握动态存储器分配的方法
    5. 理解垃圾收集的概念
    6. 了解C语言中与存储器有关的错误

    第9章 虚拟存储器

    虚拟存储器是硬件异常、硬件地址翻译、主存、磁盘文件和内核软件的完美交互,它为每个进程提供了一个大的、一致的和私有的地址空间。

    虚拟存储器提供了三个重要的能力:

    1. 它将主存看成是一个存储在磁盘上的地址空间的高速缓存,在主存中只保存活动区域,并根据需要在磁盘和主存之间来回传送数据,通过这种方式,它高效地使用了主存。
    1. 它为每个进程提供了一致的地址空间,从而简化了存储器管理。
    1. 它保护了每个进程的地址空间不被其他进程破坏。

    为何需要理解虚拟存储器?

    • 虚拟存储器是中心的。虚拟存储器遍及计算机系统的所有层面,在硬件异常、汇编器、链接器、加载器、共享对象、文件和进程的设计中扮演着重要角色。理解虚拟存储器将帮助你更好地理解系统通常是如何工作的。
    • 虚拟存储器是强大的。虚拟存储器给予应用程序强大的能力,可以创建和销毁存储器片 (chunk)、将存储器片映射到磁盘文件的某个部分,以及与其他进程共享存储器。理解虚拟存储器将帮助你利用它的强大功能在你的应用程序中添加动力。
    • 虚拟存储器是危险的。每次应用程序引用一个变量、间接引用一个指针,或者调用一个诸如 malloc 这样的动态分配程序时,它就会和虚拟存储器发生交互。如果虚拟存储器使用不当,应用将遇到复杂危险的与存储器有关的错误。理解虚拟存储器以及诸如 malloc 之类的管理虚拟存储器的分配程序,可以帮助你避免这些错误。

    9.1 物理和虚拟寻址

    计算机系统的主存被组织成一个由M个连续的字节大小的单元组成的数组。每宇节都有一个唯一的物理地址 (Physical Address, PA)。第一个字节的地址为 0,接下来的字节地址为 1,再下一个为 2, CPU 访问存储器的最自然的方式就是使用物理地址。我们把这种方式称为物理寻址 (physical addressing)。

    早期的 PC 使用物理寻址,现代处理器使用的是一种称为虚拟寻址 (virtual addressing) 的寻址形式。

    一个使用物理寻址的系统:

    一个使用虚拟寻址的系统:

    使用虚拟寻址时, CPU 通过生成一个虚拟地址 (Virtual Address, VA) 来访问主存,这个虚拟地址在被送到存储器之前先转换成适当的物理地址。将一个虚拟地址转换为物理地址的任务叫做地址翻译 (address translation)。地址翻译需要 CPU 硬件和操作系统之间的紧密合作。 CPU 芯片上叫做存储器管理单元 (Memory Management Unit, MMU) 的专用硬件, 利用存放在主存中的查询表来动态翻译虚拟地址,该表的内容是由操作系统管理的。

    9.2 地址空间

    地址空间 (address space) 是一个非负整数地址的有序集合

    如果地址空间中的整数是连续的,那么我们说它是一个线性地址空间 (linear address space)。

    在一个带虚拟存储器的系统中, CPU 从一个有N=2n次方个地址的地址空间中生成虚拟地址,这个地址空间称为虚拟地址空间(virtual address space)

    一个地址空间的大小是由表示最大地址所需要的位数来描述的。
    一个系统还有一个物理地批空间 (physical address space),它与系统中物理存储器的M个字节相对应。


    9.3 虚拟存储器作为缓存的工具

    概念上而言,虚拟存储器 (VM) 被组织为一个由存放在磁盘上的 N个连续的字节大小的单元组成的数组。每字节都有一个唯一的虚拟地址,这个唯一的虚拟地址是作为到数组的索引的。磁盘上数组的内容被缓存在主存中。和存储器层次结构中其他缓存一样,磁盘〈较低层)上的数据被分割成块,这些块作为磁盘和主存(较高层〉之间的传输单元。 VM 系统通过将虚拟存储器分割为称为虚拟页(Virtual Page, VP) 的大小固定的块来处理这个问题。每个虚拟页的大小为 P=2P次方 字节。类似地,物理存储器被分割为物理页 (Physical Page, PP ) ,大小也为 P 字节(物理页也称为页帧 (page frame) )。

    在任意时刻,虚拟页面的集合都分为三个不相交的子集 :

    • 未分配的 :VM 系统还未分配(或者创建)的页。未分配的块没有任何数据和它们相关联,因此也就不占用任何磁盘空间。
    • 缓存的:当前缓存在物理存储器中的己分配页。
    • 未缓存的 : 没有缓存在物理存储器中的已分配页。

    一个 VM 系统是如何使用主存作为缓存的:


    9.3.1 DRAM 缓存的组织结构

    SRAM 缓存位于 CPU 和主存之间的 Ll、 L2 和 L3 高速缓存。
    DRAM 缓存来表示虚拟存储器系统的缓存,它在主存中缓存虚拟页。

    在存储层次结构中, DRAM 缓存的位置对它的组织结构有很大的影响。
    DRAM 缓存中的不命中比起 SRAM 缓存中的不命中要昂贵得多,因为 DRAM 缓存不命中要由磁盘来服务,而 SRAM 缓存不命中通常是由基于 DRAM 的主存来服务的。
    DRAM 缓存的组织结构完全是由巨大的不命中开销驱动的。

    大的不命中处罚和访问第一字节的开销,虚拟页往往很大,典型地是 4KB -2MB,任何虚拟页都可以放置在任何的物理页中。不命中时的替换策略也很重要,因为替换错了虚拟页的处罚也非常之高。操作系统对 DRAM 缓存使用了更复杂精密的替换算法。因为对磁盘的访问时间很长, DRAM 缓存总是使用写回,而不是直写。


    9.3.2 页表

    由许多软硬件联合提供的,包括操作系统软件、 MMU (存储器管理单元)中的 地址翻译硬件和一个存放在物理存储器中叫做页在 (page table) 的数据结构,页表将虚拟页映射到物理页。每次地址翻译硬件将一个虚拟地址转换为物理地址时都会读取页表。操作系统负责维护页表的内容,以及在磁盘与 DRAM 之间来回传送页。
    页表就是一个页在条目 (page table entry,PTE) 的数组。虚拟地址空间中的每个页在页表中一个固定偏移量处都有一个PTE。
    有效位表明了该虚拟页当前是否被缓存在 DRAM 中。如果设置了有效位,那么地址宇段就表示 DRAM 中相应的物理页的起始位置,这个物理页中缓存了该虚拟页。如果没有设置有效位,那么一个空地址表示这个虚拟页还未被分配。否则,这个地址就指向该虚拟页在磁盘上的起始位置。

     因为 DRAM 缓存是全相连的,任意物理页都可以包含任意虚拟页。


    9.3.3 页命中

    VM 页命中:

    9.3.4 缺页

    DRAM 缓存不命中称为缺页 (page fault)。

    缺页异常调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,无论哪种情况,内核都会修改 VP4 的页表条目,反映出 VP4 不再缓存在主存中这一事实。

    内核从磁盘拷贝 VP3 到存储器中的 PP 3,更新 PTE 3,随后返回。当异常处理程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到地址翻译硬件。但是现在, VP3 已经缓存在主存中了,那么页命中也能由地址翻译硬件正常处理了。

    习惯说法中,块被称为页。在磁盘和存储器之间传送页的活动叫做交换 (swapping) 或者页面调度 (paging)。页从磁盘换入(或者页面调入) DRAM 和从 DRAM 换出(或者页面调出)磁盘。一直等待,直到最后时刻,也就是当有不命中发生时,才换入页面的这种策略称为按需页面调度 (demand paging)。

    所有现代系统都使用的是按需页面调度的方式。

    VM 缺页〈之前〉:

    VM 缺页(之后〉:

    9.3.5 分配页面


    9.3.6 又是局部性救了我们

    虚拟存储器工作得相当好,这主要归功于局部性 (locality)。

    整个运行过程中程序引用的不同页面的总数可能超出物理存储器总的大小,但是局部性原则保证了在任意时刻,程序将往往在一个较小的活动页面 (active page) 集合上工作,这个集合叫做工作集 (working set) 或者常 驻集 (resident set)。

    只要我们的程序有好的时间局部性,虚拟存储器系统就能工作得相当好。

    如果工作集的大小超出了物理存储器的大小,那么程序将产生一种不幸的状态,叫做颠簸(thrashing ) ,这时页面将不断地换进换出。

    分配一个新的虚拟页面:


    9.4 虚拟存储器作为存储器管理的工具

    VM 简化了链接和加载、代码和数据共享,以及应用程序的存储器分配。

    VM 如何为进程提供独立的地址空间:

    • 简化链接。独立的地址空间允许每个进程的存储器映像使用相同的基本格式,可而不管代码和数据实际存放在物理存储器的何处。
    • 简化加载。虚拟存储器还使得容易向存储器中加载可执行文件和共享对象文件。

    一组连续的虚拟页映射到任意一个文件中的任意位置的表示法称做存储器映射 (memory mapping). Unix 提供一个称为 mmap 的系统调用,允许应用程序自己做存储器映射。

    • 简化共享。独立地址空间为操作系统提供了一个管理用户进程和操作系统自身之间共享的一致机制。一般而言,每个进程都有自己私有的代码、数据、堆以及栈区域,是不和其他进程共享的。在这种情况下,操作系统创建页表,将相应的虚拟页映射到不同的物理页面。

       在一些情况下,还是需要进程来共享代码和数据。例如,每个进程必须调用相同的操作系统内核代码,而每个 C 程序都会调用 C标准库中的程序,如 printf。操作系统通过将不同进程中适当的虚拟页面映射到相同的物理页面,从而安排多个进程共享这部分代码的一个拷贝,而不是在每个进程中都包括单独的内核和 C 标准库的拷贝。
      
    • 简化存储器分配。虚拟存储器为向用户进程提供一个简单的分配额外存储器的机制。


    9.5 虚拟存储器作为存储器保护的工具

    提供独立的地址空间使得分离不同进程的私有存储器变得容易。但是,地址翻译机制可以以一种自然的方式扩展到提供更好的访问控制。因为每次 CPU 生成一个地址时,地址翻译硬件都会读一个 PTE,所以通过在 PTE 上添加一些额外的许可位来控制对一个虚拟页面内容的访问十分简单。

    SUP 位表示进程是否必须运行在内核(超级用户)模式下才能访问该页。运行在内核模式中的进程可以访问任何页面,但是运行在用户模式中的进程只允许访问那些 SUP 为 0 的页面。 READ 位和 WRITE 位控制对页面的读和写访 问。

    用虚拟存储器来提供页面级的存储器保护:

    9.6 地址翻译

    地址翻译符号小结:

    n 位的虚拟地址包含两个部分:一 个p 位的虚拟页面偏移 (Virtual Page Offset, VPO) 和一个 (n-p) 位的虚拟页号 (Virtual Page Number, VPN). MMU 利用 VPN 来选择适当的 PTE。

    将页表条目中物理页号 (Physical Page Number, PPN) 和虚拟地址中的 VPO 串联起来,就得到相应的物理地址。注意,因为物理和虚拟页面都是P 字节的,所以物理页面偏移 (Physical Page Offset, PPO) 和 VPO 是相同的。

    使用页表的地址翻译:

    页面命中和缺页的操作视图:

    VA: 虚拟地址。 PTEA: 页表条目地址。 PTE: 页表条目。 PA: 物理地址

    9.6.1 结合高速缓存和虚拟存储器

    使用物理寻址,多个进程同时在高速缓存中有存储块和共享来自相同虚拟页面的块成为很简单的事情。而且,高速缓存无需处理保护问题,因为访问权限的检查是地址翻译过程的一部分。

    将 VM 与物理寻址的高速缓存结合起来:

    VA: 虚拟地址。 PTEA: 页表条目地址。 PTE: 页表条目。 PA: 物理地址

    9.6.2 利用 TLB 加速地址翻译

    MMU 中包括了一个关于 PTE 的小的缓存,称为翻译后备缓冲器 (Translation Lookaside Buffer, TLB)。

    TLB 是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单个 PTE 组成的块。 TLB 通常有高度的相连性。

    一个用来访问 TLB 的虚拟地址的组成部分:

    TLB 命中和不命中的操作视图:

    9.6.3 多级页表

    用来压缩页表的常用方法是使用层次结构的页表。

    一个两级页表层次结构。 注意地址是从上往下增加的:

    这种方法从两个方面减少了存储器要求。

    1. 如果一级页表中的一个 PTE 是空的,那么相应的二级页表就根本不会存在,这代表着一种巨大的潜在节约,因为对于一个典型的程序, 4GB 的虚拟地址空间的大部分都将是未分配的。
    2. 只有一级页表才需要总是在主存中:虚拟存储器系统可以在需要时创建、页面调入或调出二级页表,这就减少了主存的压力:只有最经常使用的二级页表才需要缓存在主存中。

    带多级页表的地址翻译并不比单级页表慢很多。

    9.6.4 综合: 端到端的地址翻译

    小存储器系统的寻址:

    小存储器系统的 TLB、页表以及缓存:

    • TLB。TLB 是利用 VPN 的位进行虚拟寻址的。因为 TLB 有四个组,所以 VPN 的低两位就作为组索引 (TLBI). VPN 中剩下的高 6 位作为标记 (TLBT),用来区别可能映射到同一 个 TLB 组的不同的 VPN。
    • 页表。这个页表是一个单级设计,一共有 2的8此昂=256 个页表条目 (PTE)。为了方便,我们用索引它的 VPN 来标识每个 PTE; 但是要记住这些 VPN 并不是页表的一部分,也不储存在存储器中。另外,注意每个无效 PTE 的PPN都用一个破折号来表示,以加强一个概念:无论刚好这里存储的是什么位值,都是没有任何意义的。
    • 高速缓存。直接映射的缓存是通过物理地址中的字段来寻址的。因为每个块都是 4 字节, 所以物理地址的低 2 位作为块偏移 (CO)。因为有 16 组,所以接下来的 4 位就用来表示组索引 (CI)。剩下的 6 位作为标记 (CT)。

    开始时, MMU 从虚拟地址中抽取出 VPN (OxOF),并且检查 TLB,看它是否因为前面的某个存储器引用,缓存了PTE OxOF 的一个拷贝。

    命中,然后将缓存的 PPN (OxOD) 返回给 MMU。

    如果 TLB 不命中,那么 MMU 就需要从主存中取出相应的PTE。

    接下来, MMU 发送物理地址给缓存,缓存从物理地址中抽取出缓存偏移 co (OxO)、缓存组索引 CI (Ox5) 以及缓存标记 CT (Ox0D)。

    翻译过程的其他路径也是可能的。


    9.7 案例研究: Intel Core i7/Linux 存储器系统

    处理器也 (processor package) 包括四个核、 一个大的所有核共享的 L3 高速缓存"以及一个 DDR3存储器控制器。每个核包含一个层次结构 的 TLB、一个层次结构的数据和指令高速缓存,以及一组快速的点到点连接,这种连接是基于 Intel QuickPath 技术的,是为了让一个核与其他核和外部I/O 桥直接通信。 TLB 是虚拟寻址的, 是四路组相连的。L1、 L2 和 L3 高速缓存是物理寻址的,是八路组相连的,块大小为 64 字节。 页大小在启动时被配置为 4 KB或 4 MB。 Linux 使用的是 4 KB的页。

    Core i7 存储器系统:

    9.7.1 Core i7 地址翻译

    当 MMU 翻译每一个虚拟地址时,它还会更新另外两个内核缺页处理程序会用到的位。每次访问一个页时,MMU都会设置 A 位,称为引用位 (reference bit)。内核可以用这个引用位来实现它的页替换算法。每次对一个页进行了写之后, MMU 都会设置 D 位,又称脏位 (dirty bit)。脏位告诉内核在拷贝替换页之前是否必须写回牺牲页。内核可以通过调用一条特殊的内核模式指令来清除引用位或脏位。

    Core i7 地址翻译的概况:

    第一级、第二级和第三级页表条目格式:

    第四级页表条目的格式:

    9.7.2 Linux 虚拟存储器系统

    一个虚拟存储器系统要求硬件和内核软件之间的紧密协作。

    内核虚拟存储器包含内核中的代码和数据结构。内核虚拟存储器的某些区域被映射到所有进程共享的物理页面。

    内核虚拟存储器的其他区域包含每个进程都不相同的数据。

    一个 Linux 进程的虚拟存储器:

    1. Linux 虚拟存储器区域

    Linux 将虚拟存储器组织成一些区域(也叫做段〉的集合。一个区域(area) 就是已经存在着的(已分配的)虚拟存储器的连续片(chunk),这些页是以某种方式相关联的。

    Linux 是如何组织虚拟存储器的:

    task_struct 中的一个条目指向mm_struct,它描述了虚拟存储器的当前状态。其中 pgd 指向第一级页表(页全局目录)的基址,而 mmap 指向一个 vm-area-structs(区域结构)的链表,其中每个 vm-area-structs 都描述了当前虚拟地址空间的一个区域 (area)。当内核运行这个进程时,它就将 pgd 存放在 CR3控制寄存器中。

    • vm_start: 指向这个区域的起始处。
    • vm_end: 指向这个区域的结束处。
    • vm_prot: 描述这个区域内包含的所有页的读写许可权限。
    • vm_flags :描述这个区域内的页面是与其他进程共享的,还是这个进程私有的(还描述了其他一些信息)。
    • vm_next: 指向链表中下一个区域结构。

    2. Linux 缺页异常处理

    Linux 缺页处理:

    9.8 存储器映射

    Linux (以及其他一些形式的 Unix) 通过将一个虚拟存储器区域与一个磁盘上的对象 (object) 关联起来,以初始化这个虚拟存储器区域的内容,这个过程称为存储器映射 (memory mapping)。

    1. Unix 文件系统中的普通文件:一个区域可以映射到一个普通磁盘文件的连续部分。文件区 (sectÎon) 被分成页大小的片,每一片包含一个虚拟页面的初始内容。因为按需进行页面调度,所以这些虚拟页面没有实际交换进入物理存储器,直到 CPU 第一 次引用到页面(即发射一个虚拟地址,落在地址空间这个页面的范围之内)。如果区域比文件区要大,那么就用零来填充这个区域的余下部分。
    2. 匿名文件:一个区域也可以映射到一个匿名文件,匿名文件是由内核创建的,包含的全是二进制零。 CPU 第一次引用这样一个区域内的虚拟页面时,内核就在物理存储器中找到一个 合适的牺牲页面,如果该页面被修改过,就将这个页面换出来,用二进制零覆盖牺牲页面并更新页表,将这个页面标记为是驻留在存储器中的。注意在磁盘和存储器之间并没有实际的数据传送。因为这个原因,映射到匿名文件的区域中的页面有时也叫做请求二进制零的页 (demand­ zeropage)。

    一旦一个虚拟页面被初始化了,它就在一个由内核维护的专门的交换文件 (swap file) 之间换来换去。交换文件也叫做交换空间 (swap space) 或者交换区域 (swap area)。在任何时刻,交换空间都限制着当前运行着的进程能够 分配的虚拟页面的总数。

    9.8.1 再看共享对象

    每个进程提供自己私有的虚拟地址空间,可以免受其他进程的错误读写。许多进程有同样的只读文本区域。

    一个对象可以被映射到虚拟存储器的一个区域,要么作为共享对象,要么作为私有对象。如果一个进程将一个共享对象映射到它的虚拟地址空间的一个区域内,那么这个进程对这个区域的任何写操作,对于那些也把这个共享对象映射到它们虚拟存储器的其他进程而言也是可见的。

    对一个映射到私有对象的区域做的改变,对于其他进程来说是不可见的,并且进程对这个区域所做的任何写操作都不会反映在磁盘上的对象中。一个映射到共享对象的虚拟存储器区域叫做共享区域。类似地,也有私有区域。

    一个共享对象:

    因为每个对象都有一个唯一的文件名,内核可以迅速地判定进程 1 已经映射了这个对象,而 且可以使进程 2 中的页表条目指向相应的物理页面。关键点在于即使对象被映射到了多个共享区域,物理存储器中也只需要存放共享对象的一个拷贝。

    私有对象是使用一种叫做写时拷贝 (copy-on-write) 的巧妙技术被映射到虚拟存储器中的。一个私有对象开始生命周期的方式基本上与共享对象的一样,在物理存储器中只保存有私有对象的一份拷贝。

    对于每个映射私有对象的进程,相应私有区域的页表条目都被标记为只读,并且区域结构被标记为私有的写时拷贝。只要没有进程试图写它自己的私有区域,它们就可以继续共享物理存储器中对象的一个单独拷贝。然而,只要有一个进程试图写私有区域内的某个页面,那么这个写操作就会触发一个保护故障。

    当故障处理程序注意到保护异常是由于进程试图写私有的写时拷贝区域中的一个页面而引起的,它就会在物理存储器中创建这个页面的一个新拷贝,更新页表条目指向这个新的拷贝,然后恢复这个页面的可写权限。

    一个私有的写时拷贝对象:

    9.8.2 再看 fork 函数

    当 fork 函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的 PID。为了给这个新进程创建虚拟存储器,它创建了当前进程的 mm_struct、区域结构和页表的原样拷贝。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时拷贝。

    当 fork 在新进程中返回时,新进程现在的虚拟存储器刚好和调用 fork时存在的虚拟存储器相同。当这两个进程中的任一个后来进行写操作时,写时拷贝机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。

    9.8.3 再看 execve 函数

    execve 函数在当前进程中加载并运行包含在可执行目标文件 a.out 中的程序,用 a.out 程序有效地替代了当前程序。加载并运行 a.out 需要以下几个步骤:

    • 删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
    • 映射私有区域。为新程序的文本、数据、 bss 和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时拷贝的。文本和数据区域被映射为 a.out 文件中的文本和数据区。 bss 区域是请求二进制零的,映射到匿名文件,其大小包含在 a.out 中。栈和堆区域也是请求二进制零的,初始长度为零。
    • 映射共享区域。如果 a.out 程序与共享对象(或目标)链接,比如标准 C 库 libc. so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
    • 设置程序计数器 (PC)。 execve 做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向文本区域的入口点。

    加载器是如何映射用户地址空间的区域的:

    9.8.4 使用 mmap 函数的用户级存储器映射

    Unix 进程可以使用 mmap 函数来创建新的虚拟存储器区域,并将对象映射到这些区域中。

    mmap 函数要求内核创建一个新的虚拟存储器区域,最好是从地址 start 开始的一个区域, 并将文件描述符 fd 指定的对象的一个连续的片 (chunk) 映射到这个新的区域。连续的对象片大小为 length 宇节,从距文件开始处偏移量为offset 字节的地方开始。 start 地址仅仅是一个暗示,通常被定义为 NULL。

    mmap 参数的可视化解释:

    参数 prot 包含描述新映射的虚拟存储器区域的访问权限位(在相应区域结构中的 vm_prot 位)。

    • PROT_EXEC :这个区域内的页面由可以被 CPU 执行的指令组成。
    • PROT_READ :这个区域内的页面可读。
    • PROT_WRITE:这个区域内的页面可写。
    • PROT NONE:这个区域内的页面不能被访问。

    参数 flags 由描述被映射对象类型的位组成。

    munmap 函数删除虚拟存储器的区域:

    munmap 函数删除从虚拟地址 start 开始的,由接下来 length 字节组成的区域。接下来对已删除区域的引用会导致段错误。


    9.9 动态存储器分配

    当运行时需要额外虚拟存储器时,用动态存储器分配器 (dynamic memory allocator) 更方便,也有更好的可移植性。

    动态存储器分配器维护着一个进程的虚拟存储器区域,称为堆 (heap)

    分配器将堆视为一组不同大小的块 (block) 的 集合来维护。每个块就是一个连续的虚拟存储器片 (chunk),要么是已分配的,要么是空闲的。己分配的 块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是存储器分配器自身隐式执行的。

    堆:

    分配器有两种基本风格。两种风格都要求应用显式地分配块。它们的不同之处在于由哪个实体来负责释放己分配的块。

    • 显式分配器 (explicit allocator),要求应用显式地 释放任何已分配的块。
    • 隐式分配器 (implicit allocator),另一方面,要求分配器检测一个已分配块何时不再被程序 所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器 (garbage collector),而自动释放未使用的己分配的块的过程叫做垃圾收集 (garbage collection)。

    9.9.1 malloc 和 free 函数

    调用 malloc 函数来从堆中分配块:

    malloc 函数返回一个指针,指向大小为至少 size 字节的存储器块,这个块会为可能包含在这个块内的任何数据对象类型做对齐。Unix 系统上, malloc 返回一个 8 字节 (双宇)边界对齐的块。

    如果 malloc 遇到问题,那么它就返回 NULL,并设置 errno。 malloc 不初始化它返回的存储器。那些想要已初始化的动态存储器的应用程序可以使用 calloc, calloc 是一个基于 malloc 的瘦包装函数,它将分配的存 储器初始化为零。想要改变一个以前已分配块的大小,可以使用 realloc 函数。

    动态存储器分配器,可以通过使用 mmap 和 munmap 函数,显式地分配和释 放堆存储器,或者还可以使用 sbrk 函数:

    sbrk 函数通过将内核的 brk 指针增加 incr来扩展和收缩堆。如果成功,它就返回 brk 的旧值,否则,它就返回一1,并将 errno设置为 ENOMEM。如果 incr 为零,那么 sbrk 就返回 brk 的当前值。用一个为负的 incr 来调用 sbrk 是合法的,而且很巧妙,因为返回值 (brk 的旧值)指向距新堆顶向上 abs (incr) 字节处。

    程序是通过调用 free 函数来释放己分配的堆块:

    ptr 参数必须指向一个从 malloc、 calloc 或者 realloc 获得的已分配块的起始位置。它什么都不返回, free 就不会告诉应 用出现了错误。

    9.9.2 为什么要使用动态存储器分配

    程序使用动态存储器分配的最重要的原因是经常直到程序实际运行时,它们才知道某些数据结构的大小。
    最简单的方法就是用某种硬编码的最大数组大小静态地定义这个数组。

    一种更好的方法是在运行时,在己知了 n 的值之后,动态地分配这个数组。使用这种方法,数组大小的最大值就只由可用的虚拟存储器数量来限制了。

    9.9.3 分配器的要求和目标

    显式分配器必须在一些相当严格的约束条件下工作:

    • 处理任意请求序列。一个应用可以有任意的分配请求和释放请求序列,只要满足约束条件:每个释放请求必须对应于一个当前已分配块,这个块是由一个以前的分配请求获得的。因此,分配器不可以假设分配和释放请求的顺序。
    • 立即响应请求。分配器必须立即响应分配请求。因此,不允许分配器为了提高性能重新排列或者缓冲请求。
    • 只使用堆。为了使分配器是可扩展的,分配器使用的任何非标量数据结构都必须保存在堆里。
    • 对齐块(对齐要求)。分配器必须对齐块,使得它们可以保存任何类型的数据对象。在大多数系统中,这意味着分配器返回的块是 8 字节(双字)边界对齐的。
    • 不修改已分配的块。分配器只能操作或者改变空闲块。特别是,一旦块被分配了,就不允许修改或者移动它了。压缩已分配块这样的技术是不允许使用的。

    实现吞吐率最大化和存储器使用率最大化,而这两个性能目标通常是相互冲突的。

    最有用的标准是峰佳利用率 (peak utilization)。

    分配器的目标就是在整个序列中使峰值利用率 Un-1 最大化。
    最大化吞吐率和最大化利用率之间是互相牵制的。
    以堆利用率为代价,很容易编写出吞吐率最大化的分配器。
    在两个目标之间找到一个适当的平衡。

    9.9.4 碎片

    造成堆利用率很低的主要原因是一种称为碎片 (fragmentation) 的现象,当虽然有未使用的存储器但不能用来满足分配请求时,就会发生这种现象。有两种形式的碎片: 内部碎片 (intemal fragmentation) 和外部碎片(extemal fragmentation)。

    内部碎片是在一个已分配块比有效载荷大时发生的。就是已分配块大小和它们的有效载荷大小之差的和。因此,在任意时刻,内部碎片的数量只取决于以前请求的模式和分配器的实现方式。

    外部碎片是当空闲存储器合计起来足够满足一个分配请求,但是没有一个单独的空闲块足够大可以来处理这个请求时发生的。

    外部碎片比内部碎片的量化要困难得多,因为它不仅取决于以前请求的模式和分配器的实现方式,还取决于将来请求的模式。

    因为外部碎片难以量化且不可能预测,所以分配器通常采用启发式策略来试图维持少量的大空闲块,而不是维持大量的小空闲块。

    9.9.5 实现问题

    一个实际的分配器要在吞吐率和利用率之间把握好平衡,就必须考虑以下几个问题:

    • 空闲块组织:我们如何记录空闲块?
    • 放直:我们如何选择一个合适的空闲块来放置一个新分配的块?
    • 分割:在我们将一个新分配的块放置到某个空闲块之后,我们如何处理这个空闲块中的剩余部分?
    • 合并:我们如何处理一个刚刚被释放的块?

    9.9.6 隐式空闲链表

    任何实际的分配器都需要一些数据结构,允许它来区别块边界,以及区别己分配块和空闲块。大多数分配器将这些信息嵌入在块本身。

    一个简单的堆块的格式:

    用隐式空闲链表来组织堆:

    隐式空闲链表,是因为空闲块是通过头部中的大小字段隐含地连接着的。分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块的集合。

    隐式空闲链表的优点是简单。显著的缺点是任何操作的开销,要求空闲链表的搜索与堆中已分配块和空闲块的总数呈线性关系。

    系统对齐要求和分配器对块格式的选择会对分配器上的最小块大小有强制的要求。没有已分配块或者空闲块可以比这个最小值还小。

    9.9.7 放置已分配的块

    分配器搜索空闲链表,查找一个足够大可以放置所请求块的空闲块。分配器执行这种搜索的方式是由放直策咯 (placement policy) 确定的。一些常见的策略是首次适配 (first fit)、下一次适配 (next fit) 和最佳适配 (best fit)。

    首次适配从头开始搜索空闲链表,选择第一个合适的空闲块。下一次适配和首次适配很相似,是从上一次查询结束的地方开始。最佳适配检查每个空闲块,选择适合所需请求大小的最小空闲块。

    首次适配的优点是它往往将大的空闲块保留在链表的后面。缺点是它往
    在靠近链表起始处留下小空闲块的"碎片飞这就增加了对较大块的搜索时间。

    下一次适配比首次适配运行起来明显要快一些,尤其是当链表的前面布满了许多小的碎片时。下一次适配的存储器利用率要比首次适配低得多。最佳适配比首次适配和下一次适配的存 储器利用率都要高一些。

    然而,在简单空闲链表组织结构中,使用最佳适配的缺点是它要求对堆进行彻底的搜索。

    9.9.8 分割空闲块

    一旦分配器找到一个匹配的空闲块,它就必须做另一个策略决定,那就是分配这个空闲块中多少空间。

    一个选择是用整个空闲块。简单而快捷,但是主要的缺点就是它会造成内部碎片。如果放置策略趋向于产生好的匹配,那么额外的内部碎片也是可以接受的。

    如果匹配不太好,那么分配器通常会选择将这个空闲块分割为两部分。第一部分变成分配块,而剩下的变成一个新的空闲块。

    9.9.9 获取额外的堆存储器

    如果分配器不能为请求块找到合适的空闲块,一个选择是通过合并那些在存储器中物理上相邻的空闲块来创建一些更大的空闲块。如果这样还是不能生成一个足够大的块,或者如果空闲块已经最大程度地合并了,那么分配器就会通过调用 sbrk 函数,向内核请求额外的堆存储器。

    9.9.10 合并空闲块

    当分配器释放一个已分配块时,可能有其他空闲块与这个新释放的空闲块相邻。这些邻接的空闲块可能引起一种现象,叫做假碎片(fault fragmentation),就是有许多可用的空闲块被切割 成小的、无法使用的空闲块。

    为了解决假碎片问题,任何实际的分配器都必须合并相邻的空闲块,这个过程称为合并 ( coalescing)。分配器可以选择立即合并 (immediate coalescing),也就是在每次一个块被释放时,就合并所有的相邻块。或者它也可 以选择推迟合并 (deferred coalescing),也就是等到某个稍晚的时候再合并空闲块。

    立即合并很简单明了,可以在常数时间内执行完成,但是对于某些请求模式,这种方式会产生一种形式的抖动,块会反复地合并,然后马上分割。

    9.9.11 带边界标记的合并

    想要释放的块为当前块。合并(存储器中的)下一个空闲块很简单而且高效。当前块的头部指向下一个块的头部,可以检查这个指针以判断下一个块是否是空闲的。如果是,就将它的大小简单地加到当前块头部的大小上,这两个块在常数时间内被合并。

    边界标记 (boundary tag),允许在常数时间内进行对前面块的合并。

    考虑当分配器释放当前块时所有可能存在的情况:

    1. 前面的块和后面的块都是己分配的。
    2. 前面的块是已分配的,后面的块是空闲的。
    3. 前面的块是空闲的,而后面的块是已分配的。
    4. 前面的和后面的块都是空闲的。

    使用边界标记的合并。:

    情况 1 :前面的和后面块都已分配。
    情况 2 :前面的块已分配,后面的块空闲。
    情况 3 :前面的块空闲,后面的块己分配。
    情况 4 :后面的块和前面的块都空闲
    

    边界标记的概念是简单优雅的,它对许多不同类型的分配器和空闲链表组织都是通用的,也存在一个潜在的缺陷。它要求每个块都保持一个头部和一个脚部,在应用程序操作许多个小块时,会产生显著的存储器开销。

    9.9.12 综合 : 实现一个简单的分配器

    1.一般分配器设计

    mm_init 函数初始化分配器,如果成功就返回 0,否则就返回 -1. mm_malloc 和 mm_free 函数与它们对应的系统函数有相同的接口和语义。

    隐式空闲链表的恒定形式:

    2. 操作空闲链表的基本常数和宏

    在空闲链表中操作头部和脚部可能是很麻烦的,因为它要求大量使用强制类型转换和指针运算。

    操作空闲链表的基本常数和宏:

    GET 宏(第 12 行〉读取和返回参数 p 引用的字。
    PUT 宏(第 13 行〉将 val 存放在参数 P 指向的字中。

    GET_SIZE 和 GET_ALLOC 宏(第 16 - 17 行〉从地址 p 处的头部或脚部分别返回大小和 已分配位。剩下的宏是对块指针 (blockpointer ,用 bp 表示〉的操作,块指针指向第一个有效载 荷字节。给定一个块指针bp, HDRP 和 FTRP 宏(第 20- 21 行〉分别返回指向这个块的头部和脚部的指针。 NEXT_BLKP 和 PREV_BLKP 宏(第 24 - 25行〉分别返回指向后面的块和前面的块的块指针。

    3.创建初始空闲链表

    extend_heap 函数会在两种不同的环境中被调用:1)当堆被初始化时; 2) 当 mm_malloc 不能找到一个合适的匹配块时。

    mm_init: 创建一个带初始空闲块的堆

    extend_heap: 用一个新的空闲块扩展堆

    4. 释放和合并块

    mm_free: 释放一个块,并使用边界标记合并将其与所有的邻接空闲块在常数时间内合并

    5. 分配块

    一个应用通过调用 mm_malloc 函数来向存储器请求大小为 size 字节的块。 在检查完请求的真假之后,分配器必须调整请求块的大小,从而为头部和脚部留有空间,并满足双字对齐的要求。第 12 ~ 13 行强制了最小块大小是 16 字节: 8 字节用来满足对齐要求,而另外 8 字节用来放头部和脚部。对于超过 8 字节的请求(第 15 行),一般的规则是加上开销字节, 然后向上舍入到最接近的 8 的整数倍。

    mm_malloc: 从空闲链表分配一个块

    一旦分配器调整了请求的大小,它就会搜索空闲链表,寻找一个合适的空闲块(第 18 行〉。 如果有合适的,那么分配器就放置这个请求块,并可选地分割出多余的部分(第 19 行),然后返回新分配块的地址。

    如果分配器不能够发现一个匹配的块,那么就用一个新的空闲块来扩展堆(第 24~26 行), 把请求块放置在这个新的空闲块里,可选地分割这个块(第 27 行),然后返回一个指针,指向这个新分配的块。

    9.9.13 显式空闲链表

    块分配与堆块的总数呈线性关系,所以对于通用的分配器,隐式空闲链表是不适合的(尽管对于堆块数量预先就知道是很小的特殊的分配器来说它是可以的)。

    空闲块组织为某种形式的显式数据结构。因为根据定义,程序不需要一 个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里面。

    使用双向空闲链表的堆块的格式:

    使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。不过,释放一个块的时间可以是线性的,也可能是个常数,这取决于我们所选择的空闲链表中块的排序策略。

    一种方法是用后进先出 (LIFO) 的顺序维护链表,将新释放的块放置在链表的开始处。使用 LIFO 的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块。在这种情况下,释放一个块可以在常数时间内完成。如果使用了边界标记,那么合并也可以在常数时间内完成。

    另一种方法是按照地址顺序来维护链表,其中链表中每个块的地址都小于它后继的地址。在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。平衡点在于,按照地址排序的首次适配比 LIFO 排序的首次适配有更高的存储器利用率,接近最佳适配的利用率。

    显式链表的缺点是空闲块必须足够大,以包含所有需要的指针,以及头部和可能的脚部。这就导致了更大的最小块大小,也潜在地提高了内部碎片的程度。

    9.9.14 分离的空闲链表

    减少分配时间的方法,通常称为分离存储 (segregated storage),就是维 护多个空闲链表,其中每个链表中的块有大致相等的大小。一般的思路是将所有可能的块大小分成一些等价类,也叫做大小类 (size class)。

    分离存储方法,主要的区别在于它们如何定义大小类,何时进行合并,何时向操作系统请求额外的堆存储器,是否允许分割,等等。为了使你大致 了解有哪些可能性,我们会描述两种基本的方法: 简单分离存储 (simple segregated storage) 和 分离适配 (segregated fit)。

    1. 简单分离存储

    使用简单分离存储,每个大小类的空闲链表包含大小相等的块,每个块的大小就是这个大小类中最大元素的大小。

    如果链表非空,我们简单地分配其中第一块的全部。空闲块是不会分割以满足分配请求的。如果链表为空,分配器就向操作系统请求一个固定大小的额外存储器片(典型地是页大小的整数倍),将这个片分成大小相等的块,并 将这些块链接起来形成新的空闲链表。要释放一个块,分配器只要简单地将这个块插入到相应的空闲链表的前部。

    分配和释放块都是很快的常数时间操作。而且,每个片中都是大小相等的块,不分割,不合并,这意味着每个块只有很少的存储器开销。既然每个片只有大小相同的块,那么一个已分配块的大小就可以从它的地址中推断出来。因为没有合并,所以已分配块的头部就不需要一个已分配的/空闲标记。因此已分配块不需要头部,同时因为没有合并,它们也不需要脚部。因为分配和释放操作都是在空闲链表的起始处操作,所以链表只需要是单向的,而不用是双向的。关键点在于,在任何块中都需要的唯一字段是每个空闲块中的一个字的 succ 指针,因此最小块大小就是一个字。

    一个显著的缺点是,简单分离存储很容易造成内部和外部碎片。因为空闲块是不会被分割的,所以可能会造成内部碎片。更糟的是,因为不会合并空闲块,所以某些引用模式会引起极多的外部碎片。

    2. 分离适配

    每个空闲链表是和一个大小类相关联的,并且被组织成某种类型的显式或隐式链表。每个链表包含潜在的大小不同的块,这些块的大小是大小类的成员。有许多种不同的分离适配分配器。

    为了分配一个块,我们必须确定请求的大小类,并且对适当的空闲链表做首次适配,查找一个合适的块。如果我们找到了一个,那么我们〈可选地)分割它,并将剩余的部分插入到适当的空闲链表中。如果我们找不到合适的块,那么就搜索下一个更大的大小类的空闲链表。如此重复,直到找到一个合适的块。如果空闲链表中没有合适的块,那么我们就向操作系统请求额外的 堆存储器,从这个新的堆存储器中分配出一个块,将剩余部分放置在适当的大小类中。要释放一个块,我们执行合并,并将结果放置到相应的空闲链表中。

    3. 伙伴系统

    伙伴系统 (buddy system) 是分离适配的一种特例,其中每个大小类都是 2 的幂。

    伙伴系统的一个关键事实是,给定地址和块的大小,很容易计算出它的伙伴的地址。

    一个块的地址和它的伙伴的地址只有一位不相同。

    伙伴系统分配器的主要优点是它的快速搜索和快速合并。主要缺点是要求块大小为 2 的幂可能导致显著的内部碎片。因此,伙伴系统先配器不适合通用目的的工作负载。


    9.10 垃圾收集

    应用通过调用 malloc 和 free 来分配和释放堆块。应用要负责释放所有不再需要的已分配块。

    垃圾收集器 (garbage collector) 是一种动态存储分配器,它自动释放程序不再需要的己分配 块。这些块称为垃圾 (garbage) 。

    自动回收堆存储的过程叫做垃圾收集 (garbage collection)。在一个支持垃圾收集的系统中,应用显式分配堆块,但是从不显示地释放它们。在 C 程序的上下文中,应用调用 malloc,但是从不调用 free。反之,垃圾收集器定期识别垃圾块,并相应地调用 free,将这些块放回到空闲链表中。

    9.10.1 垃圾收集器的基本知识

    垃圾收集器将存储器视为一张有向可达图 (reachability graph) 被分成一组根节点 (root node) 和一组堆节点 (heap node)。

    垃圾收集器将存储器视为一张有向图:

    当存在一条从任意根节点出发并到达p 的有向路径时,我们说节点p 是可达的 (reachable)。在任何时刻,不可达节点对应于垃圾,是不能被应用再次使用的。垃圾收集器的角色是维护可达图的某种表示,并通过释放不可达节点并将它们返回给空闲链表,来定期地回收它们。

    通常不能维持可达图的精确表示。这样的收集器也叫做保守的垃圾收集器 (conservative garbage collector)。它们是保守的,即每个可达块都被正确地标 记为可达了,而一些不可达节点却可能被错误地标记为可达。

    收集器可以按需提供它们的服务,或者它们可以作为一个和应用并行的独立线程,不断地更新可达图和回收垃圾。

    无论何时需要堆空间,应用都会用通常的方式调用 malloc。如果 malloc 找不到一个合适的空闲块,那么它就调用垃圾收集器,希望能够回收一些垃圾到空闲链表。收集器识别出垃圾块,并通过调用 free 函数将它们返回给堆。关键的思想是收集器代替应用去调用 free。当对 收集器的调用返回时, malloc 重试,试图发现一个合造的空闲块。如果还是失败了,那么它就会向操作系统要求额外的存储器。最后, malloc 返回一个指向请求块的指针(如果成功〉或者返回一个空指针(如果不成功〉。

    9.10.2 Mark&Sweep 垃圾收集器

    Mark&Sweep垃圾收集器由标记 (mark) 阶段和清除 (sweep) 阶段组成,标记阶段标记出根节点的所有可达的和已分配的后继,而后面的清除阶段释放每个未被标记的已分配块。

    ptr 定义为 typedef void*ptr.

    • ptr isPtr (ptr p) :如果 p 指向一个己分配块中的某个字,那么就返回一个指向这个 块的起始位置的指针 b。否则返回 NULL。
    • int blockMarked (ptr b) :如果已经标记了块 b,那么就返回 true。
    • int blockAllocated (ptr b) :如果块 b 是已分配的,那么就返回 true。
    • void markBlock (ptr b) :标记块 b。
    • int length (ptr b) :返回块 b 的以字为单位的长度〈不包括头部〉。
    • void unmarkBlock (ptr b) :将块b 的状态由已标记的改为未标记的.
    • ptr nextBlock (ptr b) :返回堆中块 b 的后继。

    sweep 函数在堆中每个块上反复 循环,释放它所遇到的所有未标记的已分配块(也就是垃圾〉。

    mark 和 sweep 函数的伪代码:

    9.10.3 C 程序的保守 Mark&Sweep

    第一, C 不会用任何类型信息来标记存储器位置。因此,对 isPtr 没有一种明显的方式来判断它的输入参数 p 是不是一个指针。第二,即使我们知道 p 是一个指针,对 isPtr 也没有明 显的方式来判断 p 是否指向一个已分配块的有效载荷中的某个位置。

    一棵已分配块的平衡树中的左右指针:

    平衡树方法保证会标记所有从根节点可达的节点,从这个意义上来说它是正确的。


    9.11 C 程序中常见的与存储器有关的错误

    9.11.1 间接引用坏指针

    在进程的虚拟地址空间中有较大的洞,没有映射到任何有意义的数据。如果我们试图间接引用一个指向这些洞的指针,那么操作系统就会以段异常中止 我们的程序。而且,虚拟存储器的某些区域是只读的。试图写这些区域将会以保护异常中止这个程序。

    9.11.2 读未初始化的存储器

    常见的错误就是假设堆存储器被初始化为零。

    9.11.3 允许栈缓冲区溢出

    如果一个程序不检查输入串的大小就写人梳中的目标缓冲 区,那么这个程序就会有缓冲区溢出错误 (buffer overflow bug)。

    必须使用 fgets 函数,这个函数限制了输入串的大小

    9.11.4 假设指针和它们指向的对象是相同大小的

    常见的错误是假设指向对象的指针和它们所指向的对象是相同大小的

    9.11.5 造成错位错误

    9.11.6 引用指针,而不是它所指向的对象

    不太注意 C 操作符的优先级和结合性,我们就会错误地操作指针,而不是指针所指向的对象。

    9.11.7 误解指针运算

    忘记了指针的算术操作是以它们指向的对象的大小为单位来进行的,而 这种大小单位并不一定是字节。

    9.11.8 引用不存在的变量

    9.11.9 引用空闲堆块中的故据

    引用已经被释放了的堆块中的数据

    9.11.10 引起存储器泄漏

    存储器泄漏是缓慢、隐性的杀手,当程序员不小心忘记释放已分配块,而在堆里创建了垃圾时,会发生这种问题。

    如果经常调用 leak,那么渐渐地,堆里就会充满了垃圾,在最糟糕的情况下,会占用整个虚拟地址空间。对于像守护进程和服务器这样的程序来说,存储器泄漏是特别严重的,根据定义这些程序是不会终止的。

  • 相关阅读:
    DES介绍
    jsp知识点
    浏览器地址传中文
    cookie
    null与“ ”的区别
    验证二叉查找树 · Validate Binary Search Tree
    二叉树中的最大路径和 · Binary Tree Maximum Path Sum
    最近公共祖先 · Lowest Common Ancestor
    平衡二叉树Balanced Binary Tree
    二叉树的最大/小/平衡 深度 depth of binary tree
  • 原文地址:https://www.cnblogs.com/cdcode/p/5043772.html
Copyright © 2020-2023  润新知