链接是将代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载到内存并执行。链接可以执行于编译时(compile time),也就是在源代码被翻译成机器代码时;也可以执行于加载时(load time),也就是在程序被加载器(loader)加载到内存并执行时;甚至执行于运行时(run time),也就是应用程序来执行。
在现代系统中,链接是由叫做链接器(linker)的程序自动执行的。
执行 ./prog
Shell调用操作系统中一个叫加载器(loader)的函数,它将可执行文件prog中的代码和数据复制到内存,然后将控制转移到这个程序的开头。
执行 ld –o prog [system object files and args] /tmp/main.o /tmp/sum.o
Linux LD程序这样的静态链接器(static linker)以一组可重定位目标文件和命令行参数作为输入,生成一个完全链接的、可以加载和运行的可执行目标文件作为输出。
为了构造可执行文件,链接器必须完成两个主要任务:
1>符号解析(symbol resolution)。目标文件定义和引用符号,每个符号对应于一个函数,一个全局变量或一个静态变量(即C语言中任何以static属性声明的变量)。符号解析的目的是将每个符号引用正好和一个符号定义关联起来。
2>重定位(relocation)。编译器和汇编器生成从地址0开始的代码和数据节。链接器通过把每个符号定义与一个内存位置关联起来,从而重定位这些节,然后修改所有对这些符号的引用,使得它们指向这个内存位置。
目标文件有三种形式。
- 可重定位目标文件。包含二进制代码和数据,其形式可以在编译时与其他可重定位目标文件合并起来,创建一个可执行目标文件。
- 可执行目标文件。包含二进制代码和数据,其形式可以被直接复制到内存并执行。
- 共享目标文件。一种特殊类型的可重定位目标文件,可以在加载或者运行时被动态地加载进内存并链接。
目标文件是按照特定的目标文件格式来组织的,各个系统的目标文件格式都不相同。第一个Unix系统使用的是a.out格式。Windows使用可移植可执行(Protable Executable, PE)格式。MacOS-X使用Mach-O格式。现代x86-64 Linux和Unix系统使用可执行可链接格式(Executable and Linkable Format, ELF)。
ELF头(ELF header)以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。
.text:已编译程序的机器代码;
.rodata:只读数据,比如printf语句中的格式串和开关语句的跳转表;
.data:已初始化的全局和静态C变量。局部C变量在运行时被保存在栈中,既不出现在.data中,也不出现在.bss节中;
.bss:未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量。在目标文件中这个节不占据实际的空间,它仅仅是一个占位符。目标文件格式区分已初始化和未初始化变量是为了空间效率:在目标文件中,未初始化变量不需要占据任何实际的磁盘空间。运行时,在内存中分配这些变量,初始值为0;
.symtab:一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。每个可重定位目标文件在.symtab中都有一张符号表。和编译器中的符号表不同,.symtab符号表不包含局部变量的条目。
符号和符号表:
每个可重定位目标模块m都一个符号表,它包含m定义和引用的符号的信息。在链接器的上下文中,有三种不同的符号:
- 由模块m定义并能被其他模块引用的全局符号。全局链接器符号对应于非静态的C函数和全局变量;
- 由其他模块定义并被模块m引用的全局符号。这些符号成为外部符号,对应于在其他模块中定义的非静态C函数和全局变量和;
- 只被模块m定义和引用的局部符号。它们对应于带static属性的C函数和全局变量。这些符号在模块m中任何位置都可见,但是不能被其他模块引用。
.symtab中的符号表不包含对应于本地非静态程序变量的任何符号。这些符号在运行时在栈中被管理,链接器对此类符号不感兴趣。
在C中,源文件扮演模块的角色。任何带有static属性声明的全局变量或者函数都是模块私有的。类似的,任何不带static属性声明的全局变量和函数都是公共的,可以被其他模块访问。
GNU READELF程序是一个查看目标文件内容的很方便的工具。
在编译时,编译器向汇编器输出每个全局符号,或者是强(strong)或者是弱(weak)。函数和已初始化的全局变量是强符号,未初始化的全局变量是弱符号。
Linux链接器使用下面的规则来处理多重定义的符号名:
- 不允许有多个同名的强符号;
- 如果有一个强符号和多个弱符号同名,那么选择强符号;
- 如果有多个弱符号同名,那么从这些弱符号中任意选择一个。
使用-Werror选项,把所有的警告都变成错误。
所有的编译系统都提供一种机制,将所有相关的目标模块打包成一个单独的文件,成为静态库(static library),它可以用做链接器的输入。当链接器构造一个输出的可执行文件时,它只复制静态库里被应用程序引用的目标模块。
在链接时,链接器将只复制被程序引用的目标模块,这就减少了可执行文件在磁盘和内存中的大小。
在Linux系统中,静态库以一种称为存档(archive)的特殊文件格式存放在磁盘中。存档文件是一组连接起来的可重定位目标文件的集合,有一个头部用来描述每个成员目标文件的大小和位置。存档文件名由后缀.a标识。
gcc –static –o prog2c main2.o ./libvector.a
等价于
gcc -static -o prog2c main2.o -L. –lvector
-static参数告诉编译器驱动程序,链接器应该构建一个完全链接的可执行目标文件,它可以加载到内存并运行,在加载时无须更进一步的链接。-lvector参数是libvector.a的缩写,-L.参数告诉链接器在当前目录下查找libvector.a。
当链接器运行时,它判定main2.o引用了addvec.o定义的addvec符号,所以复制addvec.o到可执行文件。因为程序不引用任何由multvec.o定义的符号,所以链接器就不会复制这个模块到可执行文件。链接器还会复制libc.a中的printf.o模块,以及许多C运行时系统中的其他模块。
在符号解析阶段,链接器从左到右按照它们在编译器驱动程序命令行上出现的顺序来扫描可重定位目标文件和存档文件。(驱动程序自动将命令行中所有的.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中的目标文件,构建输出的可执行文件。
不幸的是,这种算法会导致一些令人困惑的链接时错误,因为命令行上的库和目标文件的顺序非常重要。关于库的一般准则是将它们放在命令行的结尾。
共享库(shared library)是致力于解决静态库缺陷的一个现代创新产物。共享库是一个目标模块,在运行或加载时,可以加载到任意的内存地址,并和一个在内存中的程序链接起来。这个过程称为动态链接(dynamic linking),是由一个叫做动态链接器(dynamic linker)的程序来执行的。共享库也称为共享目标(shared object),在Linux系统中通常用.so后缀来表示。微软的操作系统大量地使用了共享库,它们称为DLL(动态链接库)。
为了构造共享库,调用编译器驱动程序,给编译器和链接器如下特殊指令:
gcc -shared -fpic -o libvector.so addvec.c multvec.c
-fpic选项指示编译器生成与位置无关的代码。-shared选项指示链接器创建一个共享的目标文件。
Linux系统为动态链接器提供一个简单的接口,允许应用程序在运行时加载和链接共享库。
void *dlopen(const char *filename, int flag);
dlopen函数加载和链接共享库filename。
void *dlsym(void *handle, char *symbol);
dlsym函数的输入是一个指向前面已经打开了的共享库的句柄和一个symbol名字,如果该符号存在,就返回符号的地址,否则返回NULL。
Java定义了一个标准调用规则,叫做Java本地接口(Java Native Interface, JNI),它允许Java程序调用“本地的”C和C++函数。JNI的基本思想是将本地C函数(如foo)编译到一个共享库中(如foo.so)。当一个正在运行的Java程序试图调用函数foo时,java解释器利用dlopen接口(或者与其类似的接口)动态链接和加载foo.so,然后再调用foo。
可以加载而无需重定位的代码称为位置无关代码(Position-Independent Code,PIC)。用户对GCC使用-fpic选项指示GNU编译系统生成PIC代码。共享库的编译必须总是使用该选项。
----整理自《深入理解计算机系统》