在讲装载之前,我们首先要了解程序和进程的区别,我用一种不严谨的说法来一句话解释:硬盘中的程序被装载到内存中就变成了进程。
程序(狭义上是可执行文件)是一个静态概念,它是一些预先编译好的指令和数据的集合的一个文件;进程则是一个动态的概念,它是程序运行时的一个过程,或者讲,一个正在运行的程序。很多时候把动态库叫做运行时(Runtime)就有这种含义。
有一个很形象的比喻就是,程序是一个大厨做菜时的菜谱,计算机的CPU相当于这个大厨,相关的厨具就是计算机的其他硬件,整个做菜的过程就是一个进程。
所谓装载,就是把存储在硬盘等介质内的程序(指令和数据),加载到内存中的某个位置,然后才能由CPU执行。这是由传统的冯·诺伊曼结构的所决定的(学过计组的都懂)。
6.1 进程虚拟地址空间
现代计算机系统的内存中,可以同时存在许多进程,为了防止这些进程在运行的过程中相互干扰,同时也可以防止恶意程序(病毒)对其他进程的破坏,操作系统(OS)中的每个进程都有自己独立的虚拟地址空间(Virtual Address Space)。虚拟地址空间的大小由CPU的地址总线的位数决定,32位的为4G,64位就有很多很多了(多到目前为止可以看作是无限的)。
本书的讨论都是在32位的环境下。
无论是Windows还是Linux下,一个进程的4G虚拟地址空间的主要分为两部分:操作系统部分和用户进程部分。为什么操作系统也要在用户进程的虚拟地址空间中占用一部分空间?我的想法是,因为要保证用户进程在操作系统的监控之下运行,因为不同进程间的虚拟地址空间是相互独立的,如果OS(OS是一个特殊的进程)独立于用户进程,那么OS就没法对该进程进行监控了,该用户进程也不能调用OS的系统调用了(以上来自于一个没有系统学过OS的人的“猜想”)。
Linux默认占用1G的虚拟地址空间,Windows默认占用2G,不过可以通过修改Boot.ini
文件来修改Windows占用的空间。
PAE
在32位时代,为了扩大可用的内存空间,有些厂商把CPU的地址总线位数进行了扩展(使之超过CPU数据总线的位数),例如:Intel 1995年的Pentium Pro CPU开始采用了36位的物理地址,但它本身是一个32位的CPU。
那么如何利用这些高于32位地址空间的内存呢?Intel是通过修改页映射的方式来实现的,这种地址扩展方式就成为PAE(Physical Address Extension),因为是物理上扩展了地址空间。
一个常见的实现就是OS提供一个窗口映射的方法,把额外的内存映射到进程地址空间来。应用程序可以跟据需要来选择申请和映射,比如一个应用程序中的
0x10000000~0x20000000
这一段256MB的虚拟地址空间作为窗口,程序可以从高于4GB的物理空间中申请多个大小为256MB的物理空间,并依次编号,如:A、B、C。然后可以根据需要将窗口映射到不同的空间去,例如需要用到A块中的内容是,就把窗口对应的地址映射到A的实际物理地址,然后就可以通过访问窗口范围的地址来访问A块中的地址了,同理,需要用到B中的内容是,可以将窗口映射到B块。在Windows下,这种操作方式叫做AWE(Address Windowing Extensions)。
6.2 装载的方式
最简单的装载就是一次性把程序的所有内容都装载到内存中,可是内存空间是极为珍贵的,很多时候并不能满足程序的要求。同时,跟据程序运行的局部性原理,我们可以只把程序的一部分留在内存中,其他部分放在硬盘中,等用到的时候再装入内存,这就是动态装载的基本原理。
动态装载的常用方法有两种:覆盖装入(Overlay)和页映射(Paging)。
覆盖装入
覆盖装入在虚拟存储发明之前使用得比较广泛,现在几乎被淘汰了,因为它比较的使用很麻烦,需要由程序员自己写一个覆盖管理器(Overlay Manager),来管理程序不同部分(模块)的装载。
在多个模块的情况下,需要程序员手动将模块按照他们之间的调用关系组织成树状结构(如图)。
图中,同一高度代表同一内存地址,所以有重叠的模块是不能同时存在的。树中的子节点(模块)是依赖于父节点的,也就是说子节点存在于内存中的时候,父节点也要存在。
这也就引出了这种方法所需要注意的两个问题:
- 一个模块的调用路径(也就是从树根main模块,到这模块上的所有途经模块所构成的路径)都必须在内存中,才能保证程序的依赖关系
- 跨树间的调用是被禁止的。因为跨树会导致模块的内存重叠冲突,不同的路径不能同时存在于内存中。
页映射
页映射是虚拟存储机制的一部分。它的原理就是把可执行文件和内存空间都分为许多大小一致的“页”(Intel IA32的页是4KB),当进程运行到某一部分的代码不存在于内存中时,就发生“缺页中断”,把需要的页加载到内存中。同时,如果内存的页都被占满了,这时我们需要选择一个页被替换掉,选择的方法有许多,例如:FIFO、LUR等页替换算法。
在这种方法中,OS就充当着覆盖管理器的角色,更具体的说,是OS的存储管理器。
6.3 从操作系统的角度看可执行文件的装载
从操作系统的角度来看,可执行文件的装载主要包含两个大的操作:
- 进程的建立
- 页错误
6.3.1 进程的建立
创建一个进程分为三步:
- 创建一个独立的虚拟地址空间
- 读取可执行文件头,并建立虚拟空间与可执行文件的映射关系
- 将CPU的指令寄存器设置成可执行文件的入口地址,启动运行
创建虚拟地址空间 一个虚拟空间由一组页映射函数将虚拟空间的各个页映射至相应的物理空间,那么创建一个虚拟空间实际上并不是创建空间,而是创建映射函数所需要的相应的数据结构。
读取可执行文件头,并建立虚拟空间与可执行文件的映射关系 第一步完成的是虚拟空间到物理空间的映射关系,而这一步完成的是虚拟空间与可执行文件之间的映射。是装载过程中最重要的一步。
Linux中把虚拟进程空间中的一个连续的地址空间叫做虚拟内存区域(VMA,Virtual Memory Area),Windows中叫做虚拟段(Virtual Section)。一般一个VMA一半对应一个段(一个或多个页)。
https://baike.baidu.com/item/VMA/9839255
将CPU的指令寄存器设置成可执行文件的入口地址,启动运行 这一步是最简单的一步,它将CPU的控制权由OS转交给进程,由此进程开始执行。
6.3.2 页错误
完成上面的步骤,程序其实还没有被装载到内存中,因为只是建立了映射关系,并没有真正映射。
当CPU执行到程序的入口地址(以0x8048000这个虚拟地址为例),发现这个页并没有被加载到物理内存中(通过一个数据结构可得到这个信息),于是就产生一个“页错误(Page Fault)”,引发缺页中断,CPU的控制权转交给OS,由OS分配一个物理页,建立映射关系,将缺少的页加载到该物理页中,然后将CPU的控制权交还给进程,继续执行。
6.4 进程虚存空间分布
6.4.1 ELF文件链接视图和执行视图
现代OS的装载过程实际采用的是页映射的方式,也就是以页为单位装载指令和数据的,所以存在一个页地址对齐的问题。也就是不同段(Section)映射到虚拟内存空间或实际物理空间的时候,占用的空间必须是页大小的整数倍(起始地址也是)。
但是随着可执行文件中段的数量不断增多(特别是ELF,一个ELF往往有十几个段),就会产生内存碎片的问题,有许多段本身或者末尾实际仅仅使用了一个页的一小部分,造成了极大的浪费。
解决的方案就是,尽可能合并这些段(合并后的称为Segment),然后将合并后的Segment作为一个整体映射到虚拟内存空间和实际物理空间,并且一个Segment对应一个VMA(Virtual Memory Area)。这里的Segment翻译过来其实也称为段,只不过是在装载过程中的段,在讨论装载的情况下,段这个字一般都指的是Segment。
那按照什么来合并呢?总不能一股脑儿地把所有段都合并了吧,这样不利于程序内部的结构划分。
ELF采用的方案是把具有相同权限(读、写、执行)的段合并成一个Segment,ELF中主要的段的权限组合不多,主要有3种:
- 以代码段为代表的权限为可读可执行的段
- 以数据段和BSS段为代表的权限为可读可写的段
- 以只读数据段为代表的权限为只读的段
ELF中描述Segment的结构叫做程序头(Program Header),很容易与之前说过的段表(Section Header Table)弄混。
我们用readelf查看如下程序的程序头,得到如下结果:
1 | doa@LAPTOP-DOA:~/Document$ readelf -l SectionMapping.elf |
所以总的来说,“Segment”和“Section”是从不同角度来划分同一个ELF文件,这个在ELF中被称为不同的视图(View),从“Section”的角度来看ELF文件就是链接视图(Linking View),从“Segment”的角度来看就是执行视图(Execution View)。
6.4.2 堆和栈
在OS中,VMA除了被用来映射可执行文件中的各个Segment,OS还通过使用VMA来对进程的地址空间进行管理。例如进程在运行时还需用到的堆(Heap)和栈(Stack)等空间,他们在虚拟内存空间中的表现形式也是VMA。
在linux下,可以通过查看/proc
来查看进程的虚拟空间分布:
1 | doa@LAPTOP-DOA:~/Document$ cat /proc/184/maps |
其中最后两列分别指映像文件的节点号和路径。
向堆和栈这种没有指定特定的映像文件的VMA通常称为匿名虚拟内存区域(Anonymous Virtual Memory Area)。每个线程都有自己的堆和栈,C语言中使用malloc()内存分配函数就是分配堆中的内存。
可以看到其中还有一个非常特殊的VMA叫做[vsdo]
,实际上它是OS内核的一个模块,进程可以通过访问这个VMA来跟内核进行通信。
进程虚拟地址空间的概念:操作系统通过给进程空间划分出一个个VMA来管理进程的虚拟空间;基本原则是将相同权限属性的,有相同映像文件的映射成一个VMA,一个进程基本上可以分为如下几种VMA区域:
- 代码VMA,权限只读、可执行;有映像文件
- 数据VMA,权限可读写、可执行;有映像文件
- 堆VMA,权限可读写、可执行;无映像文件,匿名,可向上扩展
- 栈VMA,权限可读写、不可知性;无映像文件,匿名,可向下扩展
6.4.3 堆的最大申请数量
略
6.4.4 段地址对齐
前面我们说到为了节省由于页对齐产生的内存碎片,而想出和并为Segment的方法。但这并不能满足人类贪婪的欲望,即使是只剩三个Segment了,还是有很大可能多浪费了两个页,为了节省这两个页,我们需要进一步“合并”Segment,但这种合并与之前的合并为Segment并不一样!!!合并为Segment是在虚拟地址空间和物理空间两个地方都进行合并,而将Segment合并仅仅是在物理空间合并,在虚拟地址空间中的操作是将两个Segment交接处所在的页映射两次。这么说可能有些抽象,上图:
至于为什么虚拟地址空间中不直接合并,我个人的猜想是因为OS层面访问的都是虚拟地址,OS需要区分不同的Segment以判断权限(因为内存权限的最小粒度是页),所以不同Segment在虚拟地址空间中不能在一个页中有交集,否则虚拟地址空间中的同一个页的权限会有冲突。
如果按照正常的映射方式,3个段(SEG0、SEG1、SEG2)应该分别占用1、3、1个Page(物理内存中),总共占用5个,如下图:
6.5 Linux内核装在ELF过程简介
在用户层面,bash进程会调用fork()系统调用创建一个新的进程,然后新的进程调用execve()系统调用执行指定的ELF文件,是原先的bash进程继续返回等待各个启动的新进程结束,然后继续等待用户输入命令。
剩下的用到再查(狗头
6.6 Windows PE的装载
PE与ELF不一样,PE可执行文件的段的数量一般很少,这是因为链接器在产生可执行文件的时候就尽可能地把段合并,最后一般只剩下代码段、数据段、只读数据段和BSS等为数不多的几个段。
PE中有一个很常见的术语叫做RVA(Relative Virtual Address),其实就是相当于文件中的偏移量。它是相对于PE文件的装载机地址的偏移地址。比如一个PE文件被装载到虚拟地址(VA)0x00400000,那么一个RVA为0x1000的地址就是0x00401000。
每个PE文件在装载目标地址(Target Address),这个地址就是基地址(Base Address)。因为PE文件可以被装载到任何地址,所以这个及地址不是固定的,每次装载时都会变化。
装在一个PE可执行文件的过程:
- 读取文件的第一个页,在这个页中包含了DOS头、PE文件头和段表
- 检查进程虚拟地址空间中,目标地址是否可用,如果不可用,则选另外一个装在地址。这个问题对于可执行文件来说基本不存在,因为它往往是进程第一个装载的模块,所以目标地址不太可能被占用。这里主要是针对DLL文件的装载而言的,后面还有“Rebasing”这一节会具体介绍这个问题。
- 使用段表中提供的信息,将PE文件中所有的段一一映射到地址空间中的相应位置。
- 如果装载地址不是目标地址,则进行Rebasing。
- 装载所有PE文件所需要的DLL文件
- 对PE文件中的所有导入符号进行解析
- 跟据PE头中指定的参数,建立初始化栈和堆。
- 建立主线程并启动进程。
PE文件中,与装载有关的主要信息都包含在PE扩展头(PE Optional Header)和段表中了。