链接是将各种代码和数据部分收集起来并组合成为一个单一文件的过程。这个文件可被加载到存储器并执行。
链接可以执行在编译时,可以执行在加载时,可以执行在运行时。
为什么要学习关于链接的知识:
- 理解链接器将帮助你构造大型程序。(缺少模块,缺少库,不兼容的库等链接器错误)
- 理解链接器将帮助你避免一些危险的编程错误。(错误地定义多个全局变量)
- 理解链接将帮助你理解语言的作用域规则是如何实现的。(static属性的变量)
- 理解链接器将帮助你理解其他重要的系统概念。(加载和运行程序、虚拟存储器、分页和存储器映射)
- 理解链接器将使你能够利用共享库。(使用共享库来升级压缩包装的二进制程序,web服务器依赖于共享库的动态链接来提供动态内容。
传统静态链接——这应该是编译时,共享库的动态链接——加载时和运行时。
无论是什么样的操作系统、ISA或者目标文件格式,基本的链接概念是通用的。
7.1 编译器驱动程序
大多数编译系统提供编译驱动程序,它代表用户在需要时调用语言预处理器、编译器、汇编器和链接器。可以这么认为吧:编译驱动程序包含这些功能。
GCC编译驱动程序:cpp(c预处理器),ccl(c编译器),as(汇编器),ld(链接器)。
unix>./p时,shell调用操作系统中一个叫做加载器的函数。
7.2 静态链接
想Unix ld程序这样的静态链接器以一组可重定位目标文件和命令行参数作为输入,生成一个完全链接的可以加载和运行的可执行目标文件作为输出。
为了构造可执行文件,链接器必须完成两个主要任务:符号解析和重定位。
7.3 目标文件
目标文件有3中形式:可重定位目标文件,可执行目标文件,共享目标文件。
共享目标文件是一种特殊的可重定位目标文件,前者(上一行第一个)是指在编译时与其他可重定位目标文件(上一行第一个)合并起来的,创建一个可执行目标文件;共享目标文件是指在加载或者运行时被动态加载到存储器并链接。这对应本文第二行的3中情况吧。
编译器和汇编器生成可重定位目标文件(包含共享目标文件)。链接器生成可执行目标文件。
目标文件,就是存放在磁盘文件中的目标模块。目标模块就是一个字节序列。这两个其实指的是一个东西。
目标文件的格式,也就是目标模块的字节编码规则,在各个系统中都不相同。
有COFF,PE,ELF;概念都是类似的。现代UNIX系统使用的都是ELF——可执行和可链接格式。
之后讨论ELF。
7.4 可重定位目标文件
ELF是目标文件的格式,目标文件还有3中呢,其中ELF可重定位目标文件的格式包括:ELF头,.text,.rodata,.data,.bss,.symtab,.rel.text,.rel.data,.debug,.line,.strtab,节头部表。
12个部分。
- ELF头——16字节,包含:系统的字的大小和字节顺序,帮助链接器语法分析和解释目标文件的信息(ELF头的大小,目标文件的类型-可重定位-共享-可执行,机器类型-IA32,节头部表的文件偏移,节头部表中的条目大小和数量)。
- 节头部表——描述:不同节的位置和大小。节就是夹在ELF头和节头部表之间的。节头部表包含很多条目,每一个条目的大小都是一样的,每一个条目都对应一个节。
- .text——一个节,包含已编译的机器代码。
- .rodata—一个节,只读数据(printf中的格式串等)
- .data——一个节,已初始化的全局C变量。
- .bss——一个节,未初始化的全局C变量。(只是一个占位符,不占据空间)
- .symtab——一个节,一个符号表,包含:程序定义引用的函数和全局变量。
- .rel.text——一个节,服务于.text节,是描述.text中位置的列表。当链接器把本目标文件和其他文件结合时,这个小节,就要修改。
- .rel.data——一个节,服务于.data,被模块引用或定义的任何全局变量的重定位信息。同上,和其他文件结合时,这个小节,就要修改。
- .debug——一个节,一个调试符号表,表中记录着程序中定义的局部变量和类型定义,程序中定义和引用的全局变量,以及原始的c源文件。只有以-g选项调用编译驱动程序时才会得到这张表。
- .line——一个节,原始c源程序中的行号和.text节中机器指令之间的映射。同上-g才有。
- .strtab——一个节,一个字符串表,其中包含.symtab和.debug节中的符号表,以及节头部表中的节名字。字符串表,就是一个一个的字符串,字符串就是以NULL结尾的字符序列。
这里有一个值得注意的地方,引用的只有全局变量,引用指两个文件中的变量引用,也只有全局变量可以用引用来表示,局部变量文件之间是引用不了的,局部变量只有定义来修饰。
7.5 符号和符号表
这里讲的就是上面的.symtab小节了,这个小节包含了一个符号表。
那么什么算是符号呢?
在链接器的上下文中,有3种符号:
- 由本可重定位目标模块(就是可重定位目标文件)定义,并能被其的模块引用的全局符号。称为全局链接器符号。对应于非静态的C函数和不带static属性的全局变量。这两种。就两种是全局链接器符号。
- 由其他模块定义并被本可重定位目标模块引用的全局符号。称为外部符号。对应于定义在其他模块中的C函数和变量。可以被本模块引用,引用,那必然是在其他模块中定义的非静态C函数和不带static的全局变量。外部符号。
- 只被本可重定位目标模块定义和引用的本地符号。称为本地链接器符号。对应于带static属性的C函数和全局变量,还包含另外一点。这一点是:目标文件中对应于本模块的节和相应的源文件的名字也能获得本地符号。——————这一句不理解,暂时放在这里。
注意到一点:3中符号都是函数和全局变量,没有局部变量,即使本地连接符号,也不包括局部变量。局部变量链接器是不care的。上一节中符号表这一节的定义就只有函数和全局变量而已。
注意到一点:带static属性的局部变量,不在栈中,而在.data和.bss中分配空间,并在符号表中创建一个有唯一名字的本地链接器符号。(上面不理解的一句,这里是一个解释)。
符号表,是一个数组,数组的元素是条目,条目的格式是固定的。
- name是字符串表中的字节偏移,字符串表示小节.strtab中的内容,指向符号的字符串名字。这好理解,这里的name是int型,是数,表示的是偏移,偏移是对字符串表说的,从偏移地址开始,到null字符的这一段字符串,就是一个名字,什么名字,符号的名字,符号是什么,符号就是上面说的3种,必然的不是变量就是函数,也就是说,这里的名字要么事函数名,要么是变量名。
- value是符号的地址,符号的地址,也就是函数或者变量的地址,对于可重定位模块来说,这是一个相对的偏移地址;对于可执行目标文件来说,这就是一个绝对地址了。这里先说明下,每个符号都对应于一个节吗,对应于可重定位目标模块来说,value表明的就是本符号对应的节的偏移地址,这个地址存储这符号的值,就像name指出的地址存储的是这个符号的名字。
- size是目标的大小(字节为单位)。也好理解吧,函数就是函数的大小,变量就是变量的大小吧。比如说int类型的全局变量,其大小就是4。函数的话就是机器代码的长度吧。
- type和binding各4bit,合在一起是一个char,type表示符号是数据还是函数还是节还是路径名,binding表示是全局的还是本地的(局部的)。
- reserved,未使用。
- section,每个符号都和本可重定向目标模块的一个节相关联,这个值是一个索引值,节头部表的索引值,节头部表包含了节的条目。还有3个伪节,这3个是节头部表中没有的——ABS代表不该被重定位的符号——UNDEF本模块引用,其他模块定义的——COMMON为分配位置的未初始化的数据目标(比如本模块定义的全局变量,但没初始化)(这种符号的value和size特殊的,可以理解)。
7.6 符号解析
符号解析——指链接器,就是ld,将每个引用与ld后面的输入的可重定位目标文件的符号表中的一个确定的符号定义联系起来。
这里要注意:符号解析,指的是符号的解析,链接器收到的是可重定位目标文件,里面有符号表,符号表中有符号,这个符号就需要解析,如果是本地符号,那么肯定对应的是某个小节,如果是外部符号,那么就要到其他的模块(ld后面的其他输入)里面去找了。本地符号,编译器保证了唯一性,但外部符号,则有一些规则。
编译器值允许每个模块中每个本地符号只有一个定义。编译器还确保静态本地变量,它们也会有本地链接器符号,拥有唯一的名字。
对于外部符号,编译器会假设该符号是在其他某个模块中定义的,生成一个链接器符号表条目,并把它交给链接器处理。
对外部符号的解析很棘手,还因为多个目标文件,可能会定义相同的符号。
函数和已初始化的全局变量是强符号(static类型的函数已不在此列,那是本地符号了),未初始化的全局变量是弱符号。
Unix链接器使用下面的规则来处理多重定义的符号:
- 不允许有过个强符号。
- 如果有一个强符号和多个弱符号,那么选择强符号。
- 如果有多个弱符号,那么从这些弱符号中任意选择一个。
这里的第二条,值得注意,特别的,一个强的一个弱的,符号重复,也就是变量名或者函数名重复,但有不同的类型的时候。这种情况有时会出问题。
链接器的输入,到目前为止,我们讲的都是可重定位目标文件。但还有一种格式,叫做静态库。
静态库是一种特殊的称为存档的文件。——在Unix系统中,静态库以一种称为存档的特殊文件格式存放在磁盘中。
存档文件是一组连接起来的可重定位目标文件的集合,有一个头部用来描述每个成员目标文件的大小和位置。有后缀.a标识。
如果不使用静态库,要用什么方法向用户提供c标准里的库函数?
编译器辨认出对每个标准函数的调用。——c标准函数太多,编译器太复杂,同时一个标准函数变了,那么编译器就需要推出一个新的版本。
所有的c标准函数都放入一个可重定位目标文件中。——那么每一个可执行目标文件,都将包含所有的c标准函数,体积过大。维护这个大的可重定位目标文件也麻烦,任意一个小的修改都要完整的编译所有c标准函数。
一个c标准函数一个可重定位目标文件。——程序员费事,每一个标准调用,程序员编译的时候就加入一个可重定位目标文件。
综上,静态库是这么做的,相关的标准函数被编译成一个可重定位目标文件,这样c标准函数就变成了一个个可重定位目标文件。然后将这些目标文件封装成一个文件,这就是静态库了。
在链接时,链接器将只拷贝被程序引用的目标模块,虽然有目标模块中有其他的一些函数没用到,但这是可以接受的。
gcc -static,这个-static参数告诉编译器驱动程序,链接器应该构建一个完全链接的可执行目标文件,可以加载到存储器并运行,在加载时无需更进一步的链接。
符号解析时,链接器维持3个集合:E——可重定位目标文件的集合;U——引用了但尚未定义的符号集合;D——引用了也定义了的符号集合。
初始时,3个集合都是空的。
然后链接器开始一个一个的扫描输入文件,从左到右。
如果输入文件是目标文件,那么输入文件加入E,并解析输入文件中的符号,根据结果来修改U和D,然后下一个。
如果输入文件是静态库文件,那么链接器从静态库文件中找U中的符号,如果静态库中的一个目标文件有U中某个符号的定义,那么久将这个目标文件加入到E中。对静态库中每个目标文件都反复进行这个过程,知道UD不变化了。这里的反复,应该不是简单的顺序找一遍,应该是找了很多遍吧。同时,如果第一个输入文件是静态库文件,也就是说UDE都是空,那么编译应该也能通过吧,这里应该要求静态库是完备的,静态库中的目标文件的结合时完备的。
从上面也可以看出,目标文件和静态库文件的处理是不同的,目标文件总是完全的加入UDE,而静态库文件则不一定完全的加入UDE。
在命令行中(链接器的输入中),如果一个符号的库,出现在引用这个符号的目标文件之前,那么引用就不能被解析。这个好理解,符号引用了,只会冲后面的输入找定义,而不会找前面的。
7.7 重定位
链接器完成符号解析之后,就可以开始重定位了。两步。下面的两步其实很好理解,经过符号解析之后,静态库没有,只剩下可重定位目标文件了。可重定位目标文件就是ELF头和节和节头部表。这里的重定位呢,首先让节具有存储器地址,符号具有存储器地址。然后呢,代码节和数据节中呢,有对各个符号的引用,所以想在要将他们指向上一步分配的存储器地址上面去。这样才能正确执行吗。
- 重定位节和符号定义——所有相同类型的节合并为同一类型的新的聚合节。将运行时存储器地址赋给—聚合节—每个模块定义的每个节—每个符号。完成这一步之后,程序中的每个指令和全局变量都有唯一的运行时存储器地址了。
- 重定位节中的符号引用——这一步中,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。——这一步依赖于重定位条目,这个条目就是.rel.text和.rel.data两个节。
汇编器生成目标模块,就是ELF格式的可重定位目标模块,模块中有符号,符号中有外部符号,汇编器不知道外部符号的位置,他会在符号表中标识其为UND类型,这就是ELF可重定位目标文件,他不知道,就标为UND,然后,他会生成一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。代码的重定位条目在.rel.text,数据的重定位条目在.rel.data中。重定位条目只在两个.rel中,所以,下面第一行的节,就是指.data和.text两个节。第二行指:text节中指函数名,data节中指变量名。
- offset是需要被修改的引用的节偏移——意思是这个引用要被修改,这个引用在某个节中,这个引用在这个节中的偏移是offset。
- symbol标识被修改的引用应该指向的符号。——上面一行,指出text或者data的那一行需要修改,这一行指出这一行中需要修改的符号。
- type告知链接器如何修改新的引用。——R_386_PC32,相对地址——R_386_32,绝对地址。
在main.o中,引用了swap这个符号,当然在main.o这个可重定位目标文件中,这个是UND的。在main.o的.text中存储的有代码,代码里有一条call指令,就是调用swap的指令,call指令编码是一个字节的e8,后面是32位的字,这里存了什么暂且不管。在mian.o的.rel.text中有一个重定位条目,就是符号swap的重定位条目,因为swap是外部符号,条目说明.text偏移为7的地方开始就是swap要修改的符号,.text中call语句的地址是6,call指令一个字节e8表示,所以从偏移为7的地方开始,就是那个32位的字,就是需要修改的字。
那么现在,如果.text的存储器地址确定了,是ADDR(.text),swap这个符号的存储器地址也确定了,是ADDR(swap),那么,.text中需要修改的那个swap的32的字的地址就是ADDR(.text)+7。这个地址就是运行时的地址了,在运行时这个位置的地址,这个地址定义为refaddr,call下一行的地址为refaddr+4,call计算转移地址时,是用call下一行的地址,加上后面的32位数字算出来的,也就是refaddr+4+x,这个就将是跳转的地址,而跳转的地址已经知道了,就是ADDR(swap),所以x也就知道了:ADDR(swap)-4-refaddr。
这就明白了,相对地址是如何计算的,而,在原main.o可重定位目标文件中,.text的swap的32的字是-4。还是好理解的。
在swap.o中的bufp0是一个指向外部符号的指针,所以其也有一个重定位条目,同时,bufp0是初始化的全局变量所以,在swap.o的.data中,其对应的重定位条目在swap.o的.rel.text中,假设现在已经知道了buf的地址,也就是bufp0的值,也就是ADDR(buf),那么,绝对地址,bufp0位置的值就直接设置为ADDR(buf),绝对地址吗。
这一节理解起来真不容易,不过理解了。
7.8 可执行目标文件
ELF可执行文件:ELF头部--段头部表--.init--.text--.rodata--.data--.bass--.symtab--.debug--.line--.strtab--节头表。
12个部分。
分成3个部分,前五个-ELF头-段头部表-.init-.text-.rodata是第一部分,只读,称为代码段。.data和.bass是读写存储器,称为数据段。剩下的是第三部分,不加载到存储器中。
这里已经涉及到了进程的虚拟地址空间了,从下向上开始的位置就是只读的代码和数据,然后是读写数据,然后是堆了,堆向上增长,然后是共享库的存储器映射区域,然后是栈,栈想下增长,然后就是内核虚拟存储器了。
这里涉及到了两个部分代码段和数据段。段头部表中描述了这种对应关系,存储器段和可执行文件的两个部分。
7.9 加载可执行目标文件
加载器是一段操作系统代码(这个代码指的应该是机器代码了),这段代码驻留在存储器中。
任何Unix程序都可以通过调用execve函数来调用加载器,这个函数是Unix提供的系统接口之一吧。
加载器将可执行目标文件中的代码和数据从磁盘拷贝到存储器中,然后跳转到程序的第一条指令或入口点来执行该程序。——这一过程称为加载。
在32位系统中,代码段总是从地址0x0848000开始的。从栈的上部开始的段是为操作系统驻留存储器的部分的代码和数据保留的。
加载器跳转到程序的入口点,这个入口点就是一个符号的地址,什么符号呢,_start这个符号,这个符号源自哪里呢,源自ctrl.o。ctrl.o可以预见是标准函数的可重定位目标文件或者系统函数的可重定位目标文件。
_start是个函数,在这个函数中,会依次的调用一些其他的初始化例程。其中atexit例程附加了一系列的应用程序正常中止时应该调用的程序。exit函数运行atexit注册的函数,然后通过调用_exit将控制返回给操作系统。接着,调用main程序。
7.10 动态链接共享库
静态库还有一些缺点:printf和scanf这种函数太常使用,所以每次都将他们的可重定位目标文件放入可执行目标文件还是一种存储空间的浪费。
共享库出现了,静态库是对可重定位目标文件的存档,共享库是对共享目标文件的存档。
共享库是一个目标模块,在运行时,可以加载到任意的存储器地址,并和一个在存储器中的程序连接起来。这个过程称为动态链接,由动态链接器执行。
共享库在Unix中用后缀.so表示。在微软操作系统中称为DLL。
共享库的共享指:
一个库只有一个.so文件,这是对于磁盘来说的,比如libc.so,一个ubuntu只有一个libc.so,或者说某一个版本的,只有一个libc.so。
对于存储器,.text节的一个副本可以被不同的正在运行的进程共享。
当加载器加载和运行一个编译包含共享库的可执行文件时,加载器会注意到一个.interp节,然后,加载器会加载和运行一个动态链接器。(动态链接器本身就是一个共享目标)。
动态链接器执行重定位完成链接任务。最后,动态链接器将控制传递给应用程序。
7.11 从应用程序中加载和链接共享库
到此为止,学习了编译时候的静态链接,加载时候的动态链接。现在就是运行时的动态链接了。
动态链接在现实世界中的例子:分发软件,构建高性能web服务器。
linux系统为动态链接器提供了一个简单的接口,允许应用程序在运行时加载和链接共享库。
#include <dlfcn.h> void *dlopen(const char * filename, int floag); //成功,就返回指向句柄的指针,这里的句柄应该是指一个目标文件吧。失败,就返回NULL void * dlsym(void *handle, char *symbol); //第一个参数是上面函数返回的句柄的指针,第二个参数是符号的名字,如果符号存在就返回符号的地址,这里符号的地址应该是目标文件的符号表中的符号的地址吧,否则返回NULL int dlclose(void *handle); //如果没有其他共享库正在使用这个共享库,那么就卸载该共享库。这里值得考虑,没有其他共享库,而不是没有其他函数。卸载共享库。 const char* dlerror(void); //上面的3个函数运行之后,运行这个函数,可以看看最近发生的最近的错误,如果没有错误,就返回NULL
7.12 与位置无关的代码(PIC)
多个进程是如何共享程序的一个拷贝呢?
每个共享库分配一个实现预备的专用的地址空间片——这个地址空间片,应该是从某一地址开始的一片存储器空间。这种方法简单,但是有很多问题,比较好理解的问题是:共享库各种各样的,很多,每一个都有一个确定的地址开始的一片空间,那么就有了N个这种小片,麻烦。
更好的办法是:使链接器不需要修改库代码就可以在任何地址加载和执行这些代码,这要求编译库代码的时候要实现这种功能。
这样的代码叫做PIC(位置无关的代码),GCC用-fPIC选项来实现。
在一个IA32系统中,一个可重定位目标文件中的.text中的过程调用是不需要特殊处理的,因为call指令后面的数据是一个相对值。这是PIC,与位置无关的代码。
但,对于外部符号的引用就不是PIC了。外部符号包括其他模块的过程和全局变量。
编译器通过运用一个事实来生成对全局变量的PIC引用:无论我们在存储器中的何处加载一个目标模块(包括共享目标模块),数据段总是被分配成紧随在代码段后面。因此,代码段中任何指令和数据段中任何变量之间的距离都是一个运行时常量,与代码和数据段的绝对位置无关。——这是好理解的,但是一个问题,这也是在一个目标模块中啊,多个目标模块之间的全局引用呢?
要明确一下:这里的PIC指的是共享库。已经存在一个部分链接的可执行目标文件,这个目标文件需要共享库,这个共享库是PIC,现在说明这个共享库是如何实现PIC的。
编译器在共享模块的数据段开始的地方创建了一个表,叫做GOT。
(这一小节和下一小节理解起来很费劲,暂时放置)
7.13 处理目标文件的工具
GNU binutils包
- AR:创建静态库,插入、删除、列出和提取成员。
- STRINGS:列出一个目标文件中所有可打印的字符串。
- STRIP:从目标文件中删除符号表信息。
- NM:列出一个目标文件的符号表中定义的符号。
- SIZE:列出目标文件中节的名字和大小。
- READELF:显示一个目标文件的完整结构,包括ELF头中编码的所有信息。包括SIZE和NM的功能。
- OBJDUMP:所有二进制工具之母。能够显示一个目标文件中所有的信息。它最大的作用是反汇编.text中的二进制指令。
- LDD:列出一个可执行文件在运行时所需要的共享库。
7.14 小结
(over)