中间跳过了Winods PE/COFF这一节,以及最后Windows内核装载也会省略掉。因为我们主要面向的Mac、Linux。这一节介绍可执行文件的装载与进程。
本文导图
进程虚拟地址(操作系统讲得比较多)
程序(可执行文件)是一个静态的概念,不知是一些预先编译好的指令和数据集合的一个文件;而进程是一个动态的概念,它是程序运行时的一个过程。一个比方:把程序与进程的概念跟厨房做菜比较
- 程序就是菜谱
- CPU就是人
- 厨具就是其他硬件
- 整个炒菜就是一个进程
计算机按此程序指令把数据加工成输出数据,就像菜谱指导人把原料做成菜肴一样
每个程序被运行起来以后,它将拥有独立的虚拟地址空间。从程序角度来讲,C语言程序中指针所占用的空间来计算虚拟地址空间位数大小。一般来将,C语言指针大小与虚拟空间的位数相同,如32位平台,指针就是32位,既4字节。
进程只能使用那些操作系统分配给地址空间,如果访问了未经许可的空间,那么操作系统就会捕获这些访问,将进程这种访问视为非法访问,并强制结束进程。
如32位中,整个4GB被划分为两个部分,从地址0xC000 0000到0xFFFF FFFF共1GB。剩下的从0x0000 0000地址到0xBFFF FFFF共3GB空间都是给进程用的,其实这3GB还要减去一些物理设备的RAM大小。
PAE
32位CPU最大的寻址能力是0到4GB;Intel1995年就开始采用36位的物理地址,也就是可以访问64GB的物理内存,并且修改了页映射方式,使得新的映射方式可以访问到更多的物理内存,这种地址扩展方式叫做PAE(Physical Address Extensio)。
虽然扩展了物理地址空间,但是对于普通的应用程序而言感觉不到它的存在,因为操作系统会处理好映射关系。操作系统提供一个窗口映射的方法,把额外的内存映射到进程地址空间中来。
装载方式
静态装载,也就是把程序所有的数据和指令装载到内存中。但是很浪费资源。因为程序运行有局部性原理,可以吧最常用的部分驻留在内存中,将不太常用的数据存在磁盘中。——虚拟内存
覆盖装入
以一个程序为单位进行内存的换入换出。详细内容直接去看操作系统。
页映射方式
将换入换出的粒度变得更小,以一页的大小为单位进程换入换出。
一个列子:
程序刚刚执行的入口是P0,这时装载挂你器发现P0不在内存中,于是将F0分配给P0,并且将P0内容装载到F0,运行一段时候后发现需要用到P5,于是装载器将P5装入F1,依次类推当程序需要用到P3和P6的时候,分别被载入到F2,F3中。——有点类似懒加载
如果程序只需要这4个页,那么程序就能够一直运行下去,但是,如果程序要访问P4,那么必须舍弃4个内存页其中的一个来装载P4。至于选择哪一个页,可以根据算法选择。比如先进先出,LUR(最少使用算法)
现代的操作系统都是按照这样的方式装载可执行文件。
操作系统角度理解可执行文件的装载
如上面所示,程序使用物理地址直接操作,每次页被载入时都需要重定位。在虚拟内存中现代的硬件MMU(内存管理单元)提供了地址转换功能,有了硬件地址转换和页映射机制,可执行文件加载动态加载方式和静态加载有非常大的不同。
进程的建立(三步)
从操作系统的角度来讲,一个进程最关键的特征就是它有独立的虚拟地址空间,因此有别于其他进程。
创建一个进程,装载之后然后执行需要下面三个步骤:
- 创建一个独立的虚拟地址空间:创建映射函数所需要的相应数据结构,在Linux下,创建虚拟地址实际上只是分配了一个页目录,还没有页映射,映射关系等到后面程序发生页错误的时候在设置。
- 读取可执行文件头,建立虚拟地址与可执行文件的映射关系:上一步是页映射关系函数时虚拟地址到物理地址的映射,这一步是虚拟地址与可执行文件的映射。当程序执行发生页错误的时候,操作系统将从物理内存中分配一个物理页,然后将该
缺页
从磁盘中读取到内存中,在设置缺页的虚拟页和物理页的映射关系。——当操作系统捕获到缺页错误的时候,知道当前程序所需要的页在可执行文件哪一个位置,这就是虚拟空间与可执行文件映射关系。 - 将CPU的指令寄存器设置成可执行文件入口地址,启动运行:操作系统通过设置CPU指令的寄存器将控制权交给进程,由此进程开始执行。ELF文件头中保存了入口地址。
现在对第二个步骤说明一下。假设现在EFL文件有一个代码段.text
,虚拟地址是0x0804 8000,文件大小为0x00021,对齐为0x1000(4096,也就是一个虚拟页的大小)。因为该.text段不到一个页,考虑到对齐该段占用一个段,所以可执行完一旦被装载,映射关系如下:
这种映射关系只是保存在操作系统内部的一个数据结构,Linux将进程虚拟空间中的一段叫做虚拟内存区域(VMA)——一种数据结构.
页错误(懒加载的方式)
上面步骤执行完之后可执行文件的指令并没有被装载到内存中,只是通过可执行文件头部的信息建立可执行文件和进程虚拟内存之间的映射关系而已。也就是先占个位置。
具体流程:如上面的例子,程序入口地址为0x08048000,刚好是.text
段的起始地址,当CPU执行这个地址的指令时,发现页面0x0804 8000 到 0x0804 9000 是个空页,也是认为这个是页错误,CPU将控制权给操作系统,操作系统有专门处理页错误的例程。这个时候操作系统将会查询刚才第二步中建立的数据结构(映射关系),找到空白页所在VMA,计算出相应页面在可执行文件中的偏移,然后在物理内存中分配一个物理页面,将进程中该虚拟页与分配的物理页之间建立映射关系,然后再把控制权给进程,进程从刚才的页错误重新执行。——遇到错误页——》控制区给操作系统——》确定映射关系——》控制权交给进程——》从错误页重新执行
也就是可执行文件和虚拟内存建立先建立映射关系,然后在发生错误页的时候根据之前建立映射的数据结构确定物理页与虚拟内存的映射关系。
随着进程的执行,错误页不断产生,操作系统也会为进程分配相应的物理页来满足进程的执行需求。简单来讲如下图所示:
进程虚拟内存分布(合并)
ELF文件链接视图和执行视图
上面的例子中是以一个.text
为例,然后被操作系统装载到进程空间,对应一个VMA。但是一般可执行文件由很多段,就会产生空间浪费。因为ELF文件映射到VMA中是以页长为单位的,每个段映射之后的长度都是系统页长的整数倍,不足的就会补足多用的部分,造成空间浪费。
最终是通过合并来解决上面的问题。操作系统并不关心实际各个段包含的内容,只关心一些跟装载相关的问题,比如各个段的读写权限等。段可以分为:
- 以代码段为代表的可读可执行段
- 以数据段、BBS段为代表的可读可写段
- 以只读数据段为代表的权限为只读的段。
对于相同权限的段,把它们合并到一起当做一个段进行映射。——合并
如下图所示如果.text
段为4097.init
段为512。那么这两个段就需要三个页大小。如果把它们合并只需要两个页大小。
Segment(合并段的称号)
ELF可执行文中引入了一个概念叫做
Segment
,一个Segment包含一个或多个属性类型的Section。上面的例子中如果将.text
和.init
段合并在一起看作一个segment,那么装载的时候就可以将他们看作一个整体一起映射——也就是映射以后在进程的虚拟地址空间只有一个相对应的VMA。可以减少页面内部碎片,节省内存空间。
这里可能把Segment和Section弄混了。
- Segment是从装载的角度重新划分了ELF的各个段
- 目标文件链接成可执行文件的时候,链接器会把相同权限相同的段分在同一个空间,如可读可执行的段放在一起(代码段),可读可写放在一起(数据段),把这些属性相似的、又连在一起的段叫做segment
- 系统按照Segmen而不是Section来映射可执行文件。
也就是Section适用于目标文件,Segment适用于可执行文件。
一个列子:
可以使用readelf命令查看ELFD额segment,类比之前的section属性结构,相应的segment的结构叫做程序头,描述了ELF文件改如何被操作系统映射到进程的虚拟空间中。
根据上面的图,这个可执行文件最终有5个segment。目前只关心加载相关的Load类型的Segment,只有它是被需要映射的。
可执行文件重新被换房了三个部分,一些短被归入了可读可执行的,统一被映射到VMA0;可读可写的映射到了VMA1;还有一部分段在程序装载的时候没有被映射,比如一些调试信息和字符串表等,这些段在程序执行时没有作用,所以不需要映射。——所有相同的section都被归类到了一个segment中,映射到同一个VMA
在ELF的时候,段指的是segment,其他情况都是值section。
ELF可执行文件有一个专门的数据结构叫做程序头用来保存Segment信息,因为目标文件不需要装载所以没有程序头,动态库和ELF可执行文件都有。类似于段表,程序头同样是一个结构体数组。——对整个可执行文的segment的说明
各个字段的含义如下:
堆和栈
堆和栈在进程的虚拟空间同样是以VMA的形式存在,分别都有一个对应的VMA。Linux下可以使用/proc
来查看进程的而虚拟空间分别。
解释:可以看到该进程有5个VMA,只有前两个被映射到了可执行文件宏的两个segment,另外三个没有映射,这三个叫做匿名虚拟内存区域。可以看到还有两个区域分别是heap和stack大小分别是140kb,88kb,这两个区域在所有进程中都存在。
每个线程都有属于自己的堆栈,对于单线程的程序来讲,这个VMA堆栈就全归它使用。
操作系统通过给进程空间划分一个VMA来挂你进程的虚拟空间,将相同权限、有相同映射文件的合成一个VMA。一个进程基本上有如下几种VMA:
- 代码VMA,只读、可执行,有映射文件
- 数据VMA,可读可写、可执行,有映射文件
- 堆VMA,可读可行、可执行,无映射文件,匿名,可向上扩展
- 栈VMA,可读可行、不可执行,无映射文件,匿名,可向下扩展
段地址对齐
这里的段值Segment。前面讲过装载过程是通过虚拟内存额页映射机制完成的,也是映射的最小单位,大小是4096。所以映射的物理内存和虚拟内存都徐亚是4096的整数倍。由于长度和起始地址的显示,应该尽可能的优化空间和地址安排,节省空间。
假设现在一个ELF可执行文件有三个段,SEG0,SEG1,SEG2,长度和偏移如下:
每个段的长度都不是页长度的整数倍,一种简单的思路就是把每个段分开映射但是会导致非常多内存碎片的,因为长度不足4096的需要补足4096。
在UNIX中,采用的是让各个段接壤部分共享一个物理页面,然后将该物理页面分别映射两次,比如SGE0和SGE1接壤的那个物理页,系统将它们映射两份到虚拟地址空间。UNIX中系统将ELF的文件头也看做是系统的一个段(可以从下面的图中看到),也会映射到虚拟地址空间,这样进程中的某一段区域就是整个ELF文件的镜像了,这就操作ELF文件头就可以直接读写内存的来实现操作。
ELF文件从开始到某个点结束也4096位单位划分为若干个块,每个块单独装载到物理内存中,如果位于段中间的块,就会被映射两次。
这样内次空间就得到了充分的利用,上面例子看出本来用到5个物理页,现在只需要3个。一个极端情况,如果文件头、代码段、数据段加起来都没有4096那么只需要一个物理页就够了。
进程栈初始化
进程刚开始启动的时候,需要知道一些进程运行的环境,最基本的就是系统环境变量和进程运行参数。常见的做法就是操作系统在进程启动前将这些信息提前保存到虚拟空间的栈中(也就是VMA中的Stack VMA)。——系统环境变量,进程运行参数
假设有如下环境变量:
HOME=/home/user
PATH=/usr/bin
假设堆栈段底部地址为0xBF80 2000,那么进程初始化后的堆栈就如下图所示:
- 栈顶寄存器esp指向的位置是初始化以后堆栈的顶部,最前面4个字节表示命令行参数的数量。
- 对应这个例子是
prog 123
- 紧跟着的就是分布指向这两个参数字符串的指针,后面跟了一个0
- 紧接着是两个指向环境变量的字符串指针,分别指向字符串
HOME=/home/user
和PATH=/usr/bin
- 后面紧跟着一个0表示结束。
进程启动之后,程序的库会把堆栈里面的初始化信息中的参数信息传递给main()函数,也就是我们熟知的argc和argv连个参数,两个参数分别对应这里的命令参数数量和命令行参数字符串指针数组。
Linux内核装载ELF过程(可以省略,有点深)
当在Linux系统bash下输入一个命令执行ELF程序时.
首先在用户层面,bash进程会调用fork,系统会创建一个新的进程,新的进程会用exeve()系统调用指定ELF文件,原先bash进程继续返回等待刚才启动新进程结束,开始等待用户输入命令。
最终会调用到execve,函数原型
int execve(const char * __file, char * const * __argv, char * const * __envp)
。三个参数分别是:
- 可执行文件名
- 执行参数
- 环境变量
调用过程中还会调用到do_excve()
,do_excve()
会读取文件的前128个字节,判断文件格式,特别是开头的四个字节常常被称作魔数,通过魔数可以判断文件的格式及类型。
上面步骤可以确定文件的类型及格式了,然后调用search_binary——handle()
去搜索和匹配合适的可执行文件装载处理过程。匹配过程是通过判断文件头部的魔数确定的,比如ELF可执行文件装载处理过程叫做load_elf_binary(),a.out可执行文件的装载处理过程叫做load_aout_binary(),可执行脚本处理过程叫做load_script()。比如load_elf_binary()主要经历了如下步骤。
- 检查ELF可执行危机格式的有效性。如魔数,程序头表中段的数量
- 寻找动态链接的
.interp
段,设置动态链接路径 - 根据ELF可执行文件的程序头表描述,对ELF文件进行映射,比如代码数据,只读数据
- 初始化ELF进程环境,比如进程启动是EDX寄存器地址应该是DT_FINI地址(动态链接会讲)
- 将系统调用的返回地址修改成ELF可执行文件的入口点,这个入口点取决于程序的链接方式。对于静态链接的ELF可执行文件,入口点就是ELF文件的文件头中e_entry所指的地址;动态链接的ELF,入口点就是动态链接器
当load_elf_binary执行完毕,返回到do_execve在返回到sys_exevce()时,第5步中已经把系统调用的返回地址改为了被装载的ELF的程序入口 地址。所以当sys_execve()从内核态返回到用户态的时候,EIP寄存器直接跳到ELF程序入口地址,新的程序开始执行。
总结
关于可执行文件的装载和进程进程就介绍到这里。很多东西如果去深究,会发现是个无底洞。生有涯而学无涯!