导读
可执行文件只有装载到内存以后才能被CPU执行。早期装载的基本过程就是把程序从外部存储器中读取到内存中的某个位置,随着硬件MMU的诞生,多进程、多用户、虚拟存储的操作系统出现以后,可执行文件的装载过程变得非常复杂。现在我们通过ELF文件在linux下的装载过程,来层层拨开迷雾,看看可执行文件装载的本质到底是什么?
目录
- 进程的虚拟地址空间
- 装载方式
- 进程虚拟地址空间的分布
- Linux内核装载ELF过程简介
正文
1、进程的虚拟地址空间
我们知道每个程序被运行起来以后,它将拥有自己独立的虚拟地址空间,这个虚拟地址空间的大小由计算机的硬件平台决定,具体的说是由CPU的位数决定的。从程序角度看,我们可以通过判断C语言程序中的指针所占的空间来计算虚拟地址空间的大小。一般来说,C语言指针大小的位数与虚拟空间的位数相同,如32位平台下的指针为32位,即4个字节。现在问题来了,32位平台下的4GB虚拟地址空间,我们的程序是否可以任意使用?很遗憾,不行,因为程序在运行的时处于操作系统的监管下,操作系统为了达到监控程序运行等一系列目的,进程的虚拟地址空间都在操作掌握之中。进程只能使用那些操作系统分配给进程的地址,如果访问未经允许的空间,那么操作系统就会捕获到这些访问,将进程的这种访问当作非法操作,强制结束进程。如:windows下的“进程因非法操作需要关闭”或linux下的“Segmentation fault”很多时候就是因为进程访问未经允许的地址。
那么到底这4GB的进程虚拟地址空间是怎么样的分配状态呢?以linux操作系统为例子,默认情况下是划分成两部分:1GB分给操作系统,3GB分给进程。当然这种划分是可以修改的。
2、装载方式
程序执行时所需要的指令和数据必须在内存中才能够正常运行,最简单的办法就是将程序所需要的指令和数据全都装入内存中,这样程序就可以顺利运行,这就是最简单的静态装入的办法。但是很多情况下程序所需要的内存数量大于物理内存数量,当内存的数量不够时,根本的解决办法就是添加内存。但是这种方法不太采纳,毕竟内存是昂贵并且稀有。一般用的多的还是采用动态装入,其原理就是因为程序运行时有局部性,我们将程序最常用的部分驻留在内存,而将那些不太常用的数据存放在磁盘里面。其中两种很典型的动态装载方法就是覆盖装入(Overlay)和页映射(Paging)。
3、进程的虚拟地址空间的分布
在一个正常的进程中,可执行文件中包含的不止代码段,还有数据段、BSS等,所以映射到进程虚拟空间的往往不止一个段,当段数量增多时,就会产生空间浪费问题,毕竟ELF文件被映射时,是以系统的页长度作为单位的,那么每个段在映射时的长度应该都是系统页长度的整数倍;如果不是,那么多余的部分也将占用一个页。一个ELF文件中往往有十几个段,那么内存的浪费可想而知。
另外,ELF文件中,段的权限往往只有为数不多的几种组合,基本上分成三种:
- 以代码段为代表的权限为可读可执行的段
- 以数据段和BSS段为代表的权限为可读可写的段
- 以只读数据段为代表的权限为只读的段
那么我们可以找到一个很简单的方案就是:对于相同权限的段,把它们合并到一起当作一个段进行映射,合并一起之后就就映射到一个VMA。
操作系统通过给进程空间划分出一个个VMA来管理进程的虚拟空间;基本原则是将相同的权限属性的、有相同映像文件的映射成一个VMA;一个进程基本上可分为如下4种VMA区域:
- 代码VMA:权限只读、可执行;有映像文件
- 数据VMA:权限可读写、可执行;有映像文件
- 堆VMA:权限可读写、可执行;无映像文件,匿名,可向上扩展
- 栈VMA:权限可读写、不可执行;无映像文件,匿名,可向下扩展
4、Linux内核装载ELF过程简介
当我们在Linux系统的bash下输入一个命令执行某个ELF程序时,Linux系统是怎样装载这个ELF文件并且执行它的呢?
首先在用户层面,bash进程会调用fork()系统调用创建一个新的进程,然后新的进程调用execve()系统调用执行指定的ELF文件,原先的bash进程继续返回等待刚才启动的新进程结束,然后继续等待用户输入命令。execve()系统调用被定义在unistd.h,它的原型如下:
int execve( const char *filename, char *const argv[], char *const envp[] );
它的三个参数分别是被执行的程序文件名、执行参数和环境变量。
在进入execve()系统调用之后,Linux内核就开始进行真正的装载工作。在内核中,execve()系统调用相应的入口是sys_ execve(),sys_ execve()进行一些参数检查复制后,调用do_ execve()。do_ execve()会首先查找被执行的文件,如果找到,则读取文件的前128个字节,用心判断文件的格式,因为每种可执行文件的格式的开头几个字节都是很特殊,特别是开头4个字节。比如ELF可执行文件的开头4个字节为0x7F、’e’ 、‘l’、 ‘f’;而Java的可执行文件格式的开头4个字节为’c’ 、‘a’、 ‘f’、 ‘e’;如果被执行的是shell脚本,那么它的第一行往往往是“#!/bin/bash”,这时候,前两个字节’#’和’!’就构成了魔数,系统一旦判断到这两个字节,就对后面的字符串进行解析,以确定具体的解释程序的路径。
当do_ execve() 读取了这128个字节的文件头部之后,然后调用search_binary_handle()去搜索和匹配合适的可执行文件装载处理过程。Linux中所有被支持的可执行文件格式都有相应的装载处理过程,search_binary_handle()会通过判断文件头部的魔数确定文件的格式,并且调用相应的装载处理过程。比如ELF可执行文件的装载处理过程:load_elf_binary();a.out可执行文件的装载处理过程:load_aout_binary();可执行脚本程序的装载处理过程:load_script()。
现在我们只关心load_elf_binary(),主要步骤是:
1) 检查ELF可执行文件格式的有效性,比如魔数、程序头表中段的数量;
2) 寻找动态链接的“.interp”段,设置动态链接器路径;
3) 根据ELF可执行文件的程序头表的描述,对ELF文件进行映射,比如代码、数据、只读数据;
4) 初始化ELF进程环境,比如进程启动时EDX寄存器的地址应该是DT_FINI的地址;
5) 将系统调用的返回地址修改成ELF可执行文件的入口点,这个入口点取决于程序的链接方式,对于静态链接的ELF可执行文件,这个程序入口就是ELF文件的文件头中e_entry所指的地址;对于动态链接的ELF可执行文件,程序入口点是动态链接器。
当load_elf_binary()执行完毕,返回至do_execve(),再返回至sys_execve()时,上面的第5步中已经把系统调用的返回地址改成了被装载的ELF程序的入口地址了。所以当sys_execve()系统调用从内核态返回到用户态时,EIP寄存器直接跳转到了ELF程序的入口地址,于是新的程序开始执行,ELF可执行文件装载完成。