ELF浅析—程序的链接和加载

基本名词

首先给出一些基本的名词概念,在后续章节会解释这些名词。

ELF文件

ELF(Executable and Linkable Format) 文件,看名字意为可执行/可链接格式的文件。

它有三种类型:

  • 可执行文件(Executable File):可直接被操作系统加载和执行。
  • 可重定位文件(Relocatable File):编译器生成的代码和数据,包含程序代码和数据等,用于被连接器链接。
  • 共享对象文件(Shared Object File):动态链接库或位置无关可执行文件。

链接

程序的链接是指将程序中未解析的符号对象做解析和填充的过程。

我们在实现程序逻辑时,往往希望将程序功能进行模块化拆分,而最终的程序由多个模块“拼接”而成,这个程序的“拼接”过程我们就称之为程序的链接。而程序的链接又分为动态链接和静态链接两种方式:

  • 静态链接:在编译阶段将程序中未解析的符号搁置,输出可重定位 (Relocatable File ) 的目标文件。在链接阶段,静态链接器( Static Linker )解析和搜索所有输入的可重定位文件,处理其中所有搁置的未解析符号。最终输出一个完全链接的可执行文件 (Executable File) 。静态链接输出的可执行文件在运行时是与链接阶段输入的可重定位文件完全无关的。
  • 动态链接:在编译阶段将程序中未解析的符号搁置,并且记录程序所依赖的共享库( Shared Object File )(共享库中必须定义和实现了程序中未解析的符号),输出一个动态链接的可执行文件。其中未解析的符号会在运行时由动态链接器( Dynamic Linker )将依赖的共享库映射到进程的地址空间,并处理程序中所有未解析的符号。

加载

程序的加载是指操作系统创建或扩充进程镜像的过程。

程序文件可以看作是一串二进制数据(包括程序指令序列、程序数据及各类描述信息),这串二进制数据是静态存储在存储介质(磁盘)上的。

而程序的加载指的是操作系统创建出一个进程,并为这个进程分配CPU时间片和虚拟内存并将静态的程序文件以某种方式拷贝(映射)到进程的虚拟内存的过程。

静态分析—链接

从前一章节的名词解释中,我们可以大体上了解:程序的链接是为了解决程序解耦合或者说模块化的需求。

代码示例

那么我们先引入一个简单的例子来构造这种场景。

这是我们的程序入口 calc.c。它需要依赖外部的 数据 int extern_init_data函数 int add(int a, int b)。 它本身代码空间中也有 已初始化数据 int* init_data_ptr,int init_data ,和 未初始化数据 int not_init_data

因为希望尽可能避免其它的库依赖,代码中不使用任何c库,包括glibc。所以我们手写入口函数_start,并使用内联汇编触发退出中断,程序以123退出码退出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
extern int extern_init_data;
extern int add(int a, int b);

int *init_data_ptr = &extern_init_data;
int init_data = 2;
int not_init_data;

void _start(){
not_init_data = 3;
int result = add(*init_data_ptr, not_init_data);
result += init_data;
asm volatile(
"movq $60, %%rax\n\t"
"movq $123, %%rdi\n\t"
"syscall"
:
:
: "rax", "rdi");
}

接下来,在模块libcalc.c中实现主程序中需要的数据和函数定义:

1
2
3
4
5
int extern_init_data = 1;

int add(int a, int b) {
return a + b;
}

前期的代码准备已经完成,我们预期将两个模块化的文件合并关联起来,使 calc.c 可以正确调用libcalc.c中的数据和代码。

静态链接

首先很直观的可以想到在完成多个模块的最终合并时,可以将子模块中的数据和代码直接拷贝到主模块中,让主模块看起来像是直接从一个 “All In One” 单个源代码文件编译而来的。而这,就是我们所说的静态链接

先将两个C源代码文件编译为可重定位ELF文件:

1
2
3
4
5
6
7
8
# 编译为可重定位文件
> gcc -c calc.c -o calc.o && gcc -c libcalc.c -o libcalc.o

# 查看输出的文件信息
> file calc.o
calc.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
> file libcalc.o
libcalc.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped

要完成多个可重定位文件的合并,静态链接往往要经历如下三个步骤:

graph TD    
    B(module_a.c) --> D[compilation]
    C(main.c) --> E[compilation]
    
    D --> F[Relocatable Object File: module_a.o]
    E --> G[Relocatable Object File: main.o]
    
    F --> H[linking]
    
    G --> H
    
    H --> I[Address Allocation]
    I --> J[Symbol Resolution]
    J --> K[Relocation]
    K --> L[Static executable File: main]

!!!现代静态链接器对可重定位文件的链接过程做了高度优化,往往在一次遍历中完成这三步。

为了理解其中原理性的过程,我们将这一过程拆分,将文件先部分链接,再完成全链接。(并非真实的)

1
2
3
4
5
6
7
8
9
10
11
12
# 部分链接
> ld -r -o merged.o calc.o libcalc.o
# 输出的部分链接ELF文件,是一个可重定位文件
> file merged.o
merged.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped

# 全链接
> ld -o calc_static merged.o -nostdlib -static
# 运行该ELF文件,获得预期结果
> ./calc_static
> echo $?
123

那么这部分链接和全链接这两个步骤中,静态链接器都做了哪些工作?

地址与空间分配(Address Allocation)

  • 扫描多个输入文件的节(section),获取各节的地址、长度和属性等信息,并计算出合并后可执行文件的各节长度及位置;
  • 将多个目标文件的 相同节合并,使其位于同一地址空间;
  • 维护一个 全局符号表,用于在合并后的文件地址空间中进行符号寻址;
  • 相似节合并 生成 结构。(在部分链接时,不会将节合并为段)
block-beta
    columns 5
    
    elfhdr1["ET_REL ELF Header"]
	  space:1
    elfhdr2["ET_EXEC ELF Header"]
	  space:1
    elfhdr3["ET_REL ELF Header"]
    

    block:text_group:1
		    columns 1
				text[".text
代码节"] rel_text[".rel.text
代码重定位表"] rodata[".rodata
只读数据节"] end space:1 block:load1:1 columns 1 placeholder["LOAD Segment #1
R+X"] text_section[".text"] plt_section[".plt"] rodata_section[".rodata"] end space:1 block:text_group2:1 columns 1 text2[".text
代码节"] rel_text2[".rel.text
代码重定位表"] rodata2[".rodata
只读数据节"] end text -- "combines" --> text_section text2 -- "combines" --> text_section rodata -- "combines" --> rodata_section rodata2 -- "combines" --> rodata_section block:data_group:1 columns 1 data[".data
已初始化数据节"] bss[".bss
未初始化数据节"] rel_data[".rel.data
数据重定位表"] end space:1 block:load2:1 columns 1 placeholder3["LOAD Segment #2
RW"] init_array_section[".init_array"] fini_array_section[".fini_array"] data_seg[".data"] bss_seg[".bss"] got_section[".got"] end space:1 block:data_group2:1 columns 1 data2[".data
已初始化数据节"] bss2[".bss
未初始化数据节"] rel_data2[".rel.data
数据重定位表"] end data -- "combines" --> data_seg data2 -- "combines" --> data_seg bss -- "combines" --> bss_seg bss2 -- "combines" --> bss_seg block:table_group:1 columns 1 symtab[".symtab
符号表"] strtab[".strtab
字符串表"] end space:1 block:pht_group:1 pht["Program Header
程序头表"] end space:1 block:table_group2:1 columns 1 symtab2[".symtab
符号表"] strtab2[".strtab
字符串表"] end block:sht_group:1 sht["Section Header
节头表"] end space:3 block:sht_group2:1 sht2["Section Header
节头表"] end classDef headerStyle fill:#e3f2fd,stroke:#0277bd,stroke-width:3px classDef execStyle fill:#e8f5e8,stroke:#388e3c,stroke-width:2px classDef dataStyle fill:#fff3e0,stroke:#f57c00,stroke-width:2px classDef metaStyle fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px classDef tableStyle fill:#fce4ec,stroke:#c2185b,stroke-width:3px classDef titleStyle fill:#f8f9fa,stroke:#343a40,stroke-width:2px classDef segmentStyle fill:#e1f5fe,stroke:#01579b classDef noteSectionStyle fill:#80808030 classDef optionalStyle stroke-dasharray: 3 3 classDef placeholderStyle fill:#00000000,stroke:#00000000 classDef sectionGroupStyle fill:#00000000,stroke:#00000000 class placeholder,placeholder2,placeholder3,placeholder4 placeholderStyle class linktitle,linktitle2,runtitle titleStyle class elfhdr1,elfhdr2,elfhdr3 headerStyle class text,text2,text_section,init_section,plt_section,fini_section execStyle class rodata,rodata2,rodata_seg,rodata_section,data,data2,data_seg,bss,bss2,bss_seg,init_array_section,fini_array_section,got_section dataStyle class rel_text,rel_text2,rel_data,rel_data2,symtab,symtab2,strtab,strtab2 metaStyle class load1,load2,dynamic_seg,note_seg segmentStyle class sht,sht2,pht tableStyle class note_gnu_property,note_gnu_buildid,note_others,note_abi_tag noteSectionStyle class rel_header_group,rel_header_group2,dyn_header_group,text_group,text_group2,data_group,data_group2,table_group,table_group2,sht_group,sht_group2,pht_group sectionGroupStyle class plt_section,rodata_section,rel_text2,rel_data2,got_section,init_array_section,fini_array_section,note_gnu_property,note_gnu_buildid,note_others,note_abi_tag optionalStyle

这里我们用上面的示例来理解一下这些概念,先查看文件的节信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
> readelf --sections calc.o
There are 15 section headers, starting at offset 0x320:

节头:
[号] 名称 类型 地址 偏移量
大小 全体大小 旗标 链接 信息 对齐
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .text PROGBITS 0000000000000000 00000040
000000000000004a 0000000000000000 AX 0 0 1
[ 2] .rela.text RELA 0000000000000000 00000210
0000000000000060 0000000000000018 I 12 1 8
[ 3] .data PROGBITS 0000000000000000 0000008a
0000000000000000 0000000000000000 WA 0 0 1
[ 4] .bss NOBITS 0000000000000000 00000090
0000000000000008 0000000000000000 WA 0 0 8
[ 5] .data.rel PROGBITS 0000000000000000 00000090
0000000000000008 0000000000000000 WA 0 0 8
[ 6] .rela.data.rel RELA 0000000000000000 00000270
0000000000000018 0000000000000018 I 12 5 8
......
[12] .symtab SYMTAB 0000000000000000 00000120
00000000000000c0 0000000000000018 13 3 8
[13] .strtab STRTAB 0000000000000000 000001e0
0000000000000029 0000000000000000 0 0 1
[14] .shstrtab STRTAB 0000000000000000 000002a0
000000000000007b 0000000000000000 0 0 1

> readelf --sections libcalc.o
There are 12 section headers, starting at offset 0x1f0:

节头:
[号] 名称 类型 地址 偏移量
大小 全体大小 旗标 链接 信息 对齐
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .text PROGBITS 0000000000000000 00000040
0000000000000018 0000000000000000 AX 0 0 1
[ 2] .data PROGBITS 0000000000000000 00000058
0000000000000004 0000000000000000 WA 0 0 4
[ 3] .bss NOBITS 0000000000000000 0000005c
0000000000000000 0000000000000000 WA 0 0 1
......
[ 9] .symtab SYMTAB 0000000000000000 000000e0
0000000000000078 0000000000000018 10 3 8
[10] .strtab STRTAB 0000000000000000 00000158
0000000000000013 0000000000000000 0 0 1
[11] .shstrtab STRTAB 0000000000000000 00000188
0000000000000067 0000000000000000 0 0 1

> readelf --sections merged.o
There are 15 section headers, starting at offset 0x4c0:

节头:
[号] 名称 类型 地址 偏移量
大小 全体大小 旗标 链接 信息 对齐
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .note.gnu.pr[...] NOTE 0000000000000000 00000040
0000000000000020 0000000000000000 A 0 0 8
[ 2] .text PROGBITS 0000000000000000 00000060
0000000000000065 0000000000000000 AX 0 0 1
[ 3] .rela.text RELA 0000000000000000 00000380
0000000000000078 0000000000000018 I 12 2 8
[ 4] .eh_frame PROGBITS 0000000000000000 000000c8
0000000000000070 0000000000000000 A 0 0 8
[ 5] .rela.eh_frame RELA 0000000000000000 000003f8
0000000000000030 0000000000000018 I 12 4 8
[ 6] .data PROGBITS 0000000000000000 00000138
0000000000000008 0000000000000000 WA 0 0 4
[ 7] .data.rel PROGBITS 0000000000000000 00000140
0000000000000008 0000000000000000 WA 0 0 8
[ 8] .rela.data.rel RELA 0000000000000000 00000428
0000000000000018 0000000000000018 I 12 7 8
[ 9] .bss NOBITS 0000000000000000 00000148
0000000000000004 0000000000000000 WA 0 0 4
......
[12] .symtab SYMTAB 0000000000000000 000001a0
0000000000000198 0000000000000018 13 11 8
[13] .strtab STRTAB 0000000000000000 00000338
0000000000000043 0000000000000000 0 0 1
[14] .shstrtab STRTAB 0000000000000000 00000440
000000000000007b 0000000000000000 0 0 1

可以看到部分链接的文件中,分别只有一个.text,.data,.bss节,输入的可重定位文件对应节被合并了。

符号搜索与解析(Symbol Resolution)

输入的可重定位的目标文件中包含编译阶段搁置的未解析符号,那么符号被搁置后,在程序中是如何存储的呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
> objdump -S merged.o 
......
0000000000000000 <_start>:
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: c7 05 00 00 00 00 03 movl $0x3,0x0(%rip) # 16 <_start+0x16>
13: 00 00 00
16: 8b 15 00 00 00 00 mov 0x0(%rip),%edx # 1c <_start+0x1c>
1c: 48 8b 05 00 00 00 00 mov 0x0(%rip),%rax # 23 <_start+0x23>
23: 8b 00 mov (%rax),%eax
25: 89 d6 mov %edx,%esi
27: 89 c7 mov %eax,%edi
29: e8 00 00 00 00 call 2e <_start+0x2e>
2e: 89 45 fc mov %eax,-0x4(%rbp)
31: 8b 05 00 00 00 00 mov 0x0(%rip),%eax # 37 <_start+0x37>
37: 01 45 fc add %eax,-0x4(%rbp)
3a: 48 c7 c0 3c 00 00 00 mov $0x3c,%rax
41: 48 c7 c7 7b 00 00 00 mov $0x7b,%rdi
48: 0f 05 syscall
4a: 90 nop
4b: c9 leave
4c: c3 ret

000000000000004d <add>:
4d: f3 0f 1e fa endbr64
51: 55 push %rbp
52: 48 89 e5 mov %rsp,%rbp
55: 89 7d fc mov %edi,-0x4(%rbp)
58: 89 75 f8 mov %esi,-0x8(%rbp)
5b: 8b 55 fc mov -0x4(%rbp),%edx
5e: 8b 45 f8 mov -0x8(%rbp),%eax
61: 01 d0 add %edx,%eax
63: 5d pop %rbp
64: c3 ret

我们将部分链接的代码反汇编后观察到,对于被搁置的符号,程序会将其地址偏移值置为0(相对于 rip 指针,即下一条指令的地址),因为calc.o中未定义这些符号,无法设置正确的偏移值。

所以,为了在运行时能够正确地完成数据符号的寻址以及函数跳转,我们必须先找到程序中所有被搁置的符号,然后搜索该符号的真实地址

静态链接器通过查表来完成这两步:

  • 解析输入目标文件中的.rel.text(代码重定位表)、.rel.data(数据重定位表),获取当前待解决的未解析符号;
1
2
3
4
5
6
7
8
9
10
11
12
13
> readelf -r merged.o

重定位节 '.rela.text' at offset 0x380 contains 5 entries:
偏移量 信息 类型 符号值 符号名称 + 加数
00000000000e 001000000002 R_X86_64_PC32 0000000000000000 not_init_data - 8
000000000018 001000000002 R_X86_64_PC32 0000000000000000 not_init_data - 4
00000000001f 000b00000002 R_X86_64_PC32 0000000000000000 init_data_ptr - 4
00000000002a 000d00000004 R_X86_64_PLT32 000000000000004d add - 4
000000000033 000c00000002 R_X86_64_PC32 0000000000000000 init_data - 4

重定位节 '.rela.data.rel' at offset 0x428 contains 1 entry:
偏移量 信息 类型 符号值 符号名称 + 加数
000000000000 000f00000001 R_X86_64_64 0000000000000004 extern_init_data + 0

根据重定位表,我们需要解决以下符号的重定位:

符号名称 重定位类型 符号表索引
not_init_data 32位PC相对重定位 0x10
init_data_ptr 32位PC相对重定位 0xb
add 32位PLT间接重定位 0xd
init_data 32位PC相对重定位 0xc
extern_init_data 64位直接重定位 0xf
  • 解析出搁置符号后,我们在merged.o的.symtab(符号表)中检索这些符号的信息:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    > readelf -s merged.o        

    Symbol table '.symtab' contains 17 entries:
    Num: Value Size Type Bind Vis Ndx Name
    0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
    ......
    11: 0000000000000000 8 OBJECT GLOBAL DEFAULT 7 init_data_ptr
    12: 0000000000000000 4 OBJECT GLOBAL DEFAULT 6 init_data
    13: 000000000000004d 24 FUNC GLOBAL DEFAULT 2 add
    14: 0000000000000000 77 FUNC GLOBAL DEFAULT 2 _start
    15: 0000000000000004 4 OBJECT GLOBAL DEFAULT 6 extern_init_data
    16: 0000000000000000 4 OBJECT GLOBAL DEFAULT 9 not_init_data

    注:Ndx指该符号所在节的索引号,例如calc.o的 2号节是.txt节,6号节是.data节;Value指该符号在其所在节的节内偏移。

根据解析符号表的结果,获取到对应的符号表项,我们就可以扩充重定位表解析的表格了:

符号名称 重定位类型 符号表索引 Type Section Offset in Section
not_init_data 32位PC相对重定位 0x10 OBJECT .bss 0x0
init_data_ptr 32位PC相对重定位 0xb OBJECT .data.rel 0x0
add 32位PLT间接重定位 0xd FUNC .text 0x4d
init_data 32位PC相对重定位 0xc OBJECT .data 0x0
extern_init_data 64位直接重定位 0xf OBJECT .data 0x4

重定位(Relocation)

现在只差临门一脚,链接器在 符号搜索与解析 阶段解析出了所有搁置的符号,结合在 地址与空间分配 阶段合并而来的全局符号表,一次遍历可以将这些搁置符号设置正确的地址(R_X86_64_PC32R_X86_64_PLT32R_X86_64_64)。

至此,当前ELF文件中的代码和数据等信息均已完整,段结构也被正确初始化,规划分配了适用于运行时的虚拟地址空间。该文件可以被加载到进程并被执行。

动态链接

上述的静态链接方案看起来已经解决了代码模块化拆分的问题。但在很多场景中,这个方案并不完美。

设想,有一个封装了底层API的公共库A(例如glibc),下游成千上万的工程代码均需要依赖A:

  • 那么所有下游代码在发布时,都需要将公共库A编译并链接到自己的ELF文件中;
  • 当公共库A版本迭代时,所有的下游代码都需要重新编译A,并静态链接。

这简直就是灾难,我们的设备上将存储无数份公共库A的副本,并且每次公共库A的版本迭代都将导致我们设备上所有软件的更新。

所以这驱动我们探索一个新方案,能够让所有依赖公共库A的下游软件在设备上共享同一份A的代码,并且能够在运行时链接A。这样一来,上面的问题就迎刃而解。

将这个问题拆解:

首先,所有下游软件共享同一份公共库A的代码,那么A的发布形式必须满足A模块内部不能够使用绝对地址寻址,因为无法预测下游软件会将A的代码放在哪段地址空间。所以为了能够保证A模块内部的指令跳转和数据寻址逻辑正确,则A必须全部使用间接寻址(指令指针寄存器寻址),这就是 地址无关代码 。在linux中,我们一般将源代码编译为 Shared Object File(共享对象文件,后缀为 .so) 来实现这一目的;

其次,模块A在运行时被链接到另一个进程,这个过程由于要处理目标文件的搁置符号,并完成符号的重定位,我们一定需要一个链接器,这个链接器在运行时将把模块A的代码和数据放到目标文件的进程内存空间中,并完成符号解析和重定位。这个链接器一般是 ld-.so** ,也称之为解释器(后文称之为解释器)。而将模块A加载到目标文件的进程空间我们需要依赖 mmap 系统调用。

linux的系统调用中,有一个常用的接口:void * mmap(void *start, size_t length, int prot , int flags, int fd, off_t offset)mmap用于将一个linux对象映射到用户空间内存中。我们在用户空间对该段内存映射的所有操作都会反映到映射的linux对象上。

我们沿用上面的示例来完成这一过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

# 编译为共享对象文件和可重定位文件
> gcc -shared -fPIC -Wall -o libcalc.so libcalc.c && gcc -nostdlib -o calc_dyn calc.o -L. -lcalc

# 查看输出的文件信息
> file libcalc.so
libcalc.so: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, BuildID[sha1]=10e2a76e2e4ca4e103b6140951e3996b01124b19, not stripped
> file calc.o
calc.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped

# 静态链接
> gcc -nostdlib -o calc_dyn calc.o -L. -lcalc

# 运行该ELF文件,获得预期结果
> ./calc_dyn
> echo $?
123

在示例中,我们在运行前做了一次静态链接。但其实这次静态链接与前文的完全静态链接有所差异:

graph LR
    A[module_a.c] --> B["compilation"]
    C[main.c] --> D["compilation"]

    B --> E["Shared Object File: libmodule_a.so"]
    D --> F["Relocatable Object File: main.o"]

    E --> G["Symbol Resolution"]
    G --> G3["Mark Extern Symbol"]
    G3 --> G1[".dynsym"]
    G --> G4["Combine Global Symbol Table"]
    G4 --> G2[".symtab"]
    F --> G

    E --> I["Relocation Table Generation"]
    I --> I3["Data Relocation Section Generation"]
    I3 --> I1[".rela.dyn"]
    I --> I4["Text Relocation Section Generation"]
    I4 --> I2[".rela.plt"]

    E --> H["Dependencies Collection"]
    H --> H1["Add DT_NEEDED: libmodule_a.so"]

    E --> J["Dynamic Section Generation"]
    J --> J1[".dynamic"]
    H1 --> J1

    E --> L["Inject Interpreter"]
    L --> L1[".interp"]

    G1 --> K["Dynamic Linked Executable File: main"]
    G2 --> K
    I1 --> K
    I2 --> K
    J1 --> K
    L1 --> K

    classDef compile fill:#e6f7ff,stroke:#1890ff;
    classDef link fill:#ffe58f,stroke:#faad14;
    classDef section fill:#ffd591,stroke:#fa541c,stroke-width:2px;
    classDef file fill:#ffa0a0,stroke:#ff2020,stroke-width:2px;
    class B,D compile;
    class A,C,E,F,K file;
    class G,G1,G2,G3,G4,H,H1,H2,I,I1,I2,I3,I4,J,J1,J2,L link;
    class G1,G2,J1,I1,I2,L1 section;

在这一链接过程中,链接器事实上 不拷贝代码 ,不重定位符号 。仅将目标文件依赖的模块信息记录,解析所有输入文件,维护一个全局符号表,并标记所有当前搁置的符号。同时注入 解释器 路径。

链接阶段—符号解析

在上图中可以看到符号解析步骤,通过标记搁置符号输出了 .dynsym 节,合并输出了全局符号表 .symtab

具象化到上面例子中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
> readelf -s calc_dyn

Symbol table '.dynsym' contains 3 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FUNC GLOBAL DEFAULT UND add
2: 0000000000000000 0 OBJECT GLOBAL DEFAULT UND extern_init_data

Symbol table '.symtab' contains 15 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS calc.c
2: 0000000000000000 0 FILE LOCAL DEFAULT ABS
3: 0000000000003e80 0 OBJECT LOCAL DEFAULT 14 _DYNAMIC
4: 0000000000002000 0 NOTYPE LOCAL DEFAULT 12 __GNU_EH_FRAME_HDR
5: 0000000000003fe0 0 OBJECT LOCAL DEFAULT 15 _GLOBAL_OFFSET_TABLE_
6: 0000000000004008 8 OBJECT GLOBAL DEFAULT 16 init_data_ptr
7: 0000000000004000 4 OBJECT GLOBAL DEFAULT 16 init_data
8: 0000000000000000 0 FUNC GLOBAL DEFAULT UND add
9: 0000000000001030 77 FUNC GLOBAL DEFAULT 11 _start
10: 0000000000000000 0 OBJECT GLOBAL DEFAULT UND extern_init_data
11: 0000000000004010 0 NOTYPE GLOBAL DEFAULT 17 __bss_start
12: 0000000000004010 0 NOTYPE GLOBAL DEFAULT 16 _edata
13: 0000000000004018 0 NOTYPE GLOBAL DEFAULT 17 _end
14: 0000000000004010 4 OBJECT GLOBAL DEFAULT 17 not_init_data

链接阶段—重定位表生成

这里的重定位表将函数和数据对象分成了两个表来记录:

1
2
3
4
5
6
7
8
9
> readelf -r calc_dyn

重定位节 '.rela.dyn' at offset 0x410 contains 1 entry:
偏移量 信息 类型 符号值 符号名称 + 加数
000000004008 000200000001 R_X86_64_64 0000000000000000 extern_init_data + 0

重定位节 '.rela.plt' at offset 0x428 contains 1 entry:
偏移量 信息 类型 符号值 符号名称 + 加数
000000003ff8 000100000007 R_X86_64_JUMP_SLO 0000000000000000 add + 0

链接阶段—依赖和解释器信息注入

由于不进行代码拷贝和重定向,在ELF文件中一定是需要一个数据结构来记录这些未重定位符号需要在哪个依赖文件中搜索的,这个结构就是动态节:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
> readelf -d calc_dyn        

Dynamic section at offset 0x2e80 contains 17 entries:
标记 类型 名称/值
0x0000000000000001 (NEEDED) 共享库:[libcalc.so]
0x000000006ffffef5 (GNU_HASH) 0x380
0x0000000000000005 (STRTAB) 0x3e8
0x0000000000000006 (SYMTAB) 0x3a0
0x000000000000000a (STRSZ) 33 (bytes)
0x000000000000000b (SYMENT) 24 (bytes)
0x0000000000000015 (DEBUG) 0x0
0x0000000000000003 (PLTGOT) 0x3fe0
0x0000000000000002 (PLTRELSZ) 24 (bytes)
0x0000000000000014 (PLTREL) RELA
0x0000000000000017 (JMPREL) 0x428
0x0000000000000007 (RELA) 0x410
0x0000000000000008 (RELASZ) 24 (bytes)
0x0000000000000009 (RELAENT) 24 (bytes)
0x000000000000001e (FLAGS) BIND_NOW
0x000000006ffffffb (FLAGS_1) 标志: NOW PIE
0x0000000000000000 (NULL) 0x0

再进一步去探索这个动态节的数据结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
> readelf -x .dynamic calc_dyn

“.dynamic”节的十六进制输出:
0x00003e80 01000000 00000000 16000000 00000000 ................
0x00003e90 f5feff6f 00000000 80030000 00000000 ...o............
0x00003ea0 05000000 00000000 e8030000 00000000 ................
0x00003eb0 06000000 00000000 a0030000 00000000 ................
0x00003ec0 0a000000 00000000 21000000 00000000 ........!.......
0x00003ed0 0b000000 00000000 18000000 00000000 ................
0x00003ee0 15000000 00000000 00000000 00000000 ................
0x00003ef0 03000000 00000000 e03f0000 00000000 .........?......
0x00003f00 02000000 00000000 18000000 00000000 ................
0x00003f10 14000000 00000000 07000000 00000000 ................
0x00003f20 17000000 00000000 28040000 00000000 ........(.......
0x00003f30 07000000 00000000 10040000 00000000 ................
0x00003f40 08000000 00000000 18000000 00000000 ................
0x00003f50 09000000 00000000 18000000 00000000 ................
0x00003f60 1e000000 00000000 08000000 00000000 ................
0x00003f70 fbffff6f 00000000 01000008 00000000 ...o............
0x00003f80 00000000 00000000 00000000 00000000 ................
0x00003f90 00000000 00000000 00000000 00000000 ................
0x00003fa0 00000000 00000000 00000000 00000000 ................
0x00003fb0 00000000 00000000 00000000 00000000 ................
0x00003fc0 00000000 00000000 00000000 00000000 ................
0x00003fd0 00000000 00000000 00000000 00000000 ................

事实上,这是一个非常规整的表结构,每个表项为16个字节,其中前8字节为该项的标记,后8字节为该项的值,以第一项举例:

1
2
3
4
5
> readelf -x .dynamic calc_dyn

“.dynamic”节的十六进制输出:
0x00003e80 01000000 00000000 16000000 00000000 ................
......

该项标记为 0x0000000000000001(DT_NEED),表示这项记录一个当前ELF文件的依赖项。该项值为0x0000000000000016,表示该依赖的文件名在字符串表中的偏移值为0x16:

1
2
3
4
5
6
> readelf -x .dynstr calc_dyn

“.dynstr”节的十六进制输出:
0x000003e8 00616464 00657874 65726e5f 696e6974 .add.extern_init
0x000003f8 5f646174 61006c69 6263616c 632e736f _data.libcalc.so
0x00000408 00

从字符串表的基地址0x3e8偏移0x16,可以在0x3fe寻址该字符串 6c 69 62 63 61 6c 63 2e 73 6f(libcalc.so)。

最后,在运行时加载该ELF文件前,进程需要先加载解释器。因此解释器的路径也需要被保存在ELF文件的某节中,即 .interp:

1
2
3
4
5
> readelf -x .interp calc_dyn

“.interp”节的十六进制输出:
0x00000318 2f6c6962 36342f6c 642d6c69 6e75782d /lib64/ld-linux-
0x00000328 7838362d 36342e73 6f2e3200 x86-64.so.2.

至此,一个动态链接ELF文件完成了链接阶段的所有工作。可以明显感觉到,动态链接文件的链接阶段要比静态链接简单得多,因为重定位工作被推迟到了 加载/执行 阶段。

动态分析—加载/执行

stateDiagram-v2
    direction TB
    
    [*] --> KernelSpace
    
    state "🔴 KERNEL SPACE" as KernelSpace {
        direction TB
        [*] --> execve
        execve --> load_elf_binary
        state "load_elf_phdrs" as load_elf_phdrs
        load_elf_binary --> load_elf_phdrs
        
        state elf_load {
            direction LR
            state "mmap Segments" as mmap_segments
            [*] --> mmap_segments
            mmap_segments --> mprotect
            mprotect --> [*]
        }
        load_elf_phdrs --> elf_load
        elf_load --> elf_load
        
        state "Check Interp Exist" as check_interp
        elf_load --> check_interp
        state is_interp_exist <>
        check_interp --> is_interp_exist
        is_interp_exist --> set_elf_entry_static: interp non-exist
        is_interp_exist --> load_elf_interp: interp exist

        state "Set elf_entry to _start" as set_elf_entry_static
        state "load_elf_interp
mmap interp(ld.so)" as load_elf_interp state "Set elf_entry to dl_main" as set_elf_entry_dynamic load_elf_interp --> set_elf_entry_dynamic set_elf_entry_static --> start_thread set_elf_entry_dynamic --> start_thread start_thread --> [*] } KernelSpace --> UserSpace: context switch state "🟢 USER SPACE" as UserSpace { direction TB [*] --> entry_point state "_start" as static_start state "_start" as dynamic_start state entry_point_fork <> entry_point --> entry_point_fork entry_point_fork --> static_start entry_point_fork --> dl_main dl_main --> dynamic_linking dynamic_linking --> dynamic_start state dynamic_linking { direction LR state "Resolve and Load Dependencies" as resolve_and_load_dependencies state "Symbols Resolve" as symbols_resolve state "Symbols relocate" as symbols_relocate [*] --> resolve_and_load_dependencies resolve_and_load_dependencies --> symbols_resolve symbols_resolve --> symbols_relocate symbols_relocate --> [*] } state dynamic_start { [*] --> normal_execution normal_execution --> [*] : Program exit normal_execution --> external_function_call: Call external function external_function_call --> normal_execution: External function return state check_got_entry <> external_function_call --> check_got_entry check_got_entry --> direct_jump: GOT entry resolved check_got_entry --> plt_lazy_binding: GOT entry unresolved state "PLT Lazy Binding" as plt_lazy_binding { [*] --> push_reloc_index push_reloc_index --> jump_to_plt0 jump_to_plt0 --> _dl_runtime_resolve state _dl_runtime_resolve { direction LR [*] --> lookup_symbol_address lookup_symbol_address --> update_got_entry update_got_entry --> [*] } _dl_runtime_resolve --> [*] } plt_lazy_binding --> function_executed direct_jump --> function_executed function_executed --> external_function_call } state entry_point_join <> static_start --> entry_point_join dynamic_start --> entry_point_join entry_point_join --> exit exit --> [*] } UserSpace --> [*]

可以看到,一个ELF的的加载和执行,以execve系统调用作为入口。整个过程根据其上下文切换可以分为内核态和用户态两个阶段。

加载

这个阶段,Shell通过 execve 陷入kernel space。

在图中可以清晰看到,execve 对于静态链接文件和动态链接文件的区分并不大。

  • 两类ELF文件均需要解析程序头表,遍历加载所有LOAD段;
  • 完成LOAD段加载后,会检查该ELF文件是否存在interp段(仅动态链接文件存在该结构)。对于存在interp段的ELF文件,会将其中记录的解释器路径同样mmap到内存中;
  • 启动参数传递到进程的用户态堆栈;
  • 计算entry_point:静态链接文件的入口地址为文件本身记录的入口点(_start),动态链接文件的入口地址则为运行时解释器的入口点地址(dl_main);
  • 最后,start_thread 修改进程的内核堆栈,主要是将上一步获得的enrty_point设置到指令指针寄存器中。

至此,execve 系统调用完成其所有工作,将一个ELF文件成功加载到进程中。

执行

execve返回后,上下文切换至user space。

创建出的进程被内核调度执行,获取时间片后,恢复cpu上下文,进程从其指令指针寄存器指向的运行时地址开始执行。

静态链接

  • 静态链接的ELF文件直接从 _start 函数开始执行,其运行所需的所有数据、指令条目(函数)均已经被加载到当前进程的内存空间中;
  • 函数跳转只需要按照指令条目中的间接跳转方式进行跳转即可;
  • 程序执行完毕,退出后进程被回收。

动态链接

动态链接的ELF文件从其内存中映射的解释器entry_point开始执行;

依赖加载

  • 解释器首先检索ELF文件的动态段 .dynamic,从其中获取所有DT_NEED条目,即依赖项;
  • 在字符串表.dynstr中获取到该依赖的名称,而后从文件系统中检索并mmap加载该依赖库。

符号解析/重定位

在解释原因前,先给出其行为:

  • 解析数据重定位表 .rela.dyn,遍历每个数据重定位条目,并从加载到内存中依赖库的全局符号表搜索该符号,将该数据符号的地址写入数据重定位条目记录的地址中,完成数据重定位;
  • 解析代码重定位表.rela.plt,遍历每个代码重定位条目。此时代码重定位有两种策略:
    • 立即绑定:对于单个代码重定位条目,直接将依赖库中的符号地址写入条目记录的地址中(.got表项),完成重定位;
    • 延迟绑定(默认):对于单个代码重定位条目,什么都不做,等到代码执行需要跳转时,由_dl_runtime_resolve完成重定位。

现在看这些行为,恐怕难以理解。在代码执行阶段将更细致地解释其原因。

代码执行

我们首先将上面例子中链接出的动态链接文件反汇编,并查看其 .got(全局偏移表):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
> objdump -S calc_dyn

calc_dyn: 文件格式 elf64-x86-64

Disassembly of section .plt:

0000000000001000 <.plt>:
1000: ff 35 e2 2f 00 00 push 0x2fe2(%rip) # 3fe8 <_GLOBAL_OFFSET_TABLE_+0x8>
1006: ff 25 e4 2f 00 00 jmp *0x2fe4(%rip) # 3ff0 <_GLOBAL_OFFSET_TABLE_+0x10>
100c: 0f 1f 40 00 nopl 0x0(%rax)
1010: f3 0f 1e fa endbr64
1014: 68 00 00 00 00 push $0x0
1019: e9 e2 ff ff ff jmp 1000 <add@plt-0x20>
101e: 66 90 xchg %ax,%ax

Disassembly of section .plt.sec:

0000000000001020 <add@plt>:
1020: f3 0f 1e fa endbr64
1024: ff 25 ce 2f 00 00 jmp *0x2fce(%rip) # 3ff8 <add>
102a: 66 0f 1f 44 00 00 nopw 0x0(%rax,%rax,1)

Disassembly of section .text:

0000000000001030 <_start>:
1030: f3 0f 1e fa endbr64
1034: 55 push %rbp
1035: 48 89 e5 mov %rsp,%rbp
1038: 48 83 ec 10 sub $0x10,%rsp
103c: c7 05 ca 2f 00 00 03 movl $0x3,0x2fca(%rip) # 4010 <not_init_data>
1043: 00 00 00
1046: 8b 15 c4 2f 00 00 mov 0x2fc4(%rip),%edx # 4010 <not_init_data>
104c: 48 8b 05 b5 2f 00 00 mov 0x2fb5(%rip),%rax # 4008 <extern_init_data>
1053: 8b 00 mov (%rax),%eax
1055: 89 d6 mov %edx,%esi
1057: 89 c7 mov %eax,%edi
1059: e8 c2 ff ff ff call 1020 <add@plt>
105e: 89 45 fc mov %eax,-0x4(%rbp)
1061: 8b 05 99 2f 00 00 mov 0x2f99(%rip),%eax # 4000 <init_data>
1067: 01 45 fc add %eax,-0x4(%rbp)
106a: 48 c7 c0 3c 00 00 00 mov $0x3c,%rax
1071: 48 c7 c7 7b 00 00 00 mov $0x7b,%rdi
1078: 0f 05 syscall
107a: 90 nop
107b: c9 leave
107c: c3 ret

> readelf -x .got calc_dyn

“.got”节的十六进制输出:
NOTE: This section has relocations against it, but these have NOT been applied to this dump.
0x00003fe0 803e0000 00000000 00000000 00000000 .>..............
0x00003ff0 00000000 00000000 10100000 00000000 ................

在完成动态链接后,程序真正进入其用户代码的执行阶段。在程序的_start中,指令被按顺序执行,其中的寻址主要分为两类:

  • 数据寻址

    • 当前程序中定义的数据符号存储于数据段的.data和.bss节,指令中可以直接读写其内存地址,在例子中的 0x103c和0x1061可以观察到这类寻址方式;

    • 对于外部数据的寻址,在动态链接的重定位阶段,重定位符号的虚拟内存地址被写入数据段(非数据,而是数据的地址)。

      指令中通过获取该地址,并再次寻址来获取外部数据。在例子的0x104c和0x1053中可以观察到这一寻址方式。

  • 函数寻址

    对于动态链接的ELF文件,函数的寻址都是通过函数调用桩 .plt 来实现的。相当于在没有加载外部依赖前,在指令中使用该调用桩作为占位符以及对于某个函数调用的统一入口。

    这么做可以大幅减少重定位的工作(静态链接中,程序有多少次该函数的调用,就会产生多少个重定位表项)。而对应于动态链接时代码重定位的两种绑定方式,执行阶段也同样有所差异。

    • 立即绑定:由于在动态链接阶段,.got全局偏移表的表项已经被全部填充为了真实的函数地址,所以指令中可以通过.plt函数调用桩(例如上面的add@plt),直接查.got表的对应索引项获取到函数真实的地址。
    • 延迟绑定
      • 最初所有.got表项(_dl_runtime_resolve除外)所指向的地址均为.plt的通用逻辑(例子中的0x1010);
      • 所以指令中对所有对外部函数的首次调用,均会跳转到.plt;
      • .plt会在完成参数传递后,跳转到_dl_runtime_resolve(解释器提供的运行时函数地址解析器);
      • 解析器通过搜索解析外部依赖库的符号表,获取该符号的真实地址,而后将其写入.got表项,并完成函数跳转;
      • 当后续再次调用该函数时,.got表中该函数对应的表项已经被写入了真实的函数地址,可以直接完成寻址和跳转。

    了解完两种绑定策略后,可以发现,事实上立即绑定就是在动态链接阶段提前遍历完成了所有外部函数符号的runtime_resolve工作,使.got表在运行时可以直接被使用。

    使用立即绑定的弊端也很明显,在不同的上下文和启动参数下,当用户代码中使用的外部函数少于其所声明(重定位表项)的数量时,遍历整个重定位表将解析很多无意义的外部符号,造成性能损耗,拉长程序启动时间。