可执行文件的装载与进程

1. 进程虚拟地址空间

每个进程拥有自己独立的虚拟地址空间,这个虚拟地址空间由cpu位数决定,硬件决定了地址空间的最大理论上限,即硬件的寻址空间大小。比如32位的硬件平台决定了虚拟地址空间的地址为0到2^32-1,即0x00000000~0xFFFFFFFF,共4GB。而64位的硬件平台具有64位寻址能力,虚拟地址空间达到2^64字节,即17179869184GB。

从程序角度,比如可以判断C语言指针所占的空间来计算虚拟地址空间的大小。32位平台下指针位32位,即4字节,64位下为8字节。

下文中以32位的地址空间为主。

4GB并不是进程全都可以使用,Linux下进程虚拟地址空间分配如下:

可执行文件的装载与进程_第1张图片

可以看到操作系统用掉了一部分,从地址0xC0000000到0xFFFFFFFF共1GB,剩下的3GB留给进程使用。实际上这3GB也不是进程全部能使用,其中一部分要预留给其他用途。Windows下操作系统占有2GB,进程占用2GB。当然这可以通过启动项参数来修改,将操作系统占用的空间减少到1GB。

那么在32位CPU下,进程能使用的空间真的不能超过4GB么。如果这个“空间”指的是虚拟地址空间,那么确实不能。因为32位下指针为32位,最大寻址范围是0到4GB。如果“空间”指的是计算机的内存空间,那么答案是可以的。Intel自从1995年的Pentium Pro CPU开始采用了36位的物理地址,也就是可以访问高达64GB的物理内存。

从硬件层面讲,原先的32位地址线只能访问最多4GB物理内存。但扩展至36位地址线之后,Intel修改了页映射的方式,使新的映射方式可以访问更多的物理内存。这个地址扩展方式叫做PAE(Physical Address Extension)。

当然扩展的物理内存空间,对普通进程是透明的。进程自己所看到的还是那4GB的虚拟地址空间,那么进程如何使用这些大于常规的空间呢?一个常见的方法是操作系统提供一个窗口映射的方法,把这些额外的内存映射到地址空间中来。应用程序可以根据需要来选择申请和映射。比如一个进程中0x10000000~0x20000000这一段256MB的虚拟地址空间用来做窗口,进程可以从 高于4GB的物理空间中申请多块大小为256MB的物理空间,编号为A,B,C等,然后根据需要将这个窗口映射到不同的物理空间块。用到A时将0x10000000~0x20000000映射到A,用B,C时再映射过去。在Windows下这种访问内存的操作方式叫做AWE(Address Windowing Extensions),在Linux下则采用mmap()系统调用来实现。

2. 装载的方式

覆盖装入(Overlay)和页映射(Paging)是两种很典型的动态装载方法,原则上都是利用了程序的局部性原理。覆盖装入在没有发明虚拟存储之前使用比较广泛,但现在几乎被淘汰了。

页映射是虚拟存储机制的一部分。它将内存和所有磁盘中的数据和指令按照“页”为单位划分成若干个页,以后所有的装载和操作的单位就是页。不同硬件固定页的大小不同,有4096B,8192B,2MB,4MB等。(在同一机器下,页是固定的)。如Intel IA32处理器一般使用4096B的页,那么512MB的物理内存就拥有512*1024*1024/4096=131072页。

简单演示一下页映射的机制,假设有16KB的内存,页大小为4096B,则内存共有4个页。

可执行文件的装载与进程_第2张图片

假设程序所有指令和数据总和位32KB,则程序共有8个页,编号p0-p7。假设程序入口地址为p0,这时装载管理器(实际上装载管理器就是操作系统,准确说是操作系统的存储管理器)发现p0不在内存中,于是将内存F0分配给p0,即将p0内存装入F0,之后要用到p5,则将p5装入F1,假设之后用到p3和p6,则分别装入F2和F3。映射关系如下:

可执行文件的装载与进程_第3张图片

假设之后要用p4,那么就需要选择某种特定的算法将其中一个物理页替换掉。

Windows对PE文件的装载和Linux对ELF文件的装载都是这样完成的。

3. 从操作系统角度看可执行文件的装载

3.1 进程的建立

一个程序被执行同时伴随着一个新的进程的创建。创建一个进程,然后装载相应的可执行文件并且执行,通常有三步:

(1)创建一个独立的虚拟地址空间。

(2)读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系。

(3)将CPU的指令寄存器设置成可执行文件的入口地址,启动运行。

首先是创建虚拟地址空间。一个虚拟空间由一组页映射函数将虚拟空间的各个页映射至相应的物理空间。那么实际上,创建一个虚拟空间并不是创建空间而是创建映射函数所需要的相应的数据结构。在i386的Linux下,创建虚拟地址空间实际上只是分配一个页目录而已,甚至不设置页映射关系,这些映射关系等到后面程序发生页错误时再设置。

读取可执行文件头,并且建立虚拟空间与可执行文件头的映射关系。注意,上面这一步的映射关系是指虚拟空间到物理内存的映射关系,这一步所做的是虚拟空间与可执行文件的映射关系。当操作系统捕获到缺页错误时,它应当知道程序当前所需要的页在可执行文件的哪个位置。

假设一个ELF可执行文件只有一个代码段“.text”,它的虚拟地址为0x08048000,它在文件中的大小为0x000e1,对齐为0x1000。在32位的Intel IA32下页一般为4096字节,所以虚拟空间的对齐粒度也为0x1000。因此尽管.text大小不到一个页,但还是会占用一个页。映射关系如下所示:

可执行文件的装载与进程_第4张图片

Linux中将进程虚拟空间中的一个段叫做虚拟内存区域(VMA, Virtual Memory Area),在Windows中将这个叫做虚拟段(Virtual Section)。比如上例中,操作系统创建进程后,会在进程相应的数据结构中设置有一个.text段的VMA。

将CPU指令寄存器设置成可执行文件入口,启动运行。从进程的角度看这一步可以简单地认为操作系统执行了一条跳转指令。

3.2 页错误

上面的步骤执行完后,其实可执行文件的真正指令和数据都没有被装入内存。假设上面的例子中,程序的入口地址为0x08048000,即刚好是.text段的起始地址。当CPU开始打算执行这个地址的指令时,发现页面0x08048000~0x08049000是个空页面,于是发生页错误,CPU将控制权交给操作系统。操作系统将查询在第二步建立的那个数据结构,找到空页面所在的VMA,计算出相应的页面在可执行文件中的偏移,然后在物理内存中分配一个页面,将进程中的虚拟页与分配的物理页之间建立映射关系,然后再把控制权返回给进程,进程从刚才页错误的位置重新开始执行。

可执行文件的装载与进程_第5张图片

4. 进程虚存空间分布

4.1 ELF文件链接视图和执行视图

ELF文件被映射时,是以系统的页长度作为单位的,那么每个段在映射时的长度应该都是系统页的长度的整数倍。当多余部分不够一个页时,也会占用一个页,造成内存浪费。

操作系统在装载时,并不关心段的实际内容,主要关心段的权限,如可读,可写,可执行。ELF文件中,段的权限主要有以下三种:

(1)以代码段为代表的权限为可读可执行的段。

(2)以数据段和BSS段为代表的权限为可读写的段。

(3)以只读数据段为代表的权限为只读的段。

因此一个很简单的方案就是,对于相同权限的段,把它们合并到一起当作一个段进行映射。比如现在有.text和.init,包含的分别是可执行代码和初始化代码,它们的权限相同。.text大小为4097字节,.init为512字节。如果分别映射需要占用3个页,如果合并之后再一起映射,则只需占2个页。

可执行文件的装载与进程_第6张图片

ELF可执行文件引入一个概念叫做“Segment”,一个Segment包含一个或多个属性类似的“Section”。Section就是之前所说的在可执行文件或目标文件里的段。这里将.text和.init合并在一起看作一个Segment一起映射,映射之后在进程虚存空间中只有一个相对应的VMA,而不是两个。(注:从链接的角度,ELF文件是按Section存储的,从装载的角度,ELF文件又可以按照Segment划分)

例子程序:

#include

int main() {
	while(1)
		sleep(1000);
	return 0;
}

把源文件编译链接成可执行文件:

gcc -static SectionMapping.c -o SectionMapping.elf

使用readelf可以看到这个可执行文件总共有33个段(Section)。

可执行文件的装载与进程_第7张图片

依然可以用readelf查看ELF的Segment,描述Segment的结构叫程序头,它描述了ELF文件该如何被操作系统映射到进程的虚拟空间。

可执行文件的装载与进程_第8张图片

可以看到有5个Segment,从装载的角度看,只需要关心两个LOAD类型的Segment,只有它们是需要被映射的,其他的3个都是在装载时起辅助作用的。映射关系如下图:

可执行文件的装载与进程_第9张图片

可以认为SectionMapping.elf大致重新被分成三部分:

(1)有些段被归入可读可执行,被统一映射到VMA0。

(2)有些段被归为可读写,被统一映射到VMA1。

(3)其他的段在程序装载时没有被映射,它们是一些包含调试信息和字符串等表。

即所有相同属性的Section被归类到一个Segment,并且映射到同一个VMA。

Segment和Section就是从不同的角度来划分同一个ELF文件。从Section的角度来看ELF文件就是链接视图,从Segment的角度来看就是执行视图。

ELF可执行文件和共享库文件都有一个专门的数据结构叫做程序头表用来保存Segment的信息。因为ELF目标文件不需要被装载,因此没有程序头表。跟段表一样,程序头表也是一个结构体数组,结构和字段含义如下:

typedef struct {
	Elf32_Word p_type;
	//Segment的类型
	Elf32_Off p_offser;
	//Segment在文件中的偏移
	Elf32_Addr p_vaddr;
	//Segment的第一个字节在进程虚拟地址空间的起始位置
	Elf32_Addr p_paddr;
	//Segment的物理装载地址,一般情况下和p_vaddr一样
	Elf32_Word p_filesz;
	//Segment在ELF文件中所占的空间大小,可能是0,代表该Segment在ELF文件中不存在内容
	Elf32_Word p_memsz;
	//Segment在进程虚拟地址空间中所占用的大小,也可能是0
	Elf32_Word p_flags;
	//权限属性
	Elf32_Word p_align;
	//对其属性,如p_align为10,代表对其属性是2的10次方,即1024字节
}Elf32_Phdr;

对于LOAD类型的Segment,p_memsz不可以小于p_filesz,但可以大于。表示该Segment在内存中所分配的空间大于在文件中实际的大小,多余的部分全部填0。好处是,比如在构造ELF可执行文件时不需要再额外设立BSS的Segment了,可以把数据段Segment的p_memsz扩大,额外的部分就是BSS。因为数据段和BSS的唯一区别就是数据段从文件中初始化内容,而BSS段的内容全部初始化为0。所以前面我们只看到两个LOAD类型的段,而不是三个。

4.2 堆和栈

实际上,进程执行的时候使用到的堆和栈等空间,在虚拟空间中也是以VMA的形式存在的。一个栈和堆分别有一个对应的VMA。通过查看/proc来查看进程的虚拟空间分布:

可执行文件的装载与进程_第10张图片第一列表示VMA的地址范围,第二列是VMA的权限,r读,w写,x可执行,p表示私有,s表示共享,第三列是VMA对应的Segment在映像文件(可执行文件)中的偏移,第四列表示映像文件所在设备的主设备号和次设备号,第五列表示映像文件的节点号,最后一列是映像文件的路径。

可以看到除了前两个,其他VMA所在设备的主设备号和次设备号及文件节点号都是0,表示没有映射到文件中,这种VMA叫做匿名虚拟内存区域。有个特殊的VMA“vdso”,它的地址已经位于内核空间了,事实上这是一个内核的模块,进程通过访问这个VMA来跟内核进行一些通信。

常见进程的虚拟地址空间:

可执行文件的装载与进程_第11张图片

4.3 堆的最大申请数量

具体的最大申请数量收到操作系统版本,程序本身大小,用到的动态/共享库数量,大小,程序栈数量大小等因素的影响。甚至有可能每次运行该数值都不同。

4.4 段地址对齐

对于Intel 80x86系列处理器来说,默认的页大小为4096字节。即我们要将一段物理内存和进程虚拟地址空间之间建立映射关系,这段内存控件的长度必须是4096的整数倍,且这段空间在物理内存和进程虚拟地址空间中的起始地址也必须是4096整数倍。由于有着长度和起始地址的限制,对于可执行文件来说,它应尽量优化自己的空间和地址安排,以节省空间。

假如有一个ELF可执行文件,有三个Segment需要装载,分别为SEG0,SEG1,SEG2,如下表所示:

每个段的长度都不是页长度的整数倍,这是很常见的一种情况。最简单的方案就是分别映射,长度不足整数倍的话补全至整数倍。如下所示:

可执行文件的装载与进程_第12张图片

可执行文件的装载与进程_第13张图片

共占据了5个页,实际上有很多空间浪费了。

为了解决这种问题,有些UNIX系统将那些各个段接壤部分共享一个物理页面(共享的是物理页,虚拟页还是独立的),然后该物理页面分别映射两次。比如对于SEG0和SEG1的接壤部分的那个物理页,系统将它映射两份到虚拟地址空间,一份为SEG0,一份为SEG1,其他的页按照正常的页粒度进行映射。UNIX系统也将ELF文件头当作一个Segment,映射到进程的地址空间中,对于一些需要访问ELF文件头的操作,可以直接通过读写内存地址空间进行。改进之后的映射如下所示:

可执行文件的装载与进程_第14张图片

可以看到只用到了3个页面。这种情况下,对于一个物理页来说,可能同时包含两个段的数据,甚至多于两个段(比如在多个段加起来都没超过4096字节的情况下)。

这种方法下,各个段的虚拟地址往往就不是系统页面长度的整数倍了。例如下图中VMA1的起始地址是如何算的呢?

可执行文件的装载与进程_第15张图片

首先VMA0的地址为0x08048000,长度是0x709E5,则结束地址是0x080B89E5。因此正常来说VMA1的起始地址可以是0x080B9000(因为页大小是4096B,对齐属性是0x1000),但VMA1是和VMA0的最后一个虚拟页共享一个物理页的,而VMA0的结束地址在该页中的偏移是0x9E5,即VMA1的起始地址在该物理页的偏移是0x9E5。因此为了映射方便,VMA1在虚拟空间中的起始地址设为0x080B99E5,又因为段必须是4字节的倍数,所以向上取整至0x080B99E8。

因此有个规律,对于一个可装载的Segment,它的p_vaddr除以对齐属性的余数等于p_offset除以对齐属性的余数。

4.5 进程栈初始化

进程开始启动的时候,需要知道一些进程运行的环境,最基本的就是系统环境变量和进程的运行参数。常见的做法是操作系统在进程启动前将这些信息提前保存到进程的虚拟空间的栈中。

假设有如下两个环境变量:

HOME=/home/user

PATH=/usr/bin

运行程序的命令行如下:

prog 123

则进程初始化后的堆栈可能如下所示:

可执行文件的装载与进程_第16张图片

进程启动以后,程序的库部分会把堆栈里的初始化信息中的参数信息传递给main()函数。

5. Linux内核装载ELF过程简介

比如在bash上,用户输入可执行文件名,bash进程会调用fork()系统调用创建一个新的进程,然后新的进程调用execve()系统调用执行指定的ELF文件,原先的bash进程等待刚启动的进程结束,然后继续等待用户输入命令。execve原型如下:

int execve(const char *filename,char *const argv[], char *const envp[]);

参数分别是可执行文件名,执行参数和环境变量。Glibc对execvp()系统调用进行了包装,提供了execl(),execlp(),execle(),execv()和execvp()共5种不同形式的exec系列API,它们只是参数形式上有所区别,最终还是会调用到execve()。

进入execve()系统调用之后,就进入内核态,在内核中,execve()系统调用相应的入口是sys_execve()。sys_execve()进行一些参数的检查复制之后,调用do_execve()。do_execve()会查找可执行文件,并读取文件的前128字节。因为Linux支持不只一种可执行文件格式,需要读取前128字节判断文件的具体格式。

读取了具体的文件格式之后,调用search_binary_handle()来判断文件头部的魔数确定文件的格式,并且调用相应的装载处理过程。比如ELF格式的可执行文件的装载处理过程叫做load_elf_binary(),a.out可执行文件对应的叫做load_aout_binary(),可执行脚本程序对应的叫做load_script()。

如load_elf_binary()的主要步骤分以下五步:

(1)检查ELF可执行文件的有效性,比如魔数,程序头表中Segment的数量。

(2)寻找动态链接的“.interp”段,设置动态链接器路径。

(3)根据ELF可执行文件的程序头表的描述,对ELF文件进行映射,比如代码,数据,只读数据。

(4)初始化ELF进程环境。

(5)将系统调用的返回地址修改成ELF可执行文件的入口点。对于静态链接的ELF可执行文件,入口点就是ELF文件的文件头中e_entry所指的地址,对于动态链接的ELF可执行文件,入口点是动态链接器。

load_elf_binary执行完毕,依次返回到do_execve(),sys_execve()。当sys_execve()从内核态返回到用户态时,EIP寄存器直接跳转到了ELF程序的入口点,接着执行新的进程,ELF可执行文件装载完成。

6. Windows PE的装载

PE与ELF不同,在PE文件中,所有段的起始地址都是页的倍数,段的长度也是页的倍数(不足则向上补齐)。ELF文件经常有很多个Section,最后不得不使用Segment概念来装载。而PE文件中,链接器在生产可执行文件时,往往将所有的段尽可能地合并,所以一般只有代码段,数据段,只读数据段和BSS等为数不多的几个段。

其中有一个术语RVA(Relative Virtual Address)表示相对虚拟地址,是相对于PE文件的装载基地址的一个偏移地址。比如,一个PE文件被装载到虚拟地址0x00400000,则RVA为0x1000的地址就是0x00401000。每个PE文件装载时都会有一个装载目标地址,即基地址,这地址并不是固定不变的。

装载PE的过程:

(1)读取文件的第一个页,其中包含了DOS头,PE文件头和段表。

(2)检查基地址是否可用,不可用则另选一个。这个问题对于可执行文件来说是不存在的,因为它往往是进程第一个装入的模块。这主要是针对DLL文件的装载而言。

(3)使用段表中提供的信息,将PE文件中所有的段一一映射到地址空间中相应的位置。

(4)装载PE文件需要的所有DLL文件。

(5)对PE文件中的所有符号进行解析。

(6)根据PE头中指定的参数,建立初始化栈和堆。

(7)建立主线程并且启动进程。

 

你可能感兴趣的:(程序员的自我修养)