CPU眼里的:虚拟内存
01
提出问题
请问你的手机、电脑上的内存有大?4GB、8GB、还是:128GB?你手机上的APP,知道手机有这么大的内存吗?
让我们写一个简单的程序,随意输出一个变量的内存地址:
如此夸张的内存地址(0x7ffcd930e8fc),显然超出了普通计算机的内存上限!相信你已经猜出来了,它不是真实的物理内存地址,而是虚拟内存地址。
实际上,无论是“王者荣耀”APP,还是你的 Hello World 程序,它们都认为你的内存大约有:40亿个4GB 这么大。
在我们学习C/C++编程时,我们很少提及:虚拟内存。但忽视虚拟内存的存在,难免会对程序和操作系统的行为产生一些误解。让我们一起讨论一下这个最强大、最无感的技术:虚拟内存。
02
虚拟内存原理
先简单介绍一下:虚拟内存的工作原理。虚拟内存,就是在物理内存的基础上,为每一个进程营造一个更加庞大的内存:
这个虚拟内存的空间大小,由CPU的位数决定,也就是CPU能寻址多少位,虚拟内存就有多大。32位的CPU,虚拟内存的空间是:4GB;64位的CPU,虚拟内存的空间就是:4G * 4GB。
从此以后,CPU都会在这个虚拟内存中,进行读、写操作。但毕竟是虚拟的,真实数据最终还是要存储在实际的物理内存上。所以,还需要内存管理单元MMU和操作系统一起合作,实现虚拟内存到物理内存之间的映射:
由于有硬件加持,这种映射对程序员是无感的,也不影响CPU的运行效率。这就是虚拟内存,大概的工作原理。
03
减少内存碎片
那使用虚拟内存有什么好处呢?第一个好处是:减少内存碎片。例如,下面是一块物理内存,一共有连续的12KB(假设相互临近的两个内存颗粒,它们的内存空间是连续的)。程序APP 1需要用到4KB内存,如果它成功申请到了中间的4KB内存后,剩下的空闲内存总量就还剩8KB:
如果此时,还想运行正好需要8KB内存的APP 2就不行了!因为,虽然从总数上看还有8KB的空余内存,但它们不是连续的,而程序运行,或在malloc或new的时候,往往需要的是连续内存。
如果使用虚拟内存的话,这个问题就很容易解决了:
如你所见:尽管所剩的物理内存还是支离破碎的4KB + 4KB内存;但通过页表,我们可以对上、下两个不连续的物理内存,进行重新映射,让它们在虚拟内存空间上是连续的。
由于程序运行时,只能看见虚拟内存,所以程序并不会感知到这两块4KB的物理内存实际上是不连续的。从而避免了因为内存碎片,所造成的内存浪费。
04
简化运行条件
第二个好处是:简化程序运行条件。一些情况下,程序在编译完成后,其函数地址、变量地址都是固定的。假设编译器为a.out程序设置的起始内存地址是:0x8000;如果直接将其加载到:0x8000的物理内存上的话:
我们就需要确保这段内存,无论是现在还是未来,都专属于这个a.out,不能被其他程序使用。当然,不止程序的起始地址,程序的“堆”(heap)空间、“堆栈”(stack)空间也需要在程序运行前,提前考虑、提前规划,相信做过单片机开发的同学,对此一定不陌生。
而使用虚拟内存就没有这样的顾虑,操作系统为a.out虚构了一个内存空间,并将其加载到0x8000的虚拟地址上,至于实际映射到哪个物理内存上面?就交给操作系统了,原则上可以映射在任何一块物理内存上:
这就为程序加载,提供了极大的灵活性。让程序员不必在编程的时候,考虑如何让程序去适配计算机的内存环境。
05
隔离进程
第三个好处是:隔离进程。还是这个单进程的a.out程序,我们第一次运行的时候,操作系统为a.out构建了一个虚拟内存空间;如果再运行一次,操作系统就为a.out再构建一个虚拟内存空间:
虽然从虚拟内存上看,两个a.out所在的内存空间是完全一致的!但它们实际上被映射在不同的物理内存上,所以,两个a.out进程是完全隔离的。
即使进程A遇到错误而崩溃,也不会牵连到进程B。同时,进程A想非法探测进程B,或其他进程的内存,也是不可能的。由于有MMU的保护,进程A只能访问MMU已经为其映射好的物理内存。这在一定程度上,提高了系统的安全性。
06
内存共享
第四个好处是:有效的利用内存资源。例如:我们的电脑、手机上,会预存很多字体文件。几乎所有的APP都会使用这些字体资源,因为这些资源多为只读类型,所以就没有必要,为每个程序都复制一份资源,而是直接内存共享。
例如,系统在开机时,操作系统由于也需要使用字体文件,所以,会加载一份到内存中。此后,如果其他APP(例如:APP-1、APP-2)也需要使用到字体文件,它们就不需要再把字体文件加载到自己的内存空间里面了。
相反,它可以通过内存映射,直接共享刚才操作系统已经加载好的字体文件:
但如果需要改写共享数据的话,例如:修改一下现有字体,为了避免影响其他程序。就必须自己拷贝一份字体了,这也叫:copy on write。
07
SWAP
第五个好处是:以小博大。也就是大家常说的:SWAP,假设这是当前的计算机系统,如你所见,我们的程序已经用尽了全部的物理内存:
简单起见,我们通过设置页表,让虚拟内存页和物理内存页,一一对应。上面是虚拟内存页的编号;下面是对应的物理内存页的编号。此时,如果程序还需要申请一个内存页,应该如何处理呢?
为了让程序继续运行下去,操作系统会通过一定的算法,选择将某一个物理内存页(这里,选择的是1号物理内存页)暂时转移到硬盘上:
同时更新一下对应的页表信息,记录一下内存页所在的硬盘扇区号码H-100,从此,1号虚拟内存页,就对应着H-100硬盘扇区了。这样就临时腾出了一个空闲的内存页,更新一下页表:
从此,5号虚拟内存页,就对应着1号物理内存页了。如此完成内存映射后,就可以返回给程序使用了。
那如果程序需要读取刚才转移到硬盘上的1号虚拟内存页,那该怎么办呢?显然,这时仍然没有空闲的物理内存可供使用,还是老办法,操作系统再选择一个物理内存页(这里选择的是2号物理内存页)将它也暂时转移到硬盘上面,并更新一下页表,从此,2号虚拟内存页,就对应着H-200硬盘扇区了:
这样就又临时腾出了一个空闲的物理内存页。
此时操作系统,就可以根据1号虚拟内存页对应的硬盘扇区号码H-100,把存在硬盘上的内存页读取到刚刚空闲2号物理内存页上:
当然,由于1号虚拟内存页对应的物理内存页改变了位置,所以,对应的物理页号码就从H-100变为了:2号物理内存页。
不得不说,这是一个伟大的功能,但也是最为大家诟病的功能之一。因为,一旦系统内存窘迫到需要SWAP的时候。如你所见,内存和硬盘之间的数据,就会频繁交换,以当时机械硬盘的速度,系统效率立刻被拖慢1000倍!
如此差的体验,还不如不用!直到今天,固态硬盘在效率上取得了巨大进步后,苹果才在最新的iPad系列产品上,打开了SWAP功能。
08
总结
1. 虚拟内存是操作系统和硬件MMU的结合体,为了不损失效率,往往由MMU来作虚拟内存到物理内存的地址翻译工作。相信做过FPGA的同学,实现起来,问题不大。
2. 虚拟内存一方面简化了应用程序的开发过程,让程序员无需关心与软件功能没有直接关系的信息,例如:目标机的内存环境;它也能充分的利用计算机的内存资源、硬盘资源,还实现了进程间的安全隔离;但另一方面,它也增加了操作系统的开发难度、学习成本和CPU的硬件成本。
3. 不是所有的计算机系统都需要虚拟内存,例如:STM32单片机和嵌入式环境,它们很多是不支持虚拟内存的,甚至Linux也有无虚拟内存的版本:ucLinux。
09
热点问题
Q1:我是在校的学生,在学习虚拟内存的时候,感觉非常难以理解,有什么办法解决吗?
A1:在学习任何一种技术的时候,我们都需要了解一下这种技术存在的意义?这项技术到底在让谁受益?或许这些知识并不是考试内容,但它能让你拥有一个完整的知识链。在理解考点的时候,会有一个平稳的过渡。但还是那句话:这些东西都不是考试内容,请注意合理分配精力。
需要特别提醒一点的是:虚拟内存技术是操作系统 + MMU的软、硬结合体,本章节描述的所有内容,都不能仅靠其中的一方来实现。
Q2:有没有关于实现虚拟内存的软件代码,可以参考、学习的?
A2:Linux内核代码是比较好的学习资料,市场上的相关书籍也比较丰富。但它们往往讲述x86 CPU在虚拟内存上的实现方法,由于历史原因,x86 CPU在操作MMU上面,会有些繁琐,相比之下,ARM CPU在虚拟内存的实现上,会更加直观一点。
Q3:虚拟内存这么强大,为什么系统还会死机?
A3:以Linux、Windows内核的虚拟内存、内存保护机制为例,它能把应用程序的错误限制在一个小小的范围内,不让它祸及其他应用程序和整个系统。但它却保护不了内核本身。一旦有恶意或有缺陷的驱动程序、内核代码,访问了非法地址,或错误的设置了CPU寄存器,结果往往就是:蓝屏、死机、重启。
同时,调试内核、驱动程序,往往也是程序开发的至暗时刻。其调试环境甚至不如单片机的调试环境。普通开发者很难获得价格高昂的调试设备,往往只能依靠原始的打印输出,来进行软件调试。