链接是将各种代码和数据部分收集起来并组合成为一个单一文件的过程。链接可以执行与编译时,也就是在源代码被翻译成机器代码时;也可以执行与加载时,也就是在程序加载器加载到内存并执行时;甚至执行与运行时,由应用程序来执行。
连接器的主要作用是使分离编译成为可能。不在需要将一个大型的应用程序组织为一个巨大的源文件,而是把它分解为更小更好管理的模块。当我们改变这些模块中的一个时,只要简单的重新编译它,并重新链接即可,而不必重新编译其它文件。
总的来说理解链接器将帮助我们:构造大型程序、避免一些危险的编程错误、理解其它重要的系统概念、理解语言的作用域规则是如何实现的、利用共享库。
*.c -预处理器-> *.i -编译器-> *.s -汇编器-> *.o(可重定位目标文件) -连接器-> 可执行目标文件
运行时,shell会调用操作系统中一个叫做加载器的函数,它拷贝可执行文件中的代码到和数据到内存,然后将控制转移到这个程序的开头。
静态链接
Linux系统中,像ld程序这样的静态链接器以一组可重定位目标文件和命令行参数作为输入,生成一个完全链接的可以加载和运行的可执行目标文件作为输出。可重定位目标文件由各种不同的代码和数据节组成。指令在一个节中,初始化的全局变量在一个节中,未初始化的变量在另一个节中。
为了构造可执行文件,连接器必须:
1、解析符号。目标文件定义个引用符号。符号解析的目的是将每个符号引用刚好与一个符号定义联系起来。
2、重定位。编译器和汇编器生成从地址0开始的代码和数据。连接器通过把每个符号定义与一个内存位置联系起来,然后修改所有对这些符号的引用,使得它们指向这个存储器位置,从而重定位这些节。
目标文件纯粹是字节块的集合。这些块中,有些包含程序代码,有些则包含程序数据,而其它的则包含指导链接器和加载器的数据结构。链接器将这些块连接器来,确定被链接块的运行时的位置,并修改代码和数据块中的各种位置。链接器对目标机器了解甚少,产生目标文件的编译器和汇编器已经完成了大部分的工作。
目标文件有3中形式:
1、可重定位目标文件。包含二进制代码和数据,可以在编译时与其它可重定位目标文件合并起来,创建一个可执行目标文件。
2、可执行目标文件。包含二进制代码和数据,可以直接被拷贝到内存并执行。
3、共享目标文件。一种特殊类型的可重定位目标文件,可以在加载或者运行时被动态的加载到内存并链接。
一个目标模块就是一个字节序列,而一个目标文件就是一个存放在磁盘磁盘文件中的目标模块。
可重定位目标文件(以ELF(可执行和可链接格式)格式为例)
夹在ELF头和节头部表之间的都是节。
------------------------------------------------------------
* ELF头 以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助连接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型(如可重定位、可执行或者是共享的)、机器类型、节头部表的文件偏移,以及节头部表中的条目大小和数目。
* .text 已编译程序的机器代码
* .rodata 只读数据
* .data 已初始化的全局变量
* .bss 未初始化的全局变量。在目标文件中这个节不占据实际的空间,它仅仅是一个占位符。目标文件格式区分初始化和未初始化变量是为了空间效率。在目标文件中,未初始化变量不需要占据任何实际的磁盘空间。
* .symtab 一个符号表,它存放在程序中定义和引用的函数和全局变量的信息,每个可重定位目标文件在.symtab中都有一张符号表。然而,和编译器中的符号表不同,.symtab符号表不包含局部变量的条目。
* .rel.text 一个.text节中位置的列表,当链接器把这个目标文件和其它文件结合时,需要修改这些位置。一般而言,任何调用外部函数或者引用全局变量的指令都需要修改。调用本地函数的指令则不需要修改。注意,可执行目标文件中并不需要重定位信息,因此通常省略,除非用户显式的指示链接器包含这些信息。
* .real.data 被模块引用和定义的任何全局变量的重定位信息。一般而言,任何已初始化的全局变量,如果它的初始值是一个全局变量地址或者外部定义函数的地址,都需要被修改。
* .debug 一个调试符号表,其条目是程序中定义的局部变量和类型定义,程序中定义和引用的全局变量,以及原始的C源文件。只有以-g(在最后生成的可执行文件中加入调试信息)选项调用编译驱动程序时才会得到这张表。
* .line 原始C源程序中的行号和.text节中机器指令之间的映射。只有以-g选项调用编译驱动程序时才会得到这张表。
* .strtab 一个字符串表,其内容包括.symtab和.debug节中的符号表,以及节头部中的节名字。字符串表就是以null结尾的字符串序列。
* 节头部表
------------------------------------------------------------
符号和符号表
每个可重定位目标模块都有一个符号表,它包含该模块所定义和引用的符号的信息。在链接器的上下文中,有三种不同的符号:
1、由该模块定义并能被其他模块引用的全局符号。全局链接器符号对应于非静态的C函数以及被定义为不带C static属性的全局变量。
2、由其它模块定义并被该模块引用的全局符号。这些符号被称为外部符号,对应于定义在其它模块中的C函数和变量。
3、只被该模块定义和引用的本地符号。有的本地链接器符号对应于带static属性的C函数和全局变量。这些符号在该模块中随处可见,但不能被其它模块引用。目标文件中对应于该模块的节和相应的源文件的名字也能获得本地符号。
注意:本地链接器符号和本地程序变量时不同的,.symtab中的符号表不包含对应于本地非静态程序变量的任何符号。这些符号在运行时在栈中被管理,链接器对此类符号不感兴趣。然而定义为static的本地函数中的变量不是在栈中管理的,编译器会在.data段和.bss段中为每个定义分配空间,并在符号表中创建一个有唯一名字的本地链接器符号。
例如在同一个源文件中,两个不同的函数中定义了同名的静态变量。这种情况中,编译器在.data或.bss段中为这两个变量分配空间,并引出两个唯一的链接器符号给汇编器。
符号表是由汇编器构造的,这些符号来自于编译器。
符号解析
链接器解析符号引用的方法是将每个引用与它输入的可重定位目标文件的符号表中的本地定义联系起来。编译器只允许每个模块中每个本地符号只有一个定义。编译器还确保静态本地变量拥有唯一的本地链接器符号。
当链接器遇到一个不是在当前模块中定义的符号(变量或函数名)时,它会假设该符号是在其他某个模块中定义的,生成一个链接器符号表条目,并把它交给链接器处理。如果链接器在它的任何输入模块中都找不到这个被引用的符号,它就输出一条错误信息并终止。
C++和Jva中都允许重载,比如函数可以有相同的名字,却必须有不同的参数列表。之所以能实现这种功能,是因为编译器会为每个同名函数生成一个由函数名和参数列表组合而成的符号编码,每个编码对链接器来说都是唯一的。这种编码过程叫做毁坏,相反的过程叫做恢复。
链接器如何解析多重定义的符号
这个问题应该只会出现在类UNIX系统中,Windows系统不会出现该问题,因为在Windows的编译器中,似乎根本不允许多重定义。(以下讨论为Linux中)
在编译时,编译器为汇编器输出每个全局符号,或者是强或者是弱。函数和已初始化的全局变量是强符号,未初始化的全局变量是弱符号。
规则:
1、不允许有多个强符号;
2、如果有一个强符号和多个弱符号,那么选择强符号;
3、若果有多个弱符号,则任选一个;
静态库
所有的编译器都提供一种机制,将所有相关的目标模块打包成为一个单独的文件并作为链接器的输入,称为静态库。当链接器构造一个输出的可执行文件时,只拷贝静态库里被应用程序引用的目标模块,这就减少了可执行文件在磁盘和内存中的大小。
在Unix系统中,静态库以一种成为存档的特殊文件格式存放在磁盘中。存档文件是一组连接起来的可重定位目标文件的集合,有一个头部用来描述每个成员目标文件的大小和位置。存档文件名后缀为.a。
关于如何生成并使用静态库文件请参考《嵌入式ARM/S5PV210学习笔记/程序函数库》
在符号解析阶段,链接器从左到右按照它们在编译器驱动程序(例如:ld)命令行上出现的顺序来扫描可重定位目标文件和静态库存档文件(驱动程序会自动将命令行中的.c文件以及.s文件翻译为.o文件)。
在这次扫描中,链接器会维持一个可重定位目标文件E(这个集合中的文件会被合并起来形成可执行文件),一个未解析的符号(引用了但还未找到定义)集合U,一个在前面输入文件中已经定义的集合D。初始时,E、U、D都是空的。然后执行下面的过程:
*、对于命令行上的每个输入文件f,链接器会判断F是一个目标文件还是一个存档文件。若是一个目标文件,则把f添加到E,修改U和D来反映F中的符号定义和引用,并继续下一个输入文件。
*、如果f是一个存档文件,那么链接器就尝试匹配U中未解析的符号和由存档文件成员定义的符号。如果某个存档文件成员m定义了一个符号来解析U中的一个引用,那么就将m加载到E中,并且链接器修改U和D来反映m中的符号定义和引用。对存档文件中所有的成员目标文件都反复进行这个过程,直到U和D都不在发生变化。此时,任何不包含在E中的成员目标文件都被丢弃,链接器继续处理下一个输入文件。
*当链接器完成对命令行上所有输入文件的扫描后,U是非空的,那么链接器就会输出一个错误并终止。否则,会合并和重定位E中的目标文件,从而构建输出的可执行文件。
这种算法会导致一个令人困惑的链接时错误,因为命令行上的库和目标文件的顺序非常重要。如果定义一个符号的库出现在引用这个符号的目标文件之前,那么引用就不能被解析,链接会失败。所以,我们需要将库文件放在命令行的末尾。在使用多个静态库的情况下,如果各个库的成员是相互独立的(也就是说没有一个库的成员引用另一个库中成员的符号),那么这些库就可以按照任何顺序放置在命令行的结尾处。反之,如果库不是相互独立的,那么它们必须排序,使得对于每个被存档文件的成员外部引用的符号s,在命令行中至少有一个s的定义是在对s的引用之后的。有时为了满足需求,可以在命令行上重复使用库的名字。
重定位
从定位的作用是合并输入模块,并为每个符号分配运行时地址。
*、重定位节和符号定义。在这一步中,链接器将所有相同类型的节合并为同一类型的新的聚合节。例如,来自每个输入模块的.data节被全部合并为一个节,这个节成为输出的可执行目标文件的.data节。然后,链接器将运行时存储器地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。当这一步完成时,程序中的每个指令和全局变量都有唯一的运行时存储地址了。
*、重定位节中的符号引用。链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。为了执行这一步,链接器依赖于称为重定位条目的可重定位目标模块中的数据结构。
当汇编器生成一个目标模块时,它并不知道数据和代码最终将存放在存储器中的什么位置。它也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。所以无论何时汇编器遇到对最终位置未知的目标引用,它就会生成一个重定位条目,告诉编译器在将目标文件合并成为可执行文件时如何修改这个引用。代码的重定位条目放在.rel.text中。已经初始化的数据的重定位条目放在.rel.data中。
可执行目标文件
可执行目标文件包含加载程序到存储器并运行它所需的所有信息。
因为可执行文件时完全链接的(已被重定位),所以它不再需要,rel节。
-----------------------------------------------------------
只读存储器段(代码段)
* ELF头部 描述文件的总体格式。它还包括程序的入口点,也就是当程序运行时要执行的第一条指令的地址。
* 段头部表 将连续的文件节映射到运行时存储段
* .init 定义了一个小函数,叫做_init,程序的初始化代码会调用它
* .text 同可重定位目标文件,已重定位到运行时地址
* .rodata 同可重定位目标文件,已重定位到运行时地址
读/写存储器段(数据段)
* .data 同可重定位目标文件,已重定位到运行时地址
* .bss
不加载到内存的符号表和调试信息
* .symtab
* .debug
* .line
* .strtab
* 节头表
-----------------------------------------------------------
可执行文件的连续的片被映射到连续的存储器段。段头部表描述了这种映射关系。
加载可执行目标文件
在Unix系统中通过输入 ./可执行文件名 来运行可执行目标文件。因为可执行目标文件名不是内置的shell命令,所以shell会认为它是一个可执行目标文件。通过调用某个驻留在内存中称为加载器的操作系统代码来运行它。任何Unix程序都可以通过调用execve函数来调用加载器。加载器将可执行目标文件中的代码和数据从磁盘拷贝到内存中,然后通过跳转到程序的第一条指令或入口点来运行该程序。这个将程序拷贝到内存并运行的过程叫做加载。
每个Unix程序都有一个运行时存储器映像。在32位Linux系统中,代码段总是从地址0x08048000处开始。数据段是在接下来的下一个4KB对齐的地址处。运行时堆在读/写段之后接下来的第一个4KB对齐的地址处,并通过调用malloc库网上增长。还有一个段是为共享库保留的。用户栈总是从最大的合法用户地址开始,向下增长的(向低存储器地址方向增长)。从栈的上部开始的段是为操作系统驻留内存的部分(也就是内核)的代码和数据保留的。
-----------------------------------------------------------
* 内核虚拟存储器 用户代码不可见的存储器
* 用户栈(运行时创建)
* 空
* 共享库的存储器映射区域
* 空
* 运行时堆(有malloc创建)
* 读/写段(.data、.bss)
* 只读段(.init、.text、.rodata)
* 未使用
-----------------------------------------------------------
当加载器运行时,它创建上面所示的存储器映像。在可执行文件中段头部表的指导下,加载器将可执行文件的相关内容拷贝到代码段和数据段。接下来,加载器跳转到程序的入口点,也就是符号_start的地址。在_start地址处的启动代码是在目标文件ctrl.o中定义的,对所有的C程序都一样。
注意:上面所说的关于加载的过程是不准确的,其实际过程为:Unix系统中的每个程序都运行在一个进程的上下文中,有自己的虚拟地址空间。当shell运行一个程序时,父shell进程生成一个子进程,它是父进程的一个复制品。子进程通过execve系统调用启动加载器。加载器删除子进程现有的虚拟存储段,并创建一个新的代码、数据、堆和栈段。新的栈和堆段被初始化为零。通过将虚拟地址空间中的页映射到可执行文件的页大小的片,新的代码段和数据段被初始化为可执行文件的内容。最后,加载器跳转到_start地址,它最终会调用应用程序的main函数。除了一些头部信息,在加载过程中没有任何从磁盘到存储器的数据拷贝。直到CPU引用一个被映射的虚拟页才会进行拷贝,此时,操作系统利用它的页面调度机制自动将页面从磁盘传送到内存。
动态链接共享库
共享库是一个目标模块,在运行时可以加载到任意的存储器地址,并和一个在存储器中的程序链接起来。这个过程称为动态链接,是由一个叫做动态链接器的程序来执行的。
共享库也称为共享目标,在Unix系统中通常用.so后缀来表示。微软的操作系统大量的利用了共享库,它们称为DLL(动态链接库)。
在任何给定的文件系统中,对于一个库只有一个.so文件。所有引用该库的可执行目标文件共享这个.so文件中的代码和数据,而不是像静态库的内容那样被拷贝和嵌入到引用它们的可执行的文件中。在内存中,一个共享库的.text节的一个副本可以被不同的正在运行的进程共享。
关于如何生成并使用共享库文件请参考《嵌入式ARM/S5PV210学习笔记/程序函数库》
当使用共享库生成了一个可执行文件,此文件在运行时可以和共享库.so文件链接。基本思路是当创建可执行文件时,静态执行一些链接,然后在程序加载时,动态完成链接过程。也就是说在链接时没有任何共享库的代码节和数据节被拷贝到可执行文件中。反之,链接器拷贝了一些重定位和符号表信息,它们使得运行时可以解析对共享库中代码和数据的引用。
当加载器加载和运行包含共享库的可执行文件时,加载部分链接的可执行文件。接着,它注意到可执行文件中包含一个.interp节,这个节包含动态链接器的路径名,动态链接器本身就是一个共享目标。加载器不再像它通常那样将控制传递给应用,而是加载和运行这个动态链接器。然后,动态链接器通过执行下面的重定位完成链接任务:重定位每个.so文件的文本和数据到某个存储器段,重定位可执行目标文件中所有对.so文件定义的符号的引用。最后动态链接器将控制传递给应用程序。从这时开始,共享库的位置就固定了,并且在程序执行的过程中都不会改变。
从应用程序中加载和链接共享库
之前我们讨论了在应用程序执行之前,即应用程序被加载时,动态链接器加载和链接共享库的情景。然而,应用程序还可能在它运行时要求动态链接器加载和链接任意共享库,而无需在编译时链接那些库到应用中。
下面是一些现实中的例子、;
*、分发软件。微软Windows应用的开发者常常利用共享库来分发软件更新。他们生成一个共享库的新版本,然后用户可以下载,并用它替代当前的版本。下一次他们运行应用程序时,应用将自动链接和加载新的共享库。
*、构建高性能Web服务器。许多Web服务器生成动态内容,比如个性化的Web页面、账户余额和广告标语。早期的Web服务器通过使用fork和execve创建一个子进程,并在该子进程的上下文中运行CGI程序来生成动态内容。然而,现代高性能的Web服务器可以使用基于动态链接的更有效和完善的方法来生成动态内容。其思路是将生成动态内容的每个函数打包在共享库中。当一个来自Web浏览器的请求到达时,服务器动态的加载和链接适当的函数,然后直接调用它,而不是使用fork和execve在子进程的上下文中运行函数。函数会一直缓存在服务器的地址空间中,所以只要一个简单的函数调用的开销就可以处理随后的请求了。这对一个繁忙的网站来说是有很大影响的。并且,在运行时无需停止服务器,就可以更新已存在的函数,以及添加新的函数。
关于动态链接库的适应请参考《嵌入式ARM/S5PV210学习笔记/程序函数库》和《程序设计与计算机系统-深入理解计算机系统/7.11从应用程序中加载和链接共享库》。
位置无关代码(PIC)
不需要链接器修改库代码就可以在任何地址加载和执行这些代码,这样的代码叫做位置无关代码。
用户对GCC使用-fPIC选项指示GNU编译系统生成PIC代码。
在一个IA32系统中,对同一个目标模块中过程的调用是不需要特殊处理的,因为引用是PC相对的,已知偏移量就已经是PIC了。然而对外部定义的过程调用和对全局变量的引用通常不是PIC,因为它们都需要在链接时重定位。
1、PIC数据引用
无论我们在内存中的何处加载一个目标模块(包括共享目标模块),数据段总是被分配成紧随在代码段后面。因此代码段中任何指令和数据段中任何变量之间的距离都是一个运行时常亮,与代码段和数据段的绝对内存位置是无关的。
为了运用这个事实,编译器在数据段开始的地方创建一个表,叫做全局偏移量表(GOT)。在GOT中,每个被这个目标模块引用的全局数据对象都有一个条目。编译器还为GOT中每个条目生成一个重定位记录。在加载时,动态链接器会重定位GOT中的每个条目,使得它包含正确的绝对地址。每个引用全局数据的目标模块都有自己的GOT。
PIC代码有性能缺陷。每个全局变量的引用需要多条指令而不是一条,还需要一个额外的对GOT的内存引用。而且,PIC代码还要用一个额外的寄存器来保持GOT条目的地址。在具有大寄存器文件的机器上,这不是一个大问题。然而,在寄存器供应不足的系统总,即使失掉一个寄存器也会造成寄存器溢出到栈中。
2、PIC函数调用
PIC代码也可以使用与PIC数据相同的方法来解析外部过程调用,不过这种方法对每个过程调用都需要多条额外的指令。反之,ELF编译系统使用一种叫做延迟绑定的技术,将过程地址的绑定推迟到第一次调用该过程时。第一次调用过程的运行时开销很大,但是其后的每次调用都只会花费一条指令和一个间接地存储器引用。延迟绑定是通过两个数据结构之间简洁但又有些复杂的交互来实现的,这两个数据结构是:GOT和过程链接表(PLT)。如果一个目标模块调用定义在共享库中的任何函数,那么它就有自己的GOT和PLT。GOT是.data节的一部分。PLT是.text节的一部分。
处理目标文件的工具
在Unix系统中有大量的工具帮助你理解和处理目标文件。
AR:创建静态库,插入、删除、列出和提取成员。
STRINGS:列出一个目标文件中所有可打印的字符串。
STRIP:从目标文件中删除符号表信息。
NM:列出一个目标文件的符号表中定义的符号。
SIZE:列出目标文件中节的名字和大小。
READELF:显示一个目标文件的完整结构,包括ELF头中编码的所有信息。包含SIZE和NM功能。
OBJDUMP:所有二进制工具之母。能够显示一个目标文件中所有的信息。它最大的作用是反汇编.text节中的二进制指令。
Unix为操作共享库还提供了LDD程序
LDD:列出一个可执行文件在运行时所需要的共享库。
小结
1、链接可以在编译时由静态编译器来完成,也可以在加载时和运行时由动态链接器来完成。链接器处理目标文件的二进制文件,他有三种不同的形式:可重定位的、可执行的和可共享的。可重定位的目标文件由静态链接器合并成一个可执行的目标文件,它可以加载到存储器中并执行。共享目标文件(共享库)是在运行时由动态链接器链接和加载的,或者隐含的在调用程序被加载和开始执行时,或者根据需要在程序中调用diopen库的函数时。
2、链接器的两个主要任务是符号解析和重定位,符号解析将目标文件中的每个全局符号都绑定到一个唯一的定义,而重定位确定每个符号的最终内存地址,并修改对那些目标的引用。
3、静态链接器是由像GCC这样的编译驱动器调用的。它们将多个可重定位目标文件合并成一个单独的可执行目标文件。多个目标文件可以定义相同的符号,而链接器用来悄悄的解析这些多重定义的规则可能在用户程序中引入的微妙错误。
4、多个目标文件可以被连接到一个单独的静态库中。链接器用库来解析其它目标模块中的符号引用。许多连接器通过从左到右的顺序扫描来解析符号的引用,这是另一个引起令人迷惑的错误的来源。
5、加载器将可执行文件的内容映射到内存,并运行这个程序。连接器还可能生成部分连接的可执行文件,这样的文件有对定义在共享库中的程序和数据的未解析的引用。在加载时,加载器将部分链接的可执行文件映射到内存,然后调用动态链接器,它通过加载共享库和重定位程序中的引用来完成链接任务。
6、被编译为位置无关代码的共享库可以加载到任何地方,也可以在运行时被多个进程共享。为了加载、链接和访问共享库的函数的数据,应用程序还可以在运行时使用动态链接器。