最近在读《程序员的自我涵养:链接,装载与库》,其实这本书跟 Android 开发的联系还挺严密的,无论是 NDK 开发,或者是功能优化中一些常用的 Native Hoook 手段,都需求了解一些链接,装载相关的知识点。本文为读书笔记。
静态链接是什么?
前面咱们介绍了 ELF 文件的详细格局,接下来的问题是,假如咱们有两个或者多个方针文件时,怎样把他们链接起来,组成一个可执行文件?
# a.c
extern int shared;
extern void swap(int *a, int* b);
int main(void)
{
int a = 100;
swap(&a, &shared);
}
# b.c
int shared = 1;
void swap(int *a, int* b)
{
*a ^= *b ^= *a ^= *b;
}
比方上面的两个文件,文件 a 中引证了 文件 b 中的两个大局符号:shared
变量与swap
函数,在 a 文件编译后生成的方针文件 a.o
中,两个外部符号在符号表中的 value 都为 0。
而静态链接的效果简单来说,便是把符号替换为地址,并将a.o
与b.o
文件终究打包成一个可执行的文件
源码编译与链接
接下来咱们实操一下编译链接进程
首先咱们经过以下指令将 a.c 与 b.c 文件别离编译成方针文件 a.o 与 b.o
# 有必要在编译时参加参数-fno-stack-protector,否则链接报错
$ gcc -c a.c -fno-stack-protector -o a.o
$ gcc -c b.c -fno-stack-protector -o b.o
接下来咱们就可以使用 ld 链接器将 a.o 与 b.o 链接成可执行文件 ab
$ ld a.o b.o -e main -o ab
现在咱们现已得到了可执行文件
静态链接的进程
上面说到,静态链接主要有两个效果
- 将多个中心文件兼并成一个可执行文件
- 把程序中的符号转换成 CPU 执行时的内存地址
因而,链接的进程一般也分为两步,称为两步链接
第一步 空间与地址分配:兼并编译器生成的多个方针(.o)文件,通常经过类似段兼并的方法(如下图所示),终究生成共享文件(.so)或可执行文件。在这个进程中,链接器扫描输入的方针文件,获取各段巨细,并收集符号界说和引证信息,构建大局符号表。当链接器构造好了终究的文件布局以及虚拟内存布局后,可根据符号表确认每个符号的虚拟地址。
第二步 符号解析与重定位:链接器会对整个文件再进行第二遍扫描,这一阶段,会使用第一遍扫描得到的符号表信息,依次对文件中每个符号引证的当地进行地址替换
这便是链接器常用的两步链接 (Two-pass linking) 的进程,简而言之,第一步完结文件兼并、虚拟内存布局分配以及符号信息收集;第二步完结符号的重定位进程。
空间与地址分配
空间与地址分配的工作便是扫描一切方针文件,获取方针文件的段长度,并将他们兼并,计算出输出文件中各个段兼并后的长度与方位,并确认虚拟地址
接下来咱们使用 objdump 东西,看看链接前后的地址变化
方针文件剖析
$ objdump -h a.o
a.o: file format elf64-x86-64
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 0000002d 0000000000000000 0000000000000000 00000040 2**0
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 00000000 0000000000000000 0000000000000000 0000006d 2**0
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000000 0000000000000000 0000000000000000 0000006d 2**0
ALLOC
3 .comment 0000002c 0000000000000000 0000000000000000 0000006d 2**0
CONTENTS, READONLY
4 .note.GNU-stack 00000000 0000000000000000 0000000000000000 00000099 2**0
CONTENTS, READONLY
5 .note.gnu.property 00000020 0000000000000000 0000000000000000 000000a0 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
6 .eh_frame 00000038 0000000000000000 0000000000000000 000000c0 2**3
CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA
$ objdump -h b.o
b.o: file format elf64-x86-64
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 0000004f 0000000000000000 0000000000000000 00000040 2**0
CONTENTS, ALLOC, LOAD, READONLY, CODE
1 .data 00000004 0000000000000000 0000000000000000 00000090 2**2
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000000 0000000000000000 0000000000000000 00000094 2**0
ALLOC
3 .comment 0000002c 0000000000000000 0000000000000000 00000094 2**0
CONTENTS, READONLY
4 .note.GNU-stack 00000000 0000000000000000 0000000000000000 000000c0 2**0
CONTENTS, READONLY
5 .note.gnu.property 00000020 0000000000000000 0000000000000000 000000c0 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
6 .eh_frame 00000038 0000000000000000 0000000000000000 000000e0 2**3
CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA
可执行文件剖析
$ objdump -h ab
ab: file format elf64-x86-64
Sections:
Idx Name Size VMA LMA File off Algn
0 .note.gnu.property 00000020 00000000004001c8 00000000004001c8 000001c8 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
1 .text 0000007c 0000000000401000 0000000000401000 00001000 2**0
CONTENTS, ALLOC, LOAD, READONLY, CODE
2 .eh_frame 00000058 0000000000402000 0000000000402000 00002000 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
3 .data 00000004 0000000000404000 0000000000404000 00003000 2**2
CONTENTS, ALLOC, LOAD, DATA
4 .comment 0000002b 0000000000000000 0000000000000000 00003004 2**0
CONTENTS, READONLY
前后比照
- a.o 与 b.o 的段经过类似段兼并的方法打包到 ab 中,比方 a.o 的代码段长度为 0x2d,b.o 的代码段长度为 0x4f,而兼并之后的 ab 的代码段长度便是 0x7C = 0x2d + 0x 4f
- 链接前,a.o 和 b.o 的一切段的 VMA 都是 0,由于虚拟空间还没有分配,默以为 0
- 链接后,ab 的各个段都被分配到相应的虚拟地址
- 需求留意的是,在 linux 中 elf 文件的虚拟地址并不是从 0 开端分配的,i386 ELF 默认从地址 0x08048000 开端分配, 而 x64 是 0x400000
- 在段的虚拟地址确认之后,段内的符号根据其在段内的偏移,其虚拟地址也可以确认了
符号解析与重定位
首页咱们经过 objdump 来看看 a.o 反汇编的成果
$ objdump -d a.o
a.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <main>:
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 45 fc 64 00 00 00 movl $0x64,-0x4(%rbp)
13: 48 8d 45 fc lea -0x4(%rbp),%rax
17: 48 8d 35 00 00 00 00 lea 0x0(%rip),%rsi # 1e <main+0x1e> shared 的引证
1e: 48 89 c7 mov %rax,%rdi
21: e8 00 00 00 00 callq 26 <main+0x26> # swap 的引证
26: b8 00 00 00 00 mov $0x0,%eax
2b: c9 leaveq
2c: c3 retq
从输出的成果中可以看出:
- 在此刻 main 函数的地址仍是 0,由于此刻还没有进行空间分配,方针文件代码段中的地址仍是以 0x000000 开端
- a.o 中界说了一个
main
函数,这个函数占用 2c 个字节,共 12 条指令。最左面那列是每条指令的偏移量,一行代表一条指令 - 此刻编译器并不知道 shared 符号与 swap 符号的地址,由于界说在其他文件中,因而暂时仍是用 0 表明
链接器在完结地址和空间分配之后就可以确认一切符号的虚拟地址了,那么链接器就可以根据符号的地址对需求重定位的指令进行地址批改,接下来再看看 ab 反汇编的成果
$ objdump -d ab
ab: file format elf64-x86-64
Disassembly of section .text:
0000000000401000 <main>:
401000: f3 0f 1e fa endbr64
401004: 55 push %rbp
401005: 48 89 e5 mov %rsp,%rbp
401008: 48 83 ec 10 sub $0x10,%rsp
40100c: c7 45 fc 64 00 00 00 movl $0x64,-0x4(%rbp)
401013: 48 8d 45 fc lea -0x4(%rbp),%rax
401017: 48 8d 35 e2 2f 00 00 lea 0x2fe2(%rip),%rsi # 404000 <shared>
40101e: 48 89 c7 mov %rax,%rdi
401021: e8 07 00 00 00 callq 40102d <swap>
401026: b8 00 00 00 00 mov $0x0,%eax
40102b: c9 leaveq
40102c: c3 retq
000000000040102d <swap>:
40102d: f3 0f 1e fa endbr64
401031: 55 push %rbp
// ...
如上所示,swap
的值被替换成了0x07
,这是由于call
指令是一条近址相对位移调用指令,它后边跟的是调用指令的下一条指令的偏移量
比方上面偏移量为0x07
,下一条指令的地址为0x401026
,而0x401026+0x07=0x40102d
,正好是swap
符号的地址
重定位表
咱们前面说到,链接器会对指令进行重定位。那么问题来了,链接器怎样知道哪些指令需求调整?这些指令的哪些部分需求调整,详细怎样调整?比方上面介绍的shared
符号与swap
符号调整的方法就不相同
这些重定位相关的信息都是保存在 ELF 中的重定位表中的,关于每一个需求重定位的 ELF 段都有一个重定位表。比方 .text 段对应的 .rel.text 重定位表,.data 段对应的 .rel.data 重定位表
下面咱们看下方针文件的重定位表
$ readelf -r a.o
Relocation section '.rela.text' at offset 0x260 contains 2 entries:
Offset Info Type Sym. Value Sym. Name + Addend
00000000001a 000a00000002 R_X86_64_PC32 0000000000000000 shared - 4
000000000022 000c00000004 R_X86_64_PLT32 0000000000000000 swap - 4
可以看出,在a.o
中有两个重定位进口
-
OFFSET
表明该重定位进口在该段中的方位,对照前面的反汇编成果,也可以看到0x1a
与0x22
便是对应指令的地址部分 -
Type
表明重定位进口的类型,由于各种处理器的指令格局不相同,所以重定位批改的指令地址格局也不相同,每种处理器都有一套自己的重定位进口类型 -
Addend
表明占位符的长度,重定位进程中需求的辅佐信息
可以看出,重定位进程中,每一个重定位进口都是对一个符号的引证,那么当链接器需求要对某个符号进行引证和重定位时,他就要确认这个符号的方针地址,这时分链接器就会去查找由一切输入方针文件的符号表组成的大局符号表,找到相应的符号后进行重定位。
指令批改方法
不同的处理器指令关于地址的格局和方法都不相同。比方关于 32 位 Intel x86 处理器来说,搬运跳转指令(jmp 指令)、子程序调用指令(call 指令)和数据传送指令(mov 指令)寻址方法千差万别。直至 2006 年为止,Intel x86 系列 CPU 的 jmp 指令有 11 种寻址形式;call 指令有10种;mov 指令则有多达 34 种寻址形式
咱们示例中的重定位进口类型别离为R_X86_64_PC32
与R_X86_64_PLT32
,它们的指令批改方法都是相对寻址批改,指令计算公式为:S + A – P
- 这里的 S 表明完结链接后该符号的实践地址。在链接器将多个中心文件的段兼并今后,每个符号就按先后顺序依次都会分配到一个地址,这便是它的终究地址 S。
- A 表明 Addend 的值,它代表了占位符的长度。
- P 表明要进行重定位方位的地址或偏移,这是引证符号的当地,也便是咱们要回填地址的当地,简单说,它便是咱们上文说到的用 0 填充的占位符的地址。
其中 P – A = PC 值 = 下一条指令地址,因而其指令计算公式也可以简化为 S – 下一条指令地址,之所以这么规划是由于程序运行到这条指令的时分,能够拿到的地址就只有 PC 的值。经过这种方法,在运行时经过获取 PC 值与指令偏移值,就可以计算出符号真实的虚拟地址
比方上面swap
符号偏移量为0x07
,下一条指令的地址为0x401026
,而0x401026+0x07=0x40102d
,正好是swap
符号的地址
总结
- 静态链接的进程分为两步,第一步是空间与地址分配,第二步是符号解析与重定位
- 空间与地址分配阶段:经过类似段兼并的方法将多个中心文件兼并成一个可执行文件,在这个进程中,链接器会扫描输入的方针文件,获取各段巨细,并收集符号界说和引证信息,构建大局符号表,并确认每个符号的虚拟地址
- 符号解析与重定位阶段:链接器会把各个输入方针文件关于外部符号的引证进行解析,把每个段中须重定位的指令和数据进行批改,使其指向正确的方位