概述
本文出自《程序员的自我修养——链接、装载与库》,属于个人学习笔记
在早期的的计算机中,程序是直接运行在物理内存上的,也就是说程序在运行时所访问的地址都是物理地址。当然,如果一个计算机同时只运行一个程序,那么只要程序要求的内存空间不超过物理内存大小,运行就不会有问题。但是为了更有效地利用硬件资源,我们有时候需要同时运行多个程序。那么很明显的一个问题是,如何将计算机上有限的物理内存分配给多个程序使用。
假设我们的计算机内有100MB内存,程序A运行需要10MB,程序B运行需要80MB,程序C需要30MB。如果我们要同时运行程序A和B,那么只需要把程序0 ~ 10MB分配给A,程10 ~ 90MB分配给程序B就可以了。但是这种分配策略存在很多问题。
-
地址空间不隔离
所有程序都直接访问物理地址,程序所使用的的内存空间不是相互隔离的。恶意程序可以恶意的改写其他程序的内存数据,已达到破坏的目的;有些非恶意的也有可能不小心修改了其他程序的数据,就会影响其他程序的正常运行,给应用程序带来安全隐患。 -
内存使用效率低
由于没有有效的内存管理机制,通常一个程序执行时,监控程序就会将整个程序装入内存中然后开始执行。以上情况中,在运行A和B的情况下,如果我们忽然又要运行程序C,那么这时候的内存空间肯定是不够的,这时候我们只能是先将其他程序暂时写到磁盘里面去,等要用到了在读回来。由于程序所需要的空间是连续的,那么如果我们将程序A换到磁盘所释放出的内存空间是不够的,所以只能将B换出到磁盘,然后将C读入到内存开始运行。在这个过程中有大量的数据在换入换出,导致效率十分低下。 -
程序运行的地址不确定
因为程序每次需要装入运行时,我们都需要给他重新从内存中分配出一块足够大的空闲内存区域,这个空闲区域的位置是不确定的,这个程序的编写造成了一定的麻烦,因为程序在编写时,他访问数据和指令跳转时的目标地址很多都是固定的,这涉及程序的重定位问题。
解决这个问题的思路是:增加中间层,即使用一种间接的地址访问方法。我们把程序给出的地址看做是一个虚拟地址(Virtual Address),然后通过某些映射的方法,将这个虚拟地址转换成实际的物理地址。这样,只要我们管理好虚拟地址到物理地址的映射过程,就可以保证任意一个程序所能够访问的物理内存区域跟另外一个程序相互不重叠,已达到地址空间隔离的目的。
分段
关于地址隔离的问题,最开始人们使用的一种叫做分段(Segmentation)的方法,基本思路是把一段与程序所需要的内存空间大小的虚拟空间映射到某个地址空间。比如程序A需要10MB的内存空间,那么我们假设有一个地址从0x00000000到0x00A00000的10MB大小的一个假想空间,也就是虚拟空间,然后我们从实际的内存中分配一个相同大小的物理地址空间,假设物理地址是0x00100000开始到0x00B00000结束的一块空间。把然后我们把两块相同的地址空间一一映射,即虚拟空间的每个字节对应物理空间中的每个字节。这个映射过程有软件来设置,比如操作系统来设置这个映射函数,实际的地址转换有硬件来完成。比如当程序A访问虚拟地址0x00001000时,CPU会将这个地址转换成实际的物理地址0x00101000。那么比如程序A和程序B在运行时,他们的虚拟地址空间和物理地址空间映射关系如下图所示:
分段的方法基本解决了上面提到的3个问题中的第一个和第三个。首先它做到了地址隔离,因为程序A和程序B分别被映射到两块不同的区域,他们之间没有任何重叠。程序只能访问自己虚拟地址空间范围内。如果程序A访问虚拟空间地址超过了0x00000000 ~ 0x00A00000这个范围,那么硬件就会根据地址映射表判断这是非法访问,拒绝这个请求,并将这个请求报告给操作系统或相关监控程序,由它来决定如何处理。再者,对于每个程序来说,无论他们被分配到物理地址的哪个区域,对于程序来说都是透明的,程序不需要关心物理地址的变化,它们只需要按照0x00000000 ~ 0x00A00000的虚拟地址空间来编写程序、放置变量,所以程序不再需要重定位。
但是分段的这种方法还是没有解决我们的第二个问题,即内存使用效率的问题。分段对内存区域的映射还是按照程序为单位,如果内存不足,被换入换出到磁盘的还是整个程序,这样势必会造成大量的磁盘访问操作,从而严重影响速度,这种方法还是显得比较粗糙,粒度较大。事实上,根据程序的局部性原理,当一个程序在运行时,在某个时间内它只是频繁地用到了一小部分数据,也就是说,程序的很多数据其实在一定的时间内都是不会被用到的。人们很自然就想到了更小粒度的内存分割和映射方案,使得程序的局部性原理得到充分的应用,提高内存的使用效率。这种方法就叫做分页(Paging)。
分页
分页的基本方法是把地址空间认为地分成固定大小的页,每一页的大小由硬件决定,或硬件支持多种大小的页,有操作系统选择决定页的大小。
下面我们就来看一个简单地例子,如下图所示,每个虚拟空间有8页,每页大小为1KB,那么虚拟地址空间就是8KB。我们假设该计算机拥有13条地址线,即拥有2^13的物理寻址能力,那么理论上物理空间可以多达8KB。
我们把进程的虚拟地址空间按页分割,把常用的数据和代码页装载到内存中,把不常用的代码和数据保存在磁盘中,当需要用到的时候再从磁盘里面读出来即可。如图所示,我们假设有两个进程Process1和Process2,它们进程中的部分虚拟页面被映射到了物理页面,比如VP0、VP1和VP7映射到PP0、PP2和PP3;而部分页面却在磁盘中,比如VP2和VP3位于磁盘的DP0和DP1中;另外还有一些页面如VP4、VP5和VP6可能尚未被用到或者访问到,它们暂时处于未使用的状态。在这里,我们把虚拟空间的页就叫虚拟页(VP, Virtual Page),把物理内存中的页叫物理页(PP, Physical Page),把磁盘中的页叫做磁盘页(DP, Disk Page),页映射关系就叫页表。图中的线表示映射关系,我们可以看到虚拟空间有些页被映射到同一物理页,这样就实现了内存共享。
图中Process1的VP2和VP3不在内存中,但是当进程需要用到这两个页的时候,硬件会捕获这个消息,就是所谓的页错误(Page Fault),也叫页中断,然后操作系统接管进程,负责将VP2和VP3从磁盘里面读出来并装入内存中,然后将内存中的这两个页与VP2和VP3之间建立映射关系。以页为单位来存取和交换这些数据非常方便,硬件本身就支持这种以页为单位的操作方式。通过这种分页的方式,大大提高了内存的使用效率,能同时支持多个应用运行而且不需要把整个程序都装载到内存里面,可以按需加载。
页映射还有一个好处就是可以保护操作系统和保护进程。简单说就是每个页可以设置权限属性,谁可以修改谁可以访问,而只有操作系统有权限修改这些属性,那么操作系统就可以做到保护自己和保护进程。
虚拟内存的实现需要依靠硬件的支持,对于不同的CPU来说是不同的。但是几乎所有的硬件都采用一个叫内存管理单元(Memory Management Unit, MMU)的部件来进行页映射,如下图所示:
在页映射模式下,CPU发出的是Virtual Address, 即我们程序看到的是虚拟地址。经过MMU转换以后就变成了Physical Address。一般MMU都集成在CPU内部了,不会以独立的部件存在。