最近在读《程序员的自我修养:链接,装载与库》,其实这本书跟 Android 开发的联络还挺紧密的,无论是 NDK 开发,或许是性能优化中一些常用的 Native Hoook 手段,都需要了解一些链接,装载相关的常识点。本文为读书笔记。

ELF 文件是什么?

ELF 即 Executable and Linkable Format,是 linux 下的可履行文件。

除了 ELF 文件自身,源代码编译后但未链接的中心文件(.o 文件),动态链接库(.so 文件),静态链接库(.a 文件),都依照 ELF 文件格局存储.

ELF 文件规范中把系统中采用 ELF 格局的文件分为以下 4 类

  • 可重定位文件(relocatable file):包含 .o 文件和 .a 文件
  • 可履行文件(executable file):即 EFL 可履行文件,一般没有后缀
  • 同享库文件(shared object file):即 .so 文件
  • 中心转储文件(core dump file): 即 core dump 文件

ELF 文件总体结构

ELF 文件格式详解

一个 ELF 文件的总体结构如上图所示(上图为链接视图,履行视图略有不同),主要包含以下内容

  • ELF Header,ELF文件头,它包含了描述整个文件的根本特点
  • ELF 文件中的各个段(section)
  • 段表(section header table), 该表描述了 ELF 文件包含的一切段的信息,比方每个段的段名,段的长度等
  • 其他一些辅助结构,如字符串表,符号表

ELF 文件结构详解

在上面咱们了解了 ELF 文件的总体结构,但耳听为虚,眼见为实,咱们接下来实操一下,看下 Elf 文件具体是怎么样的

首要咱们添加一个SimpleSection.c文件,如下所示:

int printf(const char *format, ...);
int global_init_var = 84;
int global_uninit_var;
void fun1(int i)
{
    printf("%d\n", i);
}
int main(void)
{
    static int static_var = 85;
    static int static_var2;
    int a = 1;
    int b;
    fun1(static_var + static_var2 + a + b);
    return a;
}

接下来咱们经过gcc -c SimpleSection.c指令只编译不链接生成方针文件:SimpleSection.o,这也是一个 ELF 文件,咱们接下来就来剖析这个文件的内容

文件头

$ readelf -h SimpleSection.o
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 
  Class:                             ELF64
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              REL (Relocatable file)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0x0
  Start of program headers:          0 (bytes into file)
  Start of section headers:          1184 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           0 (bytes)
  Number of program headers:         0
  Size of section headers:           64 (bytes)
  Number of section headers:         14
  Section header string table index: 13

如上所示,经过readelf指令获取了文件头,能够看到,ELF 的文件头中界说了ELF魔数、文件机器字节长度、数据存储方法、版别、运转平台、ABI版别、文件类型、硬件平台、硬件平台版别、进口地址、程序头进口和长度、段表的位置和长度及段的数量等信息,包含了描述整个文件的根本特点

ELF 文件头结构及相关常数被界说在/usr/include/elf.h文件中,readelf 输出的信息与这些结构很多都能够一一对应,感兴趣的能够自行检查

ELF 魔数

头文件中比较有意思的是魔数,魔数的作用是用来确认文件的类型,操作系统在加载可履行文件时会检验魔数是否正确,假如不正确则会回绝加载

比方咱们上面的输出,最开端的 4 个字节是一切 ELF 文件都必须相同的标识码:0x7f, 0x45, 0x4c, 0x46, 第一字节对应 DEL 控制符的 ASCII 码,后边 3 个字节则正好是 ELF 三个字母的 ASCII 码

第 5 个字节用于标识 ELF 文件类,0x01 表明 32 位,0x02 表明 64 位,第 6 个字节用于标记字节序,规矩该 ELF 文件是大端仍是小端的,第 7 个字节用于标记 ELF 文件主版别号,一般是 1

而后边的 9 个字节,ELF 规范还没有界说,一般填0,有些平台会运用这 9 个字节作为扩展标志

段表

在头文件之后便是 ELF 文件中各式各样的段了,咱们经过 readelf 指令来检查段表,段表中记录了每个段的段名,段的长度,在文件中的偏移,读写权限,以及其它特点

$ readelf -S SimpleSection.o
There are 14 section headers, starting at offset 0x4a0:
Section Headers:
  [Nr] Name              Type             Address           Offset    Size              EntSize          Flags  Link  Info  Align
  [ 0]                   NULL             0000000000000000  00000000  0000000000000000  0000000000000000           0     0     0
  [ 1] .text             PROGBITS         0000000000000000  00000040  000000000000005f  0000000000000000  AX       0     0     1
  [ 2] .rela.text        RELA             0000000000000000  00000380  0000000000000078  0000000000000018   I      11     1     8
  [ 3] .data             PROGBITS         0000000000000000  000000a0  0000000000000008  0000000000000000  WA       0     0     4
  [ 4] .bss              NOBITS           0000000000000000  000000a8  0000000000000004  0000000000000000  WA       0     0     4
  [ 5] .rodata           PROGBITS         0000000000000000  000000a8  0000000000000004  0000000000000000   A       0     0     1
  [ 6] .comment          PROGBITS         0000000000000000  000000ac  000000000000002c  0000000000000001  MS       0     0     1
  [ 7] .note.GNU-stack   PROGBITS         0000000000000000  000000d8  0000000000000000  0000000000000000           0     0     1
  [ 8] .note.gnu.propert NOTE             0000000000000000  000000d8  0000000000000020  0000000000000000   A       0     0     8
  [ 9] .eh_frame         PROGBITS         0000000000000000  000000f8  0000000000000058  0000000000000000   A       0     0     8
  [10] .rela.eh_frame    RELA             0000000000000000  000003f8  0000000000000030  0000000000000018   I      11     9     8
  [11] .symtab           SYMTAB           0000000000000000  00000150  00000000000001b0  0000000000000018          12    12     8
  [12] .strtab           STRTAB           0000000000000000  00000300  000000000000007b  0000000000000000           0     0     1
  [13] .shstrtab         STRTAB           0000000000000000  00000428  0000000000000074  0000000000000000           0     0     1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
  L (link order), O (extra OS processing required), G (group), T (TLS),
  C (compressed), x (unknown), o (OS specific), E (exclude),
  l (large), p (processor specific)

能够看出,段表是一个数组,数组的每一项对应每一个段,其中第一个项是无效的,类型为NULL,因而SimpleSection.o共有 13 个有效的段,段表对应的结构体也界说在/usr/include/elf.h文件中,感兴趣的能够自行检查

下面咱们介绍一下段表各个字段的意义

字段 意义
Name 段名,段名是个字符串,位于一个叫作 .shstrtab 的字符串表中
Type 段的类型,段名只在编译和链接阶段有作用,不能真正地表明段的类型,咱们也能够将一个数据段命名为”.txt”,关于编译器和链接器来说,主要决定段的特点的是段的类型与段的标志位
Flags 段的标志位,段的标志位表明该段在进程虚拟地址空间中的特点,比方是否可写,是否可履行等
Address 段虚拟地址,假如该段能够被加载,则为该段被加载后在进程地址空间中的虚拟地址,不然为 0
Offset 段偏移,假如该段存在于文件中,则表明该段在文件中的偏移,不然无意义,比方关于 .bss 段就没有意义
Size 段的长度
Link 和 Info 段的链接信息,假如段的类型是与链接相关的,比方重定位表,符号表等,则该字段有意义,不然无意义
Align 段对齐地址,有些段对段地址对齐有要求,由于地址对齐的数量都是 2 的指数倍,Align 表明对齐数量中的指数,比方当 Align = 3 时表明 8 倍,当 Algin 为 0 或许 1 时表明没有对齐要求
EntSize 项的长度,有些段包含了一些固定巨细的项,比方符号表,它包含的每个符号所占的巨细是相同的,关于这种段,EntSize 表明每一项的巨细。假如为 0 表明该段没有固定巨细的项

在了解了段表的结构之后,接下来咱们看一下各个段的具体内容

.text 代码段

首要咱们经过 objdump 指令来看下代码段的具体内容,objdump 的 “-s” 参数能够将一切段的内容以 16 进制的方法打印出来,”-d” 参数能够将一切包含指令的段反汇编,成果如下所示:

$ objdump -s -d SimpleSection.o
Contents of section .text:
 0000 f30f1efa 554889e5 4883ec10 897dfc8b  ....UH..H....}..
 0010 45fc89c6 488d3d00 000000b8 00000000  E...H.=.........
 0020 e8000000 0090c9c3 f30f1efa 554889e5  ............UH..
 0030 4883ec10 c745f801 0000008b 15000000  H....E..........
 0040 008b0500 00000001 c28b45f8 01c28b45  ..........E....E
 0050 fc01d089 c7e80000 00008b45 f8c9c3    ...........E... 
Disassembly of section .text:
0000000000000000 <fun1>:
   0:   f3 0f 1e fa             endbr64 
   4:   55                      push   %rbp
   5:   48 89 e5                mov    %rsp,%rbp
   8:   48 83 ec 10             sub    $0x10,%rsp
   c:   89 7d fc                mov    %edi,-0x4(%rbp)
   f:   8b 45 fc                mov    -0x4(%rbp),%eax
  12:   89 c6                   mov    %eax,%esi
  14:   48 8d 3d 00 00 00 00    lea    0x0(%rip),%rdi        # 1b <fun1+0x1b>
  1b:   b8 00 00 00 00          mov    $0x0,%eax
  20:   e8 00 00 00 00          callq  25 <fun1+0x25>
  25:   90                      nop
  26:   c9                      leaveq 
  27:   c3                      retq   
0000000000000028 <main>:
  28:   f3 0f 1e fa             endbr64 
  2c:   55                      push   %rbp
  2d:   48 89 e5                mov    %rsp,%rbp
  30:   48 83 ec 10             sub    $0x10,%rsp
  34:   c7 45 f8 01 00 00 00    movl   $0x1,-0x8(%rbp)
  3b:   8b 15 00 00 00 00       mov    0x0(%rip),%edx        # 41 <main+0x19>
  41:   8b 05 00 00 00 00       mov    0x0(%rip),%eax        # 47 <main+0x1f>
  47:   01 c2                   add    %eax,%edx
  49:   8b 45 f8                mov    -0x8(%rbp),%eax
  4c:   01 c2                   add    %eax,%edx
  4e:   8b 45 fc                mov    -0x4(%rbp),%eax
  51:   01 d0                   add    %edx,%eax
  53:   89 c7                   mov    %eax,%edi
  55:   e8 00 00 00 00          callq  5a <main+0x32>
  5a:   8b 45 f8                mov    -0x8(%rbp),%eax
  5d:   c9                      leaveq 
  5e:   c3                      retq   

Contents of section .text便是.text的数据以十六进制方法打印出来的内容,一共 0x5f 字节,最左边一列是偏移量,中心4列是十六进制内容,最右面一列是 .text 段的 ASCII 码形式。

Disassembly of section .text则是代码段反汇编的成果,能够很明显地看到,.text 段中的内容便是SimpleSection.c里两个函数func1()main()的指令。

数据段与只读数据段

接下来咱们经过 objdump 指令看看数据段与只读数据段的内容

$ objdump -x -s -d SimpleSection.o
...
Sections:
Idx Name          Size      VMA               LMA               File off  Algn
  1 .data         00000008  0000000000000000  0000000000000000  000000a0  2**2
                  CONTENTS, ALLOC, LOAD, DATA
  3 .rodata       00000004  0000000000000000  0000000000000000  000000a8  2**0
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
Contents of section .data:
 0000 54000000 55000000                    T...U...        
Contents of section .rodata:
 0000 25640a00                             %d..                

.data 段保存的是那些已经初始化了的大局静态变量和部分静态变量,SimpleSection.c中的global_init_varstatic_var两个变量归于这种状况,这两个变量每个4个字节,一共刚好 8 个字节,所以 .data 段的巨细为8个字节

.rodata 段寄存的是只读数据,一般是程序中的只读变量(如 const 润饰的变量)和字符串常量,比方在SimpleSection.c中调用printf时用到的字符串常量,就储存在 .rodata 段中。需要注意的是,有的编译器会把字符串常量放在 .data 段中,而不会独自放在 .rodata 段中

接下来咱们看下两个段中存储的内容

.data 中存储的内容即0x000000540x00000055,以小端序寄存,它们的值正好对应十进制的8485,也便是咱们赋给global_init_varstatic_var的值

.rodata 中存储的内容为0x25640a00,正好对应%d\n的 ASCII 码,能够对照ASCII码一览表,ASCII码对照表检查

.bss 段

.bss 段寄存未初始化的大局变量和部分静态变量,那么问题来了,为什么要把已初始化的变量和未初始化的变量分隔存储的,为什么不直接放在 .data 段中?

答案是 .bss 段不占空间,咱们接下来看一个直观的比方,来看看 .bss 的作用

$ echo "char array[1024*1024*64] = {'A'}; int main() {return 0;}" | gcc -x c - -o data
$ ls -lh data
-rwxrwxrwx 1 codespace codespace 65M Jun 25 01:26 data
$ echo "char array[1024*1024*64]; int main() {return 0;}" | gcc -x c - -o bss
$ ls -lh bss
-rwxrwxrwx 1 codespace codespace 17K Jun 25 01:27 bss
  • 示例 1 中,array 变量已经被初始化,寄存在 .data 段中,占用文件空间,因而整个文件巨细共有 65 M
  • 示例 2 中,array 变量未被初始化,寄存在 .bss 段中,不占用文件空间,因而整个文件巨细只要 17 K

能够看到,不同十分大。当然 .bss 段不占据实际的磁盘空间,但它的巨细与符号仍是要有地方存储,.bss 段的巨细记录在段表中,符号记录在符号表中。当文件加载运转时,才分配空间以及初始化

接下来咱们用 objdump 指令来看看SimpleSection.o 的 .bss 段的内容

$ objdump -x -s -d SimpleSection.o
...
Sections:
Idx Name          Size      VMA               LMA               File off  Algn
  2 .bss          00000004  0000000000000000  0000000000000000  000000a8  2**2
                  ALLOC
SYMBOL TABLE:
0000000000000000 l     O .bss   0000000000000004 static_var2.1921
0000000000000004       O *COM*  0000000000000004 global_uninit_var

能够看到,咱们原本预期 .bass 段中会有global_uninit_varstatic_var2两个变量,共 8 个字节,实际上只要static_var2一个变量,4 个字节

这是由于有些编译器会将大局的未初始化变量寄存在方针文件.bss 段,有些则不寄存,仅仅预留一个未界说的大局变量符号,等到最终链接成可履行文件的时候再在 .bss 段分配空间

其他段

除了 .text, .data, .bss 这 3 个最常用的段之外,ELF 文件也包含一些其他的段,下面列出了一些常见的段

段名 阐明
.rodata1 只读数据段,寄存只读数据,与 .rodata 相同
.comment 寄存编译器版别信息
.debug 调试信息
.dynamic 动态链接信息
.hash 符号哈希表
.line 调试时的行号表,即源代码行号与编译后指令的对应表
.note 额外的编译信息,如程序的公司名,发布版别号等
.strtab 字符串表,用于存储 ELF 中的各种字符串
.symtab 符号表
.shstrtab 段表字符串表,用于存储段表中用到的字符串
.plt .got 动态链接的跳转表和大局进口表
.init .finit 程序初始化与完结代码段
.rel.text 重定位表

这儿边的很多段咱们之后都会用到,比方 PLT Hook 中会用到的 .plt .got 段,在静态链接中会用到重定位表,这儿能够先留个印象

Elf 中的符号

链接进程的本质是把多个方针文件依照一定的规矩拼接起来,在链接进程中,方针文件的拼接其实便是方针文件之间对地址的引证,即对函数和变量的地址的引证。

每个函数或变量都有自己独特的名字,才干避免链接进程中不同变量和函数之间的混淆。在链接中,咱们将函数和变量统称为符号(Symbol),函数名或变量名便是符号名(Symbol Name)。

整个链接进程正是根据符号才干够正确完结。链接进程中很要害的一部分便是符号的办理,每一个方针文件都会有一个相应的符号表(Symbol Table),这个表里边记录了方针文件中所用到的一切符号。每个界说的符号有一个对应的值,叫做符号值(Symbol Value),关于变量和函数来说,符号值便是它们的地址

咱们将符号表中的符号分为以下几类:

  • 界说在本方针文件的大局符号,能够被其他方针文件引证。比方SimpleSection.o里边的func1mainglobal_init_var
  • 在本方针文件中引证的大局符号,却没有界说在本方针文件,这一般叫做外部符号(External Symbol),也便是咱们前面所讲的符号引证。比方SimpleSection.o里边的printf
  • 段名,这种符号往往由编译器发生,它的值便是该段的起始地址。比方SimpleSection.o里边的.text.data等。
  • 部分符号,这类符号只在编译单元内部可见。比方SimpleSection.o里边的static_varstatic_var2。调试器能够运用这些符号来剖析程序或崩溃时的中心转储文件。这些部分符号关于链接进程没有作用,链接器往往也疏忽它们。
  • 行号信息,即方针文件指令与源代码中代码行的对应关系,它也是可选的。”

其中最值得关注的便是大局符号,由于链接进程只关怀大局符号的彼此拼接,部分符号、段名、行号等都是次要的,它们关于其他方针文件来说是“不行见”的,在链接进程中也是无关紧要的

ELF 符号表的结构

首要咱们经过 readelf 指令来检查SimpleSection.o 的符号表

$ readelf -s SimpleSection.o
Symbol table '.symtab' contains 18 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS SimpleSection.c
     2: 0000000000000000     0 SECTION LOCAL  DEFAULT    1 
     3: 0000000000000000     0 SECTION LOCAL  DEFAULT    3 
     4: 0000000000000000     0 SECTION LOCAL  DEFAULT    4 
     5: 0000000000000000     0 SECTION LOCAL  DEFAULT    5 
     6: 0000000000000004     4 OBJECT  LOCAL  DEFAULT    3 static_var.1920
     7: 0000000000000000     4 OBJECT  LOCAL  DEFAULT    4 static_var2.1921
     8: 0000000000000000     0 SECTION LOCAL  DEFAULT    7 
     9: 0000000000000000     0 SECTION LOCAL  DEFAULT    8 
    10: 0000000000000000     0 SECTION LOCAL  DEFAULT    9 
    11: 0000000000000000     0 SECTION LOCAL  DEFAULT    6 
    12: 0000000000000000     4 OBJECT  GLOBAL DEFAULT    3 global_init_var
    13: 0000000000000004     4 OBJECT  GLOBAL DEFAULT  COM global_uninit_var
    14: 0000000000000000    40 FUNC    GLOBAL DEFAULT    1 fun1
    15: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND _GLOBAL_OFFSET_TABLE_
    16: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND printf
    17: 0000000000000028    55 FUNC    GLOBAL DEFAULT    1 main

接下来咱们介绍一个各个列的意义,如下表所示

字段 意义
Name 符号名
Value 符号对应的值,不同的符号,其值的意义不同,见下文具体解析
Size 符号巨细,关于包含数据的符号,这个值是数据类型的巨细,比方一个 int 类型的符号占 4 个字节,假如该值为 0 表明该符号巨细为 0 或未知
Type 符号类型,分为未知符号类型,数据对象类型,函数类型,文件类型等
Bind 绑定信息,用于区别部分符号,大局符号与弱引证符号
Vis 在 C/C++ 中未运用,可疏忽
Ndx 符号地点的段,假如符号界说在本方针文件中,该值表明符号地点段在段表中的下标。
假如该值为 ABS,表明该符号包含了一个绝对的值,比方上面的文件类型的符号。
假如该值为 COM,表明该值是一个 Common 块类型的符号。
假如该值为 UND,表明为界说,阐明该符号在本方针文件中被引证,在其他文件中声明

上面提到不同的符号,其值意义不同,具体能够分为以下几种

  • 在方针文件中,假如是符号的界说并且该符号不是COMMON块类型的,则Value表明该符号在段中的偏移。比方SimpleSection.o中的func1mainglobal_init_var
  • 在方针文件中,假如符号是COMMON块类型的,则Value表明该符号的对齐特点。比方SimpleSection.o中的global_uninit_var
  • 在可履行文件中,Value表明符号的虚拟地址。这个虚拟地址关于动态链接器来说十分有用。

C++ 的 Name Mangling 机制

咱们前面提到每个函数或变量都有自己独特的名字,才干避免链接进程中不同变量和函数之间的混淆,因而在 C 言语方针文件链接进程中,假如有两个文件中都有fun1函数,链接进程就会报错

但当程序很大时,不同的模块由多人开发,假如命名规范不严格,很简单呈现符号抵触的问题,所以像C++这样的后来设计的言语开端考虑到了这个问题,增加了称号空间(Namespace)的方法来处理多模块的符号抵触问题。

同时 C++拥有类、承继、虚机制、重载、称号空间等这些特性,它们使得符号办理更为杂乱。最简单的比方,两个相同名字的函数func(int)func(double),尽管函数名相同,可是参数列表不同,那么编译器和链接器在链接进程中如何区别这两个函数呢?为了支持 C++ 这些杂乱的特性,人们发明晰符号润饰(Name Decoration)机制

比方下面这段代码

int    func(int i)           { return 0;     }
float  func(int i, float f)  { return i + f; }
double func(int i, double d) { return i+d;   }

经过name mangling操作后,得到的符号表中和func有关的内容如下:

$ g++ main.cc -o main.o && objdump -t main.o
main.o:     file format elf64-x86-64
SYMBOL TABLE:
0000000000001157 g     F .text  000000000000001c              _Z4funcid
000000000000113b g     F .text  000000000000001c              _Z4funcif
0000000000001129 g     F .text  0000000000000012              _Z4funci
...

能够看到,一切的符号都以_Z最初,前缀_Z是 GCC 的规矩,具体是怎样转化的这儿就不具体介绍了,有兴趣的读者能够参考GCC的称号润饰规范。同时咱们也能够使用 nm 或 c++filt 等工具来解析被润饰的符号,不必自己手动解析

Name Mangling 机制运用地十分广泛,当咱们检查 android so 的符号表时,能够看到很多以_Z最初的符号,就能够知道他们都是被润饰过的符号

总结

本文具体介绍了 ELF 文件的具体结构,包含文件头,段表,各个段的结构,符号表的结构等内容。

这些基础常识可能有些单调,可是这些常识点在 Android 性能优化中的使用仍是很广泛的,因而仍是有必要了解一下的

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。