2019-2020-8 20199317 《Linux内核原理与分析》 第八周作业

第7章  可执行程序工作原理

1  ELF目标文件格式

1.1  ELF概述

       “目标文件”,是指编译器生成的文件。“目标”指目标平台目标文件一般也叫作ABI(Application Binary Interface,应用程序二进制接口),目标文件和目标平台是二进制兼容的。二进制兼容即指该目标文件已经是适应某一种CPU体系结构上的二进制指令。

       最古老的目标文件格式是a.out,后来发展成COFF格式,现在常用的格式有PE(Windows)和ELF(Linux)。

       ELF(Executable and Linkable Format)即可执行的和可链接的格式,是一个目标文件格式的标准。

       ELF格式里有3种不同类型的目标文件:

     (1)可重定位文件:这种文件一般是中间文件,还需要继续处理。由编译器和汇编器创建,一个源代码文件会生成一个可重定位文件。用来和其他的目标文件一起来创建一个可执行文件、静态库文件或者共享目标文件(即动态库文件)。读者在编译Linux内核时可能会注意到,每个内核源代码.c文件都会生成一个同名的.o文件,该文件即为可重定位目标文件,最后所有的.o文件会链接为一个文件,即Linux内核。

     (2)可执行文件:一般由多个可重定位文件结合生成,是完成了所有重定位工作和符号解析(除了运行时解析的共享库符号)的文件,文件中保存着一个用来执行的程序。

     (3)共享目标文件:共享库,是指可以被可执行文件或其他库文件使用的目标文件。Linux下共享库后缀为.so的文件,so代表shared object。

       ELF文件的作用是参与程序的链接(建立一个程序)和程序的执行(运行一个程序)。

1.2  ELF格式简介

        创建如下图中所示的hello.c:

        

         

       ELF文件由4部分组成,分别是ELF头(ELF header)、程序头表(Program header table)、节(Section)和节头表(Section header table)。实际上,一个文件中不一定包含全部内容,而且他们的位置也未必如图所示这样安排,只有ELF头的位置是固定的,其余各部分的位置、大小等信息由ELF头中的各项值来决定。

       

        ELF Header 结构

       ELF Header 在文件最开始描述了该文件的组织情况。ELF文件头会指出可执行文件是32位还是64位的,e_ident数组的第五个字节是1表示是32位,2表示是64位。ELF Header的其他部分主要说明了其他文件内容的位置、大小等信息。ELF Header 长度为64字节,在/usr/include/elf.h文件中。

       ELF表头会给出很多关于本ELF文件的属性信息,如前面提到过的3种ELF类型就是通过e_type来体现的。e_type值1、2、3、4分别代表可重定位目标文件、可执行文件、共享目标文件和核心文件。

       其中最重要是段头表(program header table)和节头表(Section header table)的位置。段头表存储于文件的e_phoff(ELF header的字段,下同)位置,有e_phnum项内容,每项大小为e_phentsize字节;节头表存储于e_shoff位置,有e_shnum项内容,每项大小为e_shentsize字节。节头表基本定义了整个ELF文件的组成,可以说是整个ELF就是由若干个节(Section)组成的。

       Program Header table 结构

       段头表(Program Header)表是和创建进程相关的,描述了连续的几个节在文件中的位置、大小以及它被放进内存后的位置和大小,告诉系统如何创建进程映像,可执行文件加载器就可以按这个说明将可执行文件搬到内存中。用来构造进程映像的目标文件必须具有段头表,可重定位文件不需要这个表。

       可以查看前边生成的hello.m32.static这个可执行文件的段头表,使用的指令及输出的内容如下图所示:

       

        8列分别是Type类型、Offset文件偏移、VirtAddr虚拟地址、PhysAddr物理地址、FileSiz 可执行文件中该区域的大小、MemSiz内存中该区域的大小、Flg属性标识和Align对齐方式。该表描述了将可执行文件中起始位置为Offset、大小为FileSiz的一段数据,加载到内存地址VirtAddr中。Type值为LOAD表示该段(Segment)需要加载到内存,Offset全0表示其内容为从可执行文件头开始共0xa065f(FileSiz)个字节,加载到虚拟地址0x08048000(VirtAddr)处,该段为可读(R)可执行(E)权限,4k(Align,0x1000)对齐。再往下看为节与段的映射关系说明(Section to Segment mapping:),00即第一行描述的段,一共包括了.note、.ABI-tag、.init、.text等多个节。

       Section Header table 结构

       节头表Section Header table 组成的表,包含了描述文件节区的信息,每个节区在表中都有一项,每一项给出诸如节区名称、节区大小这类信息。用于链接的目标文件必须包含节区头部表,其他目标文件有没有这个表皆可。

       可以查看前边生成的hello.m32.static这个可执行文件的节头表,使用的指令及输出的内容如下图所示:

       

        6列分别是[Nr]索引、Name节名、Type类型、Addr虚拟地址、Off偏移和Size节大小。简单来说,该节描述了将可执行文件中起始位置为Off、大小为Size的一段数据加载到内存地址Addr。Type中的PROGBITS表示该节存储的是代码,Addr为080482d0是该部分将加载到内存中的虚拟地址,Off为节在可执行文件中的偏移,后半部分的Key to Flags是对Flg中标识的说明,如.text节Flg为AX,A(Alloc)表示需要加载到内存中,X(eXecute)表示对应内存需要可执行权限。

 2  程序编译

      程序从源代码到可执行文件的步骤:预处理、编译、汇编、链接。

2.1  预处理

        预处理时编译器完成的具体工作如下:

        ● 删除所有的注释“/”和“/**/”。

        ● 删除所有的“#define”,展开所有的宏定义。

        ● 处理所有的条件预编译指令。

        ● 处理“#include”预编译指令,将被包含的文件插入该预编译指令的位置,这一过程是递归进行的。

        ● 添加行号和文件名标识。

         如下指令将对hello.c进行预处理,结果保存到文件hello.i中。

  gcc -E hello.c -o hello.i

         预处理完的文件仍然是文本文件,可以用任意编辑工具打开查看。

2.2  编译

        编译时,gcc首先要检查代码的规范性、是否有语法错误等,以确定代码实际要做的工作。在检查无误后,gcc把代码翻译成汇编语言。实际编译的指令如下:

  gcc -S hello.i -o hello.s -m32

         编译完的文件仍然是文本文件,可以用任意编辑工具打开查看。

2.3  汇编

        汇编指令如下:

 gcc -c hello.s -o hello.o.m32 -m32   //-m32表示生成32位的目标文件。x32和x64位使用不同的寄存器名,指令集也不同

         汇编后形成的.o格式的文件已经是ELF格式文件了。程序编译后生成的目标文件至少含有3个节区(Section),分别为.text,.data和.bss。

        ● .bss段。BSS段(bss segment)通常是指用来存放程序中未初始化的全局变量的一块内存区域。BSS是BlockStarted by Symbol的简称。BSS段属于静态内存分配,该节区包含了在内存中的程序未初始化的数据。当程序开始运行时,系统将用0来初始化该区域。该节不占用文件空间,该section type = SHT_NOBITS。

        ● .data段。数据段(data segment)通常是指用来存放程序中已初始化的全局变量的一块内存区域。数据段属于静态内存分配。

        ● .text段。代码段(code segment/text segment)通常是指用来存放程序执行代码的一块内存区域。这部分区域的大小在程序运行前就已经确定,并且内存区域通常属于只读,某些架构也允许代码段为可写,即允许修改程序。在代码段中,也可能包含一些只读的常数变量,例如字符串常量等。

        通过readlf -S(显示所有Section信息)可以看到目标文件的节区信息表。

        其他常见节:

        ● .rodata:存放C中的字符串和#define定义的常量,该节包含了只读数据。

        ● .comment:该节包含了版本控制信息。

        ● .dynamic:该节包含了动态链接信息。

        ●  .dynsym:该节包含了动态链接符号表。

        ● .init:该节包含了用于初始化进程的可执行代码、也就是说,当一个程序开始运行时,系统将会执行在该节中的代码,然后才会调用程序的入口点(对于C程序而言就是main)。

2.4  链接

        链接是将各种代码和数据部分收集起来并组合成为一个单一文件的过程,这个文件可被加载(或被复制)到内存中并执行。链接指令如下:

  gcc hello.o.m32 -o hello.m32.static -m32 -static

          通俗地说,链接就是把多个文件拼接到一起,本质上是节的拼接。

          

3  链接与库

       在可执行文件的生成过程中,最为复杂的部分就是链接。链接从过程上讲分为符号解析和重定位两部分;根据链接时机的不同,又分为静态链接和动态链接两种。

        先以hello.c为例简要说明符号、符号解析与重定位。

        符号:简化来说,hello.c中只有两个符号------main和printf。main的实现就在hello.c中,而printf的实现显然没有在hello.c中,相应的hello.c编译为hello.o后,main这个符号是“有定义”的,printf这个符号则是“无定义”的。“有定义”的意思就是函数对应的机器指令地址在当前文件中(有明确的地址)。

        符号解析:编译器需要到其他的共享库中找到printf的“定义(机器指令片段)”,找到后把该片机器指令与hello.o拼接到一起,生成可执行文件hello。hello中printf就存在了(有定义即有了明确的地址),这就是符号解析。

       重定位:在拼接所有目标文件的同时,编译器会确定各个函数加载到内存中的运行地址,然后反过来修改所有调用该函数的机器指令,使得该指令能跳转到正确的内存地址。这个过程就是重定位。

3.1  符号与符号解析

        符号包含全局变量和 全局函数。例如printf就是一个符号,hello程序需要在函数库中找到这个符号。

        符号表(symbol table)是一种供编译器用于保存有关源程序构造的各种信息的数据结构,符号表的功能是找未知函数在其他库文件中的代码段的具体位置,还是以hello为例,其调用的printf是外部库提供的函数。在链接前,编译器需要把类似于printf这种符号都记录下来,存储于符号表中。

        符号表的查看方法为objdump -t xxx.o或readelf -s xxx.o。

3.2  重定位

        重定位是把程序的逻辑地址空间变换成内存中的实际物理地址空间的过程,也就是说在装入时对目标程序中指令和数据的修改过程。它是实现多道程序在内存中同时运行的基础。

        重定位分为如下两步:

        ● 重定位节和符号定义:链接器将所有相同类型的节合并为同一类型的新的聚合节,将运行时存储器地址赋给新的聚合节、输入模块定义的每个节,以及输入模块定义的每个符号。此时,程序中的每个指令和全局变量都有唯一的运行时存储器地址。

        ● 重定位节中的符号引用:链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。链接器依赖于重定位条目的可重定位目标模块中的数据结构。

        可重定位表中的每一条记录对应一个需要重定位的符号。汇编器将为可重定位文件中每个需要重定位符号的段都建立一个重定位表。

        可重定位表的查看方法是readelf -r xxx.o。

        简单总结一下,符号表记录了目标文件所有的全局函数及其地址;重定位表中记录了所有调用这些函数的代码位置。在链接时,这两大类数据都需要逐一修改为正确的值。

3.3  静态链接与动态链接

        静态链接:在编译链接时直接将需要的执行代码复制到最终可执行文件中,优点是代码的装载速度快,执行速度也比较快,对外部环境依赖度低。编译时它会把需要的所有代码都链接进去,应用程序相对比较大。缺点是如果多个应用程序使用同一库函数,会被装载多次,浪费内存。

        动态链接:在编译时不直接复制可执行代码,而是通过记录一系列符号和参数,在程序运行或加载时将这些信息传递给操作系统。操作系统负责将需要的动态库加载到内存中,然后程序在运行到指定的代码时,去共享执行内存中已经加载的动态库去执行代码,最终达到运行时链接的目的。优点是多个程序可以共享同一段代码,而且不需要再磁盘上存储多个复制。缺点是在运行时加载,可能会影响程序的前期执行性能,而且对使用的库依赖性较高,在升级时特别容易出现版本不兼容的问题。

       如下图所示中的动态链接的可执行文件只有7292字节,而静态链接版本大小约是其100倍。

       

        然而动态链接分为可执行程序装载时动态链接运行时动态链接。

       装载时动态链接:

       源码shlibexample.h与shlibexample.c是一个简单动态库的源码,只提供一个函数SharedLibApi()。

       shlibexample.h的源码如下:

       

        shlibexample.c的源码如下:

       

      使用如下指令可将其编译成libshlibexamplle.so文件。

 gcc -shared shlibexample.c -o libshlibexample.so -m32

      接着只要将shlibexample.h和shlibexample.c文件放置在正确的目录下,就可以像调用printf一样调用SharedLibApi()。

       运行时动态链接:

       运行时动态链接库的源文件为dllibexample.h和dllibexample.c。

       dllibexample.h的源码如下:

       

       dllibexample.c的源码如下:

       

       编译成libdllibexample.so文件的指令如下:

 gcc -shared dllibexample.c -o libdllibexample.so -m32

       运行时动态链接本质上是由程序员自己来控制整个过程的,其基本流程如下:

 // 先将动态库加载进来
 void * handle = dlopen("libdllibexample.so",RTLD_NOW);   //声明一个函数指针
 int (*func) (void);   //根据名称找到函数指针
 func = dlsym(handle, "DynamicalLoadingLibApi");  //调用已声明函数
 func();

       动态链接实例

      如下代码分别以装载时动态链接和运行时动态链接调用了两个动态链接库。从动态链接库的角度是没有差别的,差别只是程序员使用动态链接库的方法。

        

       这里的shlibexample在链接时就需要,所以需要提供其路径,对应的头文件shlibexample.h也需要在编译器能找到位置。使用参数-L指明头文件所在目录,使用-l指明库文件名,如libshlibexamplle.so去掉lib和.so的部分。dllibexample只在程序运行到相关语句时才会访问,在编译时不需要任何的相关信息,只是用参数-ldl指明其需要使用共享库dlopen等函数。当然在实际运行时,也要确保libdllibexample.so是应用可以查找到的,这也是要修改环境变量LD_LIBRARY_PATH的原因。

       最终的编译及运行效果如下:

        

4  程序装载 

4.1  程序装载概要

      Shell 本身不限制命令行参数的个数,命令行参数的个数受限于命令自身,也就是main函数愿意接收什么。典型的main函数可以写成如下几种:

 int main()
 int main(int argc, char *argv[])
 int main(int argc, char *argv[], char *envp[])

        Shell会调用execve将命令行参数和环境参数传递给可执行程序的main函数。execve的函数原型如下:

 int execve(const char *filename, char *const argv[], char *const envp[]);

        filename为可执行文件的名字,argv是以NULL结尾的命令行参数数组,envp同样是以NULL结尾的环境变量数组。编程使用的库函数exec及类似函数都是execve的封装例程。

        命令行参数和环境变量是如何保存的呢?在创建一个新的用户态堆栈时,实际上是把命令行参数内容和环境变量的内容通过指针的方式传到系统调用内核处理堆栈中,再创建一个新的用户态堆栈时会把这些char *argcv[]和char *envp[]复制到用户态堆栈中,来初始化这个新的可执行程序执行的上下文环境。所以新的程序可以从main函数开始把对应的参数接收过来,然后执行,但父进程在调用execve这个命令行时,只是压在了shell程序当前进程的堆栈上,堆栈在加载完新的可执行程序之后已经被清空了。

       如果仅加载一个静态链接可执行程序,只需要传递一些命令行参数和环境参数,就可以正常来工作了。但对于绝大多数可执行程序来讲,还有一些对动态库的依赖会稍微复杂一点。动态库链接器 ld 负责加载库并进行解析(这就是一个图的遍历),装载所以需要的动态链接库,然后 ld 将CPU的控制权交给可执行程序。动态链接的过程主要是动态链接器在起作用,而不是内核完成的。

4.2  fork与execve内核处理过程

       Linux提供了 execl、execlp、execle、execv、execvp、和execve 等6个用以执行一个可执行文件的函数(统称为exec函数,差异在于对命令行参数和环境变量参数的传递方式不同)。

       整体的调用关系为sys_execve() -> do_execve() -> do_execve_common() -> exec_binprm() -> search_binary_handler() ->load_elf_binary() -> start_thread()。

       execve()的大致处理过程简要总结如下:

     (1)sys_execve中的 do_execve() 读取128个字节的文件头部,以此判断可执行文件的类型。

     (2)调用search_binary_handle() 去搜索和匹配合适的可执行文件装载处理过程。

     (3)ELF文件由load_elf_binary()函数负责装载。load_elf_binary 函数调用了 start_thread 函数,创建新进程的堆栈,其中有 pt_regs 栈底指针。更重要的是修改了中断现场中保存的EIP寄存器,这里分静态链接和动态链接两种情况。

       ● 静态链接:elf_entry指向可执行文件的头部,一般是main函数,是新程序执行的起点。新的可执行程序起点的一般地址为0x8048xxx的位置,由编译器设定,可能是由于安全上的考虑并不严格固定。

       ● 动态链接:elf_entry指向Id(动态链接器)的起点load_elf_interp。

       execve与fork的区别与联系

       fork两次返回,第一次返回到父进程继续向下执行,第二次是子进程返回到ret_from_fork后正常返回到用户态。execve 在执行时陷入内核态,用execve中加载的程序把当前正在执行的进程覆盖掉,当系统调用返回时也就返回到新的可执行程序起点。

       内核处理这个可执行程序的装载过程,实际上是执行程序装载的一个系统调用,和前面分析的fork及其他的系统调用的主要过程是一样的。但是execve这个系统调用的内核处理过程和fork一样也是比较特殊的。因为正常的一个系统调用都是陷入内核态,再返回到用户态,然后继续执行系统调用后的下一条指令。fork和其他系统调用不同之处是它在陷入内核态之后有两次返回,第一次返回到原来的父进程的位置继续向下执行,这和其他的系统调用是一样的。在子进程中fork也返回了一次,会返回到一个特定的点------ret_from_fork,通过内核构造的堆栈环境,它可以正常返回到用户态,所以它稍微特殊一点。

       同样,execve 也比较特殊。当前的可执行程序正在执行,执行到execve 时陷入内核态,在内核里面用execve 加载的可执行文件把当前进程的可执行程序给覆盖掉。当execve 的系统调用返回时,返回的已经不是原来的那个可执行程序了,而是新的可执行程序。execve 返回的是新的可执行程序执行的起点,也就是main函数的大致位置。

5  总结

      Linux内核加载可执行程序进程,和古代庄生梦蝶的故事比较相似。如果把fork出来的shell程序的子进程比作庄子,它调用execve系统调用进入内核即入睡了(Shell子进程本身停止执行)。进入内核的execve系统调用加载了一个新的可执行程序(比如前文中的hello程序),execve系统调用return返回到用户态时发现自己已经不是原来的shell子进程,而是hello程序。如果hello程序内部也执行execve系统调用加载shell程序,同样返回到用户态(醒来)发现自己是shell进程了。这两者总是相对的,你可以装载我,我可以装载你。但都是同一个进程,只是进程里的可执行程序被替换掉了。

posted on 2019-11-09 14:55  20199317-程峥华  阅读(227)  评论(0编辑  收藏  举报

导航