链接
是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载
到内存并执行。链接可以执行于编译时
,也就是在源代码被翻译成机器代码时;也可以执行于加载时
,也就是程序被加载器
加载到内存并执行时;甚至执行于运行时
,也就是由应用程序来执行。现在系统中,链接是由叫做链接器
的程序自动执行的。
链接器
使得分离编译
成为可能。这样我们可以独立修改和编译不同的模块,改变这些模块中的一个时,只需要简单地重新编译它,并重新链接应用,而不必重新编译其他文件。
大多数编译系统提供编译器驱动程序
,它代表用户在需要时调用语言预处理器、编译器、汇编器和链接器。下图展示了驱动程序将程序从ASCII码源文件翻译成可执行目标文件时的行为。
在运行可执行文件prog时,shell调用操作系统中一个叫做加载器
的函数,他将可执行文件prog中的代码和数据复制到内存,然后将控制转移到这个程序的开头。
像Linux LD程序这样的静态链接器
以一组可重定位目标文件和命令行参数作为输入,生成一个完全链接的、可以加载和运行的可执行目标作为输出。输入的可重定位目标文件由各种不同的代码和数据节组成,每一节都是一个连续的字节序列。指令在一节中,初始化了的全局变量在另一节中,而未初始化的变量又在另外一节中。
为了构造可执行文件,链接器完成如下两个主要任务:
1.符号解析
。目标文件定义和引用符号
,每个符号对应于一个函数、一个全局变量或一个静态变量(C语言中任何以static属性声明的变量)
。符号解析的目的是将每一个符号引用
正好和一个符号定义
关联起来。
2.重定位
。编译器和汇编器生成从地址0开始的代码和数据节。链接器通过把每个符号定义与一个内存为止关联,从而重定位
这些节,然后修改所有这些符号的引用,使得它们纸箱这个内存位置。链接器使用汇编器产生的重定位条目
的详细指令,不加甄别地执行这样的重定位。
目标文件纯粹是字节块的集合。这些块中,有些包含程序代码,有些包含程序数据,而其他的则包含引导链接器和加载器的数据结构。链接器将这些块连接起来,确定被连接块的运行时位置,并且修改代码和数据块中的各种位置。
目标文件有三种形式:
1.可重定位目标文件
。包含二进制代码和数据,其形式可以在编译时与其他可重定位目标文件合并起来,创建一个可执行目标文件。
2.可执行目标文件
。包含二进制代码和数据,其形式可以被直接复制到内存并执行
3.共享目标文件
。一种特殊类型的可重定位目标文件,可以在加载或运行时被动态地加载进内存并链接。
编译器和汇编器生成可重定位目标文件(包括共享目标文件)。链接器生成可执行目标文件。
下图是一个典型的ELF可重定位目标文件的格式。不同节的位置和大小是由节头部表表述的,其中目标文件中每个节都有一个固定大小的条目。
.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节中的符号表,以及节头部中的节名字。
链接器解析符号引用的方法是将每个引用与它输入的可重定位目标文件的符号表中的一个确定的符定义关联起来。编译器只允许每个模块中每个局部符号有一个定义(这个模块就是一个C源文件)。静态局部变量也会有本地链接器符号,编译器还要确保它们的名字是唯一的。
对于全局符号比较麻烦,因为多个目标文件可能会定义相同名字的全局符号。如果遇到了一个不是在当前模块中定义的符号时,会假设该符号是在其他某个模块中定义的,生成一个链接器符号表条目,并把它交给链接器处理。如果链接器在它的任何输入模块中都找不到这个被引用符号的定义,就输出一条错误信息并终止。
在编译时,编译器向汇编器输出每个全局符号,或者是强
或者是弱
,而汇编器把这个信息隐含地编码在可重定位目标文件的符号表里。函数和已初始化的全局变量是强符号,未初始化的全局变量是弱符号。并且使用如下的规则来处理:
1.不允许有多个同名的强符号
2.如果一个强符号和多个弱符号同名,那么选择强符号
3.如果有多个弱符号同名,那么从这些弱符号中任意选择一个
如果重复的符号定义有不同的类型,经常导致错误,比如在a.c文件中定义了 int x = 15121;int y = 15322;(两个初始化的符号,强符号)而在b.c文件中定义 double x;(未初始化的符号,弱符号),并且在b.c的函数中进行了赋值:x = -0.0;如果在一台x86-64机器上,double类型是8个字节,int类型是4个字节,如果前面的x 和 y 放在了连续的地址上,后面的赋值操作就会覆盖掉前面定义的y的值。
到目前都是假设链接器读取一组可重定位目标文件,并把它们链接起来,形成一个输出的可执行文件。其实所有的编译系统还可以将所有相关的目标模块打包成一个单独的文件,称为静态库
,它可以用做链接器的输入,当链接器构造一个输出的可执行文件时,它只复制静态库里被应用程序引用的目标模块。静态库的概念提出后,相关的函数可以被编译为独立的目标模块,然后封装成一个单独的静态库文件。然后应用程序可以通过在命令行上指定单独的文件名字来使用这些库中定义的函数。链接时,链接器将只复制被程序引用的目标模块,这就减少了可执行文件在磁盘和内存中的大小。
在linux系统中,静态库以一种称为存档
的特殊文件格式存放在磁盘中。存档文件是一组连接起来的可重定位目标文件的集合,有一个头部来描述每个成员目标文件的大小和位置。存放文件名由后缀.a标识。
在符号解析阶段,链接器从左到右按照它们在编译器驱动程序命令行上出现的顺序来扫描可重定位目标文件和存档文件。(驱动程序自动将命令行中所有的.c文件翻译为.o文件)。在这次扫描中,链接器维护一个可重定位目标文件的集合E(这个集合中的文件被合并起来形成可执行文件),一个未解析的符号集合U(即引用了但尚未定义的符号),一个在前面输入文件中已定义的符号集合D。初始时,E、U和D都是空的。
1.对于命令行上每个输入文件f,链接器会判断f时一个目标文件还是一个存档文件。如果f是一个目标文件,那么链接器把f添加到E,修改U和D来反馈f中的符号定义和引用,并继续下一个输入文件。
2.如果f是一个存档文件,那么链接器就尝试匹配U中未解析的符号和存档文件成员定义的符号。如果某个存档文件成员m,定义了一个符号来解析U中的一个引用。对存档文件中所有的成员目标文件都依次进行这个过程,直到U和D都不在发生变换。此时,任何不包含在E中的成员目标文件都被丢弃,链接器继续处理下一个输入文件。
3.如果当链接器完成对命令行上输入文件的扫描后,U是非空的,那么链接器就会输出错误并终止,否则,它会合并和重定位E中的目标文件,构建输出的可执行文件。
通过这个过程可以看出,我们在命令行中输入各个静态库时,一定要按照依赖关系,从前到后的排放,为了满足特殊的依赖需求,也可以在命令行上重复排放静态库。
一旦链接器完成了符号解析,就把代码中每个符号引用和正好一个符号定义关联起来。此时,链接器就知道它的输入目标模块中的代码节和数据节的确切大小。现在就可以开始重定位步骤了,在这个步骤中,将合并输入模块,并为每个符号分配运行时地址。
1.重定位节和符号定义
。在这一步,链接器将所有相同类型的节合并为同一类型的新的聚合节。例如,来自所有输入模块的,data节全被合并成一个节,这个节成为输出的可执行目标文件的.data节。然后,链接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。当这一步完成,程序中的每条指令和全局变量都有唯一的运行时内存地址了。
2.重定位节中的符号引用
。在这一步中,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。要执行这一步,依赖于下面要介绍的可重定位目标模块中称为重定位条目
的数据结构。
当汇编器生成一个目标模块时,它并不知道数据和代码最终将放在内存中的什么位置。它也不知道这个模块引用的任何外部定义的函数或者全局变量的位置,所以当汇编器遇到对最终位置未知的目标引用,就会生成一个重定位条目
,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。
上图展示了ELF重定位条目的格式。type:32 代表了由32种不同的重定位类型,只介绍2种最基本的:
1.重定位使用32位PC相对地址的引用。回想一下,一个PC相对地址就是距程序计数器(PC)的当前运行时值的偏移量。当CPU执行一条使用PC相对寻址的指令时,它就将在指令中编码的32位值加上PC的当前运行时值,得到有效地址
,PC值通常是下一条指令在内存中的地址。
2.重定位使用32位绝对地址的引用。通过绝对寻址,CPU直接使用在指令中编码的32位值作为有效地址,不需要再修改。
上面已经能够解释清楚链接器如何将多个目标文件合并成一个可执行目标文件。下图是一个典型的ELF可执行目标文件。
可执行目标文件和格式类似于可重定位目标文件的格式。它被设计得很容易加载到内存,可执行文件的连续的片被映射到连续的内存段,这种映射关系在程序头部表中被描述。
当我们运行一个可执行文件时,加载器将可执行目标文件的代码和数据从磁盘复制到内存中,然后通过跳转到程序的第一条指令或者入口点来运行该程序。这个程序复制到内存并运行的过程叫做加载
。
每个Linux程序都有一个运行时内存映像,如上图。在Linux x86-64系统中,代码段总是从地址0x400000处开始,后面是数据段。运行时堆
在数据段之后,通过调用malloc库往上增长。堆后面的区域是为共享模块保留的。用户栈总是从最大的合法用户地址(2
的48 - 1)开始,向较小内存地址增长。栈上的区域,从地址2
的48开始,是为内核
中的代码和数据保留的。
为了简介,把堆、数据和代码段画的彼此相邻,并且把栈顶放在了最大的合法用户地址处。实际上,由于.data段有对齐要求,所以代码段和数据段之间是有间隙的。并且在分配栈、共享库和堆段运行时地址的时候,链接器还会使用地址空间布局随机化。虽然每次程序运行时,这些区域的地址都会改变,但它们的相对位置是不变的。
当加载器运行时,它创建类似上图的内存映像。在程序头部表的引导下,加载器将可执行文件的片复制到代码段和数据段。接下来,加载器跳转到程序的入口点,初始化执行环境,调用用户层的main函数,处理main函数的返回值,并且在需要的时候把控制返回给内核。
上面介绍的静态库已经解决了许多关于如何让大量相关函数对应用程序可用的问题。然而,静态库仍然有一些明显的缺点。静态库和所有的软件一样,需要定期维护和更新。而且几乎每个C程序都是用标准I/O函数。在运行时,这些函数的代码会被复制到每个运行进行的本文段中。在一个运行上百个进程的系统上,这就是对内存资源的极大浪费。
共享库
是致力于解决静态库缺陷的一个现代创新产物。动态库是一个目标模块,在运行或加载时,可以加载到任意的内存地址,并和一个在内存中的程序链接起来。这个过程称为动态链接
,是由一个叫做动态链接器
的程序来执行的。共享库也称为共享目标
,在Linux系统中通常用.so后缀来标识。微软的操作系统大量地使用了共享库,它们称为DLL。
共享库是以两种不同的方式来“共享”的。首先,在任何给定的文件系统中,对于一个库只有一个.so文件。所有引用该库的可执行目标文件共享这个.so文件中的代码和数据,而不是像静态库的内容那样被复制和嵌入到引用它们的可执行的文件中。其次,在内存中,一个共享库的.text节的一个副本可以被不同的正在运行的进程共享。
上图展示了动态链接过程。在链接器中,没有任何libvector.so的代码和数据节真的被复制到可执行文件prog21中。而是复制了一些重定位和符号表信息,它们使得运行时可以解析对libvector.so中代码和数据的引用。在加载部分链接的可执行文件prog21时,会发现prog21中包含一个.interp节,这个节包含动态链接器的路径名,动态链接器本身就是一个共享目标。加载器不会像通常那样将控制传递给应用。而是加载和运行这个动态链接器。然后,动态链接器通过执行下面的重定位完成链接任务:
1.重定位libc.so文件和数据到某个内存段
2.重定位libvector.so的文本和数据到另一个内存段
3.重定位prog21中所有对由libc.so和libvector.so定义的符号的引用
最后,动态链接器将控制传递给应用程序。
共享库的一个主要目的就是允许多个正在运行的进行共享内存中相同的库代码,从而节约内存资源。为了完美地让多个进程共享共享库中程序的一个副本,现代系统使用一种方法,使得无限多个进程可以共享一个共享模块的代码段的单一副本(每个进程仍然有它自己的读/
写数据块)。可以加载而无需重定位的代码称为位置无关代码
。