本文完全根据CSAPP第九章的结构,总结了虚拟内存的知识点。
虚拟内存是一种对内存的抽象,可以自动的完成内存管理的相关工作,并不需要应用程序员来干预。
到目前为止,我们把内存理解为一个连续的物理字节数组,以CPU一条指令为例,生成了一个物理地址,把地址发送给内存,然后内存从该地址获取其保存的字,然后将其发送回CPU,这就是物理寻址。
但这并不是大多数系统的工作方式,包括手机,台式机和服务器,这些系统反而会虚拟化这个主存储器。
在主存储器资源中,这些地址请求实际上是由一块称为MMU的内存管理单元的硬件处理。
MMU工作方式:CPU来执行一个移动指令,产生一些地址的影响,生成一个虚拟地址,CPU将该地址发送给MMU,进行一个称为地址转换的过程,然后将上图中虚拟地址4100转换为物理地址4,这对应于我们想要的数据对象的地址,一旦MMU将虚拟地址转换为物理地址,然后内存就能返回该地址的数据。
几个术语:如下图
地址空间:一组地址集合
**线性地址空间:**连续的非负整数集合
**虚拟地址空间:**一组N = 2 ^ n个虚拟地址,线性的是线性地址空间
**物理地址空间:**一组M = 2 ^ m的物理地址
几个要注意的点:
从概念上讲,可以将虚拟内存视为存储在磁盘上的字节序列,然后存储在磁盘上的虚拟内存的内容缓存在DRAM中,它在主存中缓存虚拟页,就像任何缓存一样,数据被分解成块,虚拟内存系统的那些块称为页面,它们通常比缓存块大得多,因此,从概念上讲,虚拟内存可以被视为存储在磁盘上的一系列页面。
这些页面中的每一个都将标识一个数字,如上图VP0(虚拟页面0),VP1(虚拟页面1),这些页面的子集存储在物理DRAM存储器中。
映射:
映射的作用是告诉我们哪些页面已被缓存。
如上图,我们在DRAM中的某个地方缓存了三个虚拟页面,虚拟页码与它映射到的物理页码之间没有关系,其中一些页面未缓存,因此它们仍存储在磁盘上,所以在这种情况下,VP2仍然存储在磁盘上,并且有一些页面甚至没有分配,因此它们不存在于磁盘上,地址空间中的每一页都是48位大小,我们真的不想将所有这些存储在磁盘上,因此大多数地址空间都是未分配的,而VP1、VP4、VP6被缓存在物理内存中。
这一块原书写的很好。简单概括一下,我们原来使用的Cache是位于主存和CPU之间的高级缓存(SRAM)结构,而这里使用的是基于DRAM的主存和硬盘之间的缓存结构。显而易见的,主存和硬盘结构的读取速度远远远远慢于cache和主存结构的读取速度,那么当发生不命中情况时产生的开销将是巨大的,所以DRAM缓存的组织结构完全是由巨大的不命中开销驱动的为了削弱这份巨大的开销,采取以下措施。
页表在内存中是一种数据结构,它能跟踪虚拟页面存储的位置,操作系统内核负责维护页表的内容,以及在磁盘和DRAM之间来回传送页,每个进程都有自己的页表。
页表就是一个页表条目(Page Table Entry)(PTE)的数组,其中PTR k包含DRAM中物理页面k的物理地址。Valid表示有效位,如果为1说明在内存中,如果为0则是其他情况。
三种映射:
1、这里我们有一个案例,这个PTE 1对应虚拟页面1,它表示虚拟页面1被映射到物理页面0,虚拟页面2映射到物理页面1,依此类推。
2、其中一些不在内存中的页面但已分配的页面存储在磁盘上,对于这些页面,页表条目包含指向该页面在磁盘上的位置的指针。将其视为逻辑块编号,可在磁盘上找到该页面。
3、然后是一些未分配页面,因此页表中有一个空条目。
查询页表过程:
1、命中
CPU执行移动指令生成一个虚拟地址,当存在对虚拟地址空间中的字的引用时,会发生页面命中,这包含在缓存在DRAM中的页面中,比如此虚拟地址位于虚拟页面2中的某个位置,MMU进行地址翻译定位页表条目号2,并且在内存中读取出它的物理地址(如果有效位为1)。在这种情况下,页面在内存中,它被缓存在内存中。
2、未命中
如果在DRAM中未命中,如虚拟页面VP3存储在磁盘上,所以现在触发异常,发生缺页,这导致控制转移到内核中处理称为页面错误处理程序的一大块代码,然后选择要被替换的页面,如下图是虚拟页面4。
然后从磁盘中获取虚拟页面3,将其加载到内存中,然后更新此页表条目,如果被换下的虚拟页面4被修改过,那么也必须将它的内容写入磁盘。当处理程序将虚拟页面3复制到内存后,重新执行导致页面错误的指令。接下来就是MMU来执行命中的情况了。
3、未分配
现在我们可以分配一个新的记忆页面,在此示例中,未分配虚拟页面5,内核或者malloc函数在磁盘上创建空间并更新PTE 5,指向新创建的这个页面,如下图。
局部性
上述操作如果一直进行,页面换进换出,这将是非常大的开销,但局部性原则保证在任意时刻,程序将趋向于在一个较小的活动页面集合上工作,这个集合叫工作集或常驻集合,也就是除了初始开销中将工作集页面调入内存的操作外,其余大部分操作都是命中的,而不会产生磁盘开销。
抖动:由于工作集大小超出物理内存的大小,页面不断地换进换出,程序此时性能非常之慢。
虚拟内存极大地简化了内核的各种内存管理。
内核通过为每个进程提供自己独立的页表来实现每个进程都有自己的虚拟地址空间,每个进程的页表都映射该进程的虚拟地址空间可以到DRAM中的任何位置,并且可以将不同的虚拟页面和不同的进程映射到不同的物理页面。
在进程1中,虚拟页面1映射到物理页面2。但在过程2中,虚拟页面1被映射到物理页面8。
通过这种方式,我们可以为每个人,程序员和工具提供一个视图
每个进程都有一个非常相似的地址空间虚拟地址空间,相同大小的地址空间代码和数据从同一个地方开始,但随后处理的实际页面可能会分散在内存中,它为我们提供了使用内存的最有效方式。
简化共享
在不同时间,相同的虚拟页面可以在不同的时间存储在不同的物理页面中,它提供了最灵活的调度自由度。
对于多个进程共享某些代码或数据,这是一种非常简单的直接方式,你所做的只是这些不同进程中的页表条目只需指向相同的物理页面即可。
在进程1和进程2的每个页表中,虚拟页面2都指向物理页面6,这就是共享库的实现。
所以lib.c与系统上运行的每个进程的代码相同,lib.c只需要一次加载到物理内存中,然后想要访问lib.c中的函数和数据的映射,虚拟地址空间中的页面指向实际加载lib.c的物理页面,现在系统中只有一个lib.c的副本,但每个进程都认为它有自己的副本。
简化链接
链接器现在可以假设每个程序都将在完全相同的位置加载,所以链接器提前知道所有内容将会是什么,然后它可以解决它可以相应地重新定位所有这些引用,现在它确实使加载变得简单
简化加载
要把.text和.data节加载到一个新创建的进程中,Linux加载器为代码和数据段分配虚拟页,把它们标记为无效的,将页表条目指向目标文件中适当的位置,这样加载器就不需要从磁盘复制任何数据到内存中,只需要等页面初次使用时,触发未命中,自动调入数据页。
通过在PTE上添加额外位来实现访问控制,这保证了内存安全,也能区分不同进程的私有内存,不同进程只能根据访问权限访问修改虚拟页面。
如上图,SUP表示是否运行在内核模式下才能访问该页,READ和WRITE位控制对页面的读和写访问。
例如,如果进程i运行在用户模式下,那么它有读VP0和读写VP1的权限,然而不能访问VP2。
如果有指令违反了这些许可条件,那么CPU触发一个一般保护故障,将控制传给内核中的异常处理程序,Linux shell一般将这种异常报告为段错误。
现在有M个物理地址和N个虚拟地址,我们有一个map函数可以将映射从V映射到P,对于虚拟地址a,如果对应的数据在物理地址中,则映射为a’;同样如果虚拟地址a处的数据不在物理地址中(磁盘中或未分配)则映射为空集。
TLB是一个在CPU中的小缓存,称为快表。
我们给了n位虚拟地址,前p位对应于页面偏移,这类似于我们在缓存中看到的块偏移量,然后剩余的位对应于虚拟页码。
现在页面表的开头是由这个基于页面表的寄存器指向的,在英特尔系统上,它被称为CR3或控制寄存器3,该寄存器包含内存中页表的物理地址,当CPU呈现虚拟页面时,MMU将虚拟页码用作页表的索引,然后它标识一个页表条目,其中包含是否映射到内存中,物理地址的物理页面偏移部分来自虚拟页面偏移,虚拟块中的偏移量将与物理块中的偏移量相同。
MMU通过缓存页表条目来加速这个翻译过程,在MMU内的有称为转换后备缓冲区或TLB的硬件缓存,缓存的是PTE页表条目,它包含最近使用的页表条目的缓存,TLB使用虚拟地址的VPN部分来访问它,它有一个集合索引,它有一个标记,其余位用于消除任何Cache行或PTE的歧义,所以TLBI(TLB索引)映射到这个特定的集合,TLB使用TLBT位消除歧义并确定它正在寻找的PTE是否真的存储在缓存中。
CPU生成一个虚拟地址通过MMU,MMU并不是查看内存然后直接转到页表条目,它首先将VPN发送给TLB,确定这个虚拟页面是否存在对应的PTE,如果确实如此,则TLB返回命中,然后它返回MMU可用于构造物理地址的页表条目,发送到缓存和内存系统,最终发回数据。
MMU检查这个VPN的TLB,它未命中,所以MMU必须像以前一样去内存,然后一切都是相同的,内存将PTE返回到MMU,并将其存储在TLB中,如果PTE已被修改,则必须将其写回,最终,MMU使用它来构建物理地址,然后将数据发回。
假设我们有4千字节的页面,x86-64系统有效地址空间是48位,然后,我们有一个8字节的页表条目,我们需要一个512MB字节的页表,地址空间2 ^ 48个字节,每页除以2 ^ 12个字节,好的,这就是我们需要的页表条目数,然后每个页表项的大小为8个字节,因此,我们需要几乎1TB的DRAM来保持页面表的正确性,那么显然这是不可能的,也显然不是他们的页表真正实现的方式,真正的解决方案是使用页表的层次结构。
在我们这里,如果我们有一个两级页表,第一级页面表内存中永远不会丢失,然后就是一套二级页面表的序列,它们的大小都相同,第一级1表指向第二级1表的开头,因此它包含指向基础的物理地址,第二级1表指向第二级2表,依此类推,如果我们有这种系统,大部分虚拟地址空间未使用,我们可以避免创建许多不必要的页表。
所以让我们看一下上图这个例子中的虚拟地址空间,我为这个程序分配了两个k页的代码和数据,然后是他们的6k未分配页面,这是一个24页的大小,但是大多数都是未分配的,我只为堆栈顶部分配了一个,我只需要三个级别2的页面表,第一页表格涵盖了我的代码和数据的这个区域,第1024页,下一页表格涵盖剩余的1024页,所以这两个二级页表涵盖了所有的代码和数据,类似我需要的堆栈我只需要一个页面表,它只有一个有效的PTE,这是最后一个,然后我有一个一级表,指向三个二级表,所以四个页面表已经覆盖了整个虚拟地址空间,现在,MMU使用这些多页表进行地址转换的方式如下,,我们再次有一个虚拟页面偏移量,它由第一个p位组成,然后VPN剩下的比特给VPN,对于k级页面表被分解为k个子,每个都是相同的大小,上层VPN由VPN的最高位组成。
页表基址寄存器指向的是前面的内容,所以VPN1是1级页面表的索引,1级页面表指向级别2的地址,这是1级表中的PTE条目,指向某个2级页面表的地址,或者它包含某些2级页表的地址,所以指向这个2级页面表的基础,然后将 2位用作该2级表的索引,依此类推,所以最终你得到了一个PTE和k-1级表,指向级别k页面表的开头,VPN k指向该表中的偏移量,最后包含我们要访问的页面的物理地址,然后该物理地址用于形成物理地址的PPN部分,接下来直接把虚拟页面偏移复制到物理页面偏移量就构成了物理地址。
假设我们拥有这个具有14位虚拟地址、12位物理地址、页面大小为64字节的简单内存系统。
所以在我们这里我们需要6个VPO位偏移位,然后剩下的比特是虚拟页码。
物理页面需要6个偏移位,其余位构成物理页码。
我们这个系统中的TLB有16个条目,它是4路组相联,这些页表条目由虚拟页码唯一标识,所以我们只需要使用VPN来访问TLB中的条目,我们有16个条目4路组相联,所以总共有4组,因此我们使用低位两个低位和VPN作为索引,然后剩余的位就作为标记位,最左侧的Set实际上不存在。
我们还有一个页面表,只显示前16个条目,每个页表条目由一个有效的位和物理页码组成,如果有效位为1,则表示该页面在内存中,PPN字段给出物理页码,VPN列实际上不存在于页表中。
现在我们有一个简单的直接映射缓存,它包含16组,每组有一行,一行有一个四字节的块大小,所以CO为2位表示偏移,CI为4位表示索引,剩余位给CT作为标志位。如果命中,读出数据返回给MMU,随后MMU将它返回给CPU。
在这个包中有四个核心,每个核心都是独立的cpu,并且可以分别执行指令。
每个核心都有一个寄存器,然后是一些取指令的硬件。
它有2个L1缓存,有一个称为d-cache的数据缓存用于获取数据,并且它保存从内存中获取的数据。然后有一个称为i-cache的指令缓存,其中包含从代码区域的代码中获取的指令。因此,d-cache仅具有数据,i-cache仅具有指令,每个cache32k字节8路组相联,因此它们非常小,但关联性相当高。
然后层次结构中的下一级是L2统一缓存,可以同时保存指令和数据,大小为256k字节,也8路组相联。
这两个缓存都在核心内部。
在外面有一个所有内核共享的L3缓存,大小为8M字节,16路组相联。
访问L1四个周期,访问L2大约10个周期,访问L3大约30-50个周期。
MMU还有一个TLB层次结构,有一个小的L1数据TLB和一个单独的指令TLB。数据TLB有64个条目,它是4路组关联。指令TLB有更多条目,有可能是因为指令丢失的惩罚更大。L2二级缓存是为了增加命中率。
核外有一个内存控制器计数,它从内存中获取数据。
然后有其他内核到I / O桥的链接。
cpu生成一个虚拟地址(48位),有4k大小的块,所以有12个偏移位,所以是36个VPN位。
首先,我们查看TLB中的缓存,有16个TLB条目,分解为4个TLBI索引位和32个标记位,在TLB中查看是否可以找到包含该虚拟地址的相应物理页码的PTE,如果我们有一个命中,那么MMU就可以构建物理地址,复制之前的VPO当作PPO即可形成物理地址。如果TLB未命中,那么系统必须从页表中获取相应的PPN,页表使用多级页表,最终找到一个页表条目,从中提取PPN并与PPO连接以形成物理地址。
然后MMU将该物理地址传递给缓存,L1数据缓存有64组,所以我们需要六个缓存索引位,我们使用缓存查找此物理地址,如果有一个命中,它将读取的数据返回给CPU,如果未命中,接着向L2、L3或主存储器请求数据,如果发生缺页,再向磁盘请求数据。
每个进程的内核都维护着一个PT的物理地址,内核在一个名为CR3的寄存器中记录了L1的物理页表首地址,这里VPN分为四级,每个VPN9位,L1PT覆盖了512G内存,因为L4每个PTE覆盖4KB,L3覆盖了29个L4,即覆盖了221(2MB)内存,以此类推。
因为VPO和PPO完全相同,所以可以在MMU地址翻译过程中,就把VPO送给L1Cache,这时就能获取CI索引,只需等地址翻译完成获得CT标志位就能去寻找数据,所以在L1缓存中具备了一定的并行性,加速了L1缓存访问。
程序的代码,即.text段,总是位于0x400000这个虚拟地址上,接下来时可执行二进制文件的初始化数据,也就是.data段,紧接着时未初始化数据,即可执行二进制文件中所定义的.bss段,然后是堆,它从未初始化数据段开始网上增长,有一个进程的全局变量brk指向堆顶,所以内核能够指定进程的堆顶。
接着是共享库的内存映射区域,在用户可以访问内存的顶部,有一个向下生长的用户栈,寄存器%rsp指向它的栈顶,内核代码和数据在地址空间的上部。
实际上在用户栈底和内核代码之间还有空白的地址空间,原因是在intel的体系结构中,虚拟地址是48位的,如果虚拟地址最高位是0,所有未使用的16位需要设置为0,否则设置为1(有点类似符号扩展),可以理解为内核符号位是1,用户符号位是0(或者反过来)。
内核为每个进程保存了与进程相关的数据结构,进程的上下文,对于每个进程来说,进程的上下文是不一样的。
Linux将内存组织成一些区域的集合,一个区域就是已经存在着的虚拟内存的连续片,这些页以某种方式相关联。
内核为系统中的每个进程维护一个单独的任务结构,它包含一个指针,指向mm_struct。
mm_struct有很多字段,如下:
缺页异常:在MMU翻译某个地址时,发现地址对应的页表不在内存中。
默认的错误处理需要处理可能发生的多种情况:
**内存映射:**虚拟内存区域初始化时和磁盘上的对象关联起来的过程
当我们第一次引用到某个区域,它的初始值来自磁盘上的普通文件,例如当该区域是代码区,那么这个区域会被映射到一个可执行文件的某个部分,这就是为什么可以来回的从磁盘拷贝数据到内存。
文件也可以是一个匿名文件,匿名文件是由内核创建,包含的全是二进制的0,匿名文件的大小是任意的,但只含有0,当然这个文件并不真实存在,它只是一个技巧,允许我们创建一个全为0的页,如果一个页面和匿名文件关联在一起,当第一次引用页面的时候,会分配一个物理页,并初始化全为0,所以这也叫做请求二进制全零的页。
一旦一个被匿名文件初始化的页被修改过,它需要同步到文件中,只不过这里是同步到内核维护的专门的交换文件。
正常的进程不会和其他进程有任何共享的东西,但是现在利用内存映射可以实现进程间共享对象,因为进程可以映射虚拟内存的区域到同一个对象。
假设我们有两个进程,它们都有格子不同的虚拟地址空间,它们的虚拟页都被映射到物理内存的某个部分,我们假设有一个区域,是进程1的某个段,被映射到这个对象,也就是一个文件的某个部分。现在,进程2也把相同的对象映射到字节的虚拟地址空间(两个进程是不同的虚拟地址空间),就实现了共享。
如果我们想要实现私有访问,对象被标记为私有写时复制而不是共享,那么当进程对这个区域进行写操作,它并不会同步写操作到共享对象的物理内存,而是将页面拷贝一份,一个独立的拷贝页,并且把它映射到一个没有使用的物理地址。
如果你想fork一个进程,最简单的方式就是拷贝,你需要拷贝完整的地址空间,一个独立,但是一模一样的地址空间,如果使用这种简单的方式实现,你需要拷贝所有的页表,所有的用户数据结构和内存。
当我们执行fork,内核只拷贝所以的内核数据结构mm_struct和area_struct以及页表,然后内核把两个进程中每个页面都标记为只读,然后把每一个area_struct都标记为私有写时复制,然后fork返回,此时两个进程共享相同的物理页面,如果只读那么读同一个物理页面,如果写,会创建一个新页面。
过程是:当一个进程去写一个页面,而这个页面的PTE被标记为只读,这会触发一个异常,内核查找访问地址的标志,发现那个页面被标记为私有写时复制,所以它拷贝了那个页面,并把它映射到一个物理地址空间的新区域,当异常处理返回的时候,重新执行写指令,写执行的对象就是拷贝的页面了。
它删除了当前进程的所有area_struct和页表,然后为新区域创建了新的area_struct和页表,然后程序初始化数据,这些区域被映射为一个可执行文件。
.bss段被定义为私有并且是请求二进制零的,因为这段是未初始化的,堆区的页面页都是私有和请求二进制零的。
execve做的仅仅是删除已存在的区域,创建一个新区域(私有写时复制的),并把它映射到你想执行的对象文件中,创建.bss和栈,然后把它映射到一个匿名文件中,再创建一个内存映射区域映射到lib.c,然后把程序计数器%rip设置为代码区域的入口点。所以当Linux执行第一条指令遇到的代码和数据没有时,会陷入异常,所以加载不得不推迟,直到加载一个页面的代码和数据,加载推迟到页面实际被引用或访问。
内核提供了一个mmap函数,它允许你向内核一样运行内存映射,它是一个基本的系统调用。
mmap有一个指针的参数,是一个指向虚拟地址空间的指针,mmap函数尝试将这个地址开始,长度为length字节的区域,映射到有这个文件描述符确定的某个对象的offset偏移位置。
用户可以指定不同的标志,指定这个页面的保护类型(是私有的,只读的或是可读可写的),也可以指定文件对象的类型,如果映射为匿名文件,你设置flag,然后你可以得到一个请求二进制零页面。
一个示例:
动态内存分配部分单独写一篇博客,虚拟内存这一章内容太多了,一篇放不下了/(ㄒoㄒ)/~~。