CSAPP学习笔记 -- 第九章 虚拟内存(下)

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

9.7.1 Core i7地址翻译
 
9.7.2 Linux虚拟内存系统
  • Linux虚拟内存区域
    • Linux将虚拟内存组织成一些区域(也叫做段)的集合。一个区域(area)就是已经存在着的(已分配的)虚拟内存的连续片(chunk),这些页是以某种方式相关联的。
    • 不属于某个区域的虚拟页是不存在的,并且不能被进程引用。
  • Linux缺页异常处理
    • 虚拟地址A是合法的吗?换句话说,A在某个区域结构定义的区域内吗?为了回答这个问题,缺页处理程序搜索区域结构的链表,把A和每个区域结构中的vm_start和vm end做比较。如果这个指令是不合法的,那么缺页处理程序就触发一个段错误,从而终止这个进程。这个情况在图9-28中标识为“1”。因为一个进程可以创建任意数量的新虚拟内存区域(使用在下一节中描述的mmap函数),所以顺序搜索区域结构的链表花销可能会很大。因此在实际中,Linux使用某些我们没有显示出来的字段,Linux在链表中构建了一棵树,并在这棵树上进行查找。
    • 试图进行的内存访问是否合法?换句话说,进程是否有读、写或者执行这个区域内页面的权限?例如,这个缺页是不是由一条试图对这个代码段里的只读页面进行写操作的存储指令造成的?这个缺页是不是因为一个运行在用户模式中的进程试图从内核虚拟内存中读取字造成的?如果试图进行的访问是不合法的,那么缺页处理程序会触发一个保护异常,从而终止这个进程。这种情况在图9-28中标识为“2”。
    • 此刻,内核知道了这个缺页是由于对合法的虚拟地址进行合法的操作造成的。它是这样来处理这个缺页的:选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU重新启动引起缺页的指令,这条指令将再次发送A到MMU。这次,MMU就能正常地翻译A,而不会再产生缺页中断了。
 
 
9.8 内存映射

Linux通过将一个虚拟内存区域与一个磁盘上的对象关联起来,以初始化这个虚拟内存区域的内容,这个过程称为内存映射。
虚拟内存区域可以映射到两种类型的对象中的一种
  • Linux文件系统中的普通文件
  • 匿名文件
 
9.8.1 再看共享对象
其中两个进程将一个私有对象映射到它们虚拟内存的不同区域,但是共享这个对象同一个物理副本。对于每个映射私有对象的进程,相应私有区域的页表条目都被标记为只读,并且区域结构被标记为私有的写时复制。只要没有进程试图写它自己的私有区域,它们就可以继续共享物理内存中对象的一个单独副本。然而,只要有一个进程试图写私有区域内的某个页面,那么这个写操作就会触发一个保护故障。
当故障处理程序注意到保护异常是由于进程试图写私有的写时复制区域中的一个页面而引起的,它就会在物理内存中创建这个页面的一个新副本,更新页表条目指向这个新的副本,然后恢复这个页面的可写权限,如图9-30b所示。
 
9.8.2 再看fork函数
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm  struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
 
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
 
9.8.3 再看execve函数
  • 删除已存在的用户区域。
    • 删除当前进程虚拟地址的用户部分中的已存在的区域结构。
  • 映射私有区域。
    • 为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为a.out文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在a.out中。栈和堆区域也是请求二进制零的,初始长度为零。图9-31概括了私有区域的不同映射。
  • 映射共享区域。
    • 如果a.out程序与共享对象(或目标)链接,比如标准C库1ibc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
  • 设置程序计数器(PC)
    • execve做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
 
9.8.4 使用mmap函数的用户级内存映射
 
 
9.9 动态内存分配

动态内存分配器维护着一个进程的虚拟内存区域,称为堆,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址)。
 
分配器有两种基本风格。两种风格都要求应用显式地分配块。它们的不同之处在于由哪个实体来负责释放已分配的块。
  • 显式分配器(explicit allocator),要求应用显式地释放任何已分配的块。例如,C标准库提供一种叫做malloc程序包的显式分配器。C程序通过调用malloc函数来分配一个块,并通过调用free函数来释放一个块。C++中的new和delete操作符与C中的malloc和free相当。
  • 隐式分配器(implicit allocator),另一方面,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器,而自动释放未使用的已分配的块的过程叫做垃圾收集。例如,诸如Lisp、ML以及Java之类的高级语言就依赖垃圾收集来释放已分配的块。
 
9.9.1 malloc和free函数
  • malloc不初始化它返回的内存。
  • 那些想要已初始化的动态内存的应用程序可以使用calloc,calloc是一个基于malloc的瘦包装函数,它将分配的内存初始化为零。
  • 想要改变一个以前已分配块的大小,可以使用realloc函数。
 
9.9.2 为什么要使用动态内存分配
程序使用动态内存分配的最重要的原因是经常直到程序实际运行时,才知道某些数据结构的大小。
 
9.9.3 分配器的要求和目标
分配器必须在一些相当严格的约束条件下工作
  • 处理任意请求序列
  • 立即响应请求
  • 只使用堆
  • 对齐块
  • 不修改已分配的块
 
目标
  • 最大化吞吐率
  • 最大化内存利用率
 
9.9.4 碎片
内部碎片是在一个已分配块比有效载荷大时发生的。
外部碎片是当空闲内存合计起来足够满足一个分配请求,但是没有一个单独的空闲块足够大可以来处理这个请求时发生的。
 
9.9.5 实现问题
  • 空闲块组织
  • 放置
  • 分割
  • 合并
 
9.9.6 隐式空闲链表
隐式空闲链表的优点是简单。显著的缺点是任何操作的开销,例如放置分配的块,要求对空闲链表进行搜索,该搜索所需时间与堆中已分配块和空闲块的总数呈线性关系。
 
9.9.7 放置已分配的块
首次适配从头开始搜索空闲链表,选择第一个合适的空闲块。
下一次适配和首次适配很相似,只不过不是从链表的起始处开始每次搜索,而是从上一次查询结束的地方开始。
最佳适配检查每个空闲块,选择适合所需请求大小的最小空闲块。
 
9.9.8 分割空闲块
如果匹配不太好,那么分配器通常会选择将这个空闲块分割为两部分。第一部分变成分配块,而剩下的变成一个新的空闲块。
 
9.9.9 获得额外的堆内存
如果空闲块已经最大程度地合并了,那么分配器就会通过调用sbrk函数,向内核请求额外的堆内存。分配器将额外的内存转化成一个大的空闲块,将这个块插入到空闲链表中,然后将被请求的块放置在这个新的空闲块中。
 
9.9.10 合并空闲块
当分配器释放一个已分配块时,可能有其他空闲块与这个新释放的空闲块相邻。这些邻接的空闲块可能引起一种现象,叫做假碎片(fault fragmentation),就是有许多可用的空闲块被切割成为小的、无法使用的空闲块。
为了解决假碎片问题,任何实际的分配器都必须合并相邻的空闲块,这个过程称为合并。
  • 立即合并(immediate coalescing),也就是在每次一个块被释放时,就合并所有的相邻块
  • 推迟合并(deferred coalescing),也就是等到某个稍晚的时候再合并空闲块
 
9.9.11 带边界标记的合并
边界标记(boundary tag),允许在常数时间内进行对前面块的合并。这种思想,是在每个块的结尾处添加一个脚部(footer,边界标记),其中脚部就是头部的一个副本。
边界标记的优化方法:在已分配块中不再需要脚部。
 
9.9.12 综合:实现一个简单的分配器
 
9.9.13 显式空闲链表
将空闲块组织为某种形式的显式数据结构。
使用双向链表。
空闲链表块的排序策略
  • 用后进先出(LIFO)的顺序维护链表,将新释放的块放置在链表的开始处。在这种情况下,释放一个块可以在常数时间内完成。如果使用了边界标记,那么合并也可以在常数时间内完成。
  • 按照地址顺序来维护链表,其中链表中每个块的地址都小于它后继的地址。在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。
 
一般而言,显式链表的缺点是空闲块必须足够大,以包含所有需要的指针,以及头部和可能的脚部。这就导致了更大的最小块大小,也潜在地提高了内部碎片的程度。
 
9.9.14分离的空闲链表
一种流行的减少分配时间的方法,通常称为分离存储(segregatedstorage),就是维护多个空闲链表,其中每个链表中的块有大致相等的大小。
一般的思路是将所有可能的块大小分成一些等价类,也叫做大小类(sizeclass)。
  • 简单分离存储
    • 每个大小类的空闲链表包含大小相等的块,每个块的大小就是这个大小类中最大元素的大小。
    • 为了分配一个给定大小的块,我们检查相应的空闲链表。如果链表非空,我们简单地分配其中第一块的全部。空闲块是不会分割以满足分配请求的。如果链表为空,分配器就向操作系统请求一个固定大小的额外内存片(通常是页大小的整数倍),将这个片分成大小相等的块,并将这些块链接起来形成新的空闲链表。要释放一个块,分配器只要简单地将这个块插入到相应的空闲链表的前部。
    • 优点
      • 分配和释放块都是很快的常数时间操作
      • 已分配块不需要头部,也不需要脚部
    • 缺点
      • 简单分离存储很容易造成内部和外部碎片
  • 分离适配
    • 分配器维护着一个空闲链表的数组。每个空闲链表是和一个大小类相关联的,并且被组织成某种类型的显式或隐式链表。每个链表包含潜在的大小不同的块,这些块的大小是大小类的成员。
    • 为了分配一个块,必须确定请求的大小类,并且对适当的空闲链表做首次适配,查找一个合适的块。如果找到了一个,那么就(可选地)分割它,并将剩余的部分插入到适当的空闲链表中。如果找不到合适的块,那么就搜索下一个更大的大小类的空闲链表。
    • 如果空闲链表中没有合适的块,那么就向操作系统请求额外的堆内存,从这个新的堆内存中分配出一个块,将剩余部分放置在适当的大小类中。
    • 要释放一个块,我们执行合并,并将结果放置到相应的空闲链表中。
    • 对分离空闲链表的简单的首次适配搜索,其内存利用率近似于对整个堆的最佳适配搜索的内存利用率
  • 伙伴系统
    • 伙伴系统(buddy system)是分离适配的一种特例,其中每个大小类都是2的幂。
    • 伙伴系统分配器的主要优点是它的快速搜索和快速合并。主要缺点是要求块大小为2的幂可能导致显著的内部碎片。因此,伙伴系统分配器不适合通用目的的工作负载。
 
 
9.10 垃圾收集

每个堆节点对应于堆中的一个已分配块。
根节点对应于这样一种不在堆中的位置,它们中包含指向堆中的指针。这些位置可以是寄存器、栈里的变量,或者是虚拟内存中读写数据区域内的全局变量。
当存在一条从任意根节点出发并到达p的有向路径时,我们说节点p是可达的(reachable)。在任何时刻,不可达节点对应于垃圾,是不能被应用再次使用的。垃圾收集器的角色是维护可达图的某种表示,并通过释放不可达节点且将它们返回给空闲链表,来定期地回收它们。
 
保守的垃圾收集器:每个可达块都被正确地标记为可达了,而一些不可达节点却可能被错误地标记为可达。
 
 
9.11 C程序中常见的与内存有关的错误⭐

  • 间接引用坏指针
  • 读未初始化的内存
  • 允许栈缓冲区溢出
  • 假设指针和他们指向的对象是相同大小的
  • 造成错位错误
  • 引用指针,而不是它所指向的对象
  • 误解指针运算
  • 引用不存在的变量
  • 引用空闲堆块中的数据
  • 引起内存泄漏
posted @ 2020-10-22 20:28  Yoke_cc  阅读(202)  评论(0编辑  收藏  举报