最近在读《程序员的自我修养:链接,装载与库》,其实这本书跟 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 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_var与static_var两个变量归于这种状况,这两个变量每个4个字节,一共刚好 8 个字节,所以 .data 段的巨细为8个字节
.rodata 段寄存的是只读数据,一般是程序中的只读变量(如 const 润饰的变量)和字符串常量,比方在SimpleSection.c中调用printf时用到的字符串常量,就储存在 .rodata 段中。需要注意的是,有的编译器会把字符串常量放在 .data 段中,而不会独自放在 .rodata 段中
接下来咱们看下两个段中存储的内容
.data 中存储的内容即0x00000054与0x00000055,以小端序寄存,它们的值正好对应十进制的84与85,也便是咱们赋给global_init_var与static_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_var与static_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里边的func1、main和global_init_var。 - 在本方针文件中引证的大局符号,却没有界说在本方针文件,这一般叫做外部符号(External Symbol),也便是咱们前面所讲的符号引证。比方
SimpleSection.o里边的printf。 - 段名,这种符号往往由编译器发生,它的值便是该段的起始地址。比方
SimpleSection.o里边的.text、.data等。 - 部分符号,这类符号只在编译单元内部可见。比方
SimpleSection.o里边的static_var和static_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中的func1、main和global_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 性能优化中的使用仍是很广泛的,因而仍是有必要了解一下的

