可执行文件只有被装载到内存以后才能被CPU执行。早期的程序装载十分简陋,装载的基本过程就是把程序从磁盘读取到内存的某个位置。随着硬件MMU的诞生,多进程,多用户,虚拟存储的操作系统出现以后,可执行文件的装载过程变得非常复杂。
程序被运行起来后,它将拥有自己的独立虚拟地址空间,这个虚拟地址空间大小由计算机的硬件平台决定,准确的说是有CPU的位数决定的。硬件决定了地址空间的最大理论上线,比如32位的硬件平台决定了虚拟地址空间的地址为0 到 2^32 -1 。即 0x00000000 - 0xFFFFFFFF,也就是我们常说的 4GB虚拟空间大侠。其实从程序的角度看,我们可以通过判断C语言程序中的指针所占空间来计算虚拟地址空间的大小。一般来说,C语言指针大小的位数与虚拟空间的位数相同,如32位平台下的指针为32位,即4字节,64位平台下的指针为64位,即8字节。当然也有特殊情况,现在基本不考虑。
那么在32位平台下的4GB虚拟空间,我们的程序是否能够任意使用。很遗憾不行,因为程序在运行的时候处于操作系统的监管下,操作系统为了达到监控程序运行等一系列目的,进程的虚拟空间都在操作系统的掌握之中。进程只能用那些操作系统分配给进程的地址,如果访问未经允许的空间,那么操作系统就会捕捉到这些访问,将进程的这种访问当做非法操作,强制结束进程。默认情况下,Linux操作系统将进程的虚拟地址空间做了下面的分配。
整个 4GB 被划分成两部分,其中操作系统本身用了一部分,从地址0xC00000000 到 0xFFFFFFFF,共1GB。剩下的从0x00000000地址开始到0xBfffffff 共3GB的空间都是留给进程是用的。原则上将,进程最多可以使用3GB的虚拟空间。
程序执行是所需要的指令和数据必须在内存中才能够正常运行,最简单的方法就是将程序所需要的指令和数据全部装入内存中,这样程序就可以孙俪运行,这就是最简单的静态装入的办法。但是很多情况下程序所需要的内存数量大于物理内存的数量,当内存的数量不够时,根本的解决方法急速增加内存。但是相对于磁盘来说,内存的代价还是太高了。后来研究发现,程序运行时是有局部性原理的,所以我们可以将程序最常用的部分驻留在内存中,而将一些不太常用的数据放在磁盘里面,这就是动态装入的基本原理。
覆盖装入在没有发明虚拟存储之前使用比较广泛,现在已经没有了。覆盖装入的方法吧挖掘内存潜力的任务交给了程序员,程序员在编写程序的时候必须手工将程序分割成若干块,然后编写一个小的辅助代码来管理这些模块何时应该驻留在内存何时应该被替换掉。这个小的辅助代码就是所谓的覆盖管理器。最简单的情况下,一个程序有主模块main,main会调用模块A和模块B,但是A和B之间不会互相调用,这三个模块大小分别是1024字节,521字节和256字节。假设不考虑内存对齐,装载地址限制的情况,理论上运行这个程序需要有1792个字节的内存。如果采用覆盖装入方法,A模块申请了512byte后,在加载B模块的时候可以复用A模块的那512byte.所以整个程序运行只需要1536个字节。
页映射是虚拟存储机制的一部分,它随着虚拟存储的发明而诞生。与覆盖装入的原理相似,页映射也不是一下子就把程序的所有数据和指令都装入内存,而是将内存和所有磁盘中的数据和指令按照**页(page)**为单位划分成若干个页,以后所有的装载和操作的单位就是页。以目前的情况,硬件规定的页的大小有4096字节的页(4KB)那么512MB的物理内存就有512*1024/4 = 131072个页。
假设有一个程序,其所有的指令和数据总和为32KB,那么程序总共被分为8个页。我们将其编号为p0-p7.再假设有一个16KB内存大小的电脑。氛围F0-F3页。如果程序刚开始执行的入口地址在PPPPPPP0,这时装载管理器发现程序P0不在内存中,于是将内存F0分配P0,并且将P0的内容装入F0,运行一段时间以后,程序要用到P5,于是主管你在管理器将P5装入F1,就这与,当程序用到P3和P6的时候,他们分别被主管纳入到了F2和F3,如下图:
很明显,如果程序只需要P0,P3,P5,P6四个页,程序能一直运行下去,但是有问题的是,如果这个时候程序要访问P4,那么装载管理器必须做出抉择,选择已经加载的4个页面中的一个来装载P4。如果选择F0,就是FIFO算法先进先出算法。如果选择很少被访问到的F2,就是LUR,最少使用算法。
事实上,从操作系统角度看,一个进程最关键的特征是它拥有独立的虚拟地址空间,这是他有别于其他进程。很多时候一个程序被执行的同时都伴随着一个新的进程的创建。
创建虚拟地址空间:一个虚拟空间由一组页映射函数将虚拟空间的各个也映射到相应的物理空间,所以创建一个虚拟空间实际上并不是创建空间二手创建映射函数所需要的相应的数据结构。在i386的Linux下,创建虚拟地址空间实际上只是分配一个页目录就行了,甚至不设置页映射关系,这些映射关系等到后面程序发生页错误的时候再进行设置。
读取可执行文件头,并建立虚拟空间和可执行文件的映射关系 上面的也映射关系函数是虚拟空间到物理内存的映射关系,这一步所做的是虚拟空间与可执行文件的映射关系。我们知道,当程序发生页错误的时候,操作系统将从物理内存分配一个物理页,然后将改缺页从磁盘中读取到内存中,再设置缺页的虚拟页和物理页的对应关系这样程序得以正常运行。这就是说操作系统也要知道虚拟空间对应可执行文件的哪一个位置,即虚拟空间与可执行文件的映射关系,这样才能从文件中正确的加载到内存中。
将CPU指令寄存器设置成可执行文件入口,启动执行 操作系统通过设置CPU的指令寄存器将控制权交给进程,由此进程开始执行。这一步看是简单,实际上在操作系统的层面上比较复杂,它涉及内核堆栈和用户堆栈的切换,cpu运行权限的切换。不过从进程角度看这一步简单的认为操作系统执行了一条跳转指令,直接跳转到可执行文件的入口地址。
上面的步骤执行完以后,其实可执行文件真正的指令和数据都没有被装入到内存中。操作系统只是通过可执行文件头部的信息建立起可执行文件和进程虚存之间的映射关系而已。当CPU执行某个函数时,发现没有这个页,就会发生页错误。这个时候CPU就会把控制权交给操作系统,操作系统有专门的页错误处理例程来处理这种情况。这时候我们前面提到的装载过程的中建立的数据结构起了关键的作用,操作系统将查询这个数据结构,然后找到空页面所在的VMA(虚拟内存地址),计算出相应页面在可执行文件中的便宜,然后再物理内存中分配一个物理页面,将进程中该虚拟页与分配的物理页之间建立映射关系,,然后把控制权交还给进程,进程从刚才页错误的位置开始重新执行。
当可执行文件被装载到进程地址空间后,会将各个段映射到进程空间中,当段的数量增多时,就会产生空间浪费的问题。因为ELF文件被映射时,是以系统的页长度作为单位的,那么每个段在映射时的长度应该都是系统页长度的整数倍;如果不是那么多余部分也将占用一个页,一个ELF文件中往往有10几个段,那么内存空间浪费是肯定的,如何尽量减少这种内存浪费呢。
当我们站在操作系统装载可执行文件的角度看问题时,可以发现它实际不关心可执行文件各个段包含的实际内容,操作系统只关系一些跟装载相关的问题,最主要的是段的权限(可读,可写,可执行)。ELF文件中,段的权限只有为数不多的集中组合,基本是以下三种:
可执行文件最终是要被系统装载运行的,这个装载过程一般是通过虚拟内存的页映射机制完成的。假设有一个ELF可执行文件,它有三个段(Segment)需要装载,将它们命名为SEG0,SEG1,SEG2。每个段的长度,在文件偏移如下所示:
,最基本的映射方式就是,每个段分开映射,对于长度不足一个页的部分则占一个页。这种对齐方式在文件段的内部会有很多内部碎片,浪费空间。整个可执行文件三个段的总长度只有12014字节,结果占据了5个页。空间使用率只有58.6%。
为了解决这种问题,操作系统就先根据可执行文件的段的大小以4kb大小为基础来分割,分割开后在相应的映射到物理内存上,这么来看,一个物理内存的Page,就会对应上虚拟内存的一个或者多个虚拟Segment.如下图所示: