程序装载
比尔盖茨在上个世纪 80 年代做了一个语言:“640K ought to be enough for anyone”(640K 内存对于任何人来说都够用了)。这在今天是不可理解的,如今随便一台个人 PC,少说都有 8G 内存,相较于 640K 提升了万倍不止,那比尔盖茨这句话该怎么理解呢?难道真的是无稽之谈吗?这篇文章来好好探讨一下。
程序装载面临的挑战
写在文件中的程序,计算机并不理解,需要把它转换成机器语言才行。一个庞大的项目势必会由许多源代码文件组成,需要把许多的文件通过链接器合并到一起,形成一个可执行文件。在运行可执行文件的时候,是通过一个装载器,解析 ELF
或者 PE
格式的可执行文件。装载器就会把对应的指令和数据加载到内存中来,让 CPU 去执行。
听起来似乎很容易,但其实装载器要做的工作不止这么简单。首先,装载器就要满足以下两个要求。
可执行程序加载后占用的内存空间应该是连续的。因为执行指令的时候,程序计数器是顺序的一条一条执行,这就意味着,这一条条的指令需要连续的存储在一起。
装载器能够同时加载很多个程序,并且不能让程序自己规定在内存中加载的位置。虽然编译出来的指令里已经有了对应的各种各样的内存地址,但是实际加载的时候,我们其实没有办法确保,这个程序一定加载在哪一段内存地址上。因为现代计算机通常会运行很多程序,可能你想要的内存地址已经被其他加载了的程序占用了。
要满足这两个基本的要求,我们很容易想到一个办法,那就是可以在内存地址里面,找到一段连续的内存空间,然后分配给装载的程序,然后把这段连续的内存空间地址,和整个程序指令里指定的内存地址做一个映射。
指令里用到的内存地址叫做虚拟内存地址(Virtual Memory Address),实际在内存硬件里面的空间地址,叫物理内存地址(Physical Memory Address)。
程序里有指令和各种内存地址,我们只需要关心虚拟内存地址就行了。对于任何一个程序来说,它看到的都是同样的内存地址。我们维护一个虚拟内存到物理内存的映射表,这样实际程序指令执行的时候,会通过虚拟内存地址,找到对应的物理内存地址,然后执行。因为是连续的内存地址空间,所以我们只需要维护映射关系的起始地址和对应的空间大小就可以了。
内存分段
上述描述的找出一段连续的物理内存和虚拟内存地址进行映射的方法,就叫分段(Segmentation)。这里的段,就是指系统分配出来的连续的内存空间。
这种方式相当于加了个中间层,并且欺骗了所有的程序,让他们误以为自己占用全部的内存空间,这样,编写程序的人就不需要关心具体的物理内存地址的问题,但它也有不足之处,第一个就是内存碎片(Memory Fragmentation)。
看下面这个例子,假设现在有一台内存为 1G 的电脑。我们先启动一个 IDE ,占用了 512M 内存,接着启动一个浏览器程序,占用了 128M 内存,然后启动了一个 java 程序,占用了 256M 内存。这个时候我们关掉了浏览器,于是空闲的内存还有:1024 - 512 - 256 = 256MB。按理来说,我们有足够的空间再去装载一个 200MB 的程序。但是,由于这 256MB 的内存空间是不连续的,而是被分成了两个 128MB 的内存。因此,实际情况是,我们的程序没办法加载进来。
当然,这个也有解决办法,即内存交换(Memory Swapping)。
可以把 java 程序占用的那 256M 内存写到硬盘上,然后再从硬盘上读回至内存中。不过读回来的时候,就不再把它加载到原来的位置,而是紧紧跟在那已经被占用了的 512MB 内存后面。这样,内存中就有了连续的 256MB 的内存空间,就可以去加载一个新的 200MB 得程序。还记得安装 linux 操作系统的时候,需要我们分配一个 swap 硬盘分区,这块磁盘空间,就是专门给 linux 操作系统进行内存交换用的。
虚拟内存、分段、内存交换,看起来已经解决了计算机同时装载运行很多个程序的问题。不过,不能忽略性能问题,毕竟硬盘的访问速度要比内存慢很多,每一次内存交换,都需要把一大段连续的内存数据写到硬盘上。如果内存交换时,交换的是一个很占空间的程序,整个机器可能都会卡住。
内存分页
计算机必然是朝着性能更高的方向发展,那肯定是要对以上的性能问题进行优化的。问题出在了内存碎片和内存交换的空间太大上,解决办法就是,少出现一些内存碎片,当需要进行内存交换的时候,让需要交换写入或者从磁盘装载的数据更少一点。基于这个优化思路,现代计算机出现了内存分页(Paging)
和分段这样分配一整段连续的空间给到程序相比,分页是把整个物理内存空间切成一段段固定尺寸的大小。而对应的程序需要占用的虚拟内存空间,也会切成一段段固定尺寸的大小。这样一个连续并且尺寸固定的内存空间,叫做页(Page)。从虚拟内存到物理内存的映射,不再是拿整段连续内存的物理地址,而是按照一个一个页来。页的尺寸一般远远小于整个程序的大小。在 linux 下,我们通常只设置成 4KB。可以通过命令查看 linux 系统设置的页大小。
getconf PAGE_SIZE
由于内存空间都是预先划分好的,也就没有不能使用的碎片,只有被释放出来的很多 4KB 的页。即使内存空间不够,需要让现有的、正在运行的其他程序,通过内存交换释放出一些内存的页来,一次性写入磁盘的也只有少数的一个页或者几个页,不会花太多时间,让整个机器被内存交换过程给卡住。
更进一步的,分页的方式使得我们在加载程序的时候,不再需要一次性都把程序加载到物理内存中。即可以在进行虚拟内存和物理内存的页之间的映射之后,不去加载页至物理内存中,而只是在程序运行中,需要用到对应虚拟内存页里面的指令和数据时,再加载到物理内存中。
实际上,操作系统就是这么做的。当要读取特定的页,却发现数据并没有加载到物理内存的时候,就会触发一个来自于 CPU 的缺页错误(Page Fault)。我们的操作系统会捕捉到这个错误,然后将对应的页,从存放在硬盘上的虚拟内存里读取出来,加载到物理内存中。这种方式,使我们可以运行那些远大于我们实际物理内存的程序。
通过虚拟内存、内存交换和内存分页这三个技术的组合,最终得到了一个让程序不需要考虑实际的物理内存地址、大小和当前分配空间的解决方案。这些技术和方法,对于我们程序的编写、编译和链接过程都是透明的。这也是我们在计算机的软硬件开发中常用的一种方法,就是加入一个中间层。
通过引入虚拟内存、页映射和内存交换,我们的程序本身,就不再需要考虑对应的真实的内存地址、程序加载、内存管理等问题了。任何一个程序,都只需要把内存当成是一块完整而连续的空间来使用。
总结
回到开头的问题,电脑只需要 640K 内存就够了嘛?现在来看,比尔盖茨肯定是错了,但他为什么会做出这样的判断呢?因为在虚拟内存、内存交换和内存分页这三项技术结合之下,运行程序所需的空间会大大降低。因为任何一个程序都可以分成很多页,极限情况下,内存也只需要加载一个页就可以了,每次需要用到对应的数据和指令的时候,从硬盘上交换到内存里面就好了。以我们现在 4K 内存一页的大小,640K 内存也能放下足足 160页呢,难怪比尔盖茨会说出:“640K ought to be enough for anyone”这样的话。