`

[转]ELF文件的加载和动态链接过程

阅读更多

近段时间在研究Erlang核心特性的实现,也许过段时间会有个系列的总结,期待...

 

今天看到有人写一个深入Hello World的文章,想起来读研的时候做的一个关于程序加载和链接的课程设计,也是以Hello World为例说明的,随发出来共享。文后有下载链接。

 

======================================================

 

本文的目的:大家对于Hello World程序应该非常熟悉,随便使用哪一种语言,即使还不熟悉的语言,写出一个Hello World程序应该毫不费力,但是如果让大家详细的说明这个程序加载和链接的过程,以及后续的符号动态解析过程,可能还会有点困难。本文就是以一个最基本的C语言版本Hello World程序为基础,了解LinuxELF文件的格式,分析并验证ELF文件和加载和动态链接的具有实现。

 

C代码  收藏代码
  1. /* hello.c */  
  2. #include <stdio.h>  
  3.   
  4. int main()  
  5. {  
  6.     printf(“hello world!\n”);  
  7.     return 0;  
  8. }  
  9. $ gcc –o hello hello.c  

 本文的实验平台:

 

 Ubuntu 7.04

 Linux kernel 2.6.20

 gcc 4.1.2

 glibc 2.5

 gdb 6.6

 objdump/readelf 2.17.50

本文的组织:

       第一部分大致描述ELF文件的格式;

       第二部分分析ELF文件在内核空间的加载过程;

       第三部分分析ELF文件在运行过程中符号的动态解析过程;

       (以上各部分都是以Hello World程序为例说明)

       第四部分简要总结;

       第五部分阐明需要深入了解的东西。

 

 ELF文件格式

概述

       Executable and Linking Format(ELF)文件是x86 Linux系统下的一种常用目标文件(object file)格式,有三种主要类型:

 适于连接的可重定位文件(relocatable file),可与其它目标文件一起创建可执行文件和共享目标文件。

 适于执行的可执行文件(executable file),用于提供程序的进程映像,加载的内存执行。

 共享目标文件(shared object file),连接器可将它与其它可重定位文件和共享目标文件连接成其它的目标文件,动态连接器又可将它与可执行文件和其它共享目标文件结合起来创建一个进程映像。

       ELF文件格式比较复杂,本文只是简要介绍它的结构,希望能给想了解ELF文件结构的读者以帮助。具体详尽的资料请参阅专门的ELF文档。

   文件格式

       为了方便和高效,ELF文件内容有两个平行的视角:一个是程序连接角度,另一个是程序运行角度,如图所示。

<!--[endif]-->

       ELF header在文件开始处描述了整个文件的组织,Section提供了目标文件的各项信息(如指令、数据、符号表、重定位信息等),Program header table指出怎样创建进程映像,含有每个program header的入口,section header table包含每一个section的入口,给出名字、大小等信息。

    数据表示

       ELF数据编码顺序与机器相关,数据类型有六种,见下表:

      ELF文件头

       bmpexe等文件一样,ELF的文件头包含整个文件的控制结构。它的定义如下:

 

C代码  收藏代码
  1. 190 #define EI_NIDENT       16  
  2. 191   
  3. 192 typedef struct elf32_hdr{  
  4. 193   unsigned char e_ident[EI_NIDENT];   
  5. 194   Elf32_Half    e_type;     /* file type */  
  6. 195   Elf32_Half    e_machine;  /* architecture */  
  7. 196   Elf32_Word e_version;  
  8. 197   Elf32_Addr    e_entry;    /* entry point */  
  9. 198   Elf32_Off e_phoff;        /* PH table offset */  
  10. 199   Elf32_Off e_shoff;        /* SH table offset */  
  11. 200   Elf32_Word    e_flags;  
  12. 201   Elf32_Half    e_ehsize;       /* ELF header size in bytes */  
  13. 202   Elf32_Half    e_phentsize;    /* PH size */  
  14. 203   Elf32_Half    e_phnum;        /* PH number */  
  15. 204   Elf32_Half    e_shentsize;    /* SH size */  
  16. 205   Elf32_Half    e_shnum;        /* SH number */  
  17. 206   Elf32_Half    e_shstrndx; /* SH name string table index */  
  18. 207 } Elf32_Ehdr;  

 其中E_ident16个字节标明是个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_shoffe_shentsizee_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满足几个条件:

 目标文件中的每个section都只有一个section头项描述,可以存在不指示任何sectionsection头项。

 每个section在文件中占据一块连续的空间。

 Section之间不可重叠。

 目标文件可以有非活动空间,各种headerssections没有覆盖目标文件的每一个字节,这些非活动空间是没有定义的。

       Section header结构定义如下:

 

C代码  收藏代码
  1. 288 typedef struct {  
  2. 289   Elf32_Word    sh_name;    /* name of section, index */  
  3. 290   Elf32_Word    sh_type;      
  4. 291   Elf32_Word    sh_flags;  
  5. 292   Elf32_Addr     sh_addr;       /* memory address, if any */  
  6. 293   Elf32_Off      sh_offset;  
  7. 294   Elf32_Word    sh_size;        /* section size in file */  
  8. 295   Elf32_Word    sh_link;  
  9. 296   Elf32_Word    sh_info;  
  10. 297   Elf32_Word    sh_addralign;  
  11. 298   Elf32_Word    sh_entsize;     /* fixed entry size, if have */  
  12. 299 } Elf32_Shdr;  

 其中sh_name指出section的名字,它的值是后面将会讲到的section header string table中的索引,指出一个以null结尾的字符串。sh_type是类别,sh_flags指示该section在进程执行时的特性。sh_addr指出若此section在进程的内存映像中出现,则给出开始的虚地址。sh_offset给出此section在文件中的偏移。其它字段的意义不太常用,在此不细述。

 

       文件的section含有程序和控制信息,系统使用一些特定的section,并有其固定的类型和属性(由sh_typesh_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)包含一个或者多个sectionProgram header只对可执行文件和共享目标文件有意义,对于程序的链接没有任何意义。结构定义如下:

 

C代码  收藏代码
  1. 232 typedef struct elf32_phdr{  
  2. 233   Elf32_Word    p_type;   
  3. 234   Elf32_Off      p_offset;  
  4. 235   Elf32_Addr    p_vaddr;        /* virtual address */  
  5. 236   Elf32_Addr    p_paddr;        /* ignore */  
  6. 237   Elf32_Word    p_filesz;       /* segment size in file */  
  7. 238   Elf32_Word    p_memsz;        /* size in memory */  
  8. 239   Elf32_Word    p_flags;  
  9. 240   Elf32_Word    p_align;       
  10. 241 } Elf32_Phdr;  

 其中p_type描述段的类型;p_offset给出该段相对于文件开关的偏移量;p_vaddr给出该段所在的虚拟地址;p_paddr给出该段的物理地址,在Linux x86内核中,这项并没有被使用;p_filesz给出该段的大小,在字节为单元,可能为0p_memsz给出该段在内存中所占的大小,可能为0p_fileszep_memsz的值可能会不相等。

 

Symbol Table

       目标文件的符号表包含定位或重定位程序符号定义和引用时所需要的信息。符号表入口结构定义如下:

 

C代码  收藏代码
  1. 171 typedef struct elf32_sym{  
  2. 172   Elf32_Word    st_name;  
  3. 173   Elf32_Addr    st_value;  
  4. 174   Elf32_Word    st_size;  
  5. 175   unsigned char     st_info;  
  6. 176   unsigned char st_other;  
  7. 177   Elf32_Half     st_shndx;  
  8. 178 } Elf32_Sym;  

 其中st_name包含指向符号表字符串表(strtab)中的索引,从而可以获得符号名。st_value指出符号的值,可能是一个绝对值、地址等。st_size指出符号相关的内存大小,比如一个数据结构包含的字节数等。st_info规定了符号的类型和绑定属性,指出这个符号是一个数据名、函数名、section名还是源文件名;并且指出该符号的绑定属性是localglobal还是weak

 

SectionSegment的区别和联系

       可执行文件中,一个program header描述的内容称为一个段(segment)。Segment包含一个或者多个section,我们以Hello World程序为例,看一下sectionsegment的映射关系:

 

 如上图红色区域所示,就是我们经常提到的文本段和数据段,由图中绿色部分的映射关系可知,文本段并不仅仅包含.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的数据结构,定义如下:

 

C代码  收藏代码
  1. 53 /* 
  2. 54  * This structure defines the functions that are used to load the binary formats that 
  3. 55  * linux accepts. 
  4. 56  */  
  5. 57 struct linux_binfmt {  
  6. 58         struct linux_binfmt * next;  
  7. 59         struct module *module;  
  8. 60         int (*load_binary)(struct linux_binprm *, struct  pt_regs * regs);  
  9. 61         int (*load_shlib)(struct file *)  
  10. 62         int (*core_dump)(long signr, struct pt_regs * regs, struct file * file);  
  11. 63         unsigned long min_coredump;     /* minimal dump size */  
  12. 64         int hasvdso;  
  13. 65 };  

 

 其中的load_binary函数指针指向的就是一个可执行程序的处理函数。而我们研究的ELF文件格式的定义如下:

 

C代码  收藏代码
  1. 74 static struct linux_binfmt elf_format = {  
  2. 75                 .module      = THIS_MODULE,  
  3. 76                 .load_binary = load_elf_binary,  
  4. 77                 .load_shlib      = load_elf_library,  
  5. 78                 .core_dump       = elf_core_dump,  
  6. 79                 .min_coredump    = ELF_EXEC_PAGESIZE,  
  7. 80                 .hasvdso     = 1  
  8. 81 };  

 

 要支持ELF文件的运行,则必须向内核登记这个数据结构,加入到内核支持的可执行程序的队列中。内核提供两个函数来完成这个功能,一个注册,一个注销,即:

 

C代码  收藏代码
  1. 72 int register_binfmt(struct linux_binfmt * fmt)  
  2. 96 int unregister_binfmt(struct linux_binfmt * fmt)  

 当需要运行一个程序时,则扫描这个队列,让各个数据结构所提供的处理程序,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文件的处理相关的代码):

 

C代码  收藏代码
  1. 550         struct {  
  2. 551                 struct elfhdr elf_ex;  
  3. 552                 struct elfhdr interp_elf_ex;  
  4. 553                 struct exec interp_ex;  
  5. 554         } *loc;  
  6. 556         loc = kmalloc(sizeof(*loc), GFP_KERNEL);  
  7. 562         /* Get the exec-header */  
  8. 563         loc->elf_ex = *((struct elfhdr *)bprm->buf);  
  9.                ……  
  10. 566         /* First of all, some simple consistency checks */  
  11. 567         if (memcmp(loc->elf_ex.e_ident, ELFMAG, SELFMAG) != 0)  
  12. 568                 goto out;  
  13. 570         if (loc->elf_ex.e_type != ET_EXEC && loc->elf_ex.e_type != ET_DYN)  
  14. 571                 goto out;  

 

 load_elf_binary之前,内核已经使用映像文件的前128个字节对bprm->buf进行了填充,563行就是使用这此信息填充映像的文件头(具体数据结构定义见第一部分,ELF文件头节),然后567行就是比较文件头的前四个字节,查看是否是ELF文件类型定义的“\177ELF”。除这4个字符以外,还要看映像的类型是否ET_EXECET_DYN之一;前者表示可执行映像,后者表示共享库。

 

C代码  收藏代码
  1. 577         /* Now read in all of the header information */  
  2. 580         if (loc->elf_ex.e_phnum < 1 ||  
  3. 581                 loc->elf_ex.e_phnum > 65536U / sizeof(struct elf_phdr))  
  4. 582                 goto out;  
  5. 583         size = loc->elf_ex.e_phnum * sizeof(struct elf_phdr);  
  6.                ……  
  7. 585         elf_phdata = kmalloc(size, GFP_KERNEL);  
  8.                ……  
  9. 589         retval = kernel_read(bprm->file, loc->elf_ex.e_phoff,  
  10. 590                              (char *)elf_phdata, size);  

 

 这块就是通过kernel_read读入整个program header table。从代码中可以看到,一个可执行程序必须至少有一个段(segment),而所有段的大小之和不能超过64K

 

C代码  收藏代码
  1. 614 elf_ppnt = elf_phdata;  
  2.                 ……  
  3. 623 for (i = 0; i < loc->elf_ex.e_phnum; i++) {  
  4. 624     if (elf_ppnt->p_type == PT_INTERP) {  
  5.             ……  
  6. 635         elf_interpreter = kmalloc(elf_ppnt->p_filesz, GFP_KERNEL);  
  7.             ……  
  8. 640         retval = kernel_read(bprm->file, elf_ppnt->p_offset,  
  9. 641                          elf_interpreter,  
  10. 642                          elf_ppnt->p_filesz);  
  11.             ……  
  12. 682         interpreter = open_exec(elf_interpreter);  
  13.             ……  
  14. 695         retval = kernel_read(interpreter, 0, bprm->buf,  
  15. 696                          BINPRM_BUF_SIZE);  
  16.             ……  
  17. 703         /* Get the exec headers */  
  18.             ……  
  19. 705         loc->interp_elf_ex = *((struct elfhdr *)bprm->buf);  
  20. 706             break;  
  21. 707     }  
  22. 708     elf_ppnt++;  
  23. 709 }  

 

 这个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这个字符串。

 

C代码  收藏代码
  1. 814         for(i = 0, elf_ppnt = elf_phdata;  
  2. 815             i < loc->elf_ex.e_phnum; i++, elf_ppnt++) {  
  3.                        ……   
  4. 819                 if (elf_ppnt->p_type != PT_LOAD)  
  5. 820                         continue;  
  6.                        ……   
  7. 870                 error = elf_map(bprm->file, load_bias + vaddr, elf_ppnt,  
  8. 871                                 elf_prot, elf_flags);  
  9.                        ……  
  10. 920         }  

 

 这段代码从目标映像的程序头中搜索类型为PT_LOAD的段(Segment)。在二进制映像中,只有类型为PT_LOAD的段才是需要装入的。当然在装入之前,需要确定装入的地址,只要考虑的就是页面对齐,还有该段的p_vaddr域的值(上面省略这部分内容)。确定了装入地址后,就通过elf_map()建立用户空间虚拟地址空间与目标映像文件中某个连续区间之间的映射,其返回值就是实际映射的起始地址。

 

C代码  收藏代码
  1. 946     if (elf_interpreter) {  
  2.                 ……  
  3. 951         elf_entry = load_elf_interp(&loc->interp_elf_ex,  
  4. 952                                 interpreter,  
  5. 953                                     &interp_load_addr);  
  6.                                 ……  
  7. 965     } else {  
  8. 966         elf_entry = loc->elf_ex.e_entry;  
  9.                 ……  
  10. 972     }  

 这段程序的逻辑非常简单:如果需要装入解释器,就通过load_elf_interp装入其映像(951~953),并把将来进入用户空间的入口地址设置成load_elf_interp()的返回值,即解释器映像的入口地址。而若不装入解释器,那么这个入口地址就是目标映像本身的入口地址。

 

C代码  收藏代码
  1. 991        create_elf_tables(bprm, &loc->elf_ex,  
  2. 992                           (interpreter_type == INTERPRETER_AOUT),  
  3. 993                           load_addr, interp_load_addr);  
  4.                ……  
  5. 1028       start_thread(regs, elf_entry, bprm->p);  

 在完成装入,启动用户空间的映像运行之前,还需要为目标映像和解释器准备好一些有关的信息,这些信息包括常规的argcenvc等等,还有一些“辅助向量(Auxiliary Vector)”。这些信息需要复制到用户空间,使它们在CPU进入解释器或目标映像的程序入口时出现在用户空间堆栈上。这里的create_elf_tables()就起着这个作用。

 

       最后,start_thread()这个宏操作会将eipesp改成新的地址,就使得CPU在 返回用户空间时就进入新的程序入口。如果存在解释器映像,那么这就是解释器映像的程序入口,否则就是目标映像的程序入口。那么什么情况下有解释器映像存 在,什么情况下没有呢?如果目标映像与各种库的链接是静态链接,因而无需依靠共享库、即动态链接库,那就不需要解释器映像;否则就一定要有解释器映像存 在。

       以我们的Hello World为例,gcc在编译时,除非显示的使用static标签,否则所有程序的链接都是动态链接的,也就是说需要解释器。由此可见,我们的Hello World程序在被内核加载到内存,内核跳到用户空间后并不是执行Hello World的,而是先把控制权交到用户空间的解释器,由解释器加载运行用户程序所需要的动态库(Hello World需要libc),然后控制权才会转移到用户程序。

 

ELF文件中符号的动态解析过程

 

       上面一节提到,控制权是先交到解释器,由解释器加载动态库,然后控制权才会到用户程序。因为时间原因,动态库的具体加载过程,并没有进行深入分析。大致的过程就是将每一个依赖的动态库都加载到内存,并形成一个链表,后面的符号解析过程主要就是在这个链表中搜索符号的定义。

       我们后面主要就是以Hello World为例,分析程序是如何调用printf的:

查看一下gcc编译生成的Hello World程序的汇编代码(main函数部分):

 

C代码  收藏代码
  1. 08048374 <main>:  
  2.  8048374:       8d 4c 24 04         lea     0x4(%esp),%ecx  
  3.                 ……  
  4.  8048385:       c7 04 24 6c 84 04 08    movl    $0x804846c,(%esp)  
  5.  804838c:       e8 2b ff ff ff          call        80482bc <puts@plt>  
  6.  8048391:       b8 00 00 00 00          mov     $0x0,%eax  

 从上面的代码可以看出,经过编译后,printf函数的调用已经换成了puts函数(原因读者可以想一下)。其中的call指令就是调用puts函数。但从上面的代码可以看出,它调用的是puts@plt这个标号,它代表什么意思呢?在进一步说明符号的动态解析过程以前,需要先了解两个概念,一个是global offset table,一个是procedure linkage table

 

 

       Global Offset TableGOT

       在位置无关代码中,一般不能包含绝对虚拟地址(如共享 库)。当在程序中引用某个共享库中的符号时,编译链接阶段并不知道这个符号的具体位置,只有等到动态链接器将所需要的共享库加载时进内存后,也就是在运行 阶段,符号的地址才会最终确定。因此,需要有一个数据结构来保存符号的绝对地址,这就是GOT表的作用,GOT表中每项保存程序中引用其它符号的绝对地址。这样,程序就可以通过引用GOT表来获得某个符号的地址。

       x86结构中,GOT表的前三项保留,用于保存特殊的数据结构地址,其它的各项保存符号的绝对地址。对于符号的动态解析过程,我们只需要了解的就是第二项和第三项,即GOT[1]GOT[2]GOT[1]保存的是一个地址,指向已经加载的共享库的链表地址(前面提到加载的共享库会形成一个链表);GOT[2]保存的是一个函数的地址,定义如下:GOT[2] = &_dl_runtime_resolve,这个函数的主要作用就是找到某个符号的地址,并把它写到与此符号相关的GOT项中,然后将控制转移到目标函数,后面我们会详细分析。

       Procedure Linkage TablePLT

       过程链接表(PLT)的作用就是将位置无关的函数调用转移到绝对地址。在编译链接时,链接器并不能控制执行从一个可执行文件或者共享文件中转移到另一个中(如前所说,这时候函数的地址还不能确定),因此,链接器将控制转移到PLT中的某一项。而PLT通过引用GOT表中的函数的绝对地址,来把控制转移到实际的函数。

       在实际的可执行程序或者共享目标文件中,GOT表在名称为.got.pltsection中,PLT表在名称为.pltsection中。

大致的了解了GOTPLT的内容后,我们查看一下puts@plt中到底是什么内容:

 

C代码  收藏代码
  1. Disassembly of section .plt:  
  2.   
  3. 0804828c <__gmon_start__@plt-0x10>:  
  4.  804828c:       ff 35 68 95 04 08       pushl   0x8049568  
  5.  8048292:       ff 25 6c 95 04 08       jmp     *0x804956c  
  6.  8048298:       00 00  
  7.         ......  
  8. 0804829c <__gmon_start__@plt>:  
  9.  804829c:       ff 25 70 95 04 08       jmp     *0x8049570  
  10.  80482a2:       68 00 00 00 00          push        $0x0  
  11.  80482a7:       e9 e0 ff ff ff          jmp     804828c <_init+0x18>  
  12.   
  13. 080482ac <__libc_start_main@plt>:  
  14.  80482ac:       ff 25 74 95 04 08       jmp     *0x8049574  
  15.  80482b2:       68 08 00 00 00          push        $0x8  
  16.  80482b7:       e9 d0 ff ff ff          jmp     804828c <_init+0x18>  
  17. 080482bc <puts@plt>:  
  18.  80482bc:       ff 25 78 95 04 08       jmp     *0x8049578  
  19.  80482c2:       68 10 00 00 00          push    $0x10  
  20.  80482c7:       e9 c0 ff ff ff          jmp     804828c <_init+0x18>  

 可以看到puts@plt包含三条指令,程序中所有对有puts函数的调用都要先来到这里(Hello World里只有一次)。可以看出,除PLT0以外(就是__gmon_start__@plt-0x10所标记的内容),其它的所有PLT项的形式都是一样的,而且最后的jmp指令都是0x804828c,即PLT0为目标的。所不同的只是第一条jmp指令的目标和push指令中的数据。PLT0则与之不同,但是包括PLT0在内的每个表项都占16个字节,所以整个PLT就像个数组(实际是代码段)。另外,每个PLT表项中的第一条jmp指令是间接寻址的。比如我们的puts函数是以地址0x8049578处的内容为目标地址进行中跳转的。

 

顺着这个地址,我们进一步查看此处的内容:

 

C代码  收藏代码
  1. (gdb) x/w  0x8049578  
  2. 0x8049578 <_GLOBAL_OFFSET_TABLE_+20>:   0x080482c2  

 从上面可以看出,这个地址就是GOT表中的一项。它里面的内容是0x80482c2,即puts@plt中的第二条指令。前面我们不是提到过,GOT中这里本应该是puts函数的地址才对,那为什么会这样呢?原来链接器在把所需要的共享库加载进内存后,并没有把共享库中的函数的地址写到GOT表项中,而是延迟到函数的第一次调用时,才会对函数的地址进行定位。

 

puts@plt的第二条指令是pushl $0x10,那这个0x10代表什么呢?

 

C代码  收藏代码
  1. Relocation section '.rel.plt' at offset 0x25c contains 3 entries:  
  2.  Offset       Info       Type            Sym.Value  Sym. Name  
  3. 08049570  00000107 R_386_JUMP_SLOT      00000000   __gmon_start__  
  4. 08049574  00000207 R_386_JUMP_SLOT      00000000   __libc_start_main  
  5. 08049578  00000307 R_386_JUMP_SLOT  00000000   puts  

 其中的第三项就是puts函数的重定向信息,0x10即代表相对于.rel.plt这个section的偏移位置(每一项占8个字节)。其中的Offset这个域就代表的是puts函数地址在GOT表项中的位置,从上面puts@plt的第一条指令也可以验证这一点。向堆栈中压入这个偏移量的主要作用就是为了找到puts函数的符号名(即上面的Sym.Name域的“puts”这个字符串)以及puts函数地址在GOT表项中所占的位置,以便在函数定位完成后将函数的实际地址写到这个位置。

 

puts@plt的第三条指令就跳到了PLT0的位置。这条指令只是将0x8049568这个数值压入堆栈,它实际上是GOT表项的第二个元素,即GOT[1](共享库链表的地址)。

随即PLT0的第二条指令即跳到了GOT[2]中所保存的地址(间接寻址),即_dl_runtime_resolve这个函数的入口。

_dl_runtime_resolve的定义如下:

 

C代码  收藏代码
  1. _dl_runtime_resolve:  
  2.     pushl %eax      # Preserve registers otherwise clobbered.  
  3.     pushl %ecx  
  4.     pushl %edx  
  5.     movl 16(%esp), %edx # Copy args pushed by PLT in register.  Note  
  6.     movl 12(%esp), %eax # that `fixup' takes its parameters in regs.  
  7.     call _dl_fixup      # Call resolver.  
  8.     popl %edx       # Get register content back.  
  9.     popl %ecx  
  10.     xchgl %eax, (%esp)  # Get %eax contents end store function address.  
  11.     ret $8          # Jump to function address.  

 从调用puts函数到现在,总共有两次压栈操作,一次是压入puts函数的重定向信息的偏移量,一次是GOT[1](共享库链表的地址)。上面的两次movl操作就是将这两个数据分别取到edxeax,然后调用_dl_fixup(从寄存器取参数),此函数完成的功能就是找到puts函数的实际加载地址,并将它写到GOT中,然后通过eax将此值返回给_dl_runtime_resolvexchagl这条指令,不仅将eax的值恢复,而且将puts函数的值压到栈顶,这样当执行ret指令后,控制就转移到puts函数内部。ret指令同时也完成了清栈动作,使栈顶为puts函数的返回地址(main函数中call指令的下一条指令),这样,当puts函数返回时,就返回到正确的位置。

 

       当然,如果是第二次调用puts函数,那么就不需要这么复杂的过程,而只要通过GOT表中已经确定的函数地址直接进行跳转即可。下图是前面过程的一个示意图,红色为第一次函数调用的顺序,蓝色为后续函数调用的顺序(第1步都要执行)。

 

 

ELF文件加载和链接的简要总结

 

用户通过shell执行程序,shell通过exceve进入系统调用。(User-Mode

sys_execve经过一系列过程,并最终通过ELF文件的处理函数load_elf_binary将用户程序和ELF解释器加载进内存,并将控制权交给解释器。(Kernel-Mode

       ELF解释器进行相关库的加载,并最终把控制权交给用户程序。由解释器处理用户程序运行过程中符号的动态解析。(User-Mode

分享到:
评论

相关推荐

    ELF文件的加载和动态链接过程

    ELF文件格式是一种在Linux系统下广泛使用的对象文件格式,它是x86架构中...在文章的最后,作者简要总结了ELF文件加载和动态链接的关键概念,并提出了需要深入了解的领域,比如程序的动态链接、ELF文件的高级功能等。

    分析ELF文件加载过程

    通过对 ELF 文件加载过程的深入分析,我们可以更好地理解操作系统如何管理和执行可执行文件。ELF 文件的加载不仅涉及基本的内存映射,还包括了动态链接器的加载和动态链接的过程。通过掌握 ELF 文件加载的原理,...

    elf文件格式与动态链接库(非常之好).docx

    elf 文件格式与动态链接库 在计算机科学中,高级语言编写的程序可以在不同的机器上移植,因为有为不同机器设计的编译器的存在。编译器把高级语言写的程序转换成某个机器能直接执行的二进制代码。然而,机器都是执行...

    elf文件转换为hex文件

    ELF文件对于开发者来说,提供了调试和链接的便利,但并不适用于直接烧录到微控制器或嵌入式设备中。 HEX文件,又称Intel HEX或Intel格式文件,是另一种常见的编程文件格式,主要用于8位和16位微处理器。HEX文件以...

    Android 加载执行ELF可执行文件

    Android加载ELF文件的过程主要由动态链接器(Dynamic Linker,也称为ld.so)完成。当一个ELF可执行文件被调用时,系统会通过以下步骤来执行: 1. **加载阶段**:首先,系统会在内存中为ELF文件分配空间,并按照ELF...

    ELF Format + 链接器与加载器

    ELF(Executable and Linking Format)...阅读这些资料,你可以深入了解ELF文件如何存储信息,链接器如何处理多个对象文件,以及加载器如何在内存中准备和执行程序。这将对理解和开发涉及底层操作系统的软件大有裨益。

    elf转换bin的工具

    这类工具通常会读取ELF文件的所有节区,并将其内容合并到一个连续的BIN文件中,删除ELF头信息和其他非必要数据,从而得到可以直接加载到内存并执行的二进制文件。 `DolTool.txt`可能是该工具的使用指南或帮助文档,...

    elf文件加载

    **二、ELF文件加载过程** 1. **加载器(Loader)**: 当程序启动时,操作系统的加载器负责读取ELF文件并将其加载到内存中。 2. **映射段到内存(Mapping Segments)**: 根据程序头,加载器将文件的各个段映射到进程...

    Intel平台下Linux中ELF文件动态链接的加载1

    在Intel平台下的Linux系统中,ELF(Executable and Linkable Format)文件是一种广泛使用的可执行文件格式,它支持静态和动态链接。...在Linux系统中,理解ELF文件和动态链接的工作原理对于系统级编程和调试至关重要。

    《ELF文件格式分析.pdf》与elf解析代码

    4. **重定位(Relocation)**:ELF支持动态和静态重定位,允许在程序链接或运行时修改其地址,以适应不同的内存布局。 5. **动态链接(Dynamic Linking)**:ELF文件可以包含动态链接信息,使程序能够链接到运行时...

    PE文件格式 ELF文件格式详解

    对于ELF,过程基本相似,但ELF支持动态链接,允许在运行时加载所需的库,这有助于减少内存占用和提高代码的复用性。 在Windows环境下,链接器会根据PE头的信息创建一个映像,这个映像可以直接由Windows加载器加载到...

    分析ELF的加载过程1

    ELF (Executable and Linkable ...总之,ELF加载过程涉及到内核、动态链接器、程序段表和共享库等多个组件的协作。通过理解这一过程,开发者可以更好地调试和优化程序,尤其是在涉及动态链接和共享库使用的情景下。

    ELF文件系统格式

    总的来说,ELF文件系统格式是类UNIX系统中不可或缺的一部分,它为编译器、链接器、程序加载器和调试工具提供了统一的接口,确保了软件开发和运行的高效性和可靠性。理解ELF格式有助于深入理解操作系统是如何加载和...

    Intel平台下Linux中ELF文件动态链接的加载、解析及实例分析(一)-加载1

    本文将深入探讨ELF文件在Linux下的动态链接加载过程,主要关注`dl_open`函数及其相关组件。 动态链接的概念可以追溯到上世纪五十年代,当时的设想是将常用代码集中存储,其他程序通过调用即可使用。随着时间的发展...

    ELF文件格式分析

    ELF文件的加载和链接过程涉及将文件从磁盘加载到内存,并解决符号引用。动态链接允许程序在运行时加载和链接共享库,提高了资源利用率。 - **3.8.1 程序头部(Program Header)**:描述了ELF文件在内存中的布局,...

    ELF 文件格式分析(北京大学实验室出的标准版)

    通过学习这份文档,读者可以掌握如何读取和解析ELF文件,理解动态链接机制,以及如何利用ELF格式进行程序调试和分析。 总之,对ELF文件格式有深入的理解对于系统级编程、逆向工程、软件调试以及优化都是至关重要的...

    cpp-ELF的最小动态链接器的实现

    实现这样一个最小的动态链接器可以帮助我们深入了解操作系统和程序加载的过程,但实际的动态链接器远比这复杂,需要处理更多边界情况和优化。此外,安全性也是动态链接器必须考虑的问题,例如防止恶意代码注入和缓冲...

    elf文件格式(中文版)

    ELF文件通过动态链接器(通常是操作系统的一部分)来实现这一点,允许程序在运行时按需加载库。 #### 四、C Library 在ELF标准中,C库(CLibrary)的符号列表和全局数据符号对于实现标准ANSI C和libc运行时环境至...

    ELF文件系统格式.pdf

    程序头部描述了如何将ELF文件加载到内存中,它包含了一系列段的信息,如起始地址、大小等。 **图7:程序头部数据结构** 程序头部的数据结构定义了如何加载ELF文件中的各个段到内存中。 **3.8.2 程序加载** 程序...

Global site tag (gtag.js) - Google Analytics