链接

  • 链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载到内存并执行。链接可以执行于编译时,也就是在源代码被编译成机器代码时,也可以执行于加载时,也就是在程序被加载器加载到内存并执行时,甚至执行于运行时,也就是由应用程序来执行。
  • 链接器在软件开发中扮演着一个关键的角色,因为它们使得分离编译成为可能。我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小,更好管理地模块,可以独立地修改和编译这些模块。当我们改变这些模块中国馆的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其他文件。
  • 作用
    • 理解链接器将帮助你构造大型程序
    • 理解链接器将帮助你避免一些危险的编程错误
    • 理解链接将帮助你避免一些危险的编程错误
    • 理解链接将帮助你理解语言的作用域规则是如何实现的
    • 理解链接将帮助你理解其他重要的系统概念
    • 理解链接将使你能够利用共享库

编译器驱动程序

  • 大多数编译系统提供编译器驱动程序,它代表用户在需要时调用语言预处理器、编译器、汇编器和链接器。
    • 驱动程序首先运用C预处理器,它将C地源程序main.c翻译成一个ASCII码的中间文件main.i
    • 接下来,驱动程序运行C编译器,它将main.i翻译成一个ASCII汇编语言文件main.s
    • 然后,去东莞程序运行汇编器,它将main.s翻译成一个可重定位目标文件main.o
    • 最后,运用链接器程序Id,将main.osum.o以及一些必要的系统目标文件组合起来,创建一个可执行目标文件

静态链接

  • Linxu LD程序这样的静态链接器以一组可重定位目标文件和命令行参数作为输入,生成一个完全链接的,可以加载和运行的可执行目标文件作为输出。
  • 为了构造可执行文件,链接器必须完成两个主要任务:
    • 符号解析。目标文件定义和引用符号,每个符号对应于一个函数、一个全局变量或一个静态变量。符号解析的目的是将每个符号引用正好和一个符号定义关联起来
    • 重定位。编译器和汇编器生成从高地址0开始的代码和数据节。链接器通过把每个符号定义与一个内存位置关联起来,从而重定位这些节,然后修改所有对这些符号的引用,使得它们指向这个内存位置。

目标文件

  • 目标文件有三种形式:
    • 可重定位目标文件。包含二进制代码和数据,其形式可以在编译时与其他可重定位目标文件合并起来,创建一个可执行目标文件
    • 可执行目标文件。包含二进制代码和数据,其形式可以被直接复制到内存并执行
    • 共享目标文件。一种特殊类型的可重定位目标文件,可以在加载或者运行时被动态地加载进内存并链接
  • 编译器和汇编器生成可重定位目标文件(包括共享目标文件)。链接器生成可执行目标文件。从技术上来说,一个目标模块就是一个字节序列,而一个目标文件就是一个以文件形式存放在磁盘中的目标模块。

可重定位目标文件

  • ELF头以一个1靠字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量。不同节的位置和大小时是由节头部表描述的,其中目标文件中每个节都有一个固定大小的条目
  • 夹在ELF头和节头部表之间的都是节。一个典型的ELF可重定位目标文件包含下面几个节:
    • .text:已编译程序的机器代码
    • .rodata:只读数据,比如printf语句中的格式串和开关语句的跳转表
    • .data:已初始化的全局和静态C变量。局部C变量在运行时被保存在栈中,既不出现在.data节中,也不出现在.bss节中
    • .bss:未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量
    • .symtab:一个符号表,它存放在程序中定义和引用的函数和全局变量的信息
    • .rel.text:一个.text节中位置的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置
    • .rel.data:被模块引用或定义的所有全局变量的重定位信息
    • .debug:一个调式符号表,其条目是程序中定义的局部变量和类型定义,程序中定义和引用的全局变量,以及原始的C源文件。
    • .line:原始C源程序中的行号和.text节中机器指令之间的映射
    • .strtab:一个字符串表,其内容包括.symtab.debug节中的符号包,以及节头部中的节名字。

符号和符号表

  • 每个可重定位目标模块m都有一个符号表,它包含m定义和引用的符号的信息。在连接器的上下文中,有三种不同的符号:

    • 由模块m定义并能被其他模块引用的全局符号。全局链接器符号对应于非静态的C函数和全局变量
    • 由其他模块定义并被模块m引用的全局符号。这些符号称为外部符号,对应于在其他模块中定义的非静态C函数和全局变量
    • 只被模块定义和引用的局部符号。它们对应于带static属性的C函数和全局变量。这些符号在模块m中任何位置都可见,但是不能被其他模块引用。
  • 符号表是由汇编器构造的,使用编译器输出到汇编语言.s文件中的符号。.symtab节中包含ELF符号表。

    • typedef struct{
      	int name;
      	char type:4,
      		 binding:4;
      	char reserved;
      	short section;
      	long value;
      	long size;
      } Elf64_Symbol
      
    • name是字符串中表的字节便宜,指向符号的以null结尾的字符串名字。value是符号的地址。对于可重定位的模块来说,value是距定义目标的节的起始位置的偏移。对于可执行目标文件来说,该值是一个绝对运行时地址。size是目标的大小(以字节为单位)。type通常要么是数据,要么是函数。符号表还可以包含各个节的条目,以及对应原始源文件的路径名的条目。所以这些目标的类型也有所不同。binding字段表示符号是本地的还是全局的。

    • 每个符号都要被分配到目标文件的某个节,由section字段表示,该字段也是一个到节头部表的索引。

符号解析

  • 链接器解析符号引用的方法是将每个引用与它输入的可重定位目标文件的符号表中的一个确定的符号定义关联起来。对那些和引用定义在相同模块中的局部符号的引用,符号解析是非常简单明了的。编译器只允许每个模块中每个局部符号有一个定义。静态局部变量也会有本地链接器符号,编译器还要确保它们拥有唯一的名字。
  • 不过,对全局富豪的引用解析就棘手得多。当编译器遇到一个不是在当前模块中定义的符号时,会假设该符号是在其他某个模块中定义的,生成一个链接器符号表条目,并把它交给链接器处理。

链接器如何解析多重定义的全局符号

  • 链接器的输入是一组可重定位目标模块。每个模块定义一组符号,有些是局部的(只对定义该符号的模块可见),有些是全局的(对其他模块也可见)。
  • 在编译时,编译器向汇编器输出每个全局符号,或者是强(strong)或者是弱(weak),而汇编器把这个信息隐含地编码在可重定位目标文件的符号表里。函数和已初始化的全局变量是强符号,未初始化地全局变量是弱符号。
  • 根据强弱符号的定义,Linux链接器使用下面的规则来处理多重定义的符号名:
    • 规则1:不允许有多个同名的强符号
    • 规则2:如果有一个强符号和多个弱符号同名,那么选择强符号
    • 规则3:如果有多个弱符号同名,那么从这些弱符号中任意选择一个

与静态库链接

  • 所有的编译系统都提供一种机制,将所有相关的目标模块打包成为一个单独的文件,称为静态库,它可以用作链接器的输入。当链接器构造一个输出地可执行文件时,它只复制静态库里被应用程序引用的目标模块。
  • 相关的函数可以被编译为独立的目标模块,然后封装成一个单独的静态库文件。然后,应用程序可以通过在命令行上指定单独地文件名字来使用这些在库中定义的函数。
  • LInux系统中,静态库以一种称为存档地特殊文件格式存放在磁盘中。存档文件是一组连续起来地可重定位目标文件的集合,有一个头部用来描述每个成员目标文件的大小和位置。

链接器如何使用静态库来解析引用

  • 在符号解析阶段,链接器从左到右按照它们在编译器驱动程序命令行上出现的顺序来扫描可重定位目标文件和存档文件。在这次扫描中,链接器会维护一个可重定位目标文件的集合E,一个未解析的符号集合U,以及一个在前面输入文件中已定义的符号集合D。初始时,EUD均为空。
    • 对于命令行地每个输入文件f,链接器会判断f是一个目标文件还是一个存放文件。如果f是一个目标文件,那么链接器把f添加到E,修改UD来反应f中的符号定义和引用,并继续下一个输入文件。
    • 如果f是一个存档文件,那么链接器就尝试匹配U中未解析的符号和由存档文件成员定义的符号。如果某个存档文件成m,定义了一个符号来解析U中一个引用,那么就将m加到E中,并且将链接器修改UD来反应m中地符号定义和引用。对存档文件中所有的成员目标文件都依次进行这个过程,直到UD都不再发生变化。此时,任何不包含在E中地成员目标文件都简单地被丢弃,而链接器将继续处理下一个输入文件。
    • 如果当链接器完成对命令行上输入文件的扫描后,U是非空的,那么链接器就会输出一个错误并终止。否则,它会合并和重定位E中的目标文件,构建输出的可执行文件。

重定位

  • 一旦链接器完成了符号解析这一步,就把代码中地每个符号引用和正好一个符号定义(即它的一个输入目标模块中的一个符号表条目)关联起来。此时,链接器就知道它的输入目标模块中的代码节和数据节的确切大小。现在就可以开始重定位步骤了,在这个步骤中,将合并输入模块,并未每个符号分配运行时地址。重定位由两步组成:
    • 重定位节和符号定义。在这一步中,链接器将所有相同类型的节合并为同一类型的新的聚合节。例如,来自所有输入模块.data节被全合并成一个节,这个节成为输出的可执行目标文件的.data节。然后,链接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。当这一步完成时,程序中的每条指令和全局变量都有唯一的运行时内存地址了。
    • 重定位节中的符号引用。在这一步中,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。要执行这一步,链接器依赖于可重定位目标模块中成为重定位条目的数据结构.

重定位条目

  • 当汇编器生成一个目标模块时,它并不知道数据和代码最终将放在内存中的什么位置。它也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。所以,无论何时汇编器遇到对最终位置未知的目标引用,他就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。代码的重定位条目放在.rel.text中。已初始化数据的重定位条目放在.rel.data中。

  • typedef struct{
    	long offset;
    	long type:32,
    		 symbol:32;
    	long addend;
    } Elf64_Rela
    
  • offset是需要被修改的引用的节偏移。symbol标识被修改引用应该指向的符号。type告知链接器如何修改新的应用。addend是一个有符号常数,一些类型的重定位要使用它对被修改引用的值做偏移调整。

可执行目标文件

  • 可执行目标文件的格式类似于可重定位目标文件的格式。ELF头描述文件的总体格式。它还包括程序的入口点,也就是当程序运行时要执行的第一条指令的地址。text.rodata.data节与可重定位目标文件中的节是相似的,除了这些节已经被重定位到它们最终的运行时内存地址以外。.init节定义了一个小函数,叫做_init,程序的初始化代码会调用它。因为可执行文件是完全链接的(已被重定位),所以他不再需要.rel节。

动态链接共享库

  • 静态库有一些明显的缺点。静态库和所有的软件一样,需要定期维护和更新。如果应用程序员想要使用一个库的最新成本,它们必须以某种方式了解到该库的更新情况,然后显式地将他们的程序与更新了的库重新连接。
  • 另一个问题是几乎每个C程序都使用标准I/O函数,比如printfscanf。在运行时,这些函数的代码会被复制到每个运行进程的文本段中。在一个运行有上百个进程地典型系统上,这将是对稀缺的内存系统资源的极大浪费。
  • 共享库是致力于解决静态库缺陷地一个现代创新产物。共享库是一个目标模块,在运行或加载时,可以加载到任意地内存地址,并和一个在内存中的程序链接起来,这个过程称之为动态链接,是由一个叫做动态链接器地程序来执行的。共享库也称为共享目标,在Linux系统中通常用.so后缀来表示。微软的操作系统大量地使用了共享库,它们称之为DLL
  • 共享库是以两种不同的方式来共享的。首先,在任何给定的文件系统中,对于一个库只有一个.so文件。所有引用该库地可执行目标文件共享这个.so文件中的代码和数据,而不是像静态库地内容那样被复制和嵌入到引用它们的可执行的文件中。其次,在内存中,一个共享库的.text节的一个副本可以被不同的正在运行的进程共享。