一、ELF文件(目标文件)格式

ELF指定了进程中text段、bss段、data段等应该放置到进程虚拟内存空间的什么位置,以及记录了进程需要用到的各种动态链接库的位置。 ELF格式提供了两种不同的视角,链接器(生成可执行程序时)把ELF文件看成是Section的集合,而**

1.1 格式

  1. 可重定向文件:文件保存着代码和适当的数据,用来和其他的目标文件一起来创建一个可执行文件或者是一个共享目标文件。(目标文件或者静态库文件,即linux通常后缀为.a和.o的文件)
  2. 可执行文件:文件保存着一个用来执行的程序。(例如bash,gcc等)
  3. 共享目标文件:共享库。文件保存着代码和合适的数据,用来被下连接编辑器和动态链接器链接。(linux下后缀为.so的文件。)

ELF解析 - 图1
ELF segment 和 section 用上图来解释很恰当,其实就是对于elf文件中一部分相同内容的不同描述映射而已,就是上图红框中标出的内容,就好比一个学院的学生,有人喜欢用一班的学生,二班的学生去描述,也有人用女同学,男同学去描述

ELF解析 - 图2
1.一个c源程序f1.c经过c预处理器(cpp)进行预处理后,变成f1.i(上图省略此步)
2.c编译器(ccl)将f1.i文件翻译成一个ASCII汇编语言文件f1.s
3.汇编器(as)将f1.s翻译成一个可重定位目标文件f1.o
4.链接器(ld)将所有文件链接,最终生成一个可执行文件p
从上图可以看出,一个源程序最终是要转成汇编程序最后才能生成一个可执行目标文件,写过汇编的都知道,汇编每一段开头都有不同的声明,表示接下来这一段的内容是什么,如下图,这就是section,也就是说section本身的作用就是来自于汇编中声明
ELF解析 - 图3
那么segment的作用是什么呢? 多个可重定向文件最终要整合成一个可执行的文件的时候,链接器把目标文件中相同的 section 整合成一个segment在程序运行的时候,方便加载器的加载。
.init : main函数执行前调用
.fini : main函数退出时调用

1.2 一般的 ELF 文件包括三个索引表:

1)ELF header:在文件的开始,保存了路线图,描述了该文件的组织情况。
2)Program header table:告诉系统如何创建进程映像。用来构造进程映像的目标文件必须具有程序头部表,可重定位文件不需要这个表。
3)Section header table :包含了描述文件节区的信息,每个节区在表中都有一项,每一项给出诸如节区名称、节区大小这类信息。用于链接的目标文件必须包含节区头部表,其他目标文件可以有,也可以没有这个表。
ELF解析 - 图4

ELF解析 - 图5

可执行文件是一个普通的文件,它描述了如何初始化一个新的执行上下文,也就是如何开始一个新的计算。可执行文件类别有很多,在内核中有一个链表,在init的时候会将支持的可执行程序解析程序注册添加到链表中,那么在对可执行文件进行解析时,就从链表头开始找,找到匹配的处理函数就可以对其进行解析
在shell中启动一个可执行程序时,会创建一个新进程,它通过覆盖父进程(也就是shell进程)的进程环境,并将用户态堆栈清空,获得说需要的执行上下文环境。
命令行参数和环境变量会通过shell传递给execve,excve通过系统调用参数传递,传递给sys_execve,最后sys_execve在初始化新进程堆栈的时候拷贝进去。
load_elf_binary->start_thread(…)通过修改内核堆栈中EIP的值作为新程序的起点。
如果新程序的动态链接的,那么就需要加载所需要的库函数,动态连接器ld会负责加载过程,动态链接库的装载过程类似于一个图的广度优先遍历过程,装载完成后,ld将CPU控制权交给可执行程序,继续执行可执行程序。

自定义格式

  1. // 把相应的变量或者函数放到以"name" 作为段名的段中
  2. __attribute__((section("FOOxx"))) int global = 42

自定义 section 需要配置链接脚本使用

  1. //.c中
  2. char data_func[1024] __attribute__((section(".myfunc")));
  3. //.lds中:
  4. MEMORY
  5. {
  6. myfuncrom (rwx) : org = 0x700000 , LENGTH = 100M
  7. mytext (rx) : org = 0x4003e0 , LENGTH = 100M
  8. mydata (rw) : org = 0x600000 , LENGTH = 100M
  9. }
  10. //并且将自定义段指定存储到此处定义的区域 使用`> name`.如:
  11. .myfunc : //此为自定义section名称
  12. {
  13. *(.myfunc)
  14. } > mytext

二、ELF可行档的载入

  1. 填充并且检查目标程序ELF头部
  2. load_elf_phdrs加载目标程序的程序头表
  3. 如果需要动态链接, 则寻找和处理解释器段
  4. 检查并读取解释器的程序表头
  5. 装入目标程序的段segment
  6. 填写程序的入口地址
  7. create_elf_tables填写目标文件的参数环境变量等必要信息
  8. start_kernel宏准备进入新的程序入口

为什么可以执行ELF格式

2.1 填充并且检查目标程序ELF头部

当运行程序时,内核中实际执行execv()或execve()系统调用的程序是do_execve(),这个函数先打开目标映像文件,并从目标文件的头部(从 第一个字节开始)读入若干(128)字节(实际上就是填充 ELF文件头),然后调用另一个函数search_binary_handler(),在那里面让各种可执行程序的处理程序前来认领和处理。内核所支持的每种可执行程序都有个struct linux_binfmt数据结构,通过向内核登记挂入一个队列。而search_binary_handler(),则扫描这个队列,让各个数据结构所 提供的处理程序、即各种映像格式、逐一前来认领。如果某个格式的处理程序发现特征相符而,便执行该格式映像的装入和启动。
我们从ELF格式映像的linux_binfmt数据结构开始:

  1. #define load_elf_binary load_elf32_binary
  2. static struct linux_binfmt elf_format = {
  3. .module = THIS_MODULE,
  4. .load_binary = load_elf_binary,
  5. .load_shlib = load_elf_library,
  6. .core_dump = elf_core_dump,
  7. .min_coredump = ELF_EXEC_PAGESIZE
  8. };
  1. struct pt_regs *regs = current_pt_regs();
  2. struct {
  3. struct elfhdr elf_ex;
  4. struct elfhdr interp_elf_ex;
  5. } *loc;
  6. struct arch_elf_state arch_state = INIT_ARCH_ELF_STATE;
  7. loc = kmalloc(sizeof(*loc), GFP_KERNEL);
  8. if (!loc) {
  9. retval = -ENOMEM;
  10. goto out_ret;
  11. }
  12. /* Get the exec-header
  13. 使用映像文件的前128个字节对bprm->buf进行了填充 */
  14. loc->elf_ex = *((struct elfhdr *)bprm->buf);
  15. retval = -ENOEXEC;
  16. /* First of all, some simple consistency checks
  17. 比较文件头的前四个字节
  18. 。*/
  19. if (memcmp(loc->elf_ex.e_ident, ELFMAG, SELFMAG) != 0)
  20. goto out;
  21. /* 还要看映像的类型是否ET_EXEC和ET_DYN之一;前者表示可执行映像,后者表示共享库 */
  22. if (loc->elf_ex.e_type != ET_EXEC && loc->elf_ex.e_type != ET_DYN)
  23. goto out;

ELF格式的二进制映像的认领、装入和启动是由load_elf_binary()完成的。而“共享库”、即动态连接库映像的装入则由 load_elf_library()完成。实际上共享库的映像也是二进制的,但是一般说“二进制”映像是指带有main()函数的、可以独立运行并构成 一个进程主体的可执行程序的二进制映像。
[sys_execve() > do_execve() > search_binary_handler() > load_elf_binary()]
整个ELF映像就是由文件头、区段头表、程序头表、一定数量的区段、以及一定数量的部构成
ELF映像的装入/启动过程,则就是在各种头部信息的指引下将某些部或区段装入一个进程的用户空间,并为其运行做好准备(例如装入所需的共享库),最后(在目标进程首次受调度运行时)让CPU进入其程序入口的过程
接着是对elf_bss elf_brkstart_codeend_code等等变量的初始化。这些变量分别纪录着当前(到此刻为止)目标映像的bss段、代码段、数据 段、以及动态分配“堆” 在用户空间的位置。除start_code的初始值为0xffffffff外,其余均为0。随着映像内容的装入,这些变量也会逐步得到调整,读者不妨自己 留意这些变量在整个过程中的变化。
读入了程序头表,并对start_code等变量进行初始化以后,

2.2 如果需要动态链接, 则寻找和处理解释器段

ELF格式的二进制映像在装入和启动的过程中需要得到一个工具软件的协助,其主要的目的在于为目标映像建立起跟共享库的动态连接。这个工具称为 “解释器”。一个ELF映像在装入时需要用什么解释器是在编译/链接是就决定好了的,这信息就保存在映像的“解释器”部中。“解释器”部的类型为 PT_INTERP,找到后就根据其位置p_offset和大小p_filesz把整个“解释器”部读入缓冲区。整个“解释器”部实际上只是一个字符串, 即解释器的文件名,例如“/lib/ld-linux.so.2”。有了解释器的文件名以后,就通过open_exec()打开这个文件,再通过 kernel_read()读入其开头128个字节,这就是映像的头部。早期的解释器映像是a.out格式的,现在已经都是ELF格式的了,/lib /ld-linux.so.2就是个ELF映像。

  1. for (i = 0; i < loc->elf_ex.e_phnum; i++) {
  2. /* 3.1 检查是否有需要加载的解释器 */
  3. if (elf_ppnt->p_type == PT_INTERP) {
  4. /* This is the program interpreter used for
  5. * shared libraries - for now assume that this
  6. * is an a.out format binary
  7. */
  8. /* 3.2 根据其位置的p_offset和大小p_filesz把整个"解释器"段的内容读入缓冲区 */
  9. retval = kernel_read(bprm->file, elf_ppnt->p_offset,
  10. elf_interpreter,
  11. elf_ppnt->p_filesz);
  12. if (elf_interpreter[elf_ppnt->p_filesz - 1] != '\0')
  13. goto out_free_interp;
  14. /* 3.3 通过open_exec()打开解释器文件 */
  15. interpreter = open_exec(elf_interpreter);
  16. /* Get the exec headers
  17. 3.4 通过kernel_read()读入解释器的前128个字节,即解释器映像的头部。*/
  18. retval = kernel_read(interpreter, 0,
  19. (void *)&loc->interp_elf_ex,
  20. sizeof(loc->interp_elf_ex));
  21. break;
  22. }
  23. elf_ppnt++;
  24. }

2.3 检查并读取解释器的程序表头

如果需要加载解释器, 前面经过一趟for循环已经找到了需要的解释器信息elf_interpreter, 他也是当作一个ELF文件, 因此跟目标可执行程序一样, 我们需要load_elf_phdrs加载解释器的程序头表program header table

  1. /* 4. 检查并读取解释器的程序表头 */
  2. /* Some simple consistency checks for the interpreter
  3. 4.1 检查解释器头的信息 */
  4. if (elf_interpreter) {
  5. retval = -ELIBBAD;
  6. /* Not an ELF interpreter */
  7. /* Load the interpreter program headers
  8. 4.2 读入解释器的程序头
  9. */
  10. interp_elf_phdata = load_elf_phdrs(&loc->interp_elf_ex,
  11. interpreter);
  12. if (!interp_elf_phdata)
  13. goto out_free_dentry;

至此我们已经把目标执行程序和其所需要的解释器都加载初始化, 并且完成检查工作, 也加载了程序头表program header table, 下面开始加载程序的段信息

2.4 装入目标程序的段segment

还是从目标映像的程序头表中搜索,这一次是寻找类型为PT_LOAD的部(Segment)。在二进制映像中,只有类型为PT_LOAD的部才是需要装入的
找到一个PT_LOAD片以后,先要确定其装入地址。正如代码前面的注释所述,这里先假定装入地址是固定的,然后再根据映像是否允许浮动而作出调 整。具体片头数据结构中的p_vaddr提供了映像在连接时确定的装入地址vaddr。如果映像的类型为ET_EXEC,(或者 load_addr_set已经被设置成1,见下)那么装入地址就是固定的。而若类型为ET_DYN、即共享库,那么即使装入地址固定也要加上一个偏移量,代码中给出了计算方法,其中ELF_ET_DYN_BASE对于x86定义为(TASK_SIZE / 3 2),所以这是2GB边界,而ELF_PAGESTART表示按页面边界对齐。
确定了装入地址以后,就通过elf_map()、实际上是elf32_map()、建立用户空间虚存区间与目标映像文件中某个连续区间之间的映射。这个函数基本上就是do_mmap(),*其返回值就是实际映射的(起始)地址

  1. */
  2. for(i = 0, elf_ppnt = elf_phdata;
  3. i < loc->elf_ex.e_phnum; i++, elf_ppnt++) {
  4. /* 5.1 搜索PT_LOAD的段, 这个是需要装入的 */
  5. if (elf_ppnt->p_type != PT_LOAD)
  6. continue;
  7. /* 5.2 检查地址和页面的信息 */
  8. // ......
  9. ///
  10. /* 5.3 虚拟地址空间与目标映像文件的映射
  11. 确定了装入地址后,
  12. 就通过elf_map()建立用户空间虚拟地址空间
  13. 与目标映像文件中某个连续区间之间的映射,
  14. 其返回值就是实际映射的起始地址 */
  15. error = elf_map(bprm->file, load_bias + vaddr, elf_ppnt,
  16. elf_prot, elf_flags, total_size);
  17. }

对于类型为ET_EXEC的可执行程序映像而言,代码中的 load_bias是0,所以装入的起点就是映像自己提供的地址vaddr。另一方面,对于ET_EXEC,由于参数中的elf_flags中的 MAP_FIXED标志位为1,所以给定的映射地址是刚性的而不容许变通,如果与已经映射的区间有冲突就以失败告终。不过,目标映像的映射是从一片空白开 始的,所以实际上不可能失败。顺便提一下,现在又多了一种ELF格式的目标映像,称为FDPIC,其装入地址就是可浮动的。
即使总的装入地址是浮动的,一旦装入了第一个Segment以后,下一个Segment的装入地址就应该是固定的了,所以这里一方面把load_addr_set设置成1。

2.5 填写程序的入口地址

完成了目标程序和解释器的加载, 同时目标程序的各个段也已经加载到内存了, 我们的目标程序已经准备好了要执行了, 但是还缺少一样东西, 就是我们程序的入口地址, 没有入口地址, 操作系统就不知道从哪里开始执行内存中加载好的可执行映像

  1. if (elf_interpreter) {
  2. unsigned long interp_map_addr = 0;
  3. elf_entry = load_elf_interp(&loc->interp_elf_ex,
  4. interpreter,
  5. &interp_map_addr,
  6. load_bias, interp_elf_phdata);
  7. /* 入口地址是解释器映像的入口地址 */
  8. } else {
  9. /* 入口地址是目标程序的入口地址 */
  10. elf_entry = loc->elf_ex.e_entry;
  11. }
  12. }

这段程序的逻辑很简单:如果需要装入解释器,并且解释器的映像是ELF格式的,就通过load_elf_interp()装入其映像,并把将来进 入用户空间时的入口地址设置成load_elf_interp()的返回值,那显然是解释器的程序入口而若不装入解释器,那么这个地址就是目标映像本身的程序入口
显然,关键的操作是由load_elf_interp()完成的,所以我们追下去看load_elf_interp()的代码。
do_brk()从用户空间分配一段空间。这段代码总体上与前面映射目标映像的那一段相似。注意解释器映像的类型一般都是ET_DYN,所以load_addr可能不等于0。

2.6 create_elf_tables填写目标文件的参数环境变量等必要信息

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

  1. install_exec_creds(bprm);
  2. retval = create_elf_tables(bprm, &loc->elf_ex,
  3. load_addr, interp_load_addr);
  4. if (retval < 0)
  5. goto out;
  6. /* N.B. passed_fileno might not be initialized? */
  7. current->mm->end_code = end_code;
  8. current->mm->start_code = start_code;
  9. current->mm->start_data = start_data;
  10. current->mm->end_data = end_data;
  11. current->mm->start_stack = bprm->p;

2.7 start_thread宏准备进入新的程序入口

最后,start_thread()这个宏操作会将eip和esp改成新的地址,就使得CPU在返回用户空间时就进入新的程序入口。如果存在解释器映像,那么这就是解释器映像的程序入口,否则就是目标映像的程序入口。那么什么情况下有解释器映像存在,什么情况下没有呢?如果目标映像与各种库的链接是静态链接,因而无需依靠共享库、即动态链接库,那就不需要解释器映像否则就一定要有解释器映像存在。
start_thread宏是一个体系结构相关的函数,请定义可以参照http://lxr.free-electrons.com/ident?v=4.6;i=start_thread

2.9 总结

简单来说可以分成这几步

  1. 读取并检查目标可执行程序的头信息, 检查完成后加载目标程序的程序头表
  2. 如果需要解释器则读取并检查解释器的头信息, 检查完成后加载解释器的程序头表
  3. 装入目标程序的段segment, 这些才是目标程序二进制代码中的真正可执行映像
  4. 填写程序的入口地址(如果有解释器则填入解释器的入口地址, 否则直接填入可执行程序的入口地址)
  5. create_elf_tables填写目标文件的参数环境变量等必要信息
  6. start_kernel宏准备进入新的程序入口


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

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

3.1 内核的工作

  1. 内核首先读取ELF文件头部,再读如各种数据结构,从这些数据结构中可知各段或节的地址及标识,然后调用mmap()把找到的可加载段的内容加载到内存中。同时读取段标记,以标识该段在内存中是否可读、可写、可执行。其中,文本段是程序代码,只读且可执行,而数据段是可读且可写。
  2. 从PT_INTERP的段中找到所对应的动态链接器名称,并加载动态链接器。通常是/lib/ld-linux.so.2.
  3. 内核把新进程的堆栈中设置一些标记对,以指示动态链接器的相关操作。
  4. 内核把控制权传递给动态链接器。

    动态链接器的工作并不是在内核空间完成的, 而是在用户空间完成的, 比如C语言程序则交给C运行时库来完成, 这个并不是我们今天内核学习的重点, 而是由glic完成的,但是其一般过程如下

3.2 动态链接器的工作

  1. 动态链接器检查程序对共享库的依赖性,并在需要时对其进行加载。
  2. 动态链接器对程序的外部引用进行重定位,并告诉程序其引用的外部变量/函数的地址,此地址位于共享库被加载在内存的区间内。动态链接还有一个延迟定位的特性,即只有在“真正”需要引用符号时才重定位,这对提高程序运行效率有极大帮助。
  3. 动态链接器执行在ELF文件中标记为.init的节的代码,进行程序运行的初始化。
    动态链接器把控制传递给程序,从ELF文件头部中定义的程序进入点(main)开始执行。在a.out格式和ELF格式中,程序进入点的值是显式存在的,而在COFF格式中则是由规范隐含定义。
  4. 程序开始执行

具体的信息可以参照
Intel平台下Linux中ELF文件动态链接的加载、解析及实例分析(一): 加载
Intel平台下linux中ELF文件动态链接的加载、解析及实例分析(二): 函数解析与卸载

为了解决不同模块间的链接问题,链接器主要有两个工作要做――符号解析和重定位:
符号解析:当一个模块使用了在该模块中没有定义过的函数或全局变量时,编译器生成的符号表会标记出所有这样的函数或全局变量,而链接器的责任就是要到别的模块中去查找它们的定义,如果没有找到合适的定义或者找到的合适的定义不唯一,符号解析都无法正常完成。
重定位:编译器在编译生成目标文件时,通常都使用从零开始的相对地址。然而,在链接过程中,链接器将从一个指定的地址开始,根据输入的目标文件的顺序以段为单位将它们一个接一个的拼装起来。除了目标文件的拼装之外,在重定位的过程中还完成了两个任务:一是生成最终的符号表;二是对代码段中的某些位置进行修改,所有需要修改的位置都由编译器生成的重定位表指出。

在使用动态链接时,需要在程序映象中每个调用库函数的地方打一个桩(stub)。stub是一小段代码,用于定位已装入内存的相应的库;如果所需的库还不在内存中,stub将指出如何将该函数所在的库装入内存。
当执行到这样一个stub时,首先检查所需的函数是否已位于内存中。如果所需函数尚不在内存中,则首先需要将其装入。不论怎样,stub最终将被调用函数的地址替换掉。这样,在下次运行同一个代码段时,同样的库函数就能直接得以运行,从而省掉了动态链接的额外开销。由此,用到同一个库的所有进程在运行时使用的都是这个库的同一份拷贝。(不需要再将函数再次加载到内存中,提高运行效率,以及不需要重复添加到内存中,内存中只存在一份,但是进程空间会有库的一份拷贝)