静态链接
最近在读《程序员的自我修养:链接,装载与库》,其实这本书跟 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
符号的地址
总结
- 静态链接的步骤分为两步,第一步是空间与地址分配,第二步是符号解析与重定位
- 空间与地址分配阶段:通过相似段合并的方式将多个中间文件合并成一个可执行文件,在这个过程中,链接器会扫描输入的目标文件,获取各段大小,并收集符号定义和引用信息,构建全局符号表,并确定每个符号的虚拟地址
- 符号解析与重定位阶段:链接器会把各个输入目标文件对于外部符号的引用进行解析,把每个段中须重定位的指令和数据进行修正,使其指向正确的位置