一:链接的基本概念
链接(linking)是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。
链接可以执行于编译时,也可以执行于加载时,甚至执行于运行时。在现代系统中,链接是由叫做链接器(linker)的程序自动执行的。
为什么需要链接器呢?一切都是为了简单、为了方便!试想一下,一个巨大的工程有巨大的源文件,包含N多个模块,如果没有链接的存在,那么当你改动某个模块时,不得不重新编译整个工程,消耗巨大的时间和资源。而在链接器的帮助下,你只需要简单编译修改过的模块,之后重新链接生成可执行文件就OK了。
下面,我们将基于一个运行Linux的x86-64系统,详细讨论关于链接的各个方面。
二:从代码到可执行文件
考虑如下的一个c语言程序:
code/link/main.c
int sun(int *a, int n);
int array[2] = {1, 2};
int main()
{
int val = sum(array, 2);
return val;
}
code/link/sum.c
int sum(int *a, int n)
{
int i, s = 0;
for (i = 0; i < n; i++)
s += a[i];
return s;
}
从源文件到可执行文件需要哪几个步骤呢?
1:预处理器将C的源程序main.c翻译成一个ASCII码的中间文件main.i:
cpp [other arguments] main.c /tmp/main.i
2:编译器将main.i翻译成一个ASCII汇编语言文件main.s:
cc1 /tmp/main.i -Og [other arguments] -o /tmp/main.s
3:汇编器将main.s翻译成一个可重定位目标文件main.o:
as [other arguments] -o /tmp/main.o /tmp/main.s
4:链接器将main.o和sum.o以及一些必要的系统目标文件结合起来,创建一个可执行目标文件prog:
ld -o prog [system object files and args] /tmp/main.o /tmp/sum.o
经过以上四个步骤,我们便可以在Linux Shell的命令行上执行它了:
./prog
shell调用操作系统中一个叫做加载器(loader)的函数,它将可执行文件的代码和数据复制到内存,然后将控制转移到这个程序的开头。
三:链接器任务
像Linux LD程序这样的静态链接器以一组可重定位目标文件和命令行参数作为输入,生成一个完全链接的、可以加载和运行的可执行目标文件作为输出。为了达到这个目的,链接器必须完成两个主要任务:
1:符号解析(sysbol resolution):符合可以是一个函数、一个全局变量或者一个静态变量,在目标文件中有对这个符号进行定义和引用的地方,符号解析的目的就是将每个引用和定义相关联起来。
2:重定位(relocation):编译器和汇编器生成从地址0开始的代码和数据,而当实际运行在内存中时,它们的地址肯定会发生变化。重定位的作用就是将每个符号定义与一个内存位置相关联起来,然后修改所有对这些符号的引用,使得它们指向实际的内存位置。
四:可重定位目标文件
链接器链接的是一组可重定位目标文件,那么在叙述如何链接之前,首先得了解目标文件的格式。下面是一个典型的ELF可重定位目标文件格式:
ELF头以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序,ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件类型(如可重定位的、可执行的或者共享的)、机器类型(如x86-64)、节头部表的文件偏移,以及节头部表中条目的大小和数量。不同节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定大小的条目(entry)。
在ELF头和节头部表之间的都是节。一个典型的ELF可重定位目标文件包含下面几个节:
.text:已编译程序的机器代码
.rodata:只读数据,比如printf语句中的格式串
.data:已初始化的全局和静态C变量。局部C变量运行时被报存在栈中
.bss:未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量。在目标文件中,未初始化的变量不占据任何实际的磁盘空间,它们在运行时在内存中分配并设为0
.symtab:符号表,它存放在程序中定义和引用的函数和全局变量的信息
.rel.text:代码的重定位条目
.rel.data:已初始化数据的重定位条目
.debug:调试符号表,编译加上-g选项时会用到该表
.line:原始C源程序中的行号和.text节中机器指令之间的映射,也是-g选项编译时会用到该表
.strtab:字符串表,其中包括.symtab和.debug节中的符号表,以及节头部中的节名字。它就是一串以NULL结尾的字符串的序列。
五:符号和符号表
链接器的第一个任务是符号解析,那么这些符号保存在哪里?又有哪些类型的符号呢?对于第一个问题,每个可重定位目标模块m都有一个符号表,这个符号表包含了m所定义和引用的符号的信息。对于第二个问题,在链接器的上下文中,有三种不同的符号:
1:由模块m定义并能被其它模块引用的全局符号,可以理解为非静态的C函数和全局变量。
2:由其它模块定义并被m模块所引用的全局符号,也就是外部符号,可以理解为在其它模块中定义的非静态C函数和全局变量。
3:只被模块m定义和引用的局部符号,可以理解为带static属性的C函数和全局变量。
这些符号和本地程序变量是不同的,因为符号表中不包含本地局部变量。
注意如下一个例子:
int f()
{
static int x=0;
return x;
}
int g()
{
static int x=1;
return x;
}
上述两个同一模块中的两个函数都创建了static的x变量,在这种情况下,编译器向汇编器输出两个不同名字的局部链接器符号,比如x.1、x.2这样的。
下面来说符号表,符号表是由汇编器构造的,符号来源于.s文件。.symtab节中包含ELF符号表,这种符号表包含了一个条目的数组,每个条目的格式如下图所示:
name是字符串表中的字节偏移,指向引用的函数或者变量名称。value是符号的地址,对于可重定位的模块来说,value是距定义目标的节的起始位置的偏移;对于可执行目标文件来说,该值是一个绝对运行地址。size是目标的大小。type通常要么是数据,要么是函数。binding表示是本地的还是全局的。
section表示该符号被分配到目标文件的哪个节。而除了第四章中提到的节之外,还有三个特殊的伪节(pseudosection),它们在节头部表中是没有条目的:ABS代表不该被重定位的符号;UNDEF代表未定义的符号,也就是在本地引用,却在其它模块定义的符号;COMMON表示还未被分配位置的未初始化的数据目标。对于COMMON符号,value字段给出对齐要求,而size给出最小的大小。注意,只有可重定位的目标文件才有这些伪节,可执行目标文件中是没有的。
COMMON和.bss的区别很细微。现在的GCC版本根据以下规则来将可重定位目标文件中的符号分配到COMMON和.bss中:
COMMON:未初始化的全局变量
.bss: 未初始化的静态变量,以及初始化为0的全局或静态变量
举个例子,下面是main.o的符号表中的最后三个条目:
我们可以看到全局符号main的条目,它是一个欸与.text节中偏移量为0处的24字节函数。其后是全局符号array的定义,它是一个位于.data节中偏移量为0的8字节目标。最后一个是外部符号sum的引用。Ndx=1表示.text节,而Ndx=3表示.data节,这和第四章中的ELF目标文件格式是相对应的,Ndx=UND则指向伪节UNDEF。
六:符号解析
现在已经了解了符号在符号表中的格式,那么下一步自然就是来解析这些符号。链接器解析符号引用的方法是将每个引用于它输入的可重定位目标文件的符号表示中的一个确定的符号定义关联起来。那么,对于局部的和全局的符号来说,它们的解析复杂度也是完全不一样的。
对于那么引用和定义在同一模块中的局部符号而言,符号解析是比较简单的:编译器只允许每个模块中每个局部符号有一个定义,那么链接器自然而然只需要将个唯一的定义和该符号关联起来即可。静态局部变量也是如此,同时编译器还会确保它们拥有唯一的名字。
对于全局符号的解析,问题就相对复杂的多了:当编译器遇到一个不是在当前模块定义的符号(变量或函数名)时,会假设该符号是在其它某个模块中定义的,生成一个链接器符号表条目,并把它交给链接器处理。如果链接器在它的任何输入模块中都找不到这个被引用符号的定义,就会输出一条错误信息并终止,比如我们常常见到的:undefined reference to ‘...’。
链接器除了可能找不到符号定义外,也可能发现有多个目标文件同时定义了相同名字的全局符号。在这种情况下,链接器要么标志一个错误,要么以某种方法选出一个定义并抛弃其它定义。对于Linux系统采用的方法涉及了编译器、汇编器和链接器之间的合作,下面我们来进行说明。
6.1:链接器如何解析多重定义的全局符号
对于Linux系统,当存在有多个全局符号时,那么在编译阶段,编译器会向汇编器输出每个全局符号,区别是这些符号分为强(strong)或弱(weak),汇编器则将这些强弱信息隐含在可重定位目标文件的符号表中。那么什么是强符号,什么又是弱符号呢?区别如下:函数和已初始化的全局变量是强符号,未初始化的全局变量是弱符号。
到了链接阶段,链接器从符号表中获取符号的强弱信息,并使用下面的规则来处理多重定义的符号名:
规则1:不允许有多个同名的强符号。
规则2:如果有一个强符号和多个弱符号,那么选择强符号。
规则3:如果有多个弱符号同名,那么从这些弱符号中任意选择一个。
我们通过一些例子来进行说明:
例1:假设我们试图编译和链接下面两个C模块:
/* foo1.c */ /* bar1.c */
int main() int main()
{ {
return 0; return 0;
} }
这种情况下,链接器会发出一条错误信息,因为强符号main被定义了多次:
multiple definition of ‘main’
例2:同样的对于下面两个全局变量:
/* foo2.c */ /* bar2.c */
int x = 15213; int x=15213;
int main() void f()
{ {
return 0; }
}
由于定义了两个强符号x,所以链接器会发出同样的错误信息。
例3:然而对于同上的例子,如果一个模块中的x未被初始化:
/* foo3.c */ /* bar3.c */
void f(void);
int x = 15213; int x;
int main() void f()
{ {
f(); x = 15212;
printf(“x=%d\n”,x); }
return 0;
}
在运行时,x将会打印出15212,同时,链接器通常不会表明它检测到多个x的定义,需要多加注意。
例4:如果x有两个弱定义,也会发生相同的事情:
/* foo4.c */ /* bar4.c */
void f(void);
int x; int x;
int main()
{ void f()
x = 15213; {
f(); x = 15212;
printf(“x=%d\n”,x); }
return 0;
}
还记得在第五章中,我们讲到编译器如何按照一个看似绝对的规则来把符号分配为COMMON和.bss。现在我们可以说明,采用这个规则是在某些情况下,链接器允许多个模块定义同名的全局符号。当编译器翻译某个模块时,遇到一个全局弱符号x,它并不知道其他模块是否也定义了x,如果是,编译器则无法预测链接器使用x多重定义中的哪一个,所以它将x分配成COMMON,把决定权留给链接器。另一方面,如果x被初始化了,那么它是一个强符号,因为根据规则2它必须是唯一的,所以编译器可以很自信的将它分配成.bss。类似的,静态符号的构造就必须是唯一的,所以编译器可以直接把它们分配成.data或.bss。
6.2:与静态库链接
迄今为止,我们都是假设链接器的输入是一组可重定位目标文件。然后,在实际工作中,许多地方都需要用到静态库,它同样可以作为链接器的输入。当链接器构造一个输出的可执行文件时,它只复制静态库里被应用程序引用的目标模块。
比如,使用C库函数和数学库中的函数的程序可以用如下的命令行来进行编译和链接:
gcc main.c /usr/lib/libm.a /usr/lib/libc.a
再举一个最常见的printf例子,当链接器读到printf符号时,它会复制libc.a中的printf.o模块到可执行文件。
在Linux系统中,静态库以一种成为存档(archive)的特殊文件格式存放在磁盘中。存档文件是一组连接起来的可重定位目标文件的集合,有一个头部用来描述每个成员的目标文件的大小和位置。存档文件由后缀名.a标识。
那么,链接器如歌使用静态库来解析引用呢?过程如下:
在符号解析阶段,链接器从左到右按照它们在命令行上出现的顺序来扫描可重定位目标文件和存档文件。在这次扫描中,链接器维护一个可重定位目标文件的集合E(这个集合中的文件会被合并起来形成可执行文件),一个为解析的符号集合U(即引用了但是尚未定义的符号),以及一个在前面的输入文件中已定义的符号集合D。初始时,E、U和D均为空。
1:对于命令行上的每个输入文件f,链接器会判断f是一个目标文件还是一个存档文件。如果f是一个目标文件,则把f添加到E,修改U和D来反映f中的符号定义和引用,并继续下一个输入文件。
2:如果f是一个存档文件,那么链接器就尝试匹配U中未解析的和由存档文件成员定义的符号。如果某个存档文件成员m,定义了一个符号来解析U中的一个引用,那么就将m添加到E中,并且链接器修改U和D来反映m中的符号定义和引用。对存档文件中所有的成员目标文件都依次进行这个过程,知道U和D都不再发生变化。此时,任何不包含在E中的成员目标文件都简单地被丢弃,而链接器将继续处理下一个输入文件。
3:如果当链接器完成对命令行上输入文件的扫描后,U是非空的,那么链接器就会输出一个错误并终止。否则,它会合并和重定位E中的目标文件,构建输出的可执行文件。
可惜的是,对于如上算法可能会导致一些令人困扰的链接时错误,因为在命令行上的库和目标文件的顺序非常重要。比如,我们在main函数中调用了libm.a中的某个数学函数,对应如下的命令:
gcc -static /usr/lib/libm.a main.c
在处理libm.a时,U是空的,所以libm.a中的这个数学函数成员没有被添加到E中,自然在main.c中对libm.a数学函数的引用时绝不会被解析的,所以链接器会产生一条错误信息并终止。
因此,我们对于库的使用,一般准则是将它们放在命令行的结尾。如果这些库是互相独立的,那么可以以任意顺序放在命令行的结尾处。但是如果这些库之间有依赖,那就必须对它们进行排序了,必须有一个引用在前,定义在后。
七:重定位
还记得链接器的两个任务吗?符号解析和重定位,现在终于来到重定位了!符号解析的结果是将每一个符号引用和一个符号定义关联起来,这样链接器就知道它的输入目标模块中的代码节和数据节的确切的大小了。重定位所做的工作是合并输入模块,并为每个符号分配运行时地址,具体来说,有两个步骤:
1:重定位节和符号定义。在这一步中,链接器将所有相同类型的节合并为同一类型的聚会节。例如,来自所有输入模块的.data节被全部合并成一个节,这个节成为输出的可执行目标文件的.data节。然后,链接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。当这一步完成时,程序中的每条指令和全局标量都有唯一的运行时内存地址了。
2:重定位节中的符号引用。在这一步中,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。要执行这一步,链接器依赖于可重定位目标模块中称为重定位条目(relocation entry)的数据结构,我们接下来会描述这种数据结构。
7.1:重定位条目
当汇编器生成一个目标模块时,它并不知道数据和代码最终放在内存的什么位置,它也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。所以,无论何时汇编器遇到对最终位置未知的目标引用,它就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。代码的重定位条目放在.rel.text中,已初始化数据的重定位条目放在.rel.data中。
ELF重定位条目的格式如下所示,其中offset是需要被修改的引用的节偏移,symbol标识被修改引用应该指向的符号,type告诉链接器如何修改心的引用,attend是一个由符号常数,一些类型的重定位要用它对被修改引用的值做偏移调整。
ELF共定义了32种不同的重定位类型,我们只关心其中最基本的两种类型:
1:R_X86_64_PC32。重定位一个使用32位PC相对地址的引用。一个PC相对地址就是距程序计数器(PC)的当前运行时值的偏移量。用PC的当前值加上32位的相对地址,就是一条PC相对寻址指令要找的地址。
2:R_X86_64_32。重定位一个使用32位绝对地址的引用。CPU直接使用在指令编码中的32位值作为有效地址,就是绝对寻址的过程。
7.2:重定位符号引用
如何根据上一节中的重定位条目来进行符号引用的重定位呢?下面首先展示链接器的重定位算法的伪代码:
第1行和第2行表示遍历每个节s中的每个节相关联的重定位条目r。为了更具体的描述,我们假设每个节s是一个字节数组,每个重定位条目r是一个类型位ELF64_Rela的结构,如7.1节中的定义。另外,当算法运行时,重定位已经完成了第一步:重定位节和符号定义,这样链接器就为每个节(ADD(s)表示)和每个符号(ADDR(r.symbol))都选择了运行时地址。第3行计算的是需要被重定位的引用的地址,如果这个引用使用的是PC相对寻址,那么就用第5~9行来进行重定位。如果该引用使用的是绝对地址,那么就通过第11~13行来重定位。
还记得第二章那个C语言程序吗?让我们看看链接器是如何用这个算法来重定位其中的引用的吧。下图给出的是用GNU OBJDUMP工具产生的main.o的反汇编代码:
main函数引用了两个全局符号:array和sum。为每个引用,汇编器产生一个重定位条目,如上图第5行和第7行所示,这些条目实际和指令放在目标文件不同的节中,我们为了方便将它们显示在一起。这两个条目告诉我们对sum的引用使用32位PC相对地址进行重定位,而对array的引用使用32位绝对地址进行重定位。接下来会进行详细的说明。
1:重定位PC相对引用
第6行,main函数调用sum函数,sum函数是在模块sum.o中定义的。call指令开始于节便宜0xe的地方,第一个字节0xe8是操作码,后面跟着的是对目标sum的32位PC相对引用的占位符,表明该位置是保留存放相对地址的。
相应的重定位条目r由4个字段组成:
r.offset = 0xf
r.symbol = sum;
r.type = R_X86_64_PC32
r.addend = -4
这些字段告诉链接器修改开始于偏移量0xf(偏移量0xe是操作码,操作码占一个字节)处的32位PC相对引用,这样在运行时它会指向sum例程。现在,我们假设链接器已经确定:
ADD(s)= ADDR(.text)= 0x4004d0
ADD(r.symbol)= ADDR(sum)= 0x4004e8
使用重定位算法,链接器首先计算处引用的运行时地址(第7行):
refaddr = ADDR(s)+ r.offset
= 0x4004d0 + 0xf
= 0x4004df
然后,更新该引用,使得它在运行时指向sum程序(第8行):
*refptr = (unsigned)(ADDR(r.symbol))+ r.addend - refaddr)
= (unsigned)(0x4004e8 + (-4) -0x4004df)
= (unsigned)(0x5)
在得到的可执行目标文件中,call指令有如下的重定位的形式:
4004de:e8 05 00 00 00 callq 4004e8
在运行时,call指令存放在地址0x4004de处,当CPU执行call指令时,PC的值为0x4004e3(CPU的PC值通常时下一条指令在内存中的地址),即e8 05 00 00 00占了5字节,那么下一条指令自然是从0x4004e3开始。为了执行这条指令,CPU执行以下的步骤:
因此,要执行的下一条指令就是sum例程的第一条指令,这就是我们想要的了!
2:重定位绝对引用
重定位的绝对引用就简单的多了。在第4行代码中,mov指令将array的地址(一个32位立即数值)复制到寄存器%edi中,mov指令开始于节偏移量0x9的位置,包括1字节的操作码0xbf,后面跟着array的32位绝对引用的占位符。
对应的占位符条目r包含4个字段:
r.offset = 0xa
r.symbol = array
r.type = R_X86_64_32
r.addend = 0
这些字段告诉链接器要修改从0xa开始的绝对引用,这样在运行时它将会指向array的第一个字节。现在,假设链接器已经确定:
ADDR(r.symbol)= ADDR(array)= 0x601018
算法第13行修改了引用:
*refptr = (unsigned)(ADDR(r.symbol) + r.addend)
= (unsigned)(0x601018 + 0)
= (unsigned)(0x6010180)
在得到的可执行目标文件中,该引用有下面的重定位形式:
4004d9:bf 18 10 60 00 mov $0x601018,%edi %edi=&array
八:可执行目标文件
哇,终于得到了我们的可执行目标文件啦!下面,简单的来描述以下ELF可执行目标文件的格式吧:
可执行目标文件的格式类似于可重定位目标文件的格式。ELF头描述文件的总体格式。它还包括程序的入口点,也就是当程序执行时第一条指令的地址。.text、.rodata和.data节与可重定位目标文件中的节是类似的,除了这些节已经被重定位到它们最终的运行时内存地址以外。.init节定义了一个小函数,叫做_init,程序的初始化代码会条用它。因为可执行文件是完全链接的(已被重定位),所以它不需要.rel节。
九:动态链接共享库
你以为这样就结束了吗?Oh no,当你看到静态库的时候难道没有想到还有共享库吗!
静态库相比共享库的缺点这里就不多说了。共享库是致力于解决静态库缺陷的一个现代创新产物。共享库是一个目标模块,在运行或加载时,可以加载到任意的内存地址,并和一个在内存中的程序链接起来。这个过程称为动态链接(dynamic linking),是由一个叫做动态链接器的程序来执行的。在Linux中,共享库通常用.so后缀来表示。
共享库是以两种不同的方式来“共享”的。首先,在任何给定的文件系统中,对于一个库只有一个.so文件。所以引用该库的可执行目标文件共享这个.so文件中的代码和数据。其次,在内存中,一个共享库的.text节的一个副本可以被不同的正在运行的进程共享。
在Linux系统中,在命令行通过-shared参数来创建一个共享的目标文件,比如:
gcc -shared -fpic -o ab.so a.c b.c
其中-fpic指示编译器生成位置无关码。一旦生成了ab.so这个共享库,就可以像使用静态库那么来对它进行链接了。
在利用共享库创建可执行目标文件时,基本思路是:先静态执行一些链接,然后在程序加载时,动态完成链接过程。认识到这一点是很重要的,此时没有ab.so的代码和数据节真的被复制到可执行文件中。反之,链接器复制了一些重定位和符号表信息,它们使得运行时可以解析对ab.so中代码和数据的引用。
当加载器加载和执行可执行目标文件时,动态链接器开始工作,它通过下面的重定位完成链接任务:
1:重定位ab.so的文本和数据到某个内存段
2:重定位可执行目标文件中所有对ab.so中定义的符号的引用
最后,动态链接器将控制传递给应用程序。从这个时刻开始,共享库的位置就固定了,并且在程序执行的过程中都不会改变。
十:从应用程序中加载和链接共享库
好了,为了十全十美的目标,再来最后一章吧!在第九章中,我们讨论的时应用程序被加载后执行前时,动态链接库加载和链接共享库的场景。然而,应用程序还可能在它运行时要求动态链接器加载和链接某个共享库,而无需在编译时将那么库链接到应用中。Wow,是不是很神奇!
动态链接是一个强大而有用的技术。当我们在构建高性能Web服务器时,许多Web服务器会生成动态内容,比如个性化的Web页面、账户余额和广告标语等。我们可以将每个生成动态内容的函数打包在共享库中。当一个来自Web浏览器的请求到达时,服务器动态的加载和链接适当的函数,然后直接调用它,而不是使用fork和execve在子进程的上下文中运行函数。函数会一直缓存在服务器的地址空间中,所以只要一个简单的函数调用的开销就可以处理随后的请求了。这对一个繁华的网站来说是有很大影响的。更进一步的说,在运行时无需停止服务器,就可以更新已存在的函数,以及添加新的函数。
Linux系统中位动态链接器提供了一个简单的接口,允许应用程序在运行时加载和链接共享库:
dlsym函数的输入是一个指向前面已经打开了的共享库的句柄和一个symbol名字,如果该符号存在,就返回符号的地址,否则返回NULL:
如果没有其它共享库还在使用这个共享库,dlclose函数就卸载该共享库:
dlerror函数返回一个字符串,它描述的是调用dlopen、dlsym或者dlclose函数时发生的最近的错误,如果没有错误发生,就返回NULL:
下面的一个例程中,展示了如何用这个接口动态的链接一个名叫libvector.so的共享库,然后调用其中的addvec函数。要编译这个程序,我们使用以下的命令:
gcc -rdynamic -o prog dll.c -ldl
Code/link/dll.c
结束语:
好了,一切都结束了。。。。。。。。。。。。。。。吗?还有个位置无关码,大家有兴趣的去了解一下吧!