一个程序的前世今生(一)——代码如何变成可执行文件

简介:

  本章基于linux主要讲解l编辑好的hello.c文件如何从一个存储介质上的文件编译为可执行程序,以及加载到内存执行的过程。

  第一节讲述文本方式的代码及在介质上的存储方式(ELF文件),以及关于文本如何编译成可执行文件的简单介绍。

  第二节讲述可执行文件如何加载到内存中,涉及虚拟内存和文件如何加载到内存中并执行的过程。

   

一:文件方式存储的代码

  1.1 代码编写

    本文以如下代码从文本方式存储在存储介质:hello.c

#include<stdio.h>
int main()
{
	printf("Hello World!\n");
	return 0;
}

  1.2 代码编译

    GCC可以很方便的帮我们编译文件,因为不涉及文件方式的改变,这一部分简略写过,想详细了解的可以参考编译相关书籍,毕竟这又是另一个很长的故事了。

    • 源文件预编译展开头文件,gcc命令很简单就是把C文件包含的头文件都展开到了生成的文件中,输出太长可以自行实验。命令: gcc -i hello.c > hello.i
    • 利用预编译后的文件生成汇编文件,可以生成一个叫做hello.s的汇编文件如下。命令: gcc -s hello.i
              .file   "hello.c"
              .text
              .section        .rodata
      .LC0:
              .string "Hello World!"
              .text
              .globl  main
              .type   main, @function
      main:
      .LFB0:
              .cfi_startproc
              pushq   %rbp
              .cfi_def_cfa_offset 16
              .cfi_offset 6, -16
              movq    %rsp, %rbp
              .cfi_def_cfa_register 6
              leaq    .LC0(%rip), %rdi
              call    puts@PLT
              movl    $0, %eax
              popq    %rbp
              .cfi_def_cfa 7, 8
              ret
              .cfi_endproc
      .LFE0:
              .size   main, .-main
              .ident  "GCC: (Ubuntu 7.4.0-1ubuntu1~18.04.1) 7.4.0"
              .section        .note.GNU-stack,"",@progbits  
    • 生成可重定位目标文件hello.o, 命令:as -o hello.o hello.s 或者gcc -c hello.c
    • 生成可执行目标文件hello,命令: gcc -o hello hello.c(本来想用ld命令,不过很多依赖的东西需要找,这目前不在讨论范围所以暂时不提供ld编译的命令,如果想看ld做了什么可以用这个命令看一下: gcc hello.c -o hello -Wl,-v  
      • 总算找出ld的命令了,真是复杂,机器不同需要搜索自己机器上的库文件对应的位置否则会不匹配,不过.o文件的名字是一样的,命令如下:ld -static /usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o /usr/lib/gcc/x86_64-linux-gnu/7/crtbeginT.o -L/usr/lib/gcc/x86_64-linux-gnu/7 -L/usr/lib -L/lib hello.o --start-group -lgcc -lgcc_eh -lc --end-group /usr/lib/gcc/x86_64-linux-gnu/7/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o -o hello
      • 实验结果截图:

   1.3    代码转变成目标文件

    代码转变成目标文件的过程就是上面生成可重定位目标文件的过程,将hello.s汇编文件和库文件相关的汇编代码的机器码抽取,并且添加上目标文件的头部信息,共同组成了ELF格式(linux为例)的可重定位目标文件。

    ELF的目标文件分为三类:

      • 可重定位目标文件(.o)
        • 其代码和数据可和其他可重定位文件合并为可执行文件
        • 每个 .o 文件由对应的 .c 文件生成
        • 每个 .o 文件的代码和数据地址都是从0开始的偏移
      • 可执行目标文件(默认为a.out)
        • 包含的代码和数据可以被直接复制到内存并执行
        • 代码和数据的地址是虚拟地址空间中的地址
      • 共享的目标文件(.so 共享库)
        • 特殊的可重定位目标文件,能在装载到内存或运行时自动被链接,称为共享库文件

    使用objdump -D反汇编最后生成的可重定位目标文件hello.o,得到汇编对应的机器码,使用二进制方式打开hello.o文件进行对比;

    可以观察反汇编文件中的main函数开始的机器码开始位置为55 48 80 e5:

    

    二进制方式打开hello。o文件,可以找到对应的机器码。上方的64字节可以根据零星的文本判断是ELF文件头信息。

    

  1.4 链接--可重定位目标文件到可执行目标文件

    程序连接主要包括两部分,使连接后的程序可以被加载器加载到内存特定的位置,本文之叙述了基本概念来串联从代码到程序执行的过程,详细的内容后续有机会再重开一篇文章补充(如果感兴趣可以参考《链接器和加载器》或者《深入理解计算机系统》第三版第七章 或《程序员的自我修养——链接、装载和库》)。

    重定位:编译器和汇编器通常为每个文件创建程序地址从0开始的目标代码,但是几乎没有计算机会允许从地址0加载你的程序。如果一个程序是由多个子程序组成的,那么所有的子程序必须被加载到互不重叠的地址上。重定位就是为程序不同部分分配加载地址,调整程序中的数据和代码以反映所分配地址的过程。在很多系统中,重定位不止进行一次。对于链接器的一种普遍情景是由多个子程序来构建一个程序,并生成一个链接好的起始地址为0的输出程序,各个子程序通过重定位在大程序中确定位置。当这个程序被加载时,系统会选择一个加载地址,而链接好的程序会作为整体被重定位到加载地址。

    符号解析:当通过多个子程序来构建一个程序时,子程序间的相互引用是通过符号进行的;主程序可能会调用一个名为sqrt的计算平方根例程,并且数学库中定义了sqrt例程。链接器通过标明分配给sqrt的地址在库中来解析这个符号,并通过修改目标代码使得call指令引用该地址。

    1.3章节中可以看到未进行链接的文件的各个段的位置都是0,实际上程序一般是不允许从0地址开始运行的,更何况所有代码段的地址都是0的话加载会前后覆盖,所以肯定是不行的,这就涉及到了代码的重定位,使代码可以加载到能运行的位置。

    同样的我们在main.c中调用了printf这个函数,汇编对应的是 e8 00 00 00 00 callq 10 <main+0x10>, 可是在hello.c中并没有printf这个函数,它其实是libc中进行的实现我们使用#include<stdio.h>将他的符号扩展过来了,所以我们在hello.o中并不知道这个符号具体含义是什么也不知道它在哪儿,这个就是链接器需要做的符号解析的工作,将printf找出来使程序可以正常调用。加黑的部分是汇编的机器码:e8表示mov指令,后面的是函数地址,可以看到这里的地址是一个无效值00 00 00 00.

    1.4.1 ELF文件格式

      ELF分为两种视图:

      • 链接视图:可重定位文件(Relocatable object files)
      • 执行视图:可执行目标文件(Executable object files)  

      

     可以看出比较明显的区别是链接视图有节区(section)执行视图替换成了段区(segment),这是因为section太过零散所以链接的时候把相同性质的section(比如可读写属性的所有section/所有可读可执行的section)组合到一个段中。这么做的好处是在加载进内存的时候可以有效的减少内存碎片的产生(因为内存加载一般需要按页对齐,且每个段单独加载,这样如果section大小不足一页4096Byte也需要占一页的空间就造成了很多内存碎片)。

    另外,执行视图的文件一般是由多个可重定位文件组成的,这就涉及到了这些文件的组合规则,组合的规则一般是使用链接脚本进行控制,所以下一节讲链接脚本。

      1.4.1.1 ELF文件头

        我们可以使用readelf -h查看文件的ELF头,如下图所示:

        

      可以看出ELF头中定义了ELF文件魔数、文件机器字节长度、数据存储方式、版本、运行平台、ABI版本、ELF重定位类型、硬件平台、硬件平台版本、入口地址、程序头入口和长度、段表的位置和长度、及段的数量等。

      ELF格式定义位置在/usr/include/elf.h的Elf32_Ehdr,结构如下

#define EI_NIDENT 16
typedef struct{
    unsigned char e_ident[EI_NIDENT];    //目标文件标识信息
    Elf32_Half e_type;                             //目标文件类型
    Elf32_Half e_machine;                       //目标体系结构类型
    Elf32_Word e_version;                      //目标文件版本
    Elf32_Addr e_entry;                          //程序入口的虚拟地址,若没有,可为0
    Elf32_Off e_phoff;                            //程序头部表格(Program Header Table)的偏移量(按字节计算),若没有,可为0
    Elf32_Off e_shoff;                            //节区头部表格(Section Header Table)的偏移量(按字节计算),若没有,可为0
    Elf32_Word e_flags;                        //保存与文件相关的,特定于处理器的标志。标志名称采用 EF_machine_flag的格式。
    Elf32_Half e_ehsize;                        //ELF 头部的大小(以字节计算)。
    Elf32_Half e_phentsize;                   //程序头部表格的表项大小(按字节计算)。
    Elf32_Half e_phnum;                      //程序头部表格的表项数目。可以为 0。
    Elf32_Half e_shentsize;                  //节区头部表格的表项大小(按字节计算)。
    Elf32_Half e_shnum;      //节区头部表格的表项数目。可以为 0。
    Elf32_Half e_shstrndx;  //节区头部表格中与节区名称字符串表相关的表项的索引。如果文件没有节区名称字符串表,此参数可以为 SHN_UNDEF。
}Elf32_Ehdr;

 

      1.4.1.2 段表

        可重定位目标文件有很多各种各样的段,段表(section header table)就是保存这些段基本属性的结构。如:每个段的段名、段的长度、在文件中的偏移、读写权限及段的其他属性。段表在文件中的位置是由ELF文件头中的“e_shoff”决定。

        可以有两种方式查看段表信息:1. objdump -h hello.o查看ELF中的一些关键段。 2. readelf -S hello.o 查看ELF中详细的段信息

        

         段表是由“Elf32_Shedr"(段描述符)的结构体为元素的数组,段描述符结构如下所示,文件位置/usr/include/elf.h

typedef struct
{
  Elf32_Word    sh_name;                /* Section name (string tbl index) */
  Elf32_Word    sh_type;                /* Section type, */
  Elf32_Word    sh_flags;               /* Section flags,该段在进程虚拟空间中的属性,如是否可写是否可执行等 */
  Elf32_Addr    sh_addr;                /* Section virtual addr at execution */
  Elf32_Off     sh_offset;              /* Section file offset */
  Elf32_Word    sh_size;                /* Section size in bytes */
  Elf32_Word    sh_link;                /* Link to another section */
  Elf32_Word    sh_info;                /* Additional section information */
  Elf32_Word    sh_addralign;           /* Section alignment */
  Elf32_Word    sh_entsize;             /* Entry size if section holds table */
} Elf32_Shdr;

     1.4.1.3 段内容  

      一个段可能包括一到多个节区,但是这并不会影响程序的加载。尽管如此,我们也必须需要各种各样的数据来使得程序可以执行以及动态链接等等。下面会给出一般情况下的段的内容。对于不同的段来说,它的节的顺序以及所包含的节的个数有所不同。此外,与处理相关的约束可能会改变对应的段的结构。

 

      如下所示,代码段只包含只读的指令以及数据。当然这个例子并没有给出所有的可能的段。

 

      

 

      数据段包含可写的数据以及以及指令,通常来说,包含以下内容

 

      

 

      程序头部的 PT_DYNAMIC 类型的元素指向指向 .dynamic 节。其中,got 表和 plt 表包含与地址无关的代码相关信息。尽管在这里给出的例子中,plt 节出现在代码段,但是对于不同的处理器来说,可能会有所变动。

 

      .bss 节的类型为 SHT_NOBITS,这表明它在 ELF 文件中不占用空间,但是它却占用可执行文件的内存镜像的空间。通常情况下,没有被初始化的数据在段的尾部,因此,p_memsz 才会比 p_filesz 大。

 

      注意:

 

      • 不同的段来说可能会有所重合,即不同的段包含相同的节。

  

    1.4.1.4 程序头表

        可执行目标文件同样有很多的段Segment信息,这些信息是将可执行文件中属性相同的段Section组合到了一起以方便加载时进行映射方便节省空间。从段表一节可以看到我们的程序有13个Section,使用readelf -l hello可以查看程序头表——即Section合并后的Segment信息。

      

        可以看到合并后13个Section只剩下了6个。这里我们主要关注两个LOAD段,这两个段是会加载到内存中去的,其余的都是一些辅助段不涉及加载,我们暂不分析。Section和Segment的转换关系可以参照下图,VM0和VM1分别表示两个LOAD,只是他们有不同的属性(如可读可执行和可读可写)。程序头表就描述了这写Segment在虚拟内存和物理存储中的位置以方便加载进行。

    

        同样,在内核代码中程序头表是以结构体方式定义的,它仍然在/usr/include/elf.h中可以找到

typedef struct
{
  Elf32_Word    p_type;                 /* Segment type,暂只关注LOAD类型,其他还有动态链接DYNAMIC等 */
  Elf32_Off     p_offset;               /* Segment file offset, Segment在文件中的偏移 */
  Elf32_Addr    p_vaddr;                /* Segment virtual address, Segment在虚拟地址中第一个字节的位置 */
  Elf32_Addr    p_paddr;                /* Segment physical address, Segment的物理装载地址 */
  Elf32_Word    p_filesz;               /* Segment size in file, Segment在文件中所占空间的大小 */
  Elf32_Word    p_memsz;                /* Segment size in memory, Segment在虚拟空间中所占用的长度 */
  Elf32_Word    p_flags;                /* Segment flags ,权限属性,如R W X*/
  Elf32_Word    p_align;                /* Segment alignment , 对齐属性,如两字节对齐*/
} Elf32_Phdr;

      1.4.1.3 重定位表

        在readelf -S获取的段表中可以看到一个名为.rela.text的段,它的类型为RELA,也就是说这是一个重定位表(Relocation Table)。链接器在处理目标文件时,需要对目标文件某些部位进行重定位即代码段和数据段中那些绝对地址的引用位置。这些重定位信息都保存在重定位表里,每一个需要重定位的段都会有一个对应的重定位表。

        重定位表定义如下:

typedef struct {
    Elf32_Addr r_offset;    //给出了重定位动作所适用的位置
    Elf32_Word r_info;    //给出要进行重定位的符号表索引,以及将实施的重定位类型.
} Elf32_Rel;
typedef struct {
    Elf32_Addr r_offset;
    Elf32_Word r_info;
    Elf32_Word r_addend;  //给出一个常量补齐,用来计算将被填充到可重定位字段的数值。
} Elf32_Rela;

      重定位节区会引用两个其它节区:符号表、要修改的节区。节区头部的 sh_info 和sh_link 成员给出这些关系。不同目标文件的重定位表项对 r_offset 成员具有略微不同的解释。

      1.4.1.4 字符串表

        ELF中使用了很多字符串,比如段名、变量名等。因为字符串长度往往时不固定的,所以用固定的结构表示它们比较困难。一种常见的作法是把字符串集中起来放到一个表,然后使用字符串在表中的偏移来引用字符串。

        

        通过这种方法,ELF文件中引用字符串只需要提供一个下标就可以,不用考虑字符串长度的问题。一般字符串表在ELF文件中存放在段”.strtab“或者”.shstrtab“中。分别表示字符串表(String Table)和段字符串表(Section Header String Table)。

        值得一提的是,ELF文件头最后一个变量”e_shstrndx“-Section Header string table index,存的就是短字符串表的位置,查看readelf -h的最后一个段数值是12,readelf -S的下标12的就是段shstrtbl。解析ELF头可以得到段表和段表名称的表位置,从而可以利用段和段名解析整个ELF文件。

    1.4.2 链接过程控制

      由于连接过程由很多内容要确定,使用那些目标文件?使用那些库文件?是否在最终可执行文件中保留调试信息、输出文件格式(可执行文件还是动态库)、还要考虑是否到处某些符号以供调试器或程序本身或其他程序使用。

       1.4.2.1  链接控制脚本

        链接器一般提供多种控制整个链接过程的方法,用来产生用户所需要的文件,一般由如下三种方法:

        • 使用命令行给链接器指定参数,如ld -o指定输出文件, -e main 指定链接后函数入口, -T *.lds指定链接脚本
        • 将链接指令存放在目标文件里面,编译器经常通过这种方法向链接器传送指令。如visual c++编译器会把链接参数放在放在PE文件的.derectve段用来传递参数
        • 使用链接控制脚本,属于最为灵活也最为强大的方法

        本节基于linux的链接脚本进行讲解,1.2节中的ld链接命令并没有指定链接脚本,在不指定的情况下将会使用linux默认的链接脚本,可以使用ld -verbose进行查看。

        默认的链接脚本放在/usr/lib/ldscripts目录下,不同的机器平台和输出文件都有不同的链接脚本。 普通的可执行文件链接脚本后缀为 *.x, 共享库的链接脚本后缀为 *.xs等。

      1.4.2.2 链接脚本语法

        ld链接器的链接脚本的链接语法继承AT&T链接器命令语言的语法,它由一系列语句组成,语句分两种,一种是命令语句,一种是赋值语句。语法与C语言相似处如下:

        • 语句间使用“;”作为分隔符, 原则上语句都需要以;作为结束符,不过对于命令语句来说可以以换行作为结束符,赋值语句则必须以“;”作为结束符
        • 表达式与运算符 脚本语言允许使用C语言类似的运算符,如: +、-、*、/、+=、-=、*=等,甚至包括&、 |、 >>、<<
        • 注释和字符引用 使用/**/作为注释,脚本文件中使用到的文件名、格式名或者段名凡是包含“;”或者其他分割符的都要使用双引号将该名字全称包含起来,

         一个简单的链接脚本示例:

ENTRY(main)

SESSIONS
{
    .=0X08048000 + SIZEOF_HEADERS;
    text : { *(.text) *(.data) *(.rodata) }
    /DISCARD/ : { *(.comment }      
}  

     ENTRY(main): 指定程序执行的入接口为main,一般程序入接口为_start。还可以使用ld的-e main,指定函数入口为main,命令行优先级比链接脚本高。 
      .=0X08048000 + SIZEOF_HEADERS : 表示当前的虚拟地址设置为0x08048000 + SIZEOF_HEADERS,
SIZEOF_HEADERS为输出文件文件头的大小。
      text : { *(.text) *(.data) *(.rodata) } : 段转换规则作用参考上方红字,含义是所有输入段中的text、data、rodata依次合并到输出文件的text段中。
     /DISCARD/ : { *(.comment } :特殊关键字/DISCARD/, 将输入中的comment段都丢弃,不放入输出文件中。

     其他一些命令语句:

      • INCLUDE filename: 包含名为 filename 的链接脚本。相当于 c 程序里的 #include 宏指令,用以包含另一个链接脚本。

      • INPUT(files): 将括号内的文件作为链接过程的输入文件。

      • OUTPUT(FILENAME): 定义输出文件的名字。

      • GROUP(files): 指定需要重复搜索符号定义的多个输入文件, file 必须是库文件,且 file 文件作为一组被 ld 重复扫描,直到不再有新的未定义的引用出现。

      • SEARCH_DIR(PATH): 定义搜索路径。

      • STARTUP(filename): 指定 filename 为第一个输入文件。

      • PROVIDE 关键字: 该关键字用于定义这类符号:在目标文件内被引用,但没有在任何目标文件内被定义的符号。

 

参考链接: https://wiki.x10sec.org/executable/elf/elf_structure/

posted @ 2020-08-06 23:48  Edver  阅读(1336)  评论(0编辑  收藏  举报