CSAPP-----虚拟内存

本节目录

1、物理和虚拟寻址

2、地址空间

3、虚拟内存作为缓存的工具

4、虚拟内存作为内存管理的工具

5、虚拟内存作为内存保护的工具

6、地址翻译

7、案例研究

8、内存映射

9、动态内存分配

10、垃圾收集

11、C程序中常见的与内存有关的错误

12、小结


     本系列文章的观点和图片均来自《深入理解计算机系统第3版》仅作为学习使用

       虚拟内存(VM)是对主存的抽象概念。虚拟内存提供了三个重要的能力:1)它将追村看成一个存储在磁盘上的地址空间的高速缓存,在主存中只保护活动区域,并根据需要在磁盘和主存之间来回传送数据,通过这种方式它高效的使用了主存。2)它为每个进程提供一致的地址空间,从而简化内存管理。3)他保护每个进程的地址空间不被其他进程破坏。

        虚拟内存是核心的,虚拟内存编辑计算机系统的所有层面,在硬件异常、汇编器、连接器、加载器、共享对象、文件、进程的设计中扮演重要角色。虚拟内存是强大的,虚拟内存给予应用程序强大的能力,可以创建和销毁内存片,将内存片映射到磁盘文件的某个部分,以及与其他进程共享内存。虚拟内存是危险的,每次应用程序引用一个变量、间接引用一个指针、或者调用一个诸如malloc这样的动态分配程序时,它会与虚拟内存发生交互,如果虚拟内存使用不当,应用将遇到复杂危险的与内存有关的错误。

        这一章从两个角度来看虚拟内存,前一部分描述虚拟内存是如何工作的,后一部分是描述应用程序如何使用和管理虚拟内存。

1、物理和虚拟寻址

        计算机系统的主存被组织成一个由M个连续得字节大小的单元组成的数组,每字节都有唯一的物理地址,第一个字节地址是0,接下来的是1,再下一个为2,以此类推,给定这种简单的结构,CPU访问内存的最自然的方式是使用物理地址,这种寻址方式成为物理寻址。下图为一个物理寻址的示例:

        CSAPP-----虚拟内存_第1张图片

该示例的上下文是一条加载指令,它读取从物理地址4处开始的4字节处开始的4字节字,当CPU执行这条加载指令时,会生成一个有效的物理地址,通过内存总线把它传递给主存,主存取出从物理地址4处开始的4字节字,并将它返回给CPU,CPU会将其放在一个寄存器里。早起的PC会使用物理寻址,而且诸如数字信号处理器、嵌入式微控制器以及Cray超级计算机这样的系统仍然使用这种寻址方式,但是,现代处理器使用的是一种称为虚拟寻址的寻址形式,如下图    :

         CSAPP-----虚拟内存_第2张图片

        使用虚拟寻址,CPU通过生成一个虚拟地址(VA)来访问主存,这个虚拟地址会在被送到内存之前转换成适当的物理地址,将一个虚拟地址转为物理地址的任务叫作地址翻译,就像异常处理一样,地址翻译需要CPU硬件和操作系统之间的紧密合作,CPU上叫作内存管理单元(MMU)的专用硬件,利用存放在主存中的查询表来动态翻译虚拟地址,该表的内容由操作系统管理。

2、地址空间

        地址空间是一个非负整数地址的有序集合。{0,1,2,...},如果地址空间中的整数是连续的,那么我们可以说它是一个线性地址空间,为了简化讨论,假设我们使用都是线性地址空间,在一个带虚拟内存的系统中,CPU从一个有N=2^n个地址中生成虚拟地址,这个地址被称为虚拟地址空间。

        一个地址空间的大小是由表示最大地址所需要的位数来描述的,例如一个包含N=2^n个地址的虚拟地址空间就叫做一个n位地址空间,现代系统通常支持32位或64位虚拟地址空间。

        一个系统还有一个物理地址空间,对应系统中物理内存的M个字节,{0,1,2......M-1},M不要求是2的幂,但是为了简化讨论,假设M=2^m。

        地址空间的概念是很重要的,它区分了数据对象(字节)和它们的属性(地址),一旦有这种区别,这就允许每个数据对象有多个独立的地址,其中每个地址都选自一个不同的地址空间,这就是虚拟内存的基本思想。主存中每字节都有一个选自虚拟地址空间的虚拟地址和一个选自物理地址空间的物理地址。

3、虚拟内存作为缓存的工具

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

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

        *未分配的:VM系统还未分配或者创建的页,未分配的块没有任何数据与他们相关联,因此也就不占用任何磁盘空间。

        *缓存的:当前已缓存在物理内存中的已分配页。

        *未缓存的:未缓存在物理内存中的已分配页。

        下图展示了一个有8个虚拟页的小虚拟内存,虚拟页0和虚拟页3还没有被分配,因此在磁盘上还不存在,虚拟页1、4和6都被缓存在物理内存中,页2、5和7已经被分配了,但是当前并未缓存在主存中。

        CSAPP-----虚拟内存_第3张图片

   3.1 DRAM缓存的组织结构

        SRAM缓存表示CPU和主存之间的L1,L2和L3高速缓存,并用DRAM缓存表示虚拟内存系统的缓存,它在主存中缓存虚拟页。在存储层次结构中,DRAM缓存的位置比对它的组织结构有很大影响。

    3.2 页表

        同任何缓存一样,虚拟内存系统必须有某种方法来判定一个虚拟页是否缓存在DRAM中的某个地方,如果是,系统还必须能确定虚拟页放在哪个物理页中,如果不命中,系统必须判定这个虚拟页存放在磁盘的哪个位置,在物理内存中选择一个牺牲页,并将虚拟页从磁盘复制到DRAM中,替换掉这个牺牲页。

        这些功能是软硬件联合提供的,包括操作系统软件、MMU中的地址翻译和一个存放在物理内存中的页表的数据结构,页表是将虚拟页映射到物理页,每次地址翻译硬件将一个虚拟地址转换为物理地址时,都会读取页表,操作系统负责维护页表的内容,以及在磁盘和DRAM之间来回传送页。下图展示一个页表的基本组织结构,页表就是一个页表条目(PTE)的数组,虚拟地址空间的每个页在页表中有一个固定偏移量处都有一个PTE,假设每个PTE都是由一个有效位和一个n位地址字段组成的,有效位表明该虚拟页当前是都被缓存在DRAM中,如果设置了有效位,那么地址字段就表示DRAM中想对应的物理页起始地址,这个物理页中缓存了虚拟页,如果没有设置有效位,那么一个空地址表示这个虚拟页还未被分配,否则,这个地址就指向该虚拟页在磁盘上的起始位置。

        CSAPP-----虚拟内存_第4张图片

        上图展示了一个具有8个虚拟页和4个物理页的系统的页表,四个虚拟页(VP1,VP2,VP7,VP4)被缓存在DRAM中,两个页(VP0和VP5)还没有被分配,剩下的页(VP3、VP6)已经被分配但是当前未被缓存,上图中,DRAM缓存是全相连的,所以任意物理页都可以包含任意虚拟页。

    3.3 页命中

        如果CPU要读包含在VP2中的虚拟地址中的一个字时会发生什么。VP2被缓存在DRAM中,使用地址翻译技术,地址翻译硬件将虚拟地址作为一个索引来定位PTE2,并从内存中读取它,因为设置了有效位,那么地址翻译硬件就知道VP2是缓存在内存中,所以使用PTE中的物理内存地址(该地址指向PP1中缓存页的起始位置),构造这个字的物理地址。

        CSAPP-----虚拟内存_第5张图片

    3.4 缺页

        在虚拟内存的习惯说法中,DRAM缓存不命中称为缺页,下图展示了在缺页之前的实例页表的状态。CPU引用VP3中的一个字,VP3并没有缓存在DRAM中,地址翻译硬件从内存中读取PTE3,从有效位推断VP3为被缓存,并且触发一个缺页异常,缺页异常调用内核中缺页异常处理程序,该程序会选择一个牺牲页,在此例中就是存放在PP3中的VP4,如果VP4已经被修改了,那么内核就会将它复制回磁盘,无论哪种情况,内核会修改VP4的页表条目,反映出VP4不再缓存在主存这一事实。

        CSAPP-----虚拟内存_第6张图片

        接下来,内核从磁盘复制VP3到内存PP3处,更新PTE3,随后返回,当异常处理程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重新发送到地址翻译硬件,但是现在VP3已经缓存在主存中了,那么页命中也能由地址翻译硬件正常处理了。下图为缺页之后页表的状态。

        CSAPP-----虚拟内存_第7张图片

        页从磁盘换入DRAM和从DRAM换出磁盘,一直等待,也就是说,当有不命中发生时,才换入页面的这种策略叫作按需页面调度,当然也有别的策略但是所有现代系统都使用的是按需页面调度。

    3.5 分配页面

        下图展示当操作系统分配一个新的虚拟内存页时对我们示例页表的影响。例如,调用malloc的结果,在这个例子中,VP5的分配过程是在磁盘上创建空间并更新PTE5,使它指向磁盘上这个新创建的页面。

        CSAPP-----虚拟内存_第8张图片

    3.6 局部性

        其实虚拟内存的效率不像我们想象中很低,即使不命中的惩罚很大,其实虚拟内存工作的很好,主要归功于局部性。

        尽管在整个运行过程中程序引用的不同页面的总数可能会超过物理内存总的大小,但是局部性原则保证了在任意时刻,程序将趋于在一个较小的活动页面集合上工作,这个集合叫作工作集或者常驻集合,在初始开销,也就是将工作集页面调度到内存中之后,接下来对这个工作集的引用将导致命中,而不会产生额外的磁盘流量。

        只要我们的程序有良好的时间局部性,虚拟内存系统就能工作得相当好,但是不是所有的程序都是这样的,如果工作集的大小超过了物理内存的大小,那么程序将产生一种不幸的状态,叫作抖动,这时页面将不断换进换出,虽然虚拟内存通常是有效的,但是如果一个程序特别慢的话,那么就要考虑是不是发生了抖动。

4、虚拟内存作为内存管理的工具

        操作系统为每个进程提供一个独立的页表,因而也就是一个独立的虚拟地址,如下图展示。下图中进程i的页表将VP1映射到PP2,VP2映射到PP7,进程j的页表将VP1映射到PP7,VP2映射到PP10,多个虚拟页面可以映射到同一个共享物理页面上。

        CSAPP-----虚拟内存_第9张图片

        按需页面调度和独立的虚拟地址空间结合,对系统中的内存使用和管理造成了深远的影响,特别的VM简化了链接、加载、代码和数据共享以及应用程序的内存分配。

        简化链接:独立的地址空间允许每个进程的内存映像使用相同的基本格式,而不管代码和数据实际存放在物理内存的何处。例如,一个给定的linux系统上的每个进程使用类似的内存格式,对于64位地址空间,代码段总是从虚拟地址0x400000开始,数据段跟在代码段之后,中间有一段符合要求的对齐空白,栈占用用户进程地址空间最高的部分,并向下生长,这样的一致性极大地简化了链接器的设计与实现,允许链接器生成完全链接的可执行文件,这些可执行文件是独立与物理内存中代码和数据的最终位置的。

        简化加载:虚拟内存还使得容易向内存中加载可执行文件和共享对象文件,要把目标文件中.text和.data节加载到一个新创建的进程中,linux加载器为代码和数据段分配页,把它们标记为无效的(未被缓存的),将页表条目指向目标文件中适当的位置,加载器不从磁盘到内存复制任何数据,在每个页初次被引用时,要么是CPU取指令是引用的,要么是一条正在执行的指令引用一个内存位置时引用的,虚拟内存系统会按照需要自动地调入数据页。

        将一组连续的虚拟页映射到任意一个文件中的任意位置的表示法称作内存映射,linux提供一个称为mmap的系统调用,允许应用程序自己做内存映射。

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

        然而在一些情况中,还是需要进程来共享代码和数据,例如每个进程必须调用相同的操作系统内核代码,而每个C程序都会调用C标准库中的程序,比如printf,操作系统通过将不同进程中适当的虚拟页面映射到相同的物理页面,从而安排多个进程共享这部分代码的一个副本,而不是在每个进程都包括单独的内核和C标准库的副本,如上面图所示。

        *简化内存分配:虚拟内存为用户进程提供一个简单的分配额外内存的机制,当一个运行在用户进程中程序要求额外的堆空间(如调用malloc的结果),操作系统分配一个适当的数据(k)个连续的虚拟内存页面,并将它们映射到物理内存中任意位置的k个任意的物理页面,由于页表工作的方式,操作系统没有必要分配k个连续的物理内存页面,页面可以随机的分散在物理内存中。

5、虚拟内存作为内存保护的工具

        任何现代计算机系统必须为操作系统提供手段来控制对内存系统的方式,不应该允许一个用户进程修改它的只读代码段,而且也不应该它读或修改任何与其他进程共享的虚拟页面,除非所有的共享者都显式地允许它这么做。

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

        CSAPP-----虚拟内存_第10张图片

        在这个示例中,如上图,每个PTE中已经添加了三个许可位,SUP许可位表示进程是否必须运行在内核(超级用户)模式下才能访问该页,运行在内核模式中的进程可以访问任何页面,但是运行在用户模式中的进程只允许访问那些SUP为0的页面,READ位和WRITE位控制对页面的读写访问,例如,如果进程i运行在用户模式下,那么它有读VP0和读写VP1的权限,然而不允许它访问VP2。

        如果一条指令违反了这些许可条件,那么CPU就出发一个一般保护故障,将控制传递给一个内核中的异常处理程序,Linux shell一般将这种异常报告称为段错误。

6、地址翻译

        下面的图展示了所用使用的符号:

        CSAPP-----虚拟内存_第11张图片

        形式上来说,地址翻译是一个N元素的虚拟地址空间(VAS)中的元素和一个M元素的物理地址空间(PAS)中元素之间的映射,

       

        下图展示了MMU如何利用页表来实现这种映射,CPU中的一个控制寄存器,页表基址寄存器(PTBR)指向当前页表,n位的虚拟地址包含两部分:一个p位的虚拟页面偏移(VPO)和一个(n-p)位的虚拟页号(VPN),MMU利用VPN来选择适当的PTE,例如VP0选择PTE0,VP1选择PTE1,以此类推,将页表条目中物理页号(PPN)和虚拟地址中的VPO串联起来,就得到相应的物理地址,注意,因为物理和虚拟页面都是P字节,所以物理页面偏移(PPO)和VPO是相同的。 

        CSAPP-----虚拟内存_第12张图片

       下图展示了当页面命中时,CPU硬件执行的步骤:

       CSAPP-----虚拟内存_第13张图片

    第一步:处理器生成一个虚拟地址,并把它传送至MMU。

    第二步:MMU生成PTE地址,并从高速缓存/主存请求得到它。

    第三步:高速缓存/主存向MMU返回PTE。

    第四步:MMU构造物理地址,并把它传送给高速缓存/主存。

    第五步:高速缓存/主存返回所请求的数据字给处理器。

页面命中完全是由硬件来处理,与之不同的是处理缺页要求硬件和操作系统内核协作完成,如上图b所示:

    第一步到第三步与页面命中前三步一样,

    第四步:PTE中的有效位是0,所以MMU触发一次异常,传递CPU中的控制到操作系统内核的缺页异常处理程序。

    第五步:缺页处理程序确定出物理内存中的牺牲页,如果这个页面已经被修改了,则把它换出到磁盘。

    第六步:缺页处理程序页面调入新的页面,并更新内存中的PTE。

    第七部,缺页处理程序返回到原来的进程,再次执行导致缺页的指令,CPU将引起缺页的虚拟地址重新发送给MMU,因为虚拟页面现在缓存在物理内存中,所以就会命中,在MMU执行了上图b中的步骤之后主存就会将所请求的字返回给处理器。

    6.1 结合高速缓存和虚拟内存

        在任何既使用虚拟内存又使用SRAM高速缓存的系统中,都有应该使用虚拟地址还是使用物理地址来访问SRAM高速缓存的问题,大多数系统是采用物理寻址的,使用物理寻址,多个进程同时在高速缓存中有存储块和共享来自相同虚拟页面的块成为很简单的事,而且高速缓存无需处理保护问题,因为访问权限的检查是地址翻译的一部分。下图展示了一个使用物理寻址的高速缓存如何与虚拟内存结合起来,主要的思路是地址翻译发生在高速缓存查找之前,页表条目可以缓存,就像其他数据字一样。

        CSAPP-----虚拟内存_第14张图片

    6.2 利用TLB加速地址翻译

        CPU每产生一个虚拟地址,MMU就必须查阅一个PTE,以便将虚拟地址翻译成物理地址,在最糟糕的情况下,就会要求从内存多取一次数据,代价是几十到几百个周期,如果PTE碰巧缓存在L1中,那么开销就下降到1或2个周期,然而很多系统都试图消除这个开销,它们在MMU中包括了一个关于PTE的小的缓存,称为翻译后备缓冲器(TLB)。

        TLB是一个小的、虚拟地址的缓存,其中每一行都保存一个由单个PTE组成的块,TLB通常有高度的相连度,如下图所示,用于组选择和行匹配的索引和标记字段是从虚拟地址中的虚拟页号中提取出来的,如果TLB有T=2^t个组,那么TLB索引是由VPN的t个最低位组成的,而TLB标记是由VPN中剩余的位组成的。

        CSAPP-----虚拟内存_第15张图片

        下图a展示了当TLB命中时所包括的步骤,这里的关键点是所有的地址翻译步骤都是在芯片上的MMU中执行的,所以非常快。

        *第一步:CPU产生一个虚拟地址。

        *第二步和第三步:MMU从TLB取出对应的PTE。

        *第四步:MMU将这个虚拟地址翻译成一个物理地址,并将它发送到高速缓存/主存。

        *第五步:高速缓存/主存将所请求的数据字返回给CPU。

        当TLB不命中时,MMU必须从L1缓存中取出相对应的PTE,如下图b,新取出的PTE存放在TLB中,可能覆盖一个已经存在的条目。

        TLB的操作类似与将PTE存放在MMU中。

        CSAPP-----虚拟内存_第16张图片

    6.3 多级页表

        目前为止,都假设系统只用一个单独的页表来页表来进行地址翻译,但是如果有一个32位地址空间、4KB的页面和一个4字节的PTE,那么即使应用所引用的只是虚拟地址空间中很小的一部分,也需要一个4MB的页表驻留在内存中。

        用来压缩页表的常用方法是使用层次结构的页表,用一个具体的示例是最容易理解这个思想的,假如32为虚拟地址空间被分为4KB的页,而每个页表条目都是4字节,还假设在这一时刻,虚拟地址空间有如下形式:内存的前2K个页面分配给了代码和数据接下来的6K改为分配,剩下的1023个页面也为分配,接下来一个页面分配给了用户栈,下图展示了如何为这个虚拟地址空间构造一个两级的页表层次结构。

       CSAPP-----虚拟内存_第17张图片

        一级页表中的每个PTE负责映射虚拟地址中的一个4MB的片,这里的每一片都是由1024个连续得页面组成的,比如PTE0映射第一片,PTE1映射接下来的一片,以此类推,假设地址空间是4GB,1024个PTE已经足够覆盖整个空间。

        如果片i中的每个页面都未被分配,那么一级PTE i 就为空,如上图中,片2~7是未被分配的,然而如果在片i中至少有一个页是分配了的,那么一级PTE i 就指向一个二级页表基址,如上图中,片0,1,8的所有或部分已经被分配,所以他们的一级PTE就指向二级页表。

        二级页表中的每个PTE都负责映射一个4KB的虚拟内存页面,就像查看一级页表一样,使用4字节的PYE,每个一级和二级页表都是4KB。

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

        下图描述了使用k级页表层次结构的地址翻译,虚拟地址被划分成k个VPN和1个VPO,每个VPN i 都是一个到第i级页表的索引,第j级页表中的每个PTE,都指向第j+1级的某个页表的基址,第K级页表中的每个PTE包含某个物理页面的PPN,或者一个磁盘块的地址,为了构造物理地址,能够确定PPN之前,MMU必须访问K个PTE,对于只有一级的页表结构,PPO和VPO是相同的。

        

    6.4 端到端的地址翻译

        这一节通过一个具体的端到端的地址翻译实例来综合刚刚学过的内容,这个示例运行在一个有TLB和L1 d-cache的小系统上,为了保证可管理性,做以下假设:

        *内存是按字节寻址的。

        *内存访问是针对1字节的字的(不是四字节)

        *虚拟地址是14位长的(n=14)

        *物理地址是12位长的(m=12)

        *页面大小是64字节(P=64)

        *TLB是四路组相联的,总共16个条目。

        *L1 d-cahe是物理寻址、直接映射的,行大小为4字节,总共16个组。

        下图展示虚拟地址和物理地址的格式,因为每个页面是2^6=64字节,所以虚拟地址和物理地址的低6位分别作为VPO和PPO,虚拟地址的高8位作为VPN,物理地址的高6位作为PPN。

        CSAPP-----虚拟内存_第18张图片 

        下图展示了一个小内存系统,包括TLB(a)、页表的一部分(b)、和L1高速缓存(c),在TLB和高速缓存的图上还展示了访问这些设备时硬件是如何划分虚拟地址和物理地址的位的。

        *TLB,TLB是利用VPN的位进行虚拟寻址的,因为TLB有4个组,所以VPN的低2位就作为组索引(TLBI),VPN中剩下的高6位就作为标记用来区别可能映射到同一个TLB组的不同的VPN。

        *页表,这个页表是一个单级设计,一共2^8=256个页表条目,然而我们只对这些条目中的开头16个感兴趣,为了方便,我们用索引它的VPN来标识每个PTE,但是记住这些VPN并不是页表的一部分,也不存储在内存中。每个无效的PTE的PPN都用一个破折号来表示,以加强一个概念,无论刚好这里存储的是什么位值,都没有意义。

        *高速缓存,直接映射的缓存是通过物理地址中的字段来寻址的,因为每个块都是4字节,所以物理地址的低2位作为块便宜,因为有16个组,所以接下来的4位表示组索引,剩下的6位作为标记。

        CSAPP-----虚拟内存_第19张图片

        CSAPP-----虚拟内存_第20张图片

        CSAPP-----虚拟内存_第21张图片

        现在来看看,当CPU执行一条读地址0x03d4处字节的加载指令时会发生什么,我们写下虚拟地址的各个位,表示出需要的字段,并确定他们的十六进制。

        CSAPP-----虚拟内存_第22张图片

        首先把虚拟地址写成二进制,这样从上图中可得到VPN、VPO和TLBT,TLBI,开始时,MMU从虚拟地址中抽取出VPN(0x0f),看它是否因为前面的某个内存引用缓存了PTE 0xf的一个副本,TLB从VPN中抽取TLB索引(行索引)和TLB标记(组索引),组0x3的第二个条目有效匹配,所以命中,然后将缓存的PPN(0X0D)返回MMU。

        如果TLB不命中,那么MMU就需要从主存中取出对应PTE,在上面的情况中MMU有了形成物理地址所需要的所有东西,他通过将来自PTE的PPN(0X0D)和来自虚拟地址的VPO(0x14)连接起来,这就形成了物理地址。

        接下来,MMU发送物理地址给缓存,缓存从物理地址中抽取缓存偏移CO(块索引)、缓存组索引CI(组索引),以及缓存标记CT(行索引)。查高速缓存图,可以得到在组0x05中的标记0X0D与CT匹配,所以缓存监测到一个命中,读出偏移量CO处的数据字节0x36,返回给MMU,MMU将其传回给CPU。

        CSAPP-----虚拟内存_第23张图片

7、案例研究

    7.1 core i7地址翻译(略)

    7.2 Linux虚拟内存系统

        一个虚拟系统要求硬件和内核软件之间的紧密协作,这一节对Linux的虚拟内存系统做一个描述,使你大致了解一个实际的操作系统是如何组织虚拟内存以及处理缺页的。

        Linux为每个进程维护一个单独的虚拟地址空间,如下图,这个图已经见过很多次,包括其中代码、数据、堆、共享库以及栈段,现在了解了地址翻译就能够填入更多关于内核虚拟内存的细节,这部分虚拟内存位于用户栈之上。

        CSAPP-----虚拟内存_第24张图片

        内核虚拟地址内存包含内核中代码和数据结构,内和虚拟内存的某些区域被映射到所有进程工程的物理页面,例如,每个进程共享内核的代码和全局数据结构,有趣的是,Linux也将一组连续的虚拟页面(大小等于系统中DRAM的总量)映射到相应的一组连续的物理页面,这也就为内核提供了一种便利的方法来访问物理内存中任何特定位置,例如当它需要访问页表或一些设备上执行内存映射的IO操作,而这些设备被映射特定的物理内存位置。

        内和虚拟内存的其他区域包含每个进程都不相同的数据,比如说页表、内核在进程的上下文中执行代码时使用的栈,以及记录虚拟地址空间当前组织的各种数据结构。

        (1)Linux虚拟内存地址

         Linux将虚拟内存组织成一些区域也叫做段的集合,一个区域就是已经存在的(已分配)虚拟内存的连续片(chunk),这些页是以某种方式相关联的,例如代码段、数据段、堆、共享库、以及用户栈都是不同的区域,每个存在的虚拟页面都保存在某个区域中,而不属于某个区域的虚拟页是不存在,并且不能被进程引用,区域的概念很重要,因为它允许虚拟地址空间有间隙,内核不用记录那些不存在的虚拟页,而这样的页也不占用内存、磁盘或者内核本身中的任何额外资源。

        下图强调了记录一个进程中虚拟内存区域的内核数据结构,内核为系统中每个进程维护一个单独的任务结构(源码中为task_struct),任务结构中的元素包含或者指向内核运行该进程所需要的所有信息(PID,指向用户的指针,可执行目标文件的名字,程序计数器)。

        CSAPP-----虚拟内存_第25张图片

        任务结构中有一个条目指向mm_struct,它描述虚拟内存的当前状态,我们感兴趣的两个字段是pgd和mmap,pgd指向第一级页表(页全局目录)的基址,而mmap指向一个vm_area_struct(区域结构)的链表,其中每个vm_area_stuct都描述了当前虚拟地址空间的一个区域,当内核运行这个进程时,就将pgd存放在CR3控制寄存器中,为了我们的目的一个具体的区域结构包含下面的字段:

        *vm_start:指向这个区域的起始处。

        *vm_end:指向这个区域的结束处。

        *vm_port:描述这个区域包含的所有页的读写许可权限。

        *vm_flags:描述这个区域内的页面是与其他进程共享的,还是这个进程私有的。

        *vm_next:指向链表中的下一个区域结构。

    (2)Linux缺页异常处理

        假如MMU在试图翻译某个虚拟地址A时,触发了一个缺页,这个异常导致控制转移到内核缺页处理程序,处理程序随后执行下面的步骤:

        1、虚拟地址A合法吗,换句话说,A在某个区域结构定义的区域吗,为了回答这个问题,缺页处理程序搜索区域结构的链表,把A和每个区域结构中的vm_start和vm_end作比较,如果这个指令不合法,那么缺页处理程序就出发一个段错误,从而终止这个进程,这个错误可以标记为下图中情况1。

        因为一个进程可以创建任意数量的新虚拟内存区域(mmap),所以顺序搜索区域结构的链表花销可能很大,因此实际中,linux使用某些我们没有显示出来的字段,linux在链表中构架一棵树,并在这颗树上进行查找。

        2、试图进行的内存访问是否合法,换句话说,进程是否有读写执行这个区域内页面的权限,例如,这个缺页是不是由一条视图对这个代码段的只读页面进行读写操作的存储指令造成的,这个缺页是不是因为一个运行在用户模式的进程试图从内核虚拟内存中读取字造成的,如果试图进行的访问是不合法,那么缺页处理程序就会触发一个保护异常,从而终止这个进程,这种情况下下图中标记为情况2。

        3、此刻,内核知道了这个缺页是由于对合法的虚拟地址进行合法的操作造成,它是这样来处理这个缺页的:选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表,当缺页处理程序返回时,CPU会重新启动引起缺页的指令,这条指令会再次发送A到MMU,这次MMU就能正常翻译A,而不会再产生缺页中断。

        CSAPP-----虚拟内存_第26张图片

8、内存映射

        Linux通过将一个虚拟内存区域与磁盘上一个对象关联起来,以初始化这个虚拟内存区域中的内容,这个过程称为内存映射,虚拟内存可以映射到两种类型对象中的一种:

        (1)Linux文件系统中的普通文件:一个区域可以映射到一个普通磁盘文件的连续部分,例如一个可执行目标文件,文件区被分为页大小的片,每一片包含一个虚拟页面的初始内容,因为按需进行页面调度,所以这些虚拟页面没有实际交换进入物理内存,直到CPU第一次引用到页面(即发射一个虚拟地址,落在地址空间这个页面的范围之内)如果区域比文件区还大,那么就用0来填充这个区域的剩下的部分。

        (2)匿名文件:一个区域也可以映射到匿名文件,匿名文件是由内核创建的,包含的全是二进制0,CPU第一次引用这样一个区域内的虚拟页面时,内核就在物理内存中找到一个合适的牺牲页面,如果该页面被修改过,就把这个页面换出来,用二进制0覆盖牺牲页面并更新页表,将这个页面标记为是驻留在内存中的,注意在磁盘和内存之间并没有实际的数据传送,因为这个原因,映射到匿名文件的区域中的页面有时也叫请求二进制0的页。

        无论在那种情况下,一旦一个虚拟页面被初始化,它就在一个由内核维护的专门的交换文件之间换来换去,交换文件也叫作交换空间或交换区域,在任何时刻,交换空间都限制这当前运行着的进程能够分配的虚拟页面的总数。

    8.1 再看共享对象

        内存映射的概念来源于一个比较聪明的发现,如果虚拟内存系统可以集成到传统的文件系统中,那么可以提供一种简单而高效的把程序和数据加载到内存中的方法。

        进程这一抽象能够为每个进程提供自己私有的虚拟地址空间,可以免受其他进程的错误读写,不过许多进程有同样的只读代码区域,例如每个运行Linux shell程序bash的进程都有相同的代码区域,而且许多程序需要访问只读运行时库代码的相同副本,例如每个C程序都需要来自标准库C库诸如printf这样的函数,那么每个进程在物理内存中保持这些常用代码的副本,那么就是很大的浪费了,所以通过内存映射可以提供一个清晰的机制,用来控制多个进程如何共享对象。

        一个对象可以被映射到虚拟内存的一个区域,要么作为共享对象,要么座位私有对象,如果一个进程将一个共享对象映射到它的虚拟地址空间的一个区域内,那么这个进程对这个区域的任何读写操作,对于那些也把这个共享对象映射到它们虚拟内存的其他进程而言,也是可见的,而且这些变化也会反映在磁盘上的原始对象中。

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

        假设进程1将一个共享对象映射到它的虚拟内存的一个区域中,如下图a所示,现在假设进程2将同一个共享对象映射到它的地址空间(并不一定要和进程1在相同的虚拟地址处,如下图b所示):

        CSAPP-----虚拟内存_第27张图片

        因为每个对象都有用唯一的文件名,内核可以迅速判断进程1已经映射了这个对象,而且可以使进程2中的页表条目指向相应的物理页面,关键点在于即使对象被映射到了多个共享区域,物理内存中也只需要存放共享对象的一个副本,为了方便,我们将物理页面显示为连续的,但是一般情况下不是这样的。

        私有对象使用一种叫做写时复制的巧妙技术被映射到虚拟内存中,一个私有对象开始生命周期方式基本上与共享对象一样,在物理内存中只保存有私有对象的一份副本,比如,下图a展示的一种情况,其中两个进程将一个私有对象映射到它们虚拟内存的不同区域,但是共享这个对象同一物理副本,对于每个映射私有对象的进程,相应私有区域的页表条目都被标记为已读,并且区域结构被标记为私有的写时复制,只要没有进程试图写它自己的私有区域,它们就可以继续共享物理内存中对象的一个单独副本,然而,只要有一个进程试图写私有区域内的某个页面,那么这个写操作就会触发一个保护故障。

        当故障处理程序注意到保护异常是由于进程试图写私有的写时复制区域中的一个页面而引起的,它就会在物理内存中创建这个页面的一个副本,更新页表条目指向这个新的副本,然后恢复这个页面的可写权限,如下图b所示,当故障处理程序返回时,CPU重写执行这个写操作,现在在新创建的页面上这个写操作就可以正常执行。

        CSAPP-----虚拟内存_第28张图片

        通过延迟私有对象中的副本直到最后可能的时刻,写时复制充分使用了稀有的物理内存。

    8.2 再看fork函数

        既然理解了虚拟内存和内存映射,就可以清晰的知道fork函数是如何创建一个带有自己独立虚拟地址空间的新进程的。

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

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

    8.3 再看execve函数

        虚拟内存和内存映射在将程序加载到内存的过程中也扮演着关键的角色,比如execve函数是如何加载和执行程序的,假设运行在当前进程的程序执行了如下调用:

execve("a.out",NULL,NULL);

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

        *删除已存在的用户区域。删除当前进程虚拟地址的用户部分中已存在的区域结构。

        *映射私有区域。为新程序的代码、数据、bss和战区创建新的区域结构,所有这些新的区域都是私有的、写时复制的,代码和数据区域都被映射为a.out文件中的.text和.data区。bss区域是请求二进制0的,映射到匿名文件,下图概括了私有区域的不同映射。

        *映射共享区域,如果a.out程序和共享对象(目标)连接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到虚拟地址空间中的共享区域。

        *设置程序计数器(PC),exceve做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。

        下一次调度这个进程时,它从这个入口点开始执行,linux将根据需要换入代码和数据页面。

        CSAPP-----虚拟内存_第29张图片

    8.4 使用mmap函数的用户级内存映射

        Linux可以使用mmap函数创建新的虚拟内存区域,并将对象映射到这些区域。

        CSAPP-----虚拟内存_第30张图片

        mmap函数要求内核创建一个新的虚拟内存区域,最好是从地址start开始的一个区域,并将文件描述符fd指定的对象的一个连续的片映射到这个区域,连续的片的大小为length字节,从距文件开始处偏移量为offset字节的地方开始,start地址仅仅是一个暗示,通常定义为NULL,为了我们的目的,总是假设起始地址为NULL,图9.23描述了这些参数的意义。

        CSAPP-----虚拟内存_第31张图片

        参数port包含描述新映射的虚拟内存区域的访问权限位(即在相应区域结构中的vm_port位)。

        *PORT_EXEC:这个区域内的页面可被CPU执行的指令组成。

        *PORT_READ:这个区域内的页面可读。

        *PORT_WRITE:这个区域内的页面可写。

        *PORT_NONE:这个区域内的页面不能被访问。

        参数flags是由描述被映射对象类型的位组成,如果设置了MAP_ANON标记位,那么被映射的对象就是一个匿名对象,而对应的虚拟页面是请求二进制0的,MAP_PRIVATE表示被映射的对象是一个私有的、写时复制的对象,而MAP_SHARED表示是一个共享对象,例如:

       

        让内核创建一个新的包含size个字节的只读、私有、请求二进制0的虚拟内存区域,如果调用成功,那么bufp包含新区域的地址,munmap函数删除虚拟内存区域。

        CSAPP-----虚拟内存_第32张图片

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

9、动态内存分配

        虽然可以使用低级的mmap和munmap函数来创建和删除虚拟内存的区域,但是C程序员还是会觉得当运行时需要额外的虚拟内存时用动态内存分配器更方便,也更好移植。

        动态内存分配器维护着一个进程的虚拟内存区域,称为堆(heap),系统之间细节不同但是大部分是通用的,假设堆是一个请求二进制0的区域,它紧跟着未初始化的数据区域后,并向上生长,对于每个进程,内核维护着一个变量brk(break),它指向堆的顶部。

        CSAPP-----虚拟内存_第33张图片

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

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

        显式分配器:要求应用显式地释放任何已分配的块,例如,C提供的malloc程序包的显式分配器,C程序通过调用malloc函数来分配一个块,并通过调用free函数来释放一个块,C++中的new和delete操作符和C中的malloc和free相当。

        隐式分配器,另一方面,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块,隐式分配器也叫作垃圾收集,而自动释放未使用的已分配的块的过程叫作垃圾收集,例如lisp、ML、java之类的高级语言就依赖垃圾收集来释放已分配的块。

    9.1 malloc和free函数

        C标准库提供了一个称为malloc程序包的显示分配器,程序通过调用malloc函数来从堆中分配块。

        

        malloc函数返回一个指针,指向大小至少为size字节的内存块,这个块可能为包含在这个块内的任何数据对象类型对齐,实际中,对其依赖于代码在32位模式还是64位模式中运行,在32位模式中,malloc返回的块地址总是8的倍数,64位中总是16的倍数。

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

        动态内存分配器如malloc,可以使用mmap和munmap函数,显式分配和释放堆内存,或者可以使用sbrk函数。

        

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

        程序通过free函数来释放已分配的堆块。

        

        ptr参数必须指向一个从malloc、calloc或者realloc获得的已分配块的起始位置,如果不是,那么free的行为是未定义的,更糟的是既然他什么都不返回,free就不会告诉应用出现了错误。

        下图展示了一个malloc和free的实现是如何管理一个C程序的16字的小的堆的,每个方框代表了一个4字节的字,粗线标出的矩形对应已分配块(有阴影的)和空闲块(无阴影),初始时,堆是由一个大小为16个字的、双字节对齐的空闲块组成的(本节中假设分配器返回的块是8字节双字边界对齐的。)

        CSAPP-----虚拟内存_第34张图片         

        上图a:程序请求一个4字的块,malloc的响应是,从空闲块前部切出一个4字的块,并返回指向这个块的第一个字的指针。

        上图b:程序请求一个5字的块,malloc的响应是在空闲块的前部分配一个6字的块,在本例中malloc在块里填充了一个额外的字,是为了保持空闲块是双字边界对齐的。

        上图c:程序请求一个6字节的块,而malloc从空闲块前部切出一个6字的块。

        上图d:程序释放b中分配的那6个字的块,注意调用free之后,指针p2已然指向被释放的块,应有责任在它被一个malloc调用重新初始化之前,不再使用p2。

        上图e:程序请求一个2字节的块,在这种情况下,malloc分配在前一步被释放的块的一部分,并返回一个指向这个新块的指针。

    9.2 为什么要使用动态内存分配

        程序使用动态内存分配的最重要原因是经常直到程序运行时才知道某些数据结构的大小。动态内存分配是一种有用而重要的编程艺术,然而为了正确而高效的使用它,需要对其如何工作有所了解。

    9.3 分配器的要求和目标

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

        *处理任意请求序列,一个应用可以由任意的分配请求和释放请求序列,只要满足约束条件:每次释放请求必须对应一个当前已分配块,这个块是由一个以前分配的分配请求获得的,因此分配器不可以假设分配和请求的顺序,例如分配器不能假设所有的分配请求都有相匹配的释放请求或者有相匹配的分配的空闲请求是嵌套的。

        *立即响应请求,分配器必须立即响应分配请求,因此不允许分配器为了提高性能重新排列或缓冲请求。

        *只使用堆,为了使分配器可扩展,分配器使用的任何非标量数据结构都必须保存在堆里。

        *对齐块,分配器必须对齐块,使得他们可以保存任何类型的数据对象。

        *不修改已分配的块,分配器只能操作或者改变空闲块,特别是一旦块被分配了,就不允许修改或者移动它了,因此诸如压缩已分配块这样的技术是不允许使用的。

        因此动态内存分配器的编写试图实现吞吐率最大化和内存使用率最大化,而这两个性能的目标通常是相互冲突的。

    9.4 碎片

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

        内部碎片是一个已分配块比有效载荷大时发生的。很多原因都可能造成这个原因,例如一个分配器的实现可能对已分配块强加一个最小的大小值,而这个大小比某个请求的有效载荷大。或者分配器可能增加块大小以满足对齐约束条件。

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

        外部碎片是当空闲内存合计起来足够满足一个分配请求,但是没有一个单独的空闲块足够大来处理这个请求时发生的,如下图情况:

        CSAPP-----虚拟内存_第35张图片

        此时申请6个字,如果这个时候不向内核申请额外的虚拟内存就无法满足这个请求,虽然堆中有6个字的空闲,问题的产生是由于这个6个字是分在两个空闲块中的。

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

    9.5 实现问题

        可以想象出最简单的分配器会把堆组织称一个大的字节数组,还有一个指针p,初始指向这个数组的第一个字节,为了分配size个字节,malloc将p的当前值保存在栈里,将p增加size,并将p的旧值返回调用函数,free函数只是简单的返回到调用函数而不做其他任何事情。

        这个简单的分配器是设计中的一种极端情况,因为每个malloc和free只执行很少量的指令,吞吐率会极好,然而因为分配器不重复使用任何块,内存利用率极差,一个实际的分配器要在吞吐率和内存利用率之间把握好平衡,就必须要考虑以下几个问题:

        *空闲块组织:如何记录空闲块。

        *放置:如何选择一个合适的空闲块来放置一个新分配的块?

        *分割:在一个新分配的块放置到某个空闲块之后如何处理这个空闲块中的剩余部分。

        *合并:如何处理一个刚刚被释放的块?

        其实像上面的这些问题都使用了一种叫作隐式空闲链表的简单空闲块组织结构中来介绍它们。

    9.6 隐式空闲链表

        任何实际的分配器都需要一些数据结构,允许它来区分块边界以及区别已分配块和空闲块,大多数分配器将这些信息嵌入块本身,一个简单的方法如下图所示:

        CSAPP-----虚拟内存_第36张图片

        在这种情况下,一个块是由一个字的头部、有效载荷以及可能得一些额外的填充组成的,头部编码了这个块的大小(包括头部和所有的填充)以及这个块是以分配还是空闲的,如果我们强加一个双字的对齐约束条件,那么块的大小就是8的倍数,且块大小的最低3位总是为0,因此我们只需要内存大小的29个高位,释放剩余的3位来编码其他信息。 在这种情况下,我们用其中的最低位(已分配位)来指明这个块是已分配的还是空闲的,例如我们有一个已分配的块,大小为24(0x18)字节,那么它的头部是:

0x00000018|0x1=0x00000019

类似的一个块大小为40(0x28)字节的空闲块的头部如下

0x00000028|0x0=0x00000028

        头部后面就是应用调用malloc时请求的有效载荷,有效载荷后面是一片不使用的填充块,其大小是任意的,需要填充有很多原因,比如填充可能是分配器策略的一部分,用来对付外部碎片,或者也需要用它来满足对齐要求。

        假如块的格式如上图所示,我们可以将堆组成为一个连续的已分配块和空闲块的序列,如下图所示:

        CSAPP-----虚拟内存_第37张图片

        这种结构称为隐式空闲链表,因为空闲块是通过头部中的大小字段隐含的连接着的,分配器可以通过遍历堆中的所有的块,从而间接的遍历整个空闲块的集合,注意,还需要某种特殊标记的结束块,在这个示例中就是一个设置了已分配为而大小为0的终止头部。

        隐式空闲链表的优点是简单,显著的缺点是任何操作的开销,例如放置分配的块,要求对空闲链表进行搜索,该搜索苏旭的时间与堆中已分配块和空闲块的总数呈线性关系。

        很重要的一点是意识到系统对齐要求与分配器对块格式的选择会对分配器上的最小块大小有强制的要求,没有已分配块或空闲块可以比这个最小值还小,例如,如果我们假设一个双字的对齐要求,那么每个块的大小必须是双字(8字节)的倍数,因此,上上图中的块格式就导致最小的块大小为两个字,一个字作头,另一个字维持对齐要求,即使应用只请求一个字节,分配器仍然也要创建一个两字的块。

    9.7 放置已分配的块

        当应用请求一个k字节的块的时候,分配器搜索空闲链表,查找一个足够大可以放置所有请求块的空闲块,分配器执行这种搜索方式是由放置策略确定的,一些常见的策略是首次匹配、下一次适配和最佳适配。

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

       首次适配的优点是趋向于将大的空闲块保留在链表后,缺点是它趋向于在靠近链表起始处留下小的空闲块的碎片,增加了对较大块的搜索时间。下一次适配内存利用率比首次适配低。

    9.8 分割空闲块

        一旦分配器找到一个匹配的空闲块,它就必须做另一个策略决定,那就是分配这个空闲块中多少空间,一个选择是用整个空闲块,虽然这种方式简单快捷,但是主要的缺点是它会造成内部碎片,如果放置策略趋向于产生好的匹配,那么额外的内部碎片也是可以接受的。

        然而如果匹配不太好,那么分配器通常会选择将这个空闲块分割为两部分,第一部分变成分配块,剩下的变成新的空闲块,下图展示了分配器如何分割上图中8个字的空闲块来满足一个应用对堆内存3个字的请求。

        CSAPP-----虚拟内存_第38张图片

    9.9 获取额外的堆内存

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

    9.10 合并空闲块

        当分配器释放一个已分配块时,可能有其他空闲块与这个新释放的空闲块相邻,这些邻接的空闲块可能引起一种现象叫作假碎片,就是有许多可用的空闲块被切割成小的、无法使用的空闲块,每一个的有效载荷都是3个字,因此接下来对一个4字有效载荷的请求就会事变,即使两个空闲块的合计大小足够大,可以满足这个请求。

        CSAPP-----虚拟内存_第39张图片

       为了解决假碎片问题,任何实际的分配器都必须合并相邻的空闲块,这个过程称为合并,这就出现一个重要的策略决定,那就是何时执行合并,分配器可以选择立即合并,也就是在每一次块释放时,就合并所有的相邻块,或者它也可以选择推迟合并,也就是等到某个稍晚的时候再合并空闲块,例如,分配器可以推迟合并,直到某个分配请求失败,然后扫描整个堆,合并所有空闲块。

    9.11 带边界标记的合并

        分配器如何实现合并?让我们称想要释放的块为当前块,那么合并(内存中)下一个空闲块很简单而且高效,当前块的头部指向下一个块的头部,可以检查这个指针以判断下一个块是否空闲,如果是就将它的大小简单地加到当前块头部大小上。

        对前面的块的合并有一种常熟时间内进行的技术,叫作边界标记。这种思想是在每个块的结尾处添加一个脚部,其中脚部是头部的一个副本,如果每个块包含这样一个脚部,那么分配器就可以通过检查它的脚部判断前一个块的起始位置和状态,这个脚部总是在距当前块开始位置一个字的距离。

        CSAPP-----虚拟内存_第40张图片

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

        1)前面的块和后面的块是已分配的。

        2)前面的块是已分配,后面是空闲。

        3)前面是空闲,后面是已分配。

        4)前后都是空闲。

        下图展示了对着四种情况进行合并

         CSAPP-----虚拟内存_第41张图片

        情况1中,两个邻接的块都已分配,因此不能合并,所以当前块只是更改状态。情况2中当前块与后面块合并,用当前块和后面块的大小的和来更新前面块的头部和当前块的脚部。情况3和4类似。

        边界标记的概念是简单优雅的,它对许多不同类型的分配器和空闲链表组织都是通用的,然而他要求每个块都保持一个头部和脚部,在应用程序操作许多小块时会产生显著的内存开销。例如如果一个图形应用通过反复调用malloc和free来动态的创建和销毁图形节点,并且每个图形节点只要求两个字,那么头部和脚部将占用每个已分配块的一般空间。

       幸运的是,有一种聪明的边界标记的优化方法,能够使得在已分配块中不再需要脚部,刚才在内存合并当前块和后面块时,只有前面块是空闲时,才会需要用到脚部,如果我们把前面块的已分配/空闲位存放在当前块中多出来的低位中,那么已分配的块就不需要脚部了,这样我们就可以将这个多出来的空间作为有效载荷,不过空闲块仍然需要脚部。

    9.12 实现一个简单的分配器

        后续再做这个实验。

    9.13 显式空闲链表

        隐式空闲链表为我们提供了一种介绍一些基本分配器概念的简单方法,然而因为块分配与堆块总数呈线性关系,所以对于通用的分配器隐式空闲链表是不适合的。

        一种更好的方法是将空闲块组织为某种形式的显式数据结构,因为根据定义程序不需要一个空闲块的主体,所以实现数据结构的指针可以存放在这些空闲块的主体里,例如堆可由组织成一个双向空闲链表,在每个空闲块中都包含一个pred(前驱)和succ(后继)指针,如下图所示

        CSAPP-----虚拟内存_第42张图片

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

        一种方法是后进先出的顺序维护链表,一种是按照地址顺序维护链表,其中链表中每个块的地址都小于它后继的地址。

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

    9.14 分离的空闲链表

        前面可以看到一个使用单向空闲链表的分配器需要与空闲块数量呈线性关系的时间来分配块,一种流行的减少分配时间的方法,通常称为分离存储,就是维护多个空闲链表,其中每个链表的块有大致相等的大小,一般的思路是将所有可能的块的大小分成一些等价类,也叫作大小类。有关动态内存分配的文献描述了几十种分离存储方法,主要区别是在于他们如何定义大小类、何时进行合并、何时向操作系统申请额外的堆内存、是否允许分割等等。我们会描述两种基本的方法:简单分离存储和分离适配。

        1、简单分离存储

        使用简单分离存储,每个大小类的空闲链表包含大小相等的块,每个块的大小就是这个大小类中最大元素的大小,例如某个大小类定义为{17~32},那么这个类的空闲链表全由大小为32的块组成。  

      这种简单的方法有很多优点,分配块和释放块都是很快的常数时间,一个显著的缺点是,简单分离存储很容易造成内部和外部碎片。因为空闲块不能被分割,所以可能造成内部碎片,又因为不能合并,会造成很多外部碎片。

        2、分离适配

        使用这个方法,分配器维护着一个空闲链表的数组,每个空闲链表是和一个大小类相关联,并被组织称某种类型的显式或隐式链表,每个链表包含潜在的大小不同的块,这些块的大小是大小类的成员有许多种不同的分离适配分配器,这里描述一个简单版本。

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

        分离适配方法是很常见的选择,C标准库中GNU Cmalloc就采用这种方法,因为这种方法既快速对内存的使用也很有效率,搜索时间减少了,因为搜索被限制在堆的某个部分,而不是整个堆。内存利用率得到改善。

        3、伙伴系统

        伙伴系统是分离适配的一种特例。每个大小类都是2的幂。

10、垃圾收集

        垃圾收集器是一种动态内存分配器,自动释放程序不再需要的已分配块,这些块被称为垃圾,自动回收堆存储的过程称为垃圾收集,在一个支持垃圾收集的系统中,应用显式分配堆块,但是从不显式的释放,C中调用malloc但从不调用free,反之垃圾收集器定期识别辣鸡块,并相应的调用free,将这些块放回空闲链表。(不详细看着一部分,C/C++基本不使用)

11、C程序中常见的与内存有关的错误

        对C程序员来说,管理和使用虚拟内存是比较困难和容易出错的,与内存有关的错误属于比较令人惊恐的错误,因为它们在时间和空间上距离出错的地方有一段距离,很难检查出来。

    11.1 间接引用坏指针

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

       间接引用坏指针的一个常见示例是scanf错误,假设我们想要使用scanf从stdin读一个整数到一个变量,正确的方法是传递给scanf一个格式串和一个变量的地址:

       

        然而对于C程序员初学者而言,很容易传递val的内容,而不是它的地址。

        

        这样就会出错,scanf会把val内容解释为一个地址,并试图将一个字写到这个位置,最好的情况下程序立即终止,糟糕的情况下,val的内容对应一个合法的读写区域,于是就覆盖了这块内存,这可能在相当一段长时间之后造成灾难性、令人困惑的后果。

    11.2 读未初始化的内存

        虽然bss内存位置(诸如未初始化的全局C变量)总是被加载器初始化为0,但是对于堆内存并不是这样,一个常见的错误就是假设堆内存被初始化为0,如下:

        CSAPP-----虚拟内存_第43张图片

        这个实例中,程序员不正确的假设y被初始化为0,正确的实现方式是显式的将y[i]设置为0。

    11.3 允许栈缓冲区溢出

        一个程序如果不检查输入串的大小就写入栈中的目标缓冲区,那么这个程序就会有缓冲区溢出错误,例如下面程序,因为gets函数复制一个任意长度的串到缓冲区,为了纠正这个错误,可以使用fgets函数,这个函数限制了输入串的大小

        CSAPP-----虚拟内存_第44张图片

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

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

        CSAPP-----虚拟内存_第45张图片

        这里的目的就是创建一个由n个指针组成的数组,每个指针指向一个包含m个int的数组,然而,因为程序员在第5行将sizeof(int *)写成了sizeof(int),代码实际上创建的是一个int的数组。

        这个代码只有在int和指向int的指针大小相同的机器上运行良好,但是如果机器不是这样那么第7 8行很可能写到A数组结尾的地方。

    11.5 造成错位错误

        错位(off-by-one)错误是另一种很常见的覆盖错误来源:

        CSAPP-----虚拟内存_第46张图片

        在第5行创建了一个n个元素的指针数组,但是随后在第7行和第8行试图初始化这个数组的n+1个元素,在这个过程中覆盖了A数组后面某个内存位置。

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

        如果不太注意C操作符的优先级和结合性,我们就会错误地操作指针,而不是指针所指向的对象,比如,考虑下面的函数,其目的是删除一个有*size项的二叉堆里的第一项,然后对剩下的*size-1项重新建堆。

        CSAPP-----虚拟内存_第47张图片

       第6行,目的是减少size指针所指向的整数的值,然而因为一元运算符--和*的优先级相同,从右向左结合,所以第6行实际减少的指针自己的值而不是它所指向的数组的值。

    11.7 误解指针运算

        另一种常见的错误是忘记指针的算术操作是以它们指向的对象大小为单位来进行的,而这种大小不一定是字节,例如下面函数的目的是扫描一个int的数组并返回一个指针,指向val的首次出现:

        CSAPP-----虚拟内存_第48张图片

        然而每次扫描时,第4行都把指针加4,函数就不正确的扫描数组中每4个整数。

    11.8 引用不存在的变量

        没有太多经验的C程序员不理解栈的规则,有时会引用不再合法的本地变量,如下:

        CSAPP-----虚拟内存_第49张图片

        这个函数返回一个指针,指向栈里一个局部变量,然后弹出它的栈帧,尽管p仍然是一个合法的内存地址,但是他已经不再指向一个合法的变量了。

    11.9 引用空闲堆块中的数据

        一个相似的错误时引用已经被释放了的堆块中的数据,下面示例,在第6行分配了一个整数数组x,第10行先释放了块x,然后又在第14行引用了它:

        CSAPP-----虚拟内存_第50张图片

        取决于第6行和第10行malloc和free的调用模式,当14行引用x[i]时,数组x可能是其他已分配的堆块的一部分了。

    11.10 引起内存泄漏

        内存泄漏是缓慢、隐性的杀手,当程序员不小心忘记释放已分配块,而在堆里创建了垃圾时,就会发生这种问题,例如下面函数分配了一个堆块X,然后不释放就返回。

        CSAPP-----虚拟内存_第51张图片

       如果经常调用这个函数,那么堆里就会充满垃圾,对于像守护进程和服务器这样的程序内存泄漏是特别严重的,根据定义这些程序是不会终止的。

12、小结

        虚拟内存是对主存的一个抽象,支持虚拟内存的处理器通过使用一种叫做虚拟寻址的间接形式来引用内存,处理器产生一个虚拟地址在被发送到主存之前,这个地址被翻译成一个物理地址,从虚拟地址空间到物理地址空间的地址翻译要求硬件和软件紧密合作,专门的硬件通过使用页表来翻译虚拟地址,而页表的内容是操作系统提供的。

       虚拟内存有三个重要功能:一,它在主存中自动缓存最近使用的存放磁盘上的虚拟地址空间内容,虚拟内存缓存中的块叫作页,对此盘上的页的引用会触发缺页,缺页将控制转移到操作系统中的一个缺页处理程序,缺页处理程序将页面从磁盘复制到主存缓存,如果必要将写会被驱逐的页。第二,虚拟内存简化了内存管理,进而简化了连接、在进程间共享数据,进程内存分配以及程序加载,最后,虚拟内存在每条页表中加入保护位,从而简化了内存保护。

        现代系统将虚拟内存片和磁盘上的文件片关联起来,来初始化虚拟内存片,应用可以使用mmap函数来手工地创建和删除虚拟地址空间的区域,然而大多数程序还是依赖于动态内存分配器,比如malloc,它管理虚拟地址空间区域内一个称为堆的区域。分配器有两种一种是显式分配器(应用显式的释放他们的内存块),隐式分配器(垃圾收集器)自动释放任何未使用和不可达的块。

       对于C程序员来说,管理和使用虚拟内存是一件困难和容易出错的事,上面一节提到了大部分容易出错的点,应该注意。

你可能感兴趣的:(CSAPP-----虚拟内存)