文章目录
概述
链接是将各种代码和数据部分收集起来并组合成为一个单一文件的过程。这个文件可被加载(拷贝)到内存中并执行:
- 链接可以执行于编译时(compile time),也就是源代码翻译成机器码时。
- 也可以执行于加载时(load time),也就是程序被加载器加载到内存并执行时。
- 甚至执行于运行时(run time),由应用程序来执行。
链接是由叫做链接器(linker)的程序自动执行的。链接器的出现,使得分离编译成为可能,我们不用将一个大型的应用程序组织为一个巨大的源文件,而是把它分解成更小、更好管理的模块。可以独立的修改和编译这些模块。当我们改变这些模块中的一个时,我们只要单独地编译它,并将它重新链接到应用上,而不用编译其他文件。
可以先学会如何打包静态库和动态库,再理解动态库和静态库的原理会事半功倍。
一、编译器驱动程序
下面是两个源文件的组成main.c
和sum.c
// main.c
int sum(int *a, int n);
int array[2] = { 1, 2 };
int main() {
int val = sum(array, 2);
return val;
}
// sum.c
int sum(int *a, int n) {
int i, s = 0;
for (i = 0; i < n; i++) {
s += a[i];
}
return s;
}
使用命令:gcc -Og -o prog main.c sum.c
调用GCC的驱动程序。下图是静态链接,链接器将可重定位的目标文件组合起来,形成一个可执行目标文件prog
。这个链接的过程可以分为三个步骤:
- 它首先运行C预处理器cpp,将C源程序
main.c
翻译成一个ASCII码的中间文件main.i
- 接下来,C编译器cc1将
main.i
翻译成一个ASCII汇编语言文件main.s
- 最后,汇编器as将
main.s
翻译成一个可重定位目标文件main.o
在运行可执行文件prog
时,shell
调用操作系统中一个叫做加载器的函数,他将可执行文件prog
中的代码和数据复制到内存,然后将控制转移到这个程序的开头。
二、静态链接
像Linux ld程序这样的静态链接器以一组可重定位目标文件和命令行参数作为输入,生成一个完全链接的可以加载和运行的可执行目标文件作为输出。输入的可重定位目标文件由各种不同的代码和数据节组成。指令在一个节中,初始化的全局变量在另一个节中,而未初始化的变量又在另外一个节中。
为了构造可执行文件,链接器必须完成两个主要任务:
- 符号解析。目标文件定义和引用符号。符号解析的目的是将每个符号引用刚好和一个符号定义联系起来。
- 重定位。编译器和汇编器生成从地址0开始的代码和数据节。链接器通过把每个符号定义与一个存储器位置联系起来,然后修改所有对这些符号的引用,使得他们指向这个存储器,从而重定位这些节。
目标文件纯粹是字节块的结合,有些包含程序代码,有些包含程序数据。
三、目标文件
目标文件有三种形式:
- 可重定位目标文件。包含二进制代码和数据,其可以在编译时与其他可重定位目标文件合并起来,创建一个可执行目标文件
- 共享目标文件。一种特殊类型的可重定位目标文件,可以在加载或者运行时被动态地加载到存储器并链接
- 可执行目标文件。包含二进制代码和数据,其形式可以被直接拷贝到存储器并执行
编译器和汇编器生成可重定位目标文件(包括共享目标文件)。链接器生成可执行目标文件。
从技术上来讲,一个目标模块就是一个字节序列,而一个目标文件就是一个以文件形式存放在磁盘中的目标模块。
四、可重定位目标文件
ELF(Executable and Linkable Format)可重定位目标文件的格式,夹在ELF头和节头部表之间的都是节。一个典型的ELF可重定位目标文件包含下面几个节:
ELF头
:以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。.text:
已编译程序的机器代码。.rodata:
只读数据,比如printf语句中格式串和开关语句的跳转表。.data:
已初始化的全局和静态C变量。局部C变量在运行时被保存在栈中,既不出现在.data
节中,也不在.bss
节中。.bss:
未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量。在目标文件中这个节不占据实际的空间,它仅仅是一个占位符。在目标文件中,未初始化变量不需要占据任何实际的磁盘空间。运行时,在内存中分配这些变量,初始值为0,这样提高了空间效率。.symtab:
符号表,它存放在程序中定义和引用的函数和全局变量的信息。.rel.text:
一个.text
节中位置的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。一般而言,任何调用外部函数或引用全局变量的指令都需要修改。调用本地函数的指令则不需要修改。可执行目标文件中并不需要重定位信息,因此通常忽略。.rel.data:
被模块引用或定义的全局变量的重定位信息。一般任何已初始化的全局变量,如果它的初始值是一个全局变量地址或外部定义函数的地址,都需要修改。.debug:
一个调试符号表,其条目是程序中定义的局部变量和类型定义,程序中定义和引用的全局变量,以及原始的C源程序。.line:
原始C源程序中的行号和.text
节中机器指令之间的映射。.strtab:
一个字符串表,其内容包括.symtab
和.debug
节中的符号表,以及节头部中的节名字。
五、符号和符号表
每个可重定位目标模块(可理解为目标文件)m都有一个符号表,它包含m所定义和引用的符号信息。在链接器的上下文中,有三种不同的符号:
- 由m定义并能被其他模块引用的全局符号。全局链接器符号对应于非静态的C函数以及被定义为不带C
static
属性的全局变量。 - 由其他模块定义并被模块m引用的全局符号。这些符号称为外部符号,对应于定义在其他模块中的C函数和变量。
- 只被模块m定义和引用的局部符号。它们对应于带
static
属性的C函数和全局变量,这些符号在模块m中任何位置都可见,但是不能被其他模块引用。
本地链接器符号和本地程序变量是不同的。.symtab
中的符号表不包含对应于本地非静态程序变量的任何符号。这些符号在运行时栈中管理,链接器对此类符号不感兴趣。
定义为带有C static
属性的本地过程变量是不在栈中管理的,相反,编译器在.data
或.bss
中为每个定义分配空间,并在符号表中创建一个有唯一名字的本地链接器符号。比如,同一个模块中两个函数各自定义了一个静态局部变量x
:
int f() {
static int x = 0;
return x;
}
int g() {
static int x = 1;
return x;
}
这种情况下,编译器向汇编器输出两个不同名字的局部链接器符号,比如它可以用x.1
表示函数f
中的定义,而用x.2
表示函数g
中的定义。
【注】C语言中,不带static
属性的全局变量和函数是公有的,相当于C++中的public
;带static
属性的是私有的,相当于private
。可以使用static
属性隐藏全局变量和函数的名字,避免被其他的源文件引用。
以下是符号表中包含的符号条目结构,也可参考链接的接口——符号。
typedef struct {
int name; /* String table offset */
char type:4, /* Function or data (4 bits) */
binding:4; /* Local or global (4 bits) */
char reserved; /* Unused */
short section; /* Section header index */
long value; /* Section offset or absolute address */
long size; /* Object size in bytes */
} Elf64_Symbol;
每个符号都被分配到目标文件的某个节,由 section
字段表示,该字段也是一个到节头部表的索引。有三个特殊的伪节(pseudosection),它们在节头部表中是没有条目的:
ABS
代表不该被重定位的符号;UNDEF
代表未定义的符号,也就是在本目标模块中引用,但是却在其他地方定义的符号;COMMON
表示还未被分配位置的未初始化的数据目标。对于COMMON
符号,value
字段给出对齐要求,而size
给出最小的大小。
六、符号解析
链接器解析符号引用的方法是将每个引用与它输入的可重定位目标文件的符号表中的一个确定的符号定义关联起来。编译器只允许每个模块中每个局部符号有一个定义。静态局部变量也会有本地链接器符号,编译器还要确保它们的名字是唯一的。
如果遇到了一个不是在当前模块中定义的符号时,会假设该符号是在其他某个模块中定义的,生成一个链接器符号表条目,并把它交给链接器处理。如果链接器在它的任何输入模块中都找不到这个被引用符号的定义,就输出一条错误信息并终止。还因为多个目标文件可能会定义相同名字的全局符号,此时链接器要么抛出一个错误,要么以某种方法选出一个定义并抛弃其他定义。
6.1 链接器如何解析多重定义的全局符号
链接器的输入是一组可重定位目标模块,每个模块定义一组符号,有些是局部的(只对定义该符号的模块可见),有些是全局的(对其他模块可见)。
如果多个模块定义同名的全局符号,Linux编译系统则在编译时,编译器向汇编器输出每个全局符号,包括强符号与弱符号,而汇编器把这个信息隐含地编码在可重定位目标文件的符号表里。
函数和已初始化的全局变量是强符号,未初始化的全局变量是弱符号。并且使用如下的规则来处理:
- 规则1:不允许有多个强符号。
- 规则2:如果有个强符号和多个弱符号,那么选择强符号。
- 规则3:如果有多个弱符号,那么从这些弱符号中任意选择一个(或占用空间最大的一个)。
链接器将生成一条错误信息,因为强符号main
被定义了多次(规则1):
/* foo1.c */
int main() {
return 0;
}
/* bar1.c */
int main() {
return 0;
}
如果在一个模块里x
未被初始化,那么链接器将安静的选择在另一个模块中定义的强符号(规则2):
/* foo3.c */
#include<stdio.h>
void f(void);
int x = 123456;
int main() {
f();
printf("x=%d", x);
return 0;
}
/* bar3.c */
int x;
void f() {
x = 12345;
}
// 函数f将x的值由123456改成12345
如果x
有两个弱定义,也会发生一样的事情(规则3),所以2、3规则会造成一些不易察觉的运行时错误:
/* foo4.c */
#include<stdio.h>
void f(void);
int x;
int main() {
x = 123456;
f();
printf("x = %d", x);
return 0;
}
/* bar4.c */
int x;
void f() {
x = 12345;
}
6.2 与静态库链接
所有的编译系统都提供一种机制,将所有相关的目标模块打包成一个单独的文件,叫做静态库,它可以用做链接器的输入,当链接器构造一个输出的可执行文件时,它只复制静态库里被应用程序引用的目标模块。
比如说:有一个静态库mylib.a
,是由三个目标模块sum.o
(只有sum
函数)、max.o
(只有max
函数)和sin.o
(只有sin
函数)打包而成,应用程序main.c
使用了max
函数。编译器与汇编器将main.c
编译成可重定位目标文件main.o
,然后再将main.o
和mylib.a
交由链接器,链接器此时会把静态库mylib.a
中的目标模块max.o
拷贝一份副本,再将副本与main.o
生成完全链接的可执行文件main
。虽然sum.o
和sin.o
都在静态库mylib.a
中,但是应用程序并没有引用其中的符号,所以生成的可执行文件main
没有它们的副本。
静态库的概念提出后,相关的函数可以被编译为若干个独立的目标模块,然后封装成一个单独的静态库文件。然后,应用程序可以通过在命令行上指定单独的文件名字来使用这些库中定义的函数。链接时,链接器将只复制被程序引用的目标模块,这就减少了可执行文件对内存和磁盘的浪费。
在linux系统中,静态库以一种称为存档(archive)的特殊文件格式存放在磁盘中。存档文件是一组连接起来的可重定位目标文件的集合,有一个头部来描述每个成员目标文件的大小和位置。存放文件名由后缀.a
标识。
6.3 链接器如何使用静态库来解析引用
在符号解析阶段,链接器从左到右按照它们在编译器驱动程序命令行上出现的顺序来扫描可重定位目标文件和存档文件。(驱动程序自动将命令行中所有的.c
文件翻译为.o
文件)。在这次扫描中,链接器维护一个可重定位目标文件的集合E
(这个集合中的文件被合并起来形成可执行文件),一个未解析的符号集合U
(即引用了但尚未定义的符号),一个在前面输入文件中已定义的符号集合D
。初始时,E、U和D都是空的。
- 对于命令行上每个输入文件
f
,链接器会判断f
是一个目标文件还是一个存档文件。如果f
是一个目标文件,那么链接器把f
添加到E,修改U和D来反馈f
中的符号定义和引用,并继续下一个输入文件。 - 如果
f
是一个存档文件,那么链接器就尝试匹配U中未解析的符号和存档文件成员定义的符号。如果某个存档文件成员m
,定义了一个符号来解析U中的一个引用,那么就会拷贝一份m
的副本添加到E中,并且链接器修改U和D来反映m
中的符号定义和引用。对存档文件中所有的成员目标文件都依次进行这个过程,直到U和D都不再发生变换。此时,任何不包含在E中的成员目标文件都被丢弃,链接器继续处理下一个输入文件。 - 如果当链接器完成对命令行上输入文件的扫描后,U是非空的,那么链接器就会输出错误并终止,否则,它会合并和重定位E中的目标文件,构建输出的可执行文件。
不过这种算法会因为链接时,命令行上面目标文件和静态库的出现的位置而产生一些链接时错误,一般关于库的准则是将他们放在命令行的结尾,如果有满足依赖需求(库与库之间互相引用),则可以在命令行上面重复库。总之就是链接器扫描时,符号的引用必须在符号的定义之后。
七、重定位
一旦链接器完成了符号解析,就把代码中每个符号引用和正好一个符号定义关联起来。此时,链接器就知道它的输入目标模块中的代码节和数据节的确切大小。现在就可以开始重定位步骤了,在这个步骤中,将合并输入模块,并为每个符号分配运行时地址。
- 重定位节和符号定义。在这一步,链接器将所有相同类型的节合并为同一类型的新的聚合节。例如,来自所有输入模块的
.data
节全被合并成一个节,这个节成为输出的可执行目标文件的.data
节。然后,链接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。当这一步完成,程序中的每条指令和全局变量都有唯一的运行时内存地址了。 - 重定位节中的符号引用。在这一步中,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。要执行这一步,依赖于下面要介绍的可重定位目标模块中称为重定位条目的数据结构。
7.1 重定位条目
当汇编器生成一个目标模块时,它并不知道数据和代码最终将放在内存中的什么位置。也不知道这个模块引用的任何外部定义的函数或者全局变量的位置,所以当汇编器遇到对最终位置未知的目标引用,就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。代码的重定位条目放在.rel.text
中。已初始化数据的重定位条目放在.rel.data
中。
以下是ELF 重定位条目。每个条目表示一个必须被重定位的引用,并指明如何计算被修改的引用。
typedef struct {
long offset; /* Offset of the reference to relocate */
long type:32, /* Relocation type */
symbol:32; /* Symbol table index */
long addend; /* Constant part of relocation expression */
} Elf64_Rela;
各个字段的含义:
offset
:需要被修改的引用的节偏移。type
:告知链接器如何修改新的引用。symbol
:标识被修改引用应该指向的符号。addend
:一个有符号常数,一些类型的重定位要使用它对被修改引用的值做偏移调整。
ELF 定义了 32 种不同的重定位类型,有些相当隐秘。我们只关心其中两种最基本的重定位类型:
R_X86_64_PC32
。重定位一个使用 32 位 PC 相对地址的引用。一个 PC 相对地址就是距程序计数器(PC)的当前运行时值的偏移量。当 CPU 执行一条使用 PC 相对寻址的指令时,它就将在指令中编码的 32 位值加上 PC 的当前运行时值,得到有效地址(如 call 指令的目标),PC 值通常是下一条指令在内存中的地址。R_X86_64_32
。重定位一个使用 32 位绝对地址的引用。通过绝对寻址,CPU 直接使用在指令中编码的 32 位值作为有效地址,不需要进一步修改。
这两种重定位类型支持 x86-64 小型代码模型(small code model),该模型假设可执行目标文件中的代码和数据的总体大小小于 2GB,因此在运行时可以用 32 位 PC 相对地址来访问。GCC 默认使用小型代码模型。大于 2GB 的程序可以用 -mcmodel=medium
(中型代码模型)和 -mcmodel=large
(大型代码模型)标志来编译,不过在此我们不讨论这些模型。
7.2 重定位符号引用
阅读符号解析与重定位吧!内容和这一样。思路都是:先找到每个可重定位文件的重定位条目(叫重定位表也行,objdump -r rel_file.o
文件的重定位条目),确定哪些符号的位置需要重定位。然后再扫描全局符号表,此时符号表中所有的符号的虚拟地址都已经确定,用匹配到符号的地址值替换掉原来的位置上值,重定位过程完成。
八、可执行目标文件
链接器将多个目标文件合并成一个可执行目标文件,一个简单的C程序,开始时是一组ASCII文本文件,到后来转化成一个二进制文件,并且这个二进制文件包含加载程序到内存并运行它所需要的所有信息。
一个典型的ELF可执行文件中的各类信息:
EFL头部描述文件的总体格式。它还包括程序的入口点,也就是程序执行的第一条指令地址。.text
、.data
节和可重定位目标文件中的节是相似的,除了这些节已经被重定位到他们最终的运行时存储器地址以外。.init
节定义了一个小函数,叫做_init
,程序的初始化代码会调用它。因为可执行文件是完全链接的(已经被重定位),所以它不再需要.rel
节。
ELF可执行文件被设计的很容易加载到内存,可执行文件的连续的片(chunk)被映射到连续的内存段,程序头部表描述了这种映射关系。下面是一个可执行文件的头部表:
从上面程序头部表来看,根据可执行文件的内容初始化两个内存段:1、2行的代码段,3、4行的数据段。
- 代码段:有读/执行的权限,开始于内存地址vaddr处,总共的内存大小是
memsz
,并且被初始化为可执行目标文件的头filesz
个字节,其中包括ELF的头、程序的头部表以及.init
、.text
和.rodata
节。 - 数据段:有读写权限,开始于内存地址
vaddr
处,总共的内存大小是memsz
,并用从目标文件中偏移off
处开始的.data
节中的filesz
个字节初始化。该段中剩下的8个字节对应于运行时被初始化为0的.bss
数据。
对于任何段,链接器必须选择一个起始地址vaddr
,使得:
vaddr mod align = off mod align
九、加载可执行目标文件
在Linux中运行可执行目标文件prog,可以在Linux shell的命令行运行:
linux> ./prog
通过调用某个驻留在存储器中叫做加载器的操作系统代码来运行它,加载器将可执行目标文件中的代码与数据从磁盘复制到内存,然后通过跳转到程序的第一条指令或入口点来运行该程序,这个将程序复制到内存并运行的过程叫做加载。
可执行文件在加载之前,其位于磁盘上。操作系统首先创建虚拟内存,然后建立虚拟内存与磁盘上的可执行文件之间的映射关系。待可执行文件的程序要执行时,CPU将PC设置为可执行文件的入口地址(虚拟地址),此时入口处的内容还没有装入内存,也就是说入口处的虚拟地址对应的虚拟页和物理内存的物理页之间没有建立映射关系,CPU发现PC中的虚拟地址所在页是一个空白页,则认为其是一个页错误。CPU将控制权交给操作系统,这时候就需要用到前面提到的可执行文件和虚存之间的映射结构。操作系统会查询这个数据结构,然后知道空页面所在的VMA,计算出相应的页面在可执行文件中的偏移,然后在物理内存中分配一个物理页面,将进程中的该虚拟页与分配的物理页之间建立映射关系,然后把控制权交给进程,进程从刚才页错误的位置重新开始执行。——总结于《链接装载与库:第六章——可执行文件的装载与进程》
每个Linux程序都有一个运行时内存映像:
代码段总是从地址0x400000
处开始,后面是数据段。运行时堆在数据段之后,通过调用malloc库往上增长。堆后面的区域是为共享模块保留的。用户栈总是从最大的合法用户地址2^(48-1)
开始,向较小内存地址增长。栈上的区域,从地址2^(48-1)
开始,是为内核中的代码和数据保留的。
【总结】当加载器运行时,它创建类似上图的内存映像。在程序头部表的引导下,加载器将可执行文件的片复制到代码段和数据段。接下来,加载器跳转到程序的入口点,初始化执行环境,调用用户层的main
函数,处理main
函数的返回值,并且在需要的时候把控制返回给内核。
十、动态链接共享库
为了解决静态库的一些缺点:
- 需要定期维护和更新。
- 几乎每个C程序都使用标准IO函数,在运行时,这些函数的代码会被复制到每个运行进程的文本段中,在一个运行上百个进程的典型系统上,这是对稀缺的内存系统资源的极大浪费。
共享库是一个目标模块,在运行或加载时,可以加载到任意的内存地址,并和一个在内存中的程序链接起来。这个过程称为动态链接,是由一个叫做动态链接器的程序来执行的。共享库也称为共享目标,在Linux系统中通常用.so
后缀来标识。微软的操作系统大量地使用了共享库,它们称为DLL
。
共享库是以两种不同的方式“共享”的:
- 在任何给定的文件系统中,对于一个库只有一个
.so
文件。所有引用该库的可执行目标文件共享这个.so
文件中的代码和数据,而不是像静态库的内容那样被复制和嵌入到引用它们的可执行的文件中。 - 在内存中,一个共享库的
.text
节的一个副本可以被不同的正在运行的进程共享。
下图是程序main2.c的一个动态链接的过程:
// main2.c
#include <stdio.h>
#include "vector.h"
int x[2] = { 1, 2 };
int y[2] = { 3, 4 };
int z[2];
int main() {
addvec(x, y, z, 2);
printf("z = [%d %d]\n", z[0], z[1]);
return 0;
}
在链接器中,没有任何libvector.so
的代码和数据节真的被复制到可执行文件prog21
中。而是复制了一些重定位和符号表信息,它们使得运行时可以解析对libvector.so
中代码和数据的引用。在加载部分链接的可执行文件prog21
时,会发现prog21
中包含一个.interp
节,这个节包含动态链接器的路径名,动态链接器本身就是一个共享目标。加载器不会像通常那样将控制传递给应用。而是加载和运行这个动态链接器。然后,动态链接器通过执行下面的重定位完成链接任务:
- 重定位
libc.so
文件和数据到某个内存段 - 重定位
libvector.so
的文本和数据到另一个内存段 - 重定位
prog21
中所有对由libc.so
和libvector.so
定义的符号的引用
最后,动态链接器将控制传递给应用程序。
十一、从应用程序中加载和链接共享库
动态链接是一项强大有用的技术,现实世界中的一些例子:
- 分发软件更新。
- 构建高性能Web服务器。
十二、位置无关代码
《链接装载与库:第七章——动态链接》中地址无关代码也有对这一块做总结。
共享库的一个关键目的是为了使多个进程能够共享内存中的同一份代码拷贝,已达到节约内存资源的目的。如何做到呢?
-
一种方法是预先为每一个共享库指定好加载的地址范围,然后要求加载器总是将共享库加载至指定的位置。这种方法尽管很简单,但是会产生一些严重的问题。因为就算一个进程并没有用到某个库,相应的地址范围依然会被保留下来,这是一种效率很低的内存使用方式。另外,这种方法管理起来也很困难。我们必须保证预留的地址块之间没有重叠。每当一个库被修改后,我们还必须要保证它能被放回到修改前的位置,否则,我们还要为它重新找一个新的位置。当我们创建一个新的库时,我们还要为它寻找合适的空间,地址空间碎片化造成的大量无用的内存空洞。更糟糕的是,不同的系统为动态库分配内存的方式不尽相同,这使得管理起来更为困难。
-
一个更好的方法是将动态库编译成可以在任意位置加载而无需链接器进行修改。这样的代码被称作位置无关代码(PIC)
GNU编译系统可以通过指定-fPIC
选项来生成PIC代码,在IA32系统中,对于同一个模块中的符号的引用无需特殊处理使之成为PIC,因为其引用相对于PC地址的偏移量是已知的。但是,对外部过程的调用和对全局变量的引用一般却不是PIC的,因此需要在链接的时候进行重定位
1) PIC数据引用
ELF文件有一个事实:无论我们在内存中的何处加载一个目标模块(包括共享目标模块),数据段与代码段的距离总是保持不变。因此,代码段中任何指令和数据段中任何变量之间的距离都是一个运行时常量,与代码段和数据段的绝对内存位置是无关的。
编译器利用这个事实生成对全局变量PIC的引用,它在数据段开始的地方创建了一个表,叫做全局偏移量表(Global Offset Table,GOT)。在GOT中,每个被这个目标模块引用的全局数据目标(过程或全局变量)都有一个8字节条目。编译器还为GOT中每个条目生成一个重定位记录。在加载时,动态链接器会重定位GOT中的每个条目,使得它包含目标的正确的绝对地址。每个引用全局目标的目标模块都有自己的GOT。
下图展示了示例libvector. so共享模块的GOT。addvec例程通过GOT[3]间接地加载全局变量addcnt
的地址,然后把addcnt
在内存中加1。
因为addcnt
是由libvector. so模块定义的,编译器可以利用代码段和数据段之间不变的距离,产生对addcnt
的直接PC相对引用,并增加一个重定位,让链接器在构造这个共享模块时解析它。不过,如果addcnt是由另一个共享模块定义的,那么就需要通过GOT进行间接访问。在这里,编译器选择采用最通用的解决方案,为所有的引用使用GOT。
2) PIC函数调用
这里主要用的是延迟绑定技术。
延迟绑定是通过两个数据结构之间简洁但又有些复杂的交互来实现的,这两个数据结构是:GOT和过程链接表(Procedure Linkage Table,PLT)。如果一个目标模块调用定义在共享库中的任何函数,那么它就有自己的GOT和PLT。GOT是数据段的一部分,而PLT是代码段的一部分。
图7-19展示的是PLT和GOT如何协作在运行时解析函数的地址。
- 过程链接表(PLT):PLT是一个数组,其中每个条目是16字节代码。
PLT[0]
是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。PLT[1]
(图中未显示)调用系统启动函数(_libc_start_main
),它初始化执行环境,调用main
函数并处理其返回值。从PLT[2]
开始的条目调用用户代码调用的函数。在我们的例子中,PLT[2]
调用addvec
,PLT[3]
(图中未显示)调用printf。 - 全局偏移量表(GOT):正如我们看到的,GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,
GOT[0]
和GOT[1]
包含动态链接器在解析函数地址时会使用的信息。GOT[2]
是动态链接器在ld-linux. so
模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。例如,GOT[4]
和PLT[2]
对应于addvec
。初始时,每个GOT条目都指向对应PLT条目的第二条指令。
以下是上图调用addvec时,延迟解析它的运行时地址过程:
- 第1步。在addvec被第一次调用时,不直接调用
addvec
,程序调用进入PLT[2]
,这是addvec
的PLT条目。 - 第2步。第一条PLT指令通过
GOT[4]
进行间接跳转。因为每个GOT条目初始时都指向它对应的PLT条目的第二条指令,这个间接跳转只是简单地把控制传送回PLT[2]
中的下一条指令。 - 第3步。在把
addvec
的ID(0x1
)压入栈中之后,PLT[2]
跳转到PLT[0]
。 - 第4步。
PLT[0]
通过GOT[1]
间接地把动态链接器的一个参数压入栈中,然后通过GOT[2]
间接跳转进动态链接器中。动态链接器使用两个栈条目来确定addvec
的运行时位置,用这个地址重写GOT[4]
,再把控制传递给addvec
。 - 后续再调用addvec时,和前面一样,控制传递到
PLT[2]
。不过这次通过GOT[4]
的间接跳转会将控制直接转移到addvec
。