赞
踩
原文地址:http://www.iteye.com/topic/1121480
近段时间在研究Erlang核心特性的实现,也许过段时间会有个系列的总结,期待...
今天看到有人写一个深入Hello World的文章,想起来读研的时候做的一个关于程序加载和链接的课程设计,也是以Hello World为例说明的,随发出来共享。文后有下载链接。
======================================================
本文的目的:大家对于Hello World程序应该非常熟悉,随便使用哪一种语言,即使还不熟悉的语言,写出一个Hello World程序应该毫不费力,但是如果让大家详细的说明这个程序加载和链接的过程,以及后续的符号动态解析过程,可能还会有点困难。本文就是以一个最基本的C语言版本Hello World程序为基础,了解Linux下ELF文件的格式,分析并验证ELF文件和加载和动态链接的具有实现。
本文的实验平台:
<!--[if !supportLists]-->Ø <!--[endif]-->Ubuntu 7.04
<!--[if !supportLists]-->Ø <!--[endif]-->Linux kernel 2.6.20
<!--[if !supportLists]-->Ø <!--[endif]-->gcc 4.1.2
<!--[if !supportLists]-->Ø <!--[endif]-->glibc 2.5
<!--[if !supportLists]-->Ø <!--[endif]-->gdb 6.6
<!--[if !supportLists]-->Ø <!--[endif]-->objdump/readelf 2.17.50
本文的组织:
第一部分大致描述ELF文件的格式;
第二部分分析ELF文件在内核空间的加载过程;
第三部分分析ELF文件在运行过程中符号的动态解析过程;
(以上各部分都是以Hello World程序为例说明)
第四部分简要总结;
第五部分阐明需要深入了解的东西。
ELF文件格式
概述
Executable and Linking Format(ELF)文件是x86 Linux系统下的一种常用目标文件(object file)格式,有三种主要类型:
<!--[if !supportLists]-->1) <!--[endif]-->适于连接的可重定位文件(relocatable file),可与其它目标文件一起创建可执行文件和共享目标文件。
<!--[if !supportLists]-->2) <!--[endif]-->适于执行的可执行文件(executable file),用于提供程序的进程映像,加载的内存执行。
<!--[if !supportLists]-->3) <!--[endif]-->共享目标文件(shared object file),连接器可将它与其它可重定位文件和共享目标文件连接成其它的目标文件,动态连接器又可将它与可执行文件和其它共享目标文件结合起来创建一个进程映像。
ELF文件格式比较复杂,本文只是简要介绍它的结构,希望能给想了解ELF文件结构的读者以帮助。具体详尽的资料请参阅专门的ELF文档。
文件格式
为了方便和高效,ELF文件内容有两个平行的视角:一个是程序连接角度,另一个是程序运行角度,如图所示。
<!--[endif]-->
ELF header在文件开始处描述了整个文件的组织,Section提供了目标文件的各项信息(如指令、数据、符号表、重定位信息等),Program header table指出怎样创建进程映像,含有每个program header的入口,section header table包含每一个section的入口,给出名字、大小等信息。
数据表示
ELF数据编码顺序与机器相关,数据类型有六种,见下表:
ELF文件头
象bmp、exe等文件一样,ELF的文件头包含整个文件的控制结构。它的定义如下:
其中E_ident的16个字节标明是个ELF文件(7F+'E'+'L'+'F')。e_type表示文件类型,2表示可执行文件。e_machine说明机器类别,3表示386机器,8表示MIPS机器。e_entry给出进程开始的虚地址,即系统将控制转移的位置。e_phoff指出program header table的文件偏移,e_phentsize表示一个program header表中的入口的长度(字节数表示),e_phnum给出program header表中的入口数目。类似的,e_shoff,e_shentsize,e_shnum 分别表示section header表的文件偏移,表中每个入口的的字节数和入口数目。e_flags给出与处理器相关的标志,e_ehsize给出ELF文件头的长度(字节数表示)。e_shstrndx表示section名表的位置,指出在section header表中的索引。
Section Header
目标文件的section header table可以定位所有的section,它是一个Elf32_Shdr结构的数组,Section头表的索引是这个数组的下标。有些索引号是保留的,目标文件不能使用这些特殊的索引。
Section包含目标文件除了ELF文件头、程序头表、section头表的所有信息,而且目标文件section满足几个条件:
<!--[if !supportLists]-->1) <!--[endif]-->目标文件中的每个section都只有一个section头项描述,可以存在不指示任何section的section头项。
<!--[if !supportLists]-->2) <!--[endif]-->每个section在文件中占据一块连续的空间。
<!--[if !supportLists]-->3) <!--[endif]-->Section之间不可重叠。
<!--[if !supportLists]-->4) <!--[endif]-->目标文件可以有非活动空间,各种headers和sections没有覆盖目标文件的每一个字节,这些非活动空间是没有定义的。
Section header结构定义如下:
其中sh_name指出section的名字,它的值是后面将会讲到的section header string table中的索引,指出一个以null结尾的字符串。sh_type是类别,sh_flags指示该section在进程执行时的特性。sh_addr指出若此section在进程的内存映像中出现,则给出开始的虚地址。sh_offset给出此section在文件中的偏移。其它字段的意义不太常用,在此不细述。
文件的section含有程序和控制信息,系统使用一些特定的section,并有其固定的类型和属性(由sh_type和sh_info指出)。下面介绍几个常用到的section:“.bss”段含有占据程序内存映像的未初始化数据,当程序开始运行时系统对这段数据初始为零,但这个section并不占文件空间。“.data.”和“.data1”段包含占据内存映像的初始化数据。“.rodata”和“.rodata1”段含程序映像中的只读数据。“.shstrtab”段含有每个section的名字,由section入口结构中的sh_name索引。“.strtab”段含有表示符号表(symbol table)名字的字符串。“.symtab”段含有文件的符号表,在后文专门介绍。“.text”段包含程序的可执行指令。
当然一个实际的ELF文件中,会包含很多的section,如.got,.plt等等,我们这里就不一一细述了,需要时再详细的说明。
Program Header
目标文件或者共享文件的program header table描述了系统执行一个程序所需要的段或者其它信息。目标文件的一个段(segment)包含一个或者多个section。Program header只对可执行文件和共享目标文件有意义,对于程序的链接没有任何意义。结构定义如下:
其中p_type描述段的类型;p_offset给出该段相对于文件开关的偏移量;p_vaddr给出该段所在的虚拟地址;p_paddr给出该段的物理地址,在Linux x86内核中,这项并没有被使用;p_filesz给出该段的大小,在字节为单元,可能为0;p_memsz给出该段在内存中所占的大小,可能为0;p_filesze与p_memsz的值可能会不相等。
Symbol Table
目标文件的符号表包含定位或重定位程序符号定义和引用时所需要的信息。符号表入口结构定义如下:
其中st_name包含指向符号表字符串表(strtab)中的索引,从而可以获得符号名。st_value指出符号的值,可能是一个绝对值、地址等。st_size指出符号相关的内存大小,比如一个数据结构包含的字节数等。st_info规定了符号的类型和绑定属性,指出这个符号是一个数据名、函数名、section名还是源文件名;并且指出该符号的绑定属性是local、global还是weak。
Section和Segment的区别和联系
可执行文件中,一个program header描述的内容称为一个段(segment)。Segment包含一个或者多个section,我们以Hello World程序为例,看一下section与segment的映射关系:
如上图红色区域所示,就是我们经常提到的文本段和数据段,由图中绿色部分的映射关系可知,文本段并不仅仅包含.text节,数据段也不仅仅包含.data节,而是都包含了多个section。
ELF文件的加载过程
加载和动态链接的简要介绍
从编译/链接和运行的角度看,应用程序和库程序的连接有两种方式。一种是固定的、静态的连接,就是把需要用到的库函数的目标代码(二进制)代码从程序库中抽取出来,链接进应用软件的目标映像中;另一种是动态链接,是指库函数的代码并不进入应用软件的目标映像,应用软件在编译/链接阶段并不完成跟库函数的链接,而是把函数库的映像也交给用户,到启动应用软件目标映像运行时才把程序库的映像也装入用户空间(并加以定位),再完成应用软件与库函数的连接。
这样,就有了两种不同的ELF格式映像。一种是静态链接的,在装入/启动其运行时无需装入函数库映像、也无需进行动态连接。另一种是动态连接,需要在装入/启动其运行时同时装入函数库映像并进行动态链接。Linux内核既支持静态链接的ELF映像,也支持动态链接的ELF映像,而且装入/启动ELF映像必需由内核完成,而动态连接的实现则既可以在内核中完成,也可在用户空间完成。因此,GNU把对于动态链接ELF映像的支持作了分工:把ELF映像的装入/启动入在Linux内核中;而把动态链接的实现放在用户空间(glibc),并为此提供一个称为“解释器”(ld-linux.so.2)的工具软件,而解释器的装入/启动也由内核负责,这在后面我们分析ELF文件的加载时就可以看到。
这部分主要说明ELF文件在内核空间的加载过程,下一部分对用户空间符号的动态解析过程进行说明。
Linux可执行文件类型的注册机制
在说明ELF文件的加载过程以前,我们先回答一个问题,就是:为什么Linux可以运行ELF文件?
回答:内核对所支持的每种可执行的程序类型都有个struct linux_binfmt的数据结构,定义如下:
其中的load_binary函数指针指向的就是一个可执行程序的处理函数。而我们研究的ELF文件格式的定义如下:
要支持ELF文件的运行,则必须向内核登记这个数据结构,加入到内核支持的可执行程序的队列中。内核提供两个函数来完成这个功能,一个注册,一个注销,即:
当需要运行一个程序时,则扫描这个队列,让各个数据结构所提供的处理程序,ELF中即为load_elf_binary,逐一前来认领,如果某个格式的处理程序发现相符后,便执行该格式映像的装入和启动。
内核空间的加载过程
内核中实际执行execv()或execve()系统调用的程序是do_execve(),这个函数先打开目标映像文件,并从目标文件的头部(第一个字节开始)读入若干(当前Linux内核中是128)字节(实际上就是填充ELF文件头,下面的分析可以看到),然后调用另一个函数search_binary_handler(),在此函数里面,它会搜索我们上面提到的Linux支持的可执行文件类型队列,让各种可执行程序的处理程序前来认领和处理。如果类型匹配,则调用load_binary函数指针所指向的处理函数来处理目标映像文件。在ELF文件格式中,处理函数是load_elf_binary函数,下面主要就是分析load_elf_binary函数的执行过程(说明:因为内核中实际的加载需要涉及到很多东西,这里只关注跟ELF文件的处理相关的代码):
在load_elf_binary之前,内核已经使用映像文件的前128个字节对bprm->buf进行了填充,563行就是使用这此信息填充映像的文件头(具体数据结构定义见第一部分,ELF文件头节),然后567行就是比较文件头的前四个字节,查看是否是ELF文件类型定义的“\177ELF”。除这4个字符以外,还要看映像的类型是否ET_EXEC和ET_DYN之一;前者表示可执行映像,后者表示共享库。
这块就是通过kernel_read读入整个program header table。从代码中可以看到,一个可执行程序必须至少有一个段(segment),而所有段的大小之和不能超过64K。
这个for循环的目的在于寻找和处理目标映像的“解释器”段。“解释器”段的类型为PT_INTERP,找到后就根据其位置的p_offset和大小p_filesz把整个“解释器”段的内容读入缓冲区(640~640)。事个“解释器”段实际上只是一个字符串,即解释器的文件名,如“/lib/ld-linux.so.2”。有了解释器的文件名以后,就通过open_exec()打开这个文件,再通过kernel_read()读入其开关128个字节(695~696),即解释器映像的头部。我们以Hello World程序为例,看一下这段中具体的内容:
其实从readelf程序的输出中,我们就可以看到需要解释器/lib/ld-linux.so.2,为了进一步的验证,我们用hd命令以16进制格式查看下类型为INTERP的段所在位置的内容,在上面的各个域可以看到,它位于偏移量为0x000114的位置,文件内占19个字节:
从上面红色部分可以看到,这个段中实际保存的就是“/lib/ld-linux.so.2”这个字符串。
这段代码从目标映像的程序头中搜索类型为PT_LOAD的段(Segment)。在二进制映像中,只有类型为PT_LOAD的段才是需要装入的。当然在装入之前,需要确定装入的地址,只要考虑的就是页面对齐,还有该段的p_vaddr域的值(上面省略这部分内容)。确定了装入地址后,就通过elf_map()建立用户空间虚拟地址空间与目标映像文件中某个连续区间之间的映射,其返回值就是实际映射的起始地址。
这段程序的逻辑非常简单:如果需要装入解释器,就通过load_elf_interp装入其映像(951~953),并把将来进入用户空间的入口地址设置成load_elf_interp()的返回值,即解释器映像的入口地址。而若不装入解释器,那么这个入口地址就是目标映像本身的入口地址。
在完成装入,启动用户空间的映像运行之前,还需要为目标映像和解释器准备好一些有关的信息,这些信息包括常规的argc、envc等等,还有一些“辅助向量(Auxiliary Vector)”。这些信息需要复制到用户空间,使它们在CPU进入解释器或目标映像的程序入口时出现在用户空间堆栈上。这里的create_elf_tables()就起着这个作用。
最后,start_thread()这个宏操作会将eip和esp改成新的地址,就使得CPU在返回用户空间时就进入新的程序入口。如果存在解释器映像,那么这就是解释器映像的程序入口,否则就是目标映像的程序入口。那么什么情况下有解释器映像存在,什么情况下没有呢?如果目标映像与各种库的链接是静态链接,因而无需依靠共享库、即动态链接库,那就不需要解释器映像;否则就一定要有解释器映像存在。
以我们的Hello World为例,gcc在编译时,除非显示的使用static标签,否则所有程序的链接都是动态链接的,也就是说需要解释器。由此可见,我们的Hello World程序在被内核加载到内存,内核跳到用户空间后并不是执行Hello World的,而是先把控制权交到用户空间的解释器,由解释器加载运行用户程序所需要的动态库(Hello World需要libc),然后控制权才会转移到用户程序。
ELF文件中符号的动态解析过程
上面一节提到,控制权是先交到解释器,由解释器加载动态库,然后控制权才会到用户程序。因为时间原因,动态库的具体加载过程,并没有进行深入分析。大致的过程就是将每一个依赖的动态库都加载到内存,并形成一个链表,后面的符号解析过程主要就是在这个链表中搜索符号的定义。
我们后面主要就是以Hello World为例,分析程序是如何调用printf的:
查看一下gcc编译生成的Hello World程序的汇编代码(main函数部分):
从上面的代码可以看出,经过编译后,printf函数的调用已经换成了puts函数(原因读者可以想一下)。其中的call指令就是调用puts函数。但从上面的代码可以看出,它调用的是puts@plt这个标号,它代表什么意思呢?在进一步说明符号的动态解析过程以前,需要先了解两个概念,一个是global offset table,一个是procedure linkage table。
Global Offset Table(GOT)
在位置无关代码中,一般不能包含绝对虚拟地址(如共享库)。当在程序中引用某个共享库中的符号时,编译链接阶段并不知道这个符号的具体位置,只有等到动态链接器将所需要的共享库加载时进内存后,也就是在运行阶段,符号的地址才会最终确定。因此,需要有一个数据结构来保存符号的绝对地址,这就是GOT表的作用,GOT表中每项保存程序中引用其它符号的绝对地址。这样,程序就可以通过引用GOT表来获得某个符号的地址。
在x86结构中,GOT表的前三项保留,用于保存特殊的数据结构地址,其它的各项保存符号的绝对地址。对于符号的动态解析过程,我们只需要了解的就是第二项和第三项,即GOT[1]和GOT[2]:GOT[1]保存的是一个地址,指向已经加载的共享库的链表地址(前面提到加载的共享库会形成一个链表);GOT[2]保存的是一个函数的地址,定义如下:GOT[2] = &_dl_runtime_resolve,这个函数的主要作用就是找到某个符号的地址,并把它写到与此符号相关的GOT项中,然后将控制转移到目标函数,后面我们会详细分析。
Procedure Linkage Table(PLT)
过程链接表(PLT)的作用就是将位置无关的函数调用转移到绝对地址。在编译链接时,链接器并不能控制执行从一个可执行文件或者共享文件中转移到另一个中(如前所说,这时候函数的地址还不能确定),因此,链接器将控制转移到PLT中的某一项。而PLT通过引用GOT表中的函数的绝对地址,来把控制转移到实际的函数。
在实际的可执行程序或者共享目标文件中,GOT表在名称为.got.plt的section中,PLT表在名称为.plt的section中。
大致的了解了GOT和PLT的内容后,我们查看一下puts@plt中到底是什么内容:
可以看到puts@plt包含三条指令,程序中所有对有puts函数的调用都要先来到这里(Hello World里只有一次)。可以看出,除PLT0以外(就是__gmon_start__@plt-0x10所标记的内容),其它的所有PLT项的形式都是一样的,而且最后的jmp指令都是0x804828c,即PLT0为目标的。所不同的只是第一条jmp指令的目标和push指令中的数据。PLT0则与之不同,但是包括PLT0在内的每个表项都占16个字节,所以整个PLT就像个数组(实际是代码段)。另外,每个PLT表项中的第一条jmp指令是间接寻址的。比如我们的<span l
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。