第七章 链接

链接器:把程序的各个部分联合成一个文件,处理器将这些文件加载(复制)到内存,并且执行它。

链接可以执行于编译时(源代码被翻译成机器代码时),也可以执行于加载时(程序被加载器加载到内存并执行时),甚至执行于运行时。

1.静态链接
     为了构造可执行文件,链接器必须完成两个主要任务:
      符号解析 ,目标文件定义和引用符号,每个符号对应于一个函数、一个全局变量或一个静态变量(即C语言中任何以static属性声明的变量)。符号解析的目的是将每个符号引用正好和一个符号定义关联起来。
       ● 重定位,编译器和汇编器生成从地址0开始的代码和数据节。链接器通过把每个符号定义与一个内存位置关联起来,从而重定位这些节,然后修改所有对这些符号的引用,使得它们指向这个内存位置。链接器使用汇编器产生的重定位条目的详细指令,不加甄别地执行这样的重定位。
链接器的基本事实:目标文件纯粹是由链接器连接起来的字节块的集合。
2.目标文件
    目标文件有三种形式:
    ●可重定位目标文件,包含二进制代码和数据,其形式可以在编译时与其他可重定位目标文件合并起来,创建一个可执行目标文件。
    可执行目标文件,包含二进制代码和数据,其形式可以被直接复制到内存并执行。
    ●共享目标文件,一种特殊类型的可重定位目标文件,可以加在或者运行时被动态地加载进内存并链接。
    编译器和汇编器生成可重定位目标文件(包括共享目标文件)。链接器生成可执行目标文件从技术上来说,一个目标模块就是一个字节序列,而一个目标文件就是一个以文件形式存放在磁盘的目标模块。现代的x86-64 Linux和Unix系统使用可执行可链接格式(Executable and Linkable Format, ELF)。
     下图展示了一个典型的ELF可重定位目标文件的格式。ELF头(ELF header)以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量。
    
     夹在ELF头和节头部表之间的都是节,一个典型的ELF可重定位目标文件包含下面几个节:
       .text:  已编译程序的机器代码。
      .rodata:  只读数据,比如printf语句中的格式串和开关语句的跳转表。
      .data:  已初始化的库和静态C变量。局部C变量在运行时被保存在栈中,既不出现在 .data 节中,也不出现在 .bss 节中。
      .bss(Block Storage Start):  未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量。在目标文件中这个字节不占据实际的空间,它仅仅是一个占位符。
      . symtab:  一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。一些程序员错误的认为必须通过-g选项来编译一个程序,才能得到符号表信息。实际上,每个可重定位目标文件在 .symtab 中都有一张符号表(除非程序员特意用STRIP命令去掉它)。然而,和编译器中的符号表不同, .symtab 符号表不包含局部变量的条目。
      .re.text:  一个 .text 节中位置的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。一般而言,任何调用外部函数引用全局变量的指令都需要修改。另一方面,调用本地函数的指令则不需要修改。
      .rel.data:  被模块引用或定义的所有全局的重定位信息,一般而言,任何已初始化的全局变量,如果它的初始值是一个全局变量地址或者外部定义函数的地址,都需要被修改。
      .debug:  一个调试符号表,其条目是程序中定义的局部变量和类型定义,程序中定义和引用的全局变量,以及原始的C源文件。只有以-g选项调用编译器驱动程序时,才会得到这张表。
      .line:  原始C源程序中的行号和.text节中机器指令之间的映射。只有以-g选项调用编译器驱动程序时,才会得到这张表。
     .strtab: 一个字符串表,其内容包括.symtab和.debug节中的符号表,以及节头部中的节名字。字符串表就是以null结尾的字符串的序列。
3.符号和符号表
      每个可重定位目标模块都有一个符号表,它包含m定义和引用的符号的信息。在链接器的上下文中,有三种不同的符号:
      由模块m定义并能被其他模块引用的全局符号。全局链接器符号对应于非静态的C函数和全局变量。
     ● 由其它模块定义并被模块m引用的全局符号。这些符号称为外部符号,对应于在其他模块中定义的非静态C函数和全局变量。
    ● 只被模块m定义和引用的局部符号。它们对应于带static属性的C函数和全局变量。这些符号在模块m中任何位置都可见,但是不能被其他模块引用。
    符号表是由汇编器构造的,使用编译器输出到汇编语言.s文件中的符号。
一、符号解析
    链接器的输入是一组可重定位目标模块。每个模块定义一组符号,有些是局部的(只对定义该符号的模块可见),有些是局部的(对其它模块也可见)。在编译时,编译器向汇编器输出每个全局符号,或者是强或者是弱,而汇编器把这个信息隐含地编码在可重定位目标文件的符号表里。函数和已初始化的全局变量是强符号,未初始化的全局变量是弱符号
    根据强弱符号的定义,Linux链接器使用下面的规则来处理多重定义的符号名:
    ● 规则1: 不允许有多个同名的强符号。
    ● 规则2: 如果有一个强符号和多个弱符号同名,那么选择强符号。
    ● 规则3: 如果有多个弱符号同名,那么从这些弱符号中任意选择一个。
     
    C++和Java都允许重载方法,这些方法在源代码中有相同的名字,却有不同的参数列表。那么链接器是如何区别这些不同的重载函数之间的差异呢?C++和Java中能使用重载函数,是因为编译器将每个唯一地方法和参数列表组合编码抢一个对链接器来说唯一地名字。这种编码过程叫做重整,而相反的过程叫做恢复。
      
    编译器按照前面的规则把符号分配为COMMON(未初始化的全局变量)和.bss(未初始化的静态变量,以及初始化为0的全局或静态变量)。实际上,采用这个惯例是由于某些情况中链接器允许多个模块定义同名的全局符号。当编译器在翻译某个模块时,遇到一个弱全局符号,比如说x,它并不知道其他模块是否也定义了x,如果是,它无法预测链接器该使用x的多重定义中的哪一个。所以编译器把x分配成COMMON,把决定权留给链接器。另一方面,如果x初始化为0,那么它是一个强符号(因此根据规则2必须是唯一的),所以编译器可以很自信地将他分配成.bss。类似地,静态符号的构造就必须唯一地,所以编译器可以自信地把它们分配成.data或.bss。

二、重定位
     一旦链接器完成了符号解析这一步,就把代码中的每个符号引用和正好一个符号定义关联起来。此时,链接器就知道它的输入目标模块中的代码节和数据节的确切大小。
    重定位由两步组成:
     ● 重定位节和符号定义在这一步中,链接器将所有相同类型的节合并为同一类型的新的聚合节。例如:来自所有输入模块的.data节被全部合并成一个节,这个节成为输出的可执行目标文件的.data节。然后,链接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。当这一步完成时,程序中的每条指令和全局变量都有唯一的运行时内存地址了。
    ● 重定位节中的符号引用。在这一步中,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。要执行这一步,链接器依赖于可重定位目标模块中称为重定位条目的数据结构。
1.重定位条目
    当汇编器生成一个目标模块时,它并不知道数据和代码最终将放在内存中的什么位置。它也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。所以,无论何时汇编器遇到对最终位置未知的目标引用,它将会生成一个重定位条目,告诉编译器将目标文件合并成可执行文件时如何修改这个引用。代码的重定位条目放在.rel.text中。已初始化数据的重定位条目放在.rel.data中。
    下图展示了ELF重定位条目的格式。offset是需要被修改的引用的节偏移。symbol标识被修改引用应该指向的符号。type告知链接器如何修改新的引用。addend是一个有符号常数,一些类型的重定位要使用它对被修改引用值做偏移调整。
    
    ELF定义了32种不同的重定位类型,有些相当隐秘。我们只关心其中两种最基本的重定位类型:
     ● R_X86_64_PC32。重定位一个使用32位PC相对地址的引用。一个PC相对地址就是距程序计数器(PC)的当前运行时值得偏移量。当CPU执行一条使用PC相对寻址的指令时,它将在指令中编码的32位值加上PC的当前运行时值,得到有效地址(如call指令的目标),PC值通常是下一条指令在内存中的地址。
    ● R_X86_64_32。重定位一个使用32位绝对地址的引用。通过绝对寻址,CPU直接使用在指令中编码的32位值作为有效地址,不需要进一步修改。
与静态库链接过程图:包括符号解析和重定位过程
      迄今为止,我们都是假设链接器读取一组可重定位目标文件,并把它们链接起来,形成一个输出的可执行文件。实际上,所有的编译系统都提供一种机制,将所有相关的目标模块打包成为一个单独的文件,称为 静态库 ,它可以用做链接器的输入。
    在Linux系统中,静态库是以一种称为存档的特殊文件格式存放在磁盘中。存档文件是一 组连接起来的可重定位目标文件的集合,有一个头部来描述每个成员目标文件的大小和位置。存档文件名由后缀.a标识。
    关于库的一般准则是将库文件它们放在命令行的结尾。如果各个库的成员是相互独立的(也就是说没有成员引用另一个成员定义的符号),那么这些库就可以以任何顺序放置在命令行的结尾处。另一方面,如果库不是相互独立的,那么必须对他们排序,使得对于每个被存档的文件成员外部引用的符号s,在命令行中至少有一个s的定义是在对s的引用之后的。比如:假设foo,c调用libx.a和libz.a中的函数,而这两个库又调用liby.a中的函数。那么,在命令行中libx.a和libz.a必须处在liby.a之前:
    
与静态库链接的过程:

动态链接共享库
    共享库(shared library)是致力于解决静态库缺陷的一个现代创新产物。共享库是一个目标模块,在运行或加载时,可以加载到任意的内存地址,并和一个在内存中的程序链接起来。这个过程叫做动态链接,是由一个叫做动态链接器程序来执行的。在Linux系统中通常用.so后缀表示,windows一般称为DLL(动态链接库)。
    共享库是以两种不同的方式来"共享"的:
1、在任何给定的文件系统中,对于一个库只有一个.so文件。所有引用该库的可执行目标文件共享这个so文件中的代码和数据,而不是像静态库的内容那样被复制和嵌入到引用它们的可执行的文件中。
2、在内存中,一个共享库的.text节的一个副本可以被不同的正在运行的进程共享。
                                            
     上图是main2.c主程序调用动态库libvector.so方法生成可执行程序prog21的过程。基本思路是当创建可执行文件时,静态执行一些链接,然后在程序加载时,动态完成链接过程。认识到这一点是很重要的:此时,没有任何libvector.so的代码和数据节真的被复制到可执行文件prog21中。反之,链接器复制了一些重定位和符号表信息,它们使得运行时可以解析对libvector.so中代码和数据的引用。
     加载器(复制)是如何工作的?
    Linux系统中的每个程序都运行在一个进程的上下文中,有自己的虚拟地址空间。当shell运行一个程序时,父shell进程生成一个子进程,它是父进程的一个复制。子进程通过execve系统调用启动加载器。加载器删除子进程现有的虚拟内存段,并创建一组新的代码、数据、堆和栈段。新的栈和堆段被初始化为零。通过将虚拟地址空间中的页映射到可执行文件的页大小的片(chunk),新的代码和数据段被初始化为可执行文件的内容。最后,加载器跳转到_start地址,它最终会调用应用程序的main函数。除了一些头部信息,在加载过程中没有任何从磁盘到内存的数据复制。直到CPU引用一个被映射的虚拟页时才会进行复制,此时,操作系统利用它的页面调度机制自动将页面从磁盘传送到内存。
9.位置无关代码
    为了避免共享库中地址空间的使用率不高,以及由于一个操作系统中有成百个库和库的各个版本库,很难避免地址空间分裂成大量小的、未使用而又不再能使用的小洞等问题, 现代系统以这样一种方式编译共享模块代码段,使得把它们可以加载到内存的任何位置而无需链接器修改。使用这种方法,无限多个进程可以共享一个共享模块的代码段的单一副本。(当然,每个进程仍然会有它自己的读/写数据块。)
     可以加载而无需重定位的代码称为位置无关代码(Position-Independent Code,PIC)用户对GCC使用fpic选项指示GNU编译系统生成的PIC代码。共享库的编译必须总是使用该选项。
     对共享模块定义的外部过程和对全局变量的引用需要一些特殊的技巧:
     ● PIC数据引用
    想要生成对全局变量PIC引用的编译器利用了这个事实,它在数据段开始的地方创建了一个表,叫做全局偏移量表(Global Offset Table, GOT),在GOT中,每个被这个目标模块引用的全局数据目标都有一个8字节条目。编译器还为GOT中的每个条目生成一个重定位记录。在加载时,动态链接器会重定位GOT中的每个条目,使得它包含目标的正确的绝对地址。
    ● PIC函数调用
    假设程序调用一个由共享库定义的函数。编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候在解析它。不过,这种方法并不是PIC,因为需要链接器修改调用模块的代码段,GNU编译系统使用了一种很有趣的技术来解决这个问题,称为延迟绑定,将过程的地址的绑定推迟到第一次调用该过程时。
    使用延时绑定的动机是对于一个像libc.so这样的共享库输出的成百上千个函数中,一个典型的应用程序只会使用其中很少的一部分。把函数地址的解析推迟到它实际被调用的地方,能避免动态链接器在加载时进行成百上千个其实并不需要的重定位。第一次调用过程的运行时开销很大,但是其后的每次调用都会只花费一条指令和一个间接的内存引用。
10.库打桩机制
     Linux链接器支持一个强大的技术,称为库打桩(library interpositioning),它允许你截获对共享库函数的调用,取而代之执行自己的代码。使用打桩机制,你可以追踪对某个特殊库函数的调用次数,验证追踪它的输入和输出值,或者甚至把它替换成一个完全不同的实现。
     打桩可以发生在三个阶段:
      ● 编译时打桩
     ● 链接时打桩
     ● 运行时打桩     
11.处理目标文件的工具
    在Linux系统中有大量可用的工具可以帮助你理解和处理目标文件。特别的,GNU binutils包尤其有帮助,而且可以运行在每个Linux平台上。
     ● AR: 创建静态库,插入、删除、列出和提取成员。
    ● STRINGS: 列出一个目标文件中所有打印的字符串。
    ● STRIP: 从目标文件的中删除符号表信息。
    ● NM: 列出目标文件的符号表中定义的符号。
    ● SIZE: 列出目标文件中节的名字和大小。
    ● READELF: 显示一个目标文件的完整结构,包括ELF头中编码的所有信息。包含SIZE和NM的功能。
    ● OBJDUMP: 所有二进制工具之母。能够显示一个目标文件中所有的信息。它组ui大的作用就是反汇编.text节中的二进制指令。
    ● LDD: 列出一个可执行文件在运行时所需要的共享库。
    小结:
    链接可以在编译时有静态编译器来完成,也可以在加载时和运行时由动态链接器来完成。链接器处理目标文件的二进制文件,它有3种不同的形式:可重定位的、可执行的和共享的。可重定位的目标文件由静态链接器链接和加载的,或者隐含地在调用程序被加载和开始执行时,或者根据需要在程序调用dlopen库的函数时。
    链接器的两个主要任务是符号解析和重定位,符号解析将目标文件中的每个全局符号都绑定到一个唯一地定义,而重定位确定每个符号的最终内存地址,并修改对那些目标的引用。
    静态链接器是由像GCC这样的编译器驱动程序调用的。它们将多个可重定位目标文件合并成一个单独的可执行目标文件。多个目标文件可以定义相同的符号,而链接器用来悄悄地解析这些多重定义的规则可能在用户程序中引入微妙的错误。
    多个目标文件可以被连接到一个单独的静态库中。链接器用库来解析其他目标模块中的符号引用。许多链接器通过从左到右的顺序扫描来解析符号引用,这是另一个引起令人迷惑的链接时错误的来源。
    加载器将可执行文件的内容映射到内存,并运行这个程序。链接器还可能生成部分链接的可执行目标文件,这样的文件中有对定义在共享库中的例程和数据的未解析的引用。在加载时,加载器将部分链接的可执行文件映射到内存,然后调用动态链接器,它通过加载共享库和重定位程序中的引用来完成链接任务。
    被编译为位置无关代码的共享库可以加载到任何地方,也可以在运行时被多个进程共享。为了加载、链接和访问共享库的函数和数据,应用程序也可以在运行时使用动态链接器。

猜你喜欢

转载自blog.csdn.net/weixin_41413441/article/details/79466745