为什么要有虚拟内存?
在早期的计算机中,程序都是直接运行在物理内存上的,程序在运行时访问的地址都是物理地址。如果计算机只运行一个程序时,只要它不超过物理内存的大小,就不会有什么问题。但是为了充分的利用cpu和硬件资源,我们的计算机都是同时运行多个程序的,这时就会有一个问题,如何将计算机上有限的物理内存分配给多个程序使用?
例如程序A需要10MB的内存,程序B需要100MB的内存,程序C需要28MB的内存,我们的计算机有128MB的内存。如果同时运行程序A和 程序B,那么可以将内存的前10MB分配给A,10~110分配给B。这样做似乎也可以让程序正常的运行,但是也会有许多的问题 。
- 地址空间不隔离 所有的程序都要访问物理内存,程序所使用的内存相互之间没有隔离,都是同一块,只是地址不同。这样恶意程序很容易改写其它程序的数据,破坏其它程序。
- 内存使用效率低 当我们的内存空间饱和时,需要执行别的程序,因为上述的内存分配方法,我们需要将不常使用程序的内存暂时写到磁盘,然后将新执行的程序加载到内存中,这样的开销非常大且效率非常低。
- 程序运行地址的不确定 因为程序每次需要加载运行时,我们都需要在内存中分配一块足够大的空闲内存区域,这个空闲区域的地址是不确定的。这给程序的编写造成了一定的麻烦,因为程序编写的时候,它访问数据和指令跳转时的目标地址很多都是固定的,这涉及程序重定位的问题。
虚拟内存的概念
- 摘自维基百科:
虚拟内存是计算机系统内存管理的一种技术。它使得应用程序认为它拥有连续可用的内存(一个连续完整的地址空间),而实际上,它通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换。与没有使用虚拟内存技术的系统相比,使用这种技术的系统使得大型程序的编写变得更容易,对真正的物理内存(例如RAM)的使用也更有效率。 - 注意:虚拟内存不只是用磁盘空间来扩展物理内存的意思——这只是扩充内存级别以使其包含硬盘驱动器而已。对虚拟内存的定义是基于对地址空间的重定义的,即把地址空间定义为“连续的虚拟内存地址”,以借此“欺骗”程序,使它们以为自己正在使用一大块的“连续”地址。
虚拟内存提供了三个重要的功能
- 它将主存看成是一个存储在磁盘上的地址空间的高速缓存,在主存中只保存活动区域。并根据需要在磁盘和主存之间来回传数据,通过这种方式,它高效的适用了主存
- 它为每个进程提供了一致的地址空间,从而简化了内存管理。
- 它保护了每个进程的地址空间不被其它进程破坏。
物理和虚拟寻址
计算机中有两种地址一是物理地址,二是虚拟地址也称逻辑地址。
CPU通过生成一个虚拟地址来访问主存,这个虚拟地址在被传送到内存之前先转换成适当的物理地址。
虚拟内存作为缓存的工具
概念上而言,虚拟内存被组织为一个由存放在磁盘上的N个连续字节的大小的单元组成的数组。每个字节都有一个唯一的虚拟地址,作为到数组的索引。 磁盘上的数组的内容被缓存在主存中。
在任意时刻,虚拟页面的集合都分为三个不相交的子集
- 未分配的: 系统还为创建的页,未创建的页没有任何数据和它们相关联,因此也就不占用任何磁盘空间。
- 缓存的: 当前已缓存在物理内存中的已分配的页。
- 未缓存的: 未缓存在物理内存中已分配的页。
如下图所示:
VP1和VP4被缓存在物理内存中,VP0,VP3,VP5还未分配,因此在磁盘上并不存在,VP2 ,VP4已经分配但并未缓存在主存中。
页表
页表结构
虚拟内存中必须有某种方法来判断一个虚拟页是否缓存在DRAM(动态随机存取存储器)中的某个地方。如果是,系统还必须确定这个虚拟页存放在哪里物理页中。如果不命中,系统必须判断这个虚拟页存放在磁盘的哪个位置,在物理内存中选择一个牺牲页,并将虚拟页从磁盘复制到DRAM中,替换这个牺牲页。
这些功能由软硬件联合提供,包括操作系统,MMU(内存管理单元)中的地址翻译硬件和一个存放在物理内存中叫做页表的数据结构,页表将虚拟页映射到物理页。每次地址翻译硬件将一个虚拟地址转换为物理地址时,都会读取页表。操作系统负责维护页表的内容,以及在磁盘与DRAM之间来回传送页。
为什么要用页来管理内存呢?
- 问题回到文章的开头,内存使用效率低下那。当我们的内存饱和时,如果需要执行新的程序,我们就需要将内存中暂时不用的程序切换出去,留出空间供新程序使用,那这样做的效率是非常低下且开销很大。
- 当一个程序在运行时,它只是频繁的使用一部分数据,程序的很多数据在一个时间段内是不会被用到的。这个时候,科学家们就想到了更小粒度的内存分割和映射方法,使得程序的局部性原理得到充分的利用,大大提高了内存的使用率,这种方法就是分页。
在进程中,操作系统会为每个进程提供一个独立的页表,因而也就是一个独立的虚拟地址空间。这样子更方便了进程之间的管理和数据共享。
进程的建立过程
- 创建一个独立的虚拟地址空间
- 读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系
- 将CPU的指令寄存器设置成可执行文件的入口地址,启动运行。(_start就是入口地址)
首先是创建虚拟地址空间 创建一个虚拟空间实际上并不是创建空间而是创建映射函数所需要的相应的数据结构,在i386的Linux下,创建虚拟地址空间实际上是分配一个页目录(Page Directory)就可以了,甚至不设置页映射关系(是虚拟空间到物理内存的映射关系),这些映射关系等到后面程序发生页错误的时候在进行设置。
读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系。这一步的映射关系是虚拟空间与可执行文件的映射关系。当程序发生错误时,操作系统将从物理内存中分配一个物理页,然后将"缺页"从磁盘读取到内存中,再设置缺页的虚拟页和物理页的映射关系,这样程序才得以正常运行。 但是很明显的一点是,当操作系统捕获到缺页错误时,它应知道程序当前所需要的页在可执行文件中哪一个位置。 这就是虚拟空间与可执行文件之间的映射关系。
将CPU指令寄存器设置成可执行文件的入口,启动程序。第三步也是最简单的一步,操作系统通过设置CPU的指令寄存器将控制权交给进程,由此进程开始执行。这一步在操作系统层面上比较复杂,它涉及内核堆栈和用户堆栈的切换、cpu运行权限的切换。不过从进程的角度看这一步可以简单地认为操作系统执行了一条跳转指令,直接跳转到可执行文件的入口地址。入口地址就是ELF文件中保存的_start地址。
页错误
上面的步骤执行完以后,其实可执行文件的真正指令和数据没有被装入到内存中。
当进程执行过程中,发现有空页面,于是就认为是一个页错误(Page Fault)。CPU将控制权交给操作系统,操作系统有专门的页错误处理例程来处理这种情况,这时候我们前面提到的装载过程建立的数据结构(虚拟空间与可执行文件的映射关系)起到了关键的作用,操作系统将查询这个数据结构,然后找到空页面的VMA(虚拟内存区域),计算出相应的页面在可执行文件中的偏移,然后在物理内中分配一个物理页面,将进程中该虚拟页与分配的物理页之间建立映射关系,然后把控制权交还给进程,进程从刚才页错误的位置重新开始进行。
进程是如何进行数据共享的呢?
点这里
看完这篇文章之后,就了解到了虚拟内存可以简化进程之间的操作,并且节省了很多的资源。
地址翻译
页命中时
1.处理器生成一个虚拟地址,并把它传送个MMU(地址翻译)。
2.MMU生成PTE(页表条目)地址,并从高速缓存/主存中请求得到它
3. 高速缓存/主存 向MMU返回PTE
4. MMU构造物理地址,并把它传送给高速缓存/主存
5. 高速缓存/主存返回所请求的数据字给处理器。
(此图片来字手机拍摄csapp)
当页未命中时,需要由硬件和操作系统协作完成
1.处理器生成一个虚拟地址,并把它传送个MMU(地址翻译)。
2.MMU生成PTE(页表条目)地址,并从高速缓存/主存中请求得到它
3. 高速缓存/主存 向MMU返回PTE
4.PTE中的有效位是0,所以MMU触发了一次异常,传递CPU中的控制到操作系统内核中的缺页异常处理程序。
5.缺页处理程序确定出物理内存中的牺牲页,如果这个页面已经被修改,则把它换出到磁盘,并且将修改内容写入磁盘中。
6.缺页处理程序调入新的页面,并更新保存PTE。
虚拟内存的优点
1.既然每个进程的内存空间都是一致而且固定的,所以链接器在链接可执行文件时,可以设定内存地址,而不用去管这些数据最终实际的内存地址,这是有独立内存空间的好处。
2.当不同的进程使用同样的代码时,比如库文件中的代码,物理内存中可以只存储一份这样的代码,不同的进程只需要把自己的虚拟内存映射过去就可以了,节省内存。
3.在程序需要分配连续的内存空间的时候,只需要在虚拟内存空间分配连续空间,而不需要实际物理内存的连续空间,可以利用碎片,有效的利用了物理内存。
另外,事实上,在每个进程创建加载时,内核只是为进程“创建”了虚拟内存的布局,具体就是初始化进程控制表中内存相关的链表,实际上并不立即就把虚拟内存对应位置的程序数据和代码(比如.data段)拷贝到物理内存中,只是建立好虚拟内存和磁盘文件之间的映射就好(叫做存储器映射),等到运行到对应的程序时,才会通过缺页异常,来拷贝数据。还有进程运行过程中,要动态分配内存,比如malloc时,也只是分配了虚拟内存,即为这块虚拟内存对应的页表项做相应设置,当进程真正访问到此数据时,才引发缺页异常。
缺页处理流程此时会转入到内核为该异常创建的对应异常处理函数去执行,内核此处的代码首先会遍历当前进程的vm_area_struct链表,检查该地址是否在许可的地址范围内,如果是为其申请物理内存并建立映射。之后返回到触发了异常的代码处继续执行,所以程序会接着运行下去。如果发现该地址是非法的地址,内核为给进程发信号SIGSEGV,该信号的默认处理函数即会打印出段错误,然后结束进程。