程序是以可执行文件的形式存放在磁盘上的,可执行文件既包括目标代码也包括数据。我们一般所使用的库函数可以被静态的拷贝到可执行文件中,也可以运行时动态链接。
可执行文件是一个普通文件,它描述了如何初始化一个新的执行上下文,也就是如何开始一个新的计算。当进程开始执行一个新程序时,它的执行上下文变化很大,这是因为在进程前一个计算执行期间所获得的大部分资源会被抛弃。本文通过调用exec*库函数加载一个可执行文件来简单的分析可执行文件的处理过程。
一、ELF
可执行连接格式是UNIX系统实验室(USL)作为应用程序二进制接口(Application Binary Interface)而开发和发布的。工具接口标准委员会(TIS)选择了正在发展中的ELF标准作为工作在32位INTEL体系上不同操作系统之间可移植的二进制文件格式。
实际的操作一个例子,观察每一步得到的文件。
我们可以看看这些文件里面的内容。有的文件不可读。
用readelf -h hello看看elf文件内容:
大致转换过程是这样的:
ELF文件种类:
可重定位文件:用户和其他目标文件一起创建可执行文件或者共享目标文件,如lib*.a。
可执行文件:用于生成进程映像,载入内存执行,例如编译好的可执行文件a.out。
共享目标文件:用于和其他共享目标文件或者可重定位文件一起生成elf目标文件或者和执行文件一起创建进程映像,如lib*.so。
二、可执行程序的执行环境
-
命令行参数和shell环境,一般我们执行一个程序的Shell环境,我们的实验直接使用execve系统调用
$ ls -l /usr/bin 列出/usr/bin下的目录信息
-
Shell本身不限制命令行参数的个数, 命令行参数的个数受限于命令自身
例如,int main(int argc, char *argv[])
又如, int main(int argc, char *argv[], char *envp[])
-
Shell会调用execve将命令行参数和环境参数传递给可执行程序的main函数
int execve(const char * filename,char * const argv[ ],char * const envp[ ])
库函数exec*都是execve的封装例程
命令行参数和环境串都放在用户态堆栈中
三、可执行程序的装载
-
命令行参数和shell环境,一般我们执行一个程序的Shell环境,我们的实验直接使用execve系统调用。
-
Shell本身不限制命令行参数的个数, 命令行参数的个数受限于命令自身
例如,int main(int argc, char *argv[])
又如, int main(int argc, char *argv[], char *envp[])
-
Shell会调用execve将命令行参数和环境参数传递给可执行程序的main函数
int execve(const char * filename,char * const argv[ ],char * const envp[ ]);
库函数exec*都是execve的封装例程
-
sys_execve内部会解析可执行文件格式
do_execve -> do_exec_common -> exec_binprm
对于ELF格式的可执行文件fmt->load_binary(bprm);执行的应该是load_elf_binary其内部是和ELF文件格式解析的部分需要和ELF文件格式标准结合起来阅读
四、动态链接
高级语言的源码都是经过几个步骤才转化为目标文件的,目标文件中包括汇编语言指令的机器代码,与相应的高级语言指令对应。目标文件不能被执行,因为它不包括源代码文件所用到的全局外部符号名的线性地址。这些地址的分配或解析是由链接程序完成的,链接程序把程序所有的目标文件收集起来并构造可执行文件。 linux系统利用共享库,也就是说可执行文件仅仅指向库名。当程序被装入内存时,动态连接器程序就专门分析可执行文件中的库名,确定库所在位置,并使进程可以使用所请求的代码。 动态链接程序运行在用户态,它的第一个工作就是从内核保存在用户态堆栈信息开始,为自己建立一个基本的执行上下文。然后,动态链接程序检查被执行的程序,识别哪个共享库必须装入以及在每个共享库中哪个函数被有效的申请。接着,解释器发出系统调用创建线性区,将实际使用的库函数所在页进行映射。最后,动态链接程序通过跳转到被执行程序的入口点而终止它的执行。五、gdb跟踪分析一个execve系统调用内核处理函数sys_execve
在MenuOS里面添加一个命令:
int Exec(int argc, char *argv[])
{
int pid;
/* fork another process */
pid = fork();
if (pid < 0)
{
/* error occurred */
fprintf(stderr,"Fork Failed!");
exit(-1);
}
else if (pid == 0)
{
/* child process */
printf("This is Child Process!\n");
execlp("/hello","hello",NULL);
}
else
{
/* parent process */
printf("This is Parent Process!\n");
/* parent will wait for the child to complete*/
wait(NULL);
printf("Child Complete!\n");
}
}
MenuConfig("exec","Execute a program",Exec);
执行效果:
启用gdb调试,设置断点:sys_execve, load_elf_binary, start_thread等。
用s、n进行单步跟踪。最后完全启动。
在跟踪到start_thread时候,发现一个new_ip,用po new_ip查看它的地址值,发现这个new_ip的值(0x8048320)和hello的入口点的地址是一样的。也就是说new_ip返回到了用户态第一条指令的地址。 exec函数是linux提供的一系列函数,这里我使用了execlp()。除了第一个参数外,它的其他参数个数都是可变的。每个参数指向一个对新程序命令行参数描述的字符串。 sys_execve()接收:可执行文件路径名地址,以null结束的字符串指针数组的地址(每个字符串表示一个命令行参数),以null结束的字符串指针数组的地址(每个字符串表示一个环境变量)。sys_execve()把可执行文件路径名拷贝到一个新分配页框,然后调用do_execve()。 ELF格式的二进制映像的认领、装入和启动是由load_elf_binary()完成的。而“共享库”即动态连接库映像的装入则由load_elf_library()完成。实际上共享库的映像也是二进制的,但是一般说“二进制”映像是指带有main()函数的、可以独立运行并构成一个进程主体的可执行程序的二进制映像。 start_thread()宏主要修改保存在内核堆栈但属于用户态寄存器的eip和esp的值,以使它们分别指向动态链接程序的入口点和新的用户态堆栈的栈顶。
六、总结
内核首先读取ELF文件头部,再读如各种数据结构,从这些数据结构中可知各段或节的地址及标识,然后调用mmap()把找到的可加载段的内容加载到内存中。同时读取段标记,以标识该段在内存中是否可读、可写、可执行。其中,文本段是程序代码,只读且可执行,而数据段是可读且可写。从PT_INTERP的段中找到所对应的动态链接器名称,并加载动态链接器。内核把新进程的堆栈中设置一些标记对,以指示动态链接器的相关操作。内核把控制权传递给动态链接器。动态链接器检查程序对共享库的依赖性,并在需要时对其进行加载。动态链接器对程序的外部引用进行重定位,并告诉程序其引用的外部变量/函数的地址,此地址位于共享库被加载在内存的区间内。动态链接还有一个延迟定位的特性,即只有在“真正”需要引用符号时才重定位,这对提高程序运行效率有极大帮助。动态链接器执行在ELF文件中标记为.init的节的代码,进行程序运行的初始化。动态链接器把控制传递给程序,从ELF文件头部中定义的程序进入点(main)开始执行。在a.out格式和ELF格式中,程序进入点的值是显式存在的。最后,程序开始执行。
卢鹏 原创作品转载请注明出处 (部分内容引用网上资源,如有不当之处,可与本人联系)