虚拟内存

虚拟内存的三个能力:

  1. 它将主存看成磁盘上的地址空间的高速缓存,在主存中只保持活动区域,并根据需要在磁盘和主存之间来回传送数据。
  2. 为每个进程提供一致地址空间
  3. 保护了每个进程的地址空间不被其他进程破坏

物理和虚拟地址

  计算机的主存可以看做是一个由 M 个连续的字节大小的单元组成的数组。每个字节都有一个唯一的物理地址(Physical Address,PA)。第一个字节的地址为 0,接下来的地址为 1,以此类推。CPU 访问内存的最简单的方式是使用物理寻址(physical addressing)。

  该图例的上下文是一条加载指令,读取从物理地址 4 处开始的 4 字节字。CPU 在执行这条指令的时候,生成一个有效物理地址,通过内存总线,把这个物理地址传递给主存,主存取出从物理地址4处开始的 4 个字节字,然后将它返回给 CPU,CPU 将它存放在一个寄存器里。

  现在处理器采用的是一个程序虚拟寻址(virtual addressing)的寻址方式。CPU 通过生成一个虚拟地址(virtual address,VA)来访问主存,这个虚拟地址在被送到主存之前会先转换成一个物理地址。将虚拟地址转换成物理地址的任务叫做地址翻译(address translation),地址翻译需要 CPU 硬件和操作系统之间的配合。 CPU 芯片上叫做内存管理单元(Menory Management Unit, MMU)的专用硬件,利用存放在主存中的查询表来动态翻译虚拟地址,该表的内容由操作系统管理。

地址空间

  在一个带虚拟内存的系统中,CPU 从一个有 N= 2的n次方个地址的地址空间中生成虚拟地址,这个地址空间就称为虚拟地址空间:{0,1,2,3,…, N-1}。

  一个地址空间的大小通常是由表示最大地址所需要的位数来描述的,比如,一个包含 N = 2 的 n 次方 个地址的虚拟地址空间就叫做一个 n 位地址空间,现代操作系统通常支持 32 位或者 64 位虚拟地址空间。
  每个数据对象都有独立的地址,每个地址选自不同的一个地址空间,主存中的每个字节都有一个虚拟地址和一个物理地址。

虚拟内存作为缓存的工具

  从概念上来说,虚拟内存被组织成为一个由存放在磁盘上的 N 个连续的字节大小的单元组成的数组,也就是字节数组。每个字节都有一个唯一的虚拟地址作为数组的索引。磁盘上活动的数组内容被缓存在主存中。在存储器结构中,较低层次上的磁盘的数据被分割成块,这些块作为和较高层次的主存之间的传输单元。

  虚拟内存(VM)系统将虚拟内存分割成称为虚拟页(Virtual Page,VP)的大小固定的块,每个虚拟页的大小为 P = 2 的 p 次方 字节。同样的,物理内存被分割为物理页(Physical Page,PP),大小也为 P 字节(物理页也称作页帧(page frame))。

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

  1. 未分配的,VM 系统还未分配(或者创建)的页,未分配的页没有任何数据和它们关联,因此不占用任何内存空间。
  2. 缓存的,当前已缓存在物理内存中的已分配页。
  3. 未缓存的,未缓存在物理内存中的已分配页。

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

  SRAM缓存:位于CPU和主存之间的 L1, L2 和 L3高速缓存(cache)

  DRAM缓存:来表示虚拟内存系统中的缓存,也就是主存。

  在存储器层次结构中, DRAM 比 SRAM 慢个大约 10x 倍,磁盘比 DRAM 慢大约 10, 000x 倍。因此 DRAM 缓存的不命中比 SRAM 缓存中的不命中要昂贵的多,因为 DRAM 缓存不命中需要和磁盘传送数据,而 SRAM 缓存不命中是和 DRAM 传送数据。

页表

  页表存放在物理内存中,将虚拟页映射到物理页。每次地址翻译(MMU)硬件将一个虚拟地址转换成物理地址时都会读取页表。

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



  页表就是一个页表条目(Page Table Entry,PTE)的数组,页表条目也可以缓存,像其他数据一样。虚拟地址空间中的每个页在页表中都有一个 PTE。在这里我们假设每个 PTE 是由一个有效位(Valid bit)和一个 n 位地址字段组成的。有效位表明了该虚拟页当前是否被缓存在 DRAM 中。如果有效位为 1,那么地址字段就表示 DRAM 中相应的物理页的起始位置,这个物理页缓存了该虚拟页。如果有效位为 0,那么一个 null 地址表示这个虚拟页还未被分配,否则对应的这个地址就指向该虚拟页在磁盘上的起始位置。

  上图所示中一共有 8 个虚拟页和 4 个物理页的页表,4 个虚拟页 VP1, VP2, VP4, VP7 当前被缓存在 DRAM 中,VP0 和 VP5 还未被分配,而剩下的 VP3 和 VP6 已经被分配了,但是当前未被缓存。

页命中

  CPU要读取VP2中的虚拟内存中的一个字时,地址翻译硬件将虚拟地址作为一个索引来定位到 PTE2, 并从主存中读取它。因为 PTE2 设置了有效位,所以 VP2 是缓存在主存中的,所以地址翻译硬件使用 PTE 中的物理内存地址构造出这个字的物理地址。

缺页

  在虚拟内存中,DRAM缓存不命中称为缺页(page fault)。CPU 引用了 VP3 中的一个字, VP3 并未缓存在 DRAM 中。地址翻译硬件从内存中读取 PTE3, 从有效位判断出 VP3 未被缓存,并且触发了一个缺页异常。缺页异常会调用内核的缺页异常处理程序,该程序会选择一个牺牲页。

  接下来,内核程序从磁盘赋值 VP3 到内存中的 PP3并更新 PTE3。随后返回用户进程。当异常处理程序返回时,它会重启执行导致缺页的指令,该指令会将导致缺页的虚拟地址重新发送到地址翻译硬件。

  分配内面

虚拟内存作为内存管理工具

  多个虚拟页面可以映射到同一个共享的物理页面上。

  按需页面调度和独立的虚拟地址空间的结合,让虚拟内存简化了链接和加载,代码和数据共享,以及应用程序的内存分配。

  1. 简化链接。独立的地址空间允许每个进程的内存映像使用相同的基本格式,而不管代码和数据实际存放在物理内存的何处。
  2. 简化加载。虚拟内存使得容易向内存中加载可执行文件和共享对象文件。将一组连续的虚拟页面映射到任意一个文件中的任意位置的表示法称作内存映射(memory mapping)。Linux 提供了一个 nmap 的系统调用,允许应用程序自己做内存映射。
  3. 简化共享。独立地址空间为操作系统提供了一个管理用户进程和操作系统自身之间共享的一致机制。一般情况下,每个进程都有自己私有的代码、数据、堆栈。这些内容不与其他进程共享。在这种情况下,操作系统创建页表,将相应的虚拟页映射到不连续的物理页面。
  4. 简化内存分配。虚拟内存向用户进程提供一个简单的分配额外内存的机制。当一个用户程序要求额外的堆空间时候(malloc),操作系统分配 k个适当的连续的虚拟内存页面,并且将他们映射到物理内存的中的 k 个任意页面,操作系统没有必要分配 k个连续的物理内存页面。

虚拟内存作为内存保护工具

  每次CPU生成一个地址时,地址翻译硬件都会读一个PTE ,通过在PTE上添加一些额外的控制位来控制对一个虚拟页面内容的访问。

  SUP位表示进程是否必须运行在超级用也就是内核模式下才能访问该页,如果有指令违反了这些控制条件,那么CPU会触发一个一般保护故障(段错误),将控制传递给内核中的异常处理程序。

地址翻译

  地址翻译就是将N个元素的虚拟地址空间中的元素和M个元素的物理地址空间中的元素映射。MMU利用VPN选择PTE,将页表条目中的物理号(PPN)和虚拟地址中的VPO串联起来就得到相应的物理地址(物理地址和虚拟地址中的页面大小相同)。

  页面命中(全部由硬件来处理的)的场景,CPU 硬件的执行步骤:

  1. 处理器生成一个虚拟地址,并把它传送给 MMU。
  2. MMU 生成 PTE地址,并从高速缓存/主存中请求这个 PTE 。
  3. 高速缓存/主存向 MMU 返回 PTE。
  4. MMU构造物理地址,并把它传送给高速缓存/主存。
  5. 高速缓存/主存返回所请求的数据字给处理器。

  缺页,CPU硬件执行步骤

  1. 处理器 生成一个虚拟地址,并把它传送给 MMU。
  2. MMU 生成 PTE 地址,并从高速缓存/主存中请求这个 PTE 。
  3. 高速缓存/主存向 MMU 返回 PTE。
  4. PTE 中的有效控制位为0 ,所以 MMU 触发了一次异常,传递 CPU 中的控制到操作系统内核中的缺页异常处理程序。
  5. 缺页处理程序确定出物理内存中的牺牲页,如果这个页面已经被修改了,则把它换出到磁盘。
  6. 缺页处理程序调入新的页面,并更新内存中的 PTE。
  7. 缺页处理程序返回原来的进程,再次执行导致缺页的指令, CPU 将引起缺页的虚拟地址重新发送给 MMU ,因为虚拟页面现在存在主存中,所以会命中,主存将请求字返回给处理器。

结合高速缓存和虚拟内存

  地址翻译发生在查找缓存之前

用TLB加速地址翻译

  在MMU中包含了一个关于PTE的小缓存,称为翻译后备缓冲器(TLB)。它是个小的虚拟地址的缓存,每行是单个PTE块,用于组选择和行匹配的索引和标记字段是从虚拟地址中的虚拟页号提取出来的,如果TLB有T=2^t个组,那么TLB索引(TLBI)是由VPN的t个最低位组成,而TLB标记(TLBT)是由VPN的剩余位组成。所有的地址翻译步骤是在MMU中完成的

  1. CPU产生一个虚拟地址
  2. MMU 从 TLB 中取出对应的 PTE 。
  3. MMU 将这个虚拟地址翻译成一个物理地址,并且将它发送到高速缓存/主存。
  4. 高速缓存/主存将所请求的数据字返回 CPU。

  TLB不命中时,MMU从L1缓存中取PTE。

多级页表

  多级页表相当于树结构,它从两个方面减少了内存要求:

  • 如果一级页表中的一个PTE是空的,相应的二级页表就不会存在
  • 只有一级页表才需要总是在主存中。虚拟内存系统可以在需要时创建、页面调入或调出二级页表。
  以32位系统4GB地址空间为例,我们将物理内存分割为虚拟的页面,每个页面保存4KB大小的内容,这样我们总共需要1048576个页面,才能瓜分所有的4GB空间。那么我们的页表要能够完成所有物理内存的映射,就必须要1048576个页表项,由于每个页表项占用4B的空间,那么我们这个页表就需要占用4194304B(4M)的内存空间,每个进程都有这样的一个4M的页表占用着内存空间,才能完成映射。
  加入分级的思想以后,每一级的页表就都只有4KB的大小,数量也有原来的1048576变成了1024个,两级相乘其实表示的数量还是原来那么多。上图所示,一级页表每条PTE负责映射二级页表1024个PTE项,二级页表的每个PTE在映射虚拟存储器中4KB大小的位置。也就是说一级页表每条PTE负责映射一块4M大小的空间,而一级页表总共有1024个页表项,也就能用来映射完成所有物理内存空间。这样做的好处是,如果一级页表中有未被分配的项目,那么这条PTE直接设置成null,不指向任何二级列表,也就不再占用空间。还有一个好处是不是所有的二级列表都需要常驻内存,每个进程只需要在内存中建立一级页表(4kb)大小,二级列表按需要的时候创建调入,这样就更省了。

  虚拟地址被划分为k个vpn和一个vpo,每个vpn i都是一个到第i级页表的索引。

Linux虚拟内存系统

  内核虚拟内存的某些区域被映射到所有进程的共享物理页面,如:每个进程共享内核的代码和全局数据结构。虚拟地址空间有间隙。

  内核为系统中的没给进程单独维护一个单独的任务结构task_struct,包含内核运行进程时所需要的信息(PID,用户栈指针,可执行目标文件的名字,程序计数器)。这个结构中有一个mm字段,指向的是mm_struct中的pgd和mmap,其中pgd是一级页表的基地址,mmap指向的是一个vm_area_structs的链表,每个该链表中的一个元素描述的是当前虚拟地址空间的一个段(text、data、bss等),当内核运行该进程的时候CR3寄存器就被放入了pgd。

Linux缺页异常

  1. 访问地址是否合法:缺页处理程序只需要将这个地址A与vm_area_struct链表中的每个元素的vm_start和vm_end数据(段的起始和结束地址)比较,如果都没有的话,表示该地址不在相应的段中。就是一个段错误。
  2. 保护异常:vm_area_struct中的vm_prot结构是包含了所有页面的读写权限,所以当对只有读权限的文本内容写入数据的时候,就会引发保护异常。
  3. 正常缺页。也就是相应的页面不在物理内存的时候,缺页程序就会锁定一个牺牲页面,将它的内容与实际需要的内容交换过来,当缺页程序返回的时候就可以正常的访问了。

内存映射

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

  1. Linux文件系统中的普通文件:一个区域可以映射到普通磁盘文件的连续部分,如一个可执行目标文件。文件区被分成页片大小,每一页包含虚拟页面的初始内容,因为按需进行页面调度,所以虚拟页面没有实际交换进入物理内存,直到CPU第一次引用该页面(即发射一个虚拟地址,落在地址空间这个页面范围之内),如果区域比文件大,就用0来填充剩下的部分。
  2. 匿名文件:一个区域可以映射到一个匿名文件,匿名文件由内核创建,包含的全是二进制0,cpu第一次引用这样一个区域的虚拟页面时,内核就在屋里内存中找到一个合适的页牺牲,如果这个页面被修改过,就将这个页面换出来,用二进制0覆盖牺牲页面并修改页表。这个页面标记为是驻留内存的,磁盘和内存之间并没有实际传输数据,所以有时也称请求二进制0的页。
  3. 共享对象

  一个对象被映射到虚拟存储器的一个区域,这个区域要么是共享对象,要么是私有对象。如果一个进程A将一个共享对象映射到了它的虚拟存储器中,那么对于也把这个共享对象映射了的其他进程而言,进程A对共享对象的任何读写操作都是可见的,而这些变化也会反映在磁盘原始对象中。


  因为每个对象有唯一的文件名,内核可以判定进程1已经映射了这个对象,可以使用进程2中的页表条目指向相应的物理页面。即使共享对象被映射到了多个共享区域,物理内存也只需要保存一个共享对象的一个副本。

私有对象

  私有对象和共享对象声明周期方式基本相同,在物理内存中只保存私有对象的数据副本。对于每个映射私有对象的进程,相应区域的页表条目都被标记为只读,并且这个区域结构被标记为私有的写时复制,只要没有进程试图写自己的私有区域,他们可以继续共享物理内存中的对象副本,只要有一个进程试图写私有区域的某个页面,会触发写保护故障,此时在内存中创建这个页面的副本,更新页表条目指向新的副本,回复页面的可写权限。

 

再看fork

  当当前进程调用fork函数的时候,内核为新进程创建各种数据结构,并分配PID。为了给新进程创建一个虚拟存储器,它创建的当前进程的mm_struct、区域结构和页表的一个拷贝,内核为两个进程的每个页表标记为只读,并将每个区域标记为私有的写时拷贝。
  当fork函数返回的时候,新进程的虚拟存储器和当前进程的虚拟存储器刚好相同。任何一个进程进行写操作的时候,才会创建新的页面。

再看execv函数

  1. 删除已存在的用户区域:删除当前已存在的用户数据结构。
  2. 映射私有区域:所有的.text、.data、.bss区域都是新创建的,这些区域是私有的、写时拷贝。.bss是匿名文件区域(二进制请求0),大小包含在a.out中;栈、堆也都是二进制请求0的,长度为0。
  3. 映射共享区域:这些共享区域是动态链接到程序然后映射到虚拟地址空间的共享区域。
  4. 设置程序计数器:设置当前进程的上下文计数器,并指向.text入口

使用mmap的用户级内存映射

  该函数要求内核创建一个新的虚拟内存区域,地址最好是从start开始的一个区域,并将描述符fd指定的对象是一个连续的片(chunk)映射到这个新区域,连续对象大小为length字节,start仅是一个暗示,通常指定为NULL。

 

动态内存分配

  1. 内部碎片:已分配块比有效载荷大,比如分配器可能增加块大小满足对齐约束条件
  2. 外部碎片:空闲内存合起来满足一个请求分配,但是没有单独的一个空闲块足够大可以来处理这个请求发生。

隐式空闲链表

  分配器需要一个数据结构来区别块边界,以及区别已分配块和空闲块,大多数分配器将这些信息嵌入块本身。

 

  头部后面是调用malloc时请求的有效载荷,有效载荷后面的填充块大小任意,填充的原因可能是对付外部碎片也可能是满足对其要求。

 

  这个链表(大小/是否分配)是通过头部中的大小字段 隐含连接着的(头部+大小=下一块位置),分配器可以遍历所有的块,在遇到结束位(0/1)处停止。即使是要求分配一个数据块,也要有(8/0)一个头部,两个字来完成。

  称这种结构为隐式链表,因为空闲块是通过头部中的大小字段隐含连接着的。分配器可以遍历堆中所有的块进而遍历空闲块。优点:简单;缺点:放置分配块,要对空闲链表搜索。最重要的是系统对其要求和分配器对块格式的选择会对分配器上的最小块大小有强制要求。

放置已分配的块

  1. 首次适配:从头搜索,遇到第一个合适的块就停止;
  2. 下次适配:从头搜索,遇到下一个合适的块停止;
  3. 最佳适配:全部搜索,选择合适的块停止。

分割空闲块

  适配到合适的空闲块,分配器将空闲块分割成两个部分,一个是分配块,一个是新的空闲块

合并空闲块

  当释放已分配块时,其他空闲块可能与刚释放的空闲块相邻,这些临界快引起假碎片现象。

  虽然释放了两个3字节大小的数据空间,而且空闲的空间相邻,但是就是无法再分配4字节的空间了,这时候就需要进行一般合并:合并的策略是立即合并推迟合并,我们可能不立即推迟合并,如果有空间直接合并不好吗?有时候的确还真不好,如果我们马上合并上图的空间后又申请3字节的块,那么就会开始分割,释放以后立即合并的话,又将是一个合并分割的过程,这样的话推迟合并就有好处了。需要的时候再合并,就不会产生抖动了。

带边界标记的合并

  考虑四种情况

  1. 当前块前后都是已分配
  2. 当前块前分配后空闲
  3. 当前块前空闲后分配
  4. 当前块前后都是空闲

显示空闲链表

  程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放这些空闲块的主体里。使用显示链表,首次分配的时间从块总数的时间减少到了空闲块数量的线性时间。但是显示空闲链表的缺点是空闲块必须足够大,以包含所有需要的指针,以及头部和脚部,导致了更大的最小块,也提高了内部碎片的成都。

分离的空闲链表

  维护多个空闲链表,每个链表中的块有大致相等的大小。分配器维护空闲链表数组,每个大小类(空闲链表的大小)一个空闲链表按照大小的升序排列。分配一个大小为n的块时,他就搜索相应的空闲链表。如果不能找到合适的块相匹配就搜索下一个大小的空闲链表。

  1. 简单分离存储:每个空闲链表包含大小相等的块,分配时检查空闲链表,如果非空就把第一块空闲链表分出去,不分割;如果为空,分配器就像操作系统申请固定大小的额外存储片,将此片分成大小相等的块,释放时,简单的把此块插入到空闲链表的前部。因为不需要合并,已分配的块不需要已分配/空闲标记,不需要脚部。空闲不会被分割,会造成内部碎片,不合并空闲块,会造成外部碎片。
  2. 分离适配:分配器维护空闲链表数组,被组成链表类型是显示或隐式链表。分配时,对空闲链表做首次适配,如果找到合适的块就将其分割,将剩余的空闲链表插入到合适的链表中,如果找不到合适的块,就搜索更大的空闲链表,如果空闲链表中没有合适的块,向操作系统申请额外的堆内存,从新的堆内存中分配一个合适的块,将剩余的插入到适当的大小类中。释放一个块执行合并,将结果插入到响应的空闲链表中。malloc就采用这种方法。
  3. 伙伴系统:分离适配的特例,每个大小类都是2的幂。假设一个堆大小2^m个字。每个块大小2^k空闲链表。0<=k<=m;最开始只有一个2^m个字的空闲块。分配大小2^k的空闲块,找到第一个可用的大小为2^j空闲块k<=j<=m,如果j==k,则完成。否则递归二分这个块直到j==k,分割时剩下的半块(叫作伙伴)被置在相应的空闲链表中,释放大小为2^j的块,继续合并空闲块的伙伴,遇到一个已分配的伙伴时,停止合并。一个块的地址和他伙伴的地址只有一位不同。有点是快速搜索或合并,缺点是大小我2的幂次方可能导致显著内部碎片。

  系统内存中的每个物理内存页(页帧),都对应于一个struct page实例, 每个内存域都关联了一个struct zone的实例,其中保存了用于管理伙伴数据的主要数数组

struct zone
{
     /* free areas of different sizes */
    struct free_area        free_area[MAX_ORDER];
};
//伙伴系统的辅助数据结构
struct free_area {
    struct list_head free_list[MIGRATE_TYPES];//是用于连接空闲页的链表. 页链表包含大小相同的连续内存区
    unsigned long nr_free;//指定了当前内存区中空闲页块的数目(对0阶内存区逐页计算,对1阶内存区计算页对的数目,对2阶内存区计算4页集合的数目,依次类推
};

  阶是伙伴系统中一个非常重要的术语. 它描述了内存分配的数量单位. 内存块的长度是2^0,order , 其中order的范围从0到MAX_ORDER

  zone->free_area[MAX_ORDER]数组中阶作为各个元素的索引, 用于指定对应链表中的连续内存区包含多少个页帧.

  • 数组中第0个元素的阶为0, 它的free_list链表域指向具有包含区为单页(2^0 = 1)的内存页面链表
  • 数组中第1个元素的free_list域管理的内存区为两页(2^1 = 2)
  • 第3个管理的内存区为4页, 依次类推,直到 2^MAXORDER-1个页面大小的块,MAXORDER默认值为11

垃圾收集

  垃圾收集是一种很有用的方法,当使用了malloc分配了空间却忘记了释放,就会造成内存的极大浪费。垃圾收集就是使用特殊的方法,定期回收这部分不使用或者无效的空间。

  从根节点出发到达p有路径时,说p是可达的,任意时刻不可达对应于垃圾是不能再次利用的,垃圾器的作用是维护可达视图的某种表示,释放不可达结点将他们返回给空闲链表,定期回收它们。

posted on 2020-03-20 16:04  tianzeng  阅读(529)  评论(0编辑  收藏  举报

导航