简单来说,程序的代码无非是由各种函数、变量以及对这些变量的读、写操作所组成,而不管是变量还是函数,它们最终都要存储在内存里。而为每个变量和函数正确地分配内存空间的工作是由链接器来完成的。
每个变量和函数都有自己的名称,通常把这些名称叫做符号,通常程序员编程时是直接跟符号打交道的。简单来讲,链接器的作用就是为符号转换成地址,一般来说可以分为三种情况:
- 生成二进制可执行文件的过程中(静态链接);
- 在二进制文件被加载进内存时(动态链接,在二进制文件保留符号,在加载时再把符号解析成真实的内存地址);
- 在运行期间解析符号(把符号的解析延迟到最后不得不做时,才去做符号的解析,这也是动态链接的一种)。
链接的小例子
```c // example.c extern int extern_var; int global_var = 1; static int static_var = 2;
extern int extern_func(); int global_func() { return 10; }
static int static_func() { return 20; }
int main() { int var0 = extern_var; int var1 = global_var; int var2 = static_var; int var3 = extern_func(); int var4 = global_func(); int var5 = static_func(); return var0 + var1 + var2 + var3 + var4 + var5; }
// external.c int extern_var = 3; int extern_func() { return 30; }
```$ gcc example.c -c -o example.o -fno-PIC -g$ gcc external.c -c -o external.o -fno-PIC -g$ gcc external.o example.o -o a.out -no-pie
- -fno-PIC 是告诉编译器不要生成位置无关的代码,PIC 主要作用于动态链接;
-no-pie 表示关闭 pie 的模式,gcc 会默认打开 pie 模式,也就意味着系统 loader 会随机加载可执行文件时的起始地址,而关闭 pie 之后,在 Linux 64 位的系统下,默认的加载起始地址是 0x400000。
链接器的作用
CPU 在执行程序代码的时候,并不知道有符号的概念,CPU 所能理解的只有内存地址的概念。不管是读数据,调用函数还是读指令,对于 CPU 而言都是一个个的内存地址。所以需要一个用于连接 CPU 与程序员之间的桥梁,把程序中的符号转换成 CPU 执行时的内存地址。而这个桥梁就是链接器,它负责将符号转换为地址。
链接器的工作流程主要分为两步 (Two-pass linking) :- 链接器需要对编译器生成的多个目标(.o) 文件进行合并,一般采取的策略是相似段的合并,最终生成共享文件 (.so) 或者可执行文件。这个阶段中,链接器对输入的各个目标文件进行扫描,获取各个段的大小,并且同时会收集所有的符号定义以及引用信息,构建一个全局的符号表。通过这一次扫描,链接器也就能大体构造好程序的文件布局以及虚拟内存布局,再根据符号表,也就能确定每个符号的虚拟地址了。
- 链接器会对整个文件再进行第二遍扫描,利用第一遍扫描得到的符号表信息,依次对文件中每个符号引用进行地址替换(编译时引用到符号会被填充为 0)。这个过程也就是对符号的解析以及重定位过程。
把多个中间文件合并成一个可执行文件
每个中间文件都有自己的代码段和数据段等多个 section,在合并成一个可执行程序时,多个中间文件的代码段会被合并到可执行文件的代码段,数据段也会被合并为可执行文件的数据段。
但是链接器在合并多个目标文件的时候并不是简单地将各个 section 合并就可以了,还需要考虑每个目标中符号的地址如何转换为可执行文件中符号的地址,即重定位。深入分析重定位过程
一般情况下,在对二进制文件进行反汇编时会使用 objdump 工具,而用 readelf 工具来解析二进制文件信息。接下来,通过对比中间生成的 .o 文件以及最终的 a.out 文件中符号的差异来分析重定位的过程。 ```objdump -S example.o
0000000000000000: 0: 55 push %rbp 1: 48 89 e5 mov %rsp,%rbp 4: b8 0a 00 00 00 mov $0xa,%eax 9: 5d pop %rbp a: c3 retq
000000000000000b
0000000000000016
- 对于局部变量来说,局部变量在进程中的地址,都是基于 %rbp 的偏移这种形式,rbp 寄存器存放的是当前函数栈帧的基地址。局部变量的内存分配与释放,都是在运行时通过 %rbp 的改变来进行的,因此,局部变量的内存地址不需要链接器来操心;- 对于比较简单的 static_func 来说,**静态函数是唯一不需要重定位的符号类型**,对 static_func 的调用(40 行),所生成的指令的二进制是 e8 ae ff ff ff。其中,e8 是 callq 指令的编码,后边 4 个字节就对应被调函数的地址。注意,这里生成的 ae ff ff ff,是采用小端的字节序数值来表示,应该是 0xffffffae,也就是对应十进制的 -82。此时,当 CPU 执行到 callq 这条指令时,rip 寄存器的值已经是下一条指令的内存地址,也就是 5d 这条指令的内存地址,通过计算 0x5d – 82 可以得到 0xb,而 0xb 刚好是 static_func 的地址;> 在同一个编译单元内部,static_func 与 main 函数的相对位置是固定不变的,即便链接的过程中会对不同 .o 文件中的代码段进行合并,但是 static_func 与 main 函数的相对位置在合并后的可执行文件中也不会发生变化,因此,在编译的时候,就能确定对静态函数调用的偏移。也就是说,**静态函数的调用地址在编译阶段就可以确定下来**。- 而对于外部变量、全局变量以及静态变量来说,对 extern_var、global_var 和 static_var 的访问,都生成了一条 **mov 0x0(%rip),%eax** 的指令。在编译时候,编译器还无法确定这三个变量的地址,因此,这里先通过 0 来进行占位,以后链接器会将真正的地址回填进来;- 对于 extern_func 和 global_func 的调用,call 指令同样是通过 0 来进行占位,这和外部变量和全局变量的处理方式一样。- 上述的几个需要重定位的符号中,还有一个比较特殊的符号是 static_var 变量。可以从 Sym. Name 里找到其余变量的符号(通过 readelf -r 选项),但 static_var 的符号没有出现,只有一个 .data 的符号。这是因为 static_var 变量本身是一个静态变量,只在本编译单元内可见,不会对外进行暴露,所以它是根据本编译单元的 .data 段的地址来进行重定位。也就是说,static_var 的最终地址就是本编译单元的 .data 段的最终地址。所以,它的重定义方法与 extern_var 等符号的重定位方法是一样的,区别仅仅在于**它的符号被隐藏了**。> 既然静态函数可以在编译的时候确定相对偏移,那为什么静态变量做不到这一点呢?这是因为静态变量的位置是在 .data 段,而对静态函数的访问是在 .text 段。对应 .text 段内部的偏移可以保证在链接的过程中不发生改变,但由于 .text 段和 .data 段分属不同的段,在链接的时候由于相似段的合并,符号的地址和引用该符号的地址之间的相对位置大概率会发生变化。所以静态变量的地址就需要链接器来进行重定位了<a name="Ef8DT"></a>## 处理占位符接下来通过观察链接器对 extern_var,static_var,global_var,global_func 以及 extern_func 的重定位过程,看看它们的占位符是如何处理的。<br />链接器在处理目标文件的时候,需要对目标文件里代码段和数据段引用到的符号进行重定位,而这些重定位的信息都记录在对应的重定位表里。一般来说,重定位表的名字都是以 .rela 开头,比如 .rela.text 就是对 .text 段的重定位表,.rela.data 是对 .data 段的重定位表(比如声明一个指针变量,并且该指针变量被初始化为外部变量的地址,此时关于**这个外部变量**的重定位信息就会被放置在 .rela.data 段中)。> 重定位表描述目标的是符号引用通过 readelf -r example.o 来打印目标文件中的重定位表信息:
Relocation section ‘.rela.text’ at offset 0x330 contains 5 entries: Offset Info Type Sym. Value Sym. Name + Addend 000000000020 000d00000002 R_X86_64_PC32 0000000000000000 extern_var - 4 000000000029 000a00000002 R_X86_64_PC32 0000000000000000 global_var - 4 000000000032 000300000002 R_X86_64_PC32 0000000000000000 .data + 0 00000000003f 000e00000002 R_X86_64_PC32 0000000000000000 extern_func - 4 00000000004c 000b00000002 R_X86_64_PC32 0000000000000000 global_func - 4
> 注:这里面没有 static_val 符号(仅在本目标文件中可见),只有 .data 符号.rela.text 的重定位表里存放了 .text 段中每一个需要进行重定位的符号引用的重定位信息。所以,每个重定位项都会包含相对于符号引用 .text 段中的偏移、重定位类型和重定位符号名。重定位表的数据结构如下图所示:```ctypedef struct {Elf64_Addr› r_offset; /* 重定位表项的偏移地址 */Elf64_Xword› r_info; /* 重定位的类型以及重定位符号的索引 */Elf64_Sxword› r_addend; /* 重定位过程中需要的辅助信息 */} Elf64_Rela;
其中,r_info 的高 32bit 存放的是重定位符号在符号表的索引,r_info 的低 32bit 存放的是重定位类型的索引。符号表就是 .symtab 段,可以把它看成是一个字典,这个字典以整数为 key,以符号名为 value。
根据上文的 .rela.text 段中的重定位表来看,需要重定位的符号共有 5 项,分别位于 .text 段的 0x20,0x29,0x32,0x3f,0x4c 偏移处。这里以 0x20 为例,它对应的汇编指令是 0x1e 位置的 8b 05 00 00 00 00(22 行,其中 0x1e 位置存放的是操作码),需要重定位的符号的在没有重定位之前,它是一个四字节填充的 0,对应的是对变量 extern_var 的访问,extern_val 的重定位类型为 R_X86_64_PC32,这种类型的地址计算方式为:S + A – P。
- S 表示完成链接后对应符号的实际地址(在链接器第一次扫描后便可确定符号地址)。在链接器将多个中间文件的段合并以后,每个符号就按先后顺序依次都会分配到一个地址,这就是它在虚拟地址空间的最终地址 S;
- A 表示 Addend 的值,代表了占位符的长度;
- P 表示符号引用的在虚拟地址空间的最终位置。简单说,就是上文提到的用 0 填充的占位符的地址。
S 和 P 的值需要通过反汇编查看可执行文件才能确定
00000000004004ad <main>:4004ad: 55 push %rbp4004ae: 48 89 e5 mov %rsp,%rbp4004b1: 48 83 ec 20 sub $0x20,%rsp4004b5: 8b 05 75 0b 20 00 mov 0x200b75(%rip),%eax # 601030 <extern_var>4004bb: 89 45 e8 mov %eax,-0x18(%rbp)
S 为 601030,而 P 为 4004b5 + 2,因此重定位符号处需要填写的值应该是 0x601030 + (-4) – 0x4004b7 = 0x200b75
以上从链接器的视角推算出了重定位符号的值,那么系统为什么搞这么一套复杂的公式来计算出这么一个值呢?这个值的真正含义是什么?针对这个问题,需要从 CPU 的角度来观察,从上面 main 函数的反编译的结果可以看到,最终对 extern_var 的访问生成的汇编是:
mov 0x200b75(%rip), %eax
这是一条 PC 相对偏移的寻址方式。当 CPU 执行到这条指令的时候,%rip 的值存放的是下一条指令的地址,也就是 0x4004bb,这时候可以算出 0x4004bb + 0x200b75 = 0x601030,刚好是 extern_var 的实际地址。
这时候再来理解一下 S+A-P 公式的作用。链接器在有了整体的虚拟内存布局后,知道的信息是:符号引用对应符号的虚拟地址 S 的值是 (0x601030),以及符号引用的虚拟地址 P 的值是 (0x4004b7)。链接器需要在指令中占位符的位置填一个值,让程序运行的时候能够找到 S。但程序运行到这条指令的时候,能够拿到的地址就只有 PC 的值,也就是下一条指令的地址 (0x4004bb)。这时候会发现符号引用的虚拟地址的值跟下一条 pc 的值,相差的就是这个 Addend(-4),这个 Addend 实际上就是用来调整 P 的值和执行时 PC 的值之间的差异的,所以它刚好就是占位符的宽度。
S+A-P算出来的是相对地址,使用相对地址的好处在于可支持加载地址随机化等安全增强技术。
