malloc开辟的解释地址报错问题


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        形式上来说,地址翻译是一个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是相同的 

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

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

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

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

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

    第一步到第三步与页面命中前三步一樣

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

都是一个到第i级页表的索引第j级页表中的每个PTE,都指向第j+1级的某个页表的基址第K级页表中的每个PTE包含某个物理页面的PPN,或者┅个磁盘块的地址为了构造物理地址,能够确定PPN之前MMU必须访问K个PTE,对于只有一级的页表结构PPO和VPO是相同的。

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

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

        下圖展示了一个小内存系统包括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位作为标记

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

        首先把虚拟地址写成②进制这样从上图中可得到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,返回给MMUMMU将其传回给CPU。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

}

我要回帖

更多关于 开辟的解释 的文章

更多推荐

版权声明:文章内容来源于网络,版权归原作者所有,如有侵权请点击这里与我们联系,我们将及时删除。

点击添加站长微信