关于虚拟内存(VM)的理解
虚拟内存提供了CPU和实际的物理内存之间的一层抽象,在多个方面体现出了它的益处,本篇笔记则针对自己的理解,对于虚拟内存做出一定的总结。
总起
如图所示,在CPU和主存之间设有一个内存管理单元(MMU)用于对虚拟地址(VA)和物理地址(PA)之间进行转换,我们的问题是,为什么要费这么大劲实现这样的一层抽象呢?显然如果是直接的物理寻址,也不是不可以。这也是这篇文章要去搞懂和弄明白的地方。
基本理解
我们的目的是明确VM存在的意义,而在这之前,则首先要深入理解VM是如何运行的,也就是我们怎么来使用VM。如何使用VM?我觉得核心点在于对于分层存储结构有所了解,并在此基础上理解页表的运行机制。
分层存储结构
VM的结构和分层存储结构是异曲同工的。在分层存储结构中,基于越优质、读写速度越快的存储空间,其造价越昂贵的特点,工程师们将存储器抽象为一个金字塔形状的器件,而在金字塔中,速度更快、空间更小的第L层,为速度更慢、空间更大的第L+1层提供cache服务,当需要使用第L+1层的数据时,系统先查询第L层是否缓存有对应的数据,若有,则直接从第L层读取,否则,先从第L+1层把数据缓存至第L层,再读取第L层,这样的目的显然是为了更快。需要提及的是,相邻两层之间以块进行数据交互,所谓块,也就是多个字节构成的数据包。
说了这么一大堆,我想表达的意思就是,在某种程度上,也可以将VM和物理内存之间看作是一种分层存储结构,如果VM的地址空间为n,则VM表示了在磁盘上的一块连续的,包含个字节的数组空间,而另一方面,我们将物理内存的地址空间设为m,一般情况下,。我们将物理内存视为第L层,将地址空间更大的VM视为第L+1层(当然,在某些情况下,VM的地址空间也可能更小,这会在后面进行讨论),这样的视角可以加速我们对于VM的理解,因为VM存在的作用之一,就是为了解决主存不够的情况。
页表
上面介绍了VM和PM之间的基本关系,接下来就是对于页表的详细理解。当程序以虚拟内存进行寻址时,系统总归是需要找到对应数据在物理内存上的真实位置的,而这也就是页表的功能。理解页表的寻址流程也就是理解虚拟地址具体是怎样向物理地址进行过度的。
页表是始终存放在主存之中的,并且每个进程都独立拥有自己的页表,前面提到,分层存储结构的块的概念,而在VM之中,块就对应着页,也就是每个页表的一条entry。内存作为磁盘的高速缓存,以页为单位,将多个页缓存于内存之中。
当系统需要访问某个数据时,首先判断该数据对应的页是否已缓存于主存之中,假设该页为,那么系统将查询页表的第2条(下标从0开始)entry。此时对应着关键概念,虚拟页()在页表中的entry索引总是固定的。
随后,查询对应entry中的标记位,如果为1,说明已经缓存到了内存中,那么该条entry后面的地址部分就是对应的物理地址;如果为0,则对应两种情况,① 如果地址部分为null,说明对应的页空间尚未被分配,通俗的来讲,也就是啥都没有,主存里没有,磁盘里也没有,② 如果地址部分有值,则指示了其在磁盘上的位置。上面分别就代表了的3种状态:已缓存、未分配、未缓存。
如果VP为已缓存,那么直接根据页表记录的物理地址从主存中读出数据即可;而如果是未缓存,则需要先从磁盘上将对应的VP读取至主存中,(必要时甚至需要牺牲某些之前缓存在主存中的VP,这涉及到了相应的调度算法)再从主存中进行读取。
总结一下关于页表的一些关键知识点:
- 虚拟页()在页表中的entry索引总是固定的
- 页是主存和磁盘的数据通信单位,而虚拟页(VP)和物理页(PP)一一映射
- 页表指示了对应VP在内存或者磁盘中的位置
- 虚拟页包含3种状态:已缓存、未分配、未缓存
缺页
我们常见的一个程序错误,sigment fault
,就和缺页有着密切的关系。所谓缺页,分为3种情况。
- sigment fault
所谓的"段错误",就是程序试图去访问一个不存在的页面,就是说,此时该虚拟地址A对应的页表还处于未分配的状态,其在页表对应的entry中的地址字段为null。具体而言,有可能是访问了一个"野指针",或者一些其他情况。 - 保护异常
有可能虚拟地址A是有值的,但还是会触发异常,这可能是因为程序的权限问题造成。比如说试图去写入一个只读的虚拟页,又或者该虚拟页只有root可以访问,而程序是在普通用户下进行执行的。 - 正常缺页
正常缺页也就是"未缓存"了,此时程序将由内核接管,内核选择牺牲页,将其换出,并把目标页缓存至内存中,随后程序重新通过虚拟地址对数据进行访问。
为什么要使用虚拟内存
基于上面的认识,我们很容易理解到,因为虚拟内存的存在,由于swap的机制,使得系统即使在物理内存受限的情况下,也可以向进程分配一个比较充足的空间。但这并不是说虚拟内存就是为了这一个目的而服务,事实上,虚拟内存更重要的一点是为多个进程实现一个统一的管理的机制,为下面几个点提供了更为简洁的解决方案。
内存映射
简单谈一谈我对内存映射的理解。
前面也已经提到了,因为VM的存在,每个进程都独立的拥有一个"完整"的内存空间,这也为映射机制打下了良好的基础。我们都知道,不同的进程会使用一些相同的程序,比如说printf这种基础的标准库函数,或者更一般的,就是多个进程想要读取同一个磁盘上的文件。如果每个进程都在内存中保存一个标准库或者磁盘文件的完整副本,那样开销就太大了。内存映射就是为了解决这样的问题。系统将一个共享的文件映射到各个进程的虚拟地址空间之中,注意,各个进程中的共享文件的虚拟地址可能不相同,但它们都对应着物理内存里面的一个唯一的共享文件副本,从而达到各个进程相互独立地占有唯一一个文件的目的。
而如果进程想要对这些文件进行写操作该怎么办呢(当然,对于本身没有写权限的文件就不讨论了)?我们将映射到虚拟内存中的文件分类为共享对象或私有对象。所谓共享对象,就是所有进程都知道这个文件是被共享了的,那么,直接修改即可;而对于私有对象,就是说进程以为自己是独立拥有这个文件的,那么当要进行写操作的时候,执行写时复制。所谓写时复制,就是说,当对象刚被映射到虚拟地址中的时候,其实还是只有一个副本,而如果要写入了,那么就为这个对象对应的虚拟页重新生成一个副本,并进行写入。显然,这样一来,各个进程之间就不会相互干扰。
简化链接
每个进程都拥有相似的内存映像,比如说对于64位程序而言,代码段总是从0x400000,这样的内存映像和其真实物理内存位置是相互独立的,因此在链接的过程中,ld可以将需要用到的目标文件中的符号链接到统一的虚拟地址中,而无需去了解其真实位置。
简化加载
比如当需要加载可执行文件的时候,系统就在页表上新建entry(状态为未缓存),并使这些entry指向文件在磁盘上的真实位置。当程序真正需要引用文件的时候,虚拟内存系统执行缓存步骤。
网友评论