第7章 链接

参考资料

链接

概念

链接( linking)是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。

链接在以下三个阶段都可以执行:

  1. 编译时,即在源代码被翻译成机器代码时
  2. 加载时,即程序被加载器加载到内存并执行时
  3. 运行时,即由应用程序来执行

现代系统中,链接是由链接器自动执行的。链接器使分离编译成为可能,而分离编译正是大型项目所必不可缺的。

为什么需要了解链接器

  • 理解链接器将帮助你构造大型程序。构造大型程序的程序员经常会遇到由于缺少模块、缺少库或者不兼容的库版本引起的链接器错误。除非你理解链接器是如何解析引用、什么是库以及链接器是如何使用库来解析引用的,否则这类错误将令你感到迷惑和挫败。

  • 理解链接器将帮助你避免一些危险的编程错误。Linux链接器解析符号引用时所做的决定可以不动声色地影响你程序的正确性。在默认情况下,错误地定义多个全局变量的程序将通过链接器,而不产生任何警告信息。由此得到的程序会产生令人迷惑的运行时行为,而且非常难以调试。我们将向你展示这是如何发生的,以及该如何避免它。

  • 理解链接将帮助你理解语言的作用域规则是如何实现的。例如,全局和局部变量之间的区别是什么?当你定义一个具有 static属性的变量或者函数时,实际到底意味着什么。

  • 理解链接将帮助你理解其他重要的系统概念。链接器产生的可执行目标文件在重要的系统功能中扮演着关键角色,比如加载和运行程序、虚拟内存、分页、内存映射。

  • 理解链接将使你能够利用共享库。多年以来,链接都被认为是相当简单和无趣的然而,随着共享库和动态链接在现代操作系统中重要性的日益加强,链接成为一个复杂的过程,为掌握它的程序员提供了强大的能力。比如,许多软件产品在运行时使用共享库来升级压缩包装的( shrink- wrapped)二进制程序。还有,大多数Web服务器都依赖于共享库的动态链接来提供动态内容。

后续讨论基于这样的环境:一个运行Linux的X86-64系统,使用标准的ELF-64(简称ELF)目标文件格式。

ELF (Executable and Linkable Format)是一种为可执行文件,目标文件,共享链接库和内核转储(core dumps)准备的标准文件格式。 Linux和很多类Unix操作系统都使用这个格式。
1

编译器驱动程序

编译器驱动程序可以使用户根据需要调用语言预处理器编译器汇编器链接器

编译器驱动程序_源程序

使用GNU编译系统构建上述的示例程序:

linux> gcc -Og -o prog main.c sum.c
1

具体执行内容为:

  1. 编译器驱动程序首先运行C预处理器(cpp)将C的源程序main.c翻译成ASCII码的中间文件main.i (cpp [other arguments] main.c /tmp/main.i)
  2. 然后运行C编译器(cc1),将main.i翻译成一个ASCII汇编语言文件main.s (cc1 /tmp/main.i -Og [other arguments] -o /tmp/main.s)
  3. 接着运行汇编器(as),将main.s翻译成一个可重定位目标文件(relocatable object file)main.o (cs [other arguments] -o /tmp/main.o /tmp/main.s)
  4. sum.c经过同样的过程生成sum.o
  5. 最后运行链接器程序ld,将main.o和sum.o以及一些必要的系统目标文件组合起来,创建一个可执行的目标文件prog (ld -o prog [system object files and args ] /tmp/main.o /tmp/sum.o)

要运行可执行文件prog,直接在Linux shell命令行输入它的名称即可

linux>  ./prog
1

shell调用操作系统中一个叫做加载器(loader)的函数,它可以将可执行文件prog中的代码和数据复制到内存,然后将控制转移到这个程序的开头

静态链接

像 Linux LD程序这样的静态链接器以一组可重定位目标文件和命令行参数作为输入,生成一个完全链接的、可以加载和运行的可执行目标文件作为输出

  • 输入的可重定位目标文件由各种不同的代码和数据节(section) 组成,每一节都是一个连续的字节序列。指令在一节中,初始化了的全局变量在另一节中,而未初始化的变量又在另外节中。

为了构造可执行文件,链接器必须完成两个主要任务:

  • 符号解析( symbol resolution)
    • 符号定义:目标文件定义和引用符号,每个符号对应于一个函数、一个全局变量或一个静态变量。
    • 符号解析的目的:将每个符号引用正好和一个符号定义关联起来。
  • 重定位( relocation)
    • 由编译器和汇编器生成的可重定位目标文件中的代码和数据节是从 0 开始的。可重定位目标文件中还包含重定位条目。
    • 如何实现重定位:链接器通过把每个符号定义与一个内存位置关联起来,从而重定位这些节,然后修改所有对这些符号的引用,使得它们指向这个内存位置。

目标文件

概念

  • 共享目标文件:由编译器和汇编器生成可重定位目标文件
  • 可执行目标文件:由链接器生成
  • 目标模块:一个字节序列
  • 目标文件:一个以文件形式存放在磁盘的目标模块

一个目标文件又称目标模块。目标文件纯粹是字节块的集合。目标文件本身是一个字节序列。这些字节块中有些包含程序代码程序数据,其他的则包含引导链接器和加载器的数据结构链接器把这些块连接起来,确定被连接块的运行时位置,并修改代码和数据块中的各种位置。

目标文件有三种形式:

  1. 可重定位目标文件:包含二进制的代码数据。可以与其他可重定位目标文件合并成可执行目标文件。又称 obj 文件,gcc 经过预处理、编译、汇编后生成的 .o 文件即为可重定位目标文件。
  2. **可执行目标文件:**包含二进制的代码和数据。可以被直接复制到内存并执行。简称可执行文件,gcc 经过链接后生成的 .out 文件以及无后缀名文件都是可执行文件。
  3. 共享目标文件:一种特殊类型的可重定位目标文件,即动态链接库。可以在加载或者运行时被动态地加载进内存并链接。

可重定位目标文件

可重定位目标文件由多个不同的节组成,每一节都是一个连续的字节序列。指令、初始化了的全局变量、未初始化的的变量分别位于不同的节。

ELF可重定位目标文件的格式如下:

ELF可重定位目标文件的格式

一个 ELF 可重定位文件中包含以下节(按位置顺序排列):

  1. ELF 头:特殊的节,包含文件的一些基本属性信息,用来解释目标文件帮助链接器进行语法分析

    • 包含内容:生成该文件的系统的字的大小和字节顺序,ELF 头的大小,目标文件的类型,机器类型(如 x86-64),节头部表的文件偏移,节头部表中条目的大小和数量。
  2. .text:已编译程序的机器代码。即存放的是指令代码。

  3. .rodata:只读数据。

  4. .data:已初始化的全局和静态变量。

  5. .bbs:未初始化的全局和静态变量,以及所有被初始化为 0 的全局或静态变量。(Block Storage Start)

    • 注意:.bss 节在目标文件中仅是一个占位符,不占据实际空间。这两类变量都是运行时在内存中为其分配变量,并初始化为 0
  6. .symtab:一个符号表:存放了在程序中定义引用符号 (即函数和全局变量) 的信息。

    • 注意:与可编译器中的符号表不同,.symtab 中的符号表不包含局部变量的条目
  7. .rel.text:一个 .text 节中位置的列表,当链接器把此目标文件与其他文件组合时,需要修改这些位置。

    • 一般任何调用外部函数或引用全局变量的指令都需要修改,而调用本地函数的指令则不需要修改。为什么不需要修改呢?
    • 注意:可执行目标文件不需要重定位,一般不包含 .rel.text 和 .rel.data 节。
    • 理解**:.rel.text 中包含的实际上是代码的重定位条目**。
  8. .rel.data:被模块引用或定义的所有全局变量的重定位信息。

    • 如果一个已初始化的全局变量其初始值是一个全局变量地址或外部定义函数的地址,就需要被修改。

    • 理解:.rel.data 中包含的实际上是已初始化的数据的重定位条目

  9. .debug:一个调试符号表,内部包含的条目是程序中定义的局部变量和类型定义,程序中定义和引用的全局变量,还有原始的 C 源文件。

    • 注意:.debug 节并不总是存在,只有用 -g 选项来调用编译器驱动程序时,才会有这一节。
  10. .line:包含原始 C 源程序中的行号和 .text 节中机器指令之间的映射

    • 注意:.line 节和 .debug 节一样,并不总是存在,只有用 -g 选项来调用编译器驱动程序时,才会有这一节。
  11. .strtab:包含一个字符串表,其中包括 .symtab 和 .debug 节中的符号表,以及节头部中的节名字。

    • 字符串表实际上就是一个以 null 结尾的字符串的序列
  12. **节头部表:**特殊的节,是一个用来描述目标文件的节。

    • 内容:含有与目标文件中每个节相对应的一个条目,描述了对应节的位置和大小等信息。

注意局部变量在运行时保存在栈中,既不出现在 .data 节中,也不出现在 .bss 节中。

符号和符号表

重定位的核心就是对符号表进行符号解析

每个可重定位目标模块 m 都有一个符号表(即 .symtab 节),包含着 m 定义和引用的符号的信息。

在链接器的上下文中,有三种不同的符号:

  1. 模块 m 定义并能被其他模块引用全局符号。包括非静态的函数和全局变量
  2. 其他模块定义并被 m 引用的全局符号,称之为外部符号。对应其他模块中定义的非静态函数和全局变量。
  3. 模块 m 定义且只能被 m 引用的**局部符号****。**包括带 static 属性的函数和全局变量。

对照 C++ 的语法来理解什么是全局符号和局部符号(static 对全局变量和函数的隐藏效果是一样的):

  1. C++ 中,static 变量只能在本文件中使用,即使外其他文件中用 extern 中声明也不行。属于这里的局部符号
  2. C++ 中,非 static 的全局变量在其他文件中也能使用,只需在该文件中用 extern 声明即可。属于这里的全局符号

**注意:**符号表中没有非 static 局部变量的符号,非 static 局部变量在运行时在栈中被管理。这里的局部符号和程序中的局部变量是不同的。

编译器在 .data 或 .bss 中为每个全局变量和 static 变量的定义分配空间,并在符号表中创建一个有唯一名字的符号

符号表中的条目

符号表结构

对应各个字段的中文含义:

typedef struct{
    int name;//name 是一个字符串表(.strtab节)中的字节偏移,指向符号的名字(用一个以 null 结尾的字符串表示)
    char type:4;//表明符号的类型:数据或函数(4 bits)
         binding:4;//表明符号是本地的还是全局的(4 bits)//这里的意思似乎是 type 和 binding 分别是一个 char 类型的高四位和低四位
    char reserved;//
    short section;//表明符号位于文件的哪个节中,section 是一个到节头部表的索引。
    long value;//对于可重定位文件而言,value 是距定义目标的节的起始位置的偏移;对于可执行文件而言,value 是一个绝对运行时地址
    long size;//对象的大小,以字节为单位
}
1
2
3
4
5
6
7
8
9

符号表实际上是一个条目的数组,每个条目描述一个符号的信息。符号表中的条目除了符号外,还可以包含各个节的条目,对应原始源文件的路径名的条目。

符号解析

**链接器解析符号引用的方法:**将每个引用和它输入的可重定位文件的符号表中的一个确定的符号定义关联起来。

符号解析可以分为对局部符号的解析和对全局符号的解析:

  1. 局部符号:简单明了

    • 备注:在每个模块中,编译器只允许每个局部符号有一个定义。并且会确保每个静态变量有唯一的名字。
  2. 全局符号:更复杂一些

    • 方式:编译器遇到一个不是在当前模块定义的符号时,会假设该符号是在其他某个模块中定义的,在可重定位目标文件中生成一个符号表条目,并把它交给链接器处理。
    • 特殊情况:多个目标文件中定义了相同名字的的全局符号。

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

链接器的输入是一组可重定位目标模块。每个模块定义一组符号,有些是局部的(只对定义该符号的模块可见),有些是全局的(对其他模块也可见)。如果多个模块定义同名的全局符号,会发生什么呢?

下面是 Linux编译系统采用的方法。

在编译时,编译器向汇编器输出每个全局符号,或者是强( strong)或者是弱(weak),而汇编器把这个信息隐含地编码在可重定位目标文件的符号表里。

  • 强符号:函数和已初始化的全局变量
  • 弱符号:未初始化的全局变量

根据强弱符号的定义, Linux链接器使用下面的规则来处理多重定义的符号名:

规则1:不允许有多个同名的强符号。

规则2:如果有一个强符号和多个弱符号同名,那么选择强符号。

规则3:如果有多个弱符号同名,那么从这些弱符号中任意选择一个。

注意:vs 的链接器并未遵守规则2,规则3:如果定义了同名的全局变量,链接器会直接报错,不论是强符号还是弱符号。

与静态库链接

静态库:将所有相关的目标模块打包成一个单独的文件。

通过静态库,相关的函数可以被编译为独立的目标模块,然后封装成一个单独的静态库文件。然后,应用程序可以通过在命令行上指定单独的文件名称来使用这些在库中定义的函数。

例如:

linux> gcc main.c /usr/lib/libm.a /usr/lib/libc.a //使用C标准库和数学库中的函数
1

在链接时,链接器将只复制被程序引用的目标模块,减少了可执行文件在磁盘和内存中所占用的空间。

在 Linux 系统中,静态库以一种称为存档的特殊文件格式存放磁盘中。存档文件是一组连接起来的可重定位目标文件的集合,有一个头部用来描述每个成员目标文件的大小和位置。存档文件后缀名为 .a

理解:静态库和存档文件可以当作一个东西。存档是文件层面的描述,静态库是模块层面的描述。

在 linux 中,静态链接库是 .a 文件,动态链接库是 .so 文件。在windows 中,静态链接库是 .lib 文件,动态链接库是 .dll 文件。

静态库的应用实例

静态库例子

通过如下命令创建静态库:

linux> gcc -c addvec.c multvec.c   //将 addvec.c 和 multvec 两个文件编译成两个可重定位目标文件
linux> ar rcs libvector.a addvec.o multvec.o  //采用 ar 工具将上一步生成的两个可重定位目标文件 addvec.o 和 multvec.o 封装到静态库 libvector.o 中。
1
2

为了使用这个库,编写程序如下:

使用静态库

创建可执行文件:

linux> gcc -c main2.c
linux> gcc -static -o prog2c main2.o ./libvector.a
1
2

当链接器运行时,能自动判别出main2.o使用了addvec.o定义的addvec符号和printf.o使用的printf符号,因此复制addvec.o和printf.o到可执行文件。

与静态库链接

链接器如何解析引用

符号解析的过程

在符号解析阶段,链接器从左到右按照它们在编译器驱动程序命令行上出现的顺序来扫描可重定位目标文件和存档文件。

在扫描中,链接器会维护一个可重定位目标文件的集合 E,一个未解析的符号 (即引用了但尚未定义的符号) 集合 U已定义的符号集合 D初始时, E, U, D 都为空。

  1. 对于命令行上的每个输入文件 f,链接器会判断 f 是一个目标文件还是一个存档文件。(这里的存档文件即静态库)

  2. 如果 f 是一个目标文件,链接器会把 f 添加到 E,修改 U 和 D 来反映 f 中的符号定义和引用,并继续下一个输入文件。

  3. 如果 f 是一个存档文件,链接器会尝试匹配 U 中未解析的符号和存档文件成员定义的符号。

    1. 如果 f 中的某个成员 m 定义了一个符号来解析 U 中的一个引用,就把 m 加到 E 中,并修改 U 和 D 来反映 m 中的符号定义和引用。
    2. 对存档文件中所有的成员目标文件都依次进行这个过程。之后任何不包含在 E 中的成员目标文件都简单地被丢弃。
    3. 处理完 f,链接器会继续处理下一个输入文件。
  4. 当链接器扫描完所有输入文件后,如果 U 是非空的,链接器会输出一个错误并终止。

库在命令行中放在什么位置

在命令行中,如果定义一个符号的库出现在引用这个符号的目标文件前,引用就不能被解析,链接会失败。因为初始时 U 是空的。

一般把库放在命令行的结尾。如果库之间相互依赖,则依赖者在前,被依赖者在后。如果双向引用,可以在命令行上重复库

重定位

符号解析完成后,每个符号引用就和一个符号定义(即一个输入目标模块中的一个符号表条目)关联起来了。

此时,链接器已经知道它的输入模块中的代码节和数据节的确切大小(存储在节头部表中),接下来就是重定位步骤了。

重定位将合并输入模块并为每个符号分配运行时地址

重定位分为两步:

  1. 重定位节和符号定义。

    • 链接器将所有相同类型的节合并为同一类型的新的聚合节。
    • 链接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。
    • 上面两步完成后,程序中的每条指令和全局变量都有唯一的运行时内存地址了。
  2. 重定位节中的符号引用。

    • 链接器修改代码节和数据节中对每个符号的引用,是他们指向正确的运行时地址。链接器依赖于可重定位目标模块中的重定位条目。

重定位条目

重定位条目用来解决符号引用和符号定义的运行时地址的关联问题。

当汇编器遇到对最终位置的目标引用时,就会生成一个重定位条目,告诉链接器在合并目标文件为可执行文件时如何修改这个引用。

代码的重定位条目放在 .rel.text 中,已初始化数据的重定位条目放在 .rel.data 中。

每个重定位条目都代表了一个必须被重定位的引用

ELF重定位条目的格式:

ELF重定位条目

具体含义:

typedef struct{
    long offset;    //需要被修改的引用的节偏移(即该符号引用距离所在节的初始位置的偏移)。
    long type:32,   //重定位类型,不同的重定位类型会用不同的方式来修改引用
         symbol:32; //symbol table index,指向被修改引用应该指向的符号
    long addend;    //一个有符号常数,一些类型的重定位要使用它对被修改引用的值做偏移调整     
}
1
2
3
4
5
6

ELF 定义了32种不同的重定位类型。以下是其中最基本的两种:

  1. R_X86_64_PC32:重定位一个使用 32 位 PC 相对地址的引用。

    • PC 相对地址:一个 PC 相对地址就是距程序计数器的值的偏移量。当 CPU 执行到一条使用 PC 相对寻址的指令时,就将在指令中编码的 32 位偏移量值加上 PC 的当前运行时值,得到有效地址,PC 值通常是下一条指令在内存中的地址。
  2. R_X86_64_32:重定位一个使用 32 位绝对地址的引用。通过绝对寻址,CPU 直接使用在指令中编码的 32 位值作为有效地址。

这两种类型都使用了 x86-64 小型代码模型,该模型假设可执行目标文件中的代码和数据的总体大小小于 2GB,因此可以通过 32 位地址来访问。GCC 默认使用小型代码模型。此外还有中型代码模型和大型代码模型。

重定位符号引用

重定位 PC 相对引用

PC 相对引用的机制:在引用中存放着与 PC 的值偏移量。这实际上是符号定义的地址与符号引用的地址差。在实际运行时,当执行到了符号引用的指令时,PC 中的值就是符号引用的地址,加上 与 PC 的偏移量(即符号定义与符号引用的地址差)就得到了符号定义的地址。

重定位绝对引用

绝对引用的机制:引用中存放的就是符号定义的绝对地址

重定位算法

可执行目标文件

可执行目标文件是一个二进制文件,包含加载程序到内存并运行它所需的所有信息

可执行目标文件的格式与可重定位目标文件的格式类似。

典型的ELF可执行目标文件

其中 ELF头 描述了文件的总体格式,还包括程序的入口点(entry point),即程序运行时要执行的第一条指令的地址。

段头部表和节头部表描述了可执行文件中的片到内存映像中的段的映射关系。它描述了各节在可执行文件中的偏移、长度、在内存映射中的偏移等。

  • .text, .rodata, .data 节与可重定位目标文件中的节相似,除了这些节已经被重定位到它们最终的运行时内存地址。

  • _init 节定义了一个小函数 _init,程序的初始化代码会调用它。

  • 可执行文件是完全链接的(已被重定位),因此比可重定位目标文件少了 .rel 节。

  • 程序头部表:包括段头部表和节头部表,描述了可执行文件中的连续的片(chunk)与连续的内段之间的映射关系

加载可执行目标文件

Linux shell 命令行中执行如下:

linux > ./prog   
1

因为 prog 不是一个内置的 shell 命令,所以 shell 会认为 prog 是一个可执行目标文件,通过调用加载器(是操作系统中的一个程序)来运行它。任何 Linux 程序都可以通过 execve 函数来调用加载器

加载:加载器将可执行目标文件的代码和数据从磁盘复制到内存,然后跳转到程序的第一条指令或入口点来运行程序。

每个 Linux 程序都有一个运行时内存映像,如下图所示。代码段总是从 0x400000 处开始,后面是数据段,然后是运行时堆段,通过调用 malloc 库往上增长。堆后面的区域是为共享模块保留的。用户栈总是从最大的用户地址 2^48-1 开始,向较小内存地址增长。从地址 2^48 开始是留给内核的。

Linux运行时内存映像

在分配栈、共享库、堆的运行时地址的时候,链接器还会使用地址空间布局随机化,所以每次程序运行时这些区域的地址都会改变。

加载器的工作过程

加载器运行时,创建一个内存映像(虚拟地址空间),在程序头部表的引导下,将可执行文件的片复制到代码段和数据段。然后加载器跳转到程序的入口点,即 _start 函数的地址(函数在系统目标文件 ctrl.o 中定义),_start 函数调用系统启动函数 __libc_start_main(定义在 libc.o 中),__libc_start_main 初始化执行环境,调用用户层的 main 函数,处理 main 函数的返回值,并在需要时把控制返回给内核。

加载器实际工作流程:

加载器的实际工作流程

动态链接共享库

虽然静态库解决了如何让大量相关函数对应用程序可用的问题。但是,仍然存在很多明显的缺点:

  1. 静态库需要定期维护和更新。如果想要使用一个更新后的静态库,必须显式地将程序与更新了的静态库重新链接。
  2. 调用的静态库中的函数在运行时会被复制到每个运行进程的文本段中。在一个运行上百个进程的系统上,会对稀缺的内存系统资源造成极大浪费。

共享库是为了解决静态库缺陷的产物,其主要目的是:

  1. 共享库与可执行文件相独立,只要输出接口不变(即名称、参数、返回值类型和调用约定不变),共享库更新不会对可执行文件造成任何影响。
  2. 允许多个正在运行的进程共享内存中相同的库代码,从而节约宝贵的内存资源。

共享库是一个目标模块,在运行或加载时,可以加载到任意的内存地址,并和内存中的程序链接起来。

动态链接:在程序运行或加载时,动态链接器将共享库加载到内存中并和程序链接起来。

共享库在 Linux 中以 .so 后缀表示,在 Windows 中以 .dll 表示。Windows 操作系统中大量使用了共享库。

共享库的共享方式:

  1. 一个共享库只有一个 .so 文件,所有引用该库的可执行目标文件共享这个 .so 文件中的代码和数据,而不是像静态库那样复制和嵌入到引用它们的文件中。
  2. 在内存中,一个共享库 .text 节的一个副本可以被不同的正在运行的进程共享。

共享库实例

生成共享库的方式,以构建向量共享库为例:

linux> gcc -shared -fpic -o libvector.so addvec.c multvec.c  //将 addvec.c 和 multvec.c 封装到动态库 libvector.so 中
// -fpic 选项指示编译器生成与位置无关的代码。
// -shared 选项指示链接器创建一个共享的目标文件。
1
2
3

然后,以这个共享库为基础生成可执行目标文件:

linux> gcc -o prog21 main2.c ./libvector.so  //创建了一个可执行目标文件 prog21
1

动态链接共享库

将 main2.o 和 libvector.so 链接并不是将 libvector.so 中的内容拷贝到了可执行文件 prog21 中,而是链接器复制了一些 libvector.so 中的重定位和符号表信息,以便运行时可以解析对 libvector.so 中代码和数据的引用。

理解

  • 动态链接库是在程序运行或加载时才动态链接的,但并不意味着在执行之前不需要进行其他操作:在链接时,链接器要与动态链接库进行一次部分链接以获取到它的重定位和符号表信息。

  • 要在程序中使用动态链接库,也需要在源文件中包含相关的头文件。

动态链接器完成链接的操作:

  1. 重定位 libc.so 的文本和数据到某个内存段。
  2. 重定位 libvector.so 的文本和数据到另一个内存段。
  3. 重定位 prog21 中所有对由 libc.so 和 libvector.so 定义的符号的引用。

上述操作完成后,共享库的位置就固定了,且程序执行的过程中都不会改变。

从应用程序中加载和链接共享库

动态链接:应用程序在运行时要求动态链接器加载和链接某个共享库(共享库即动态链接库)。

动态链接的应用:

  1. 分发软件。软件开发者常利用共享库来分发软件更新,它们生成共享库的新版本,用户只需要下载共享库并替代当前版本,下一次运行应用程序时,应用将自动链接和加载新的共享库。
  2. 构建高性能 Web 服务器:许多 Web 服务器使用基于动态链接的方法来生成动态内容。将每个生成动态内容的函数打包在共享库中,当一个浏览器请求达到时,服务器就动态加载并链接相应函数,然后直接调用它,而非创建新的进程来运行函数。

dlopen 函数

Linux 系统为动态链接器提供了一个简单接口dlopen 函数,允许应用程序在运行时加载和链接共享库

#include <dlfcn.h>
void *dlopen(const char *filename, int flag);  //加载和链接共享库。若成功就返回指向句柄的指针,否则返回 NULL。
void *dlsym(void *handle, char *symbol);  //调用共享库中的函数。若成功,返回指向符号 symbol 的指针,若出错返回 NULL。
int dlclose(void *handle);  //卸载该共享库。若成功返回 0,出错返回 -1。
const char *dlerror(void);   //如果前面对 dlopen, dlsym, dlclose 的调用失败,则返回用字符串表示的错误消息,否则返回 NULL。
1
2
3
4
5

位置无关代码

共享库的一个主要目的就是允许多个正在运行的进程共享内存中相同的库代码,从而节约宝贵的内存资源。

多个进程如何共享动态库的同一个副本,两种方法:

  1. 给每个共享库分配一个事先预备的专用的地址空间片,然后要求加载器总是在这个地方加载共享库。这种方法问题很多。
  2. **使用位置无关代码。**这种方法才是实际采用的方法,列出上面那个就是为了用来衬托这个方法的。

位置无关代码(Position-Independent Code,PIC)可以加载而无需重定位。

用户可以对 GCC 使用 -fpic 选项来生成 PIC 代码。共享库的编译必须总是使用此选项。

  1. PIC数据引用
  2. PIC函数调用
    • 延迟绑定——程序调用一个由共享库定义的函数,将函数地址的解析推迟到实际被调用的地方,能避免动态链接库在加载时进行成百上千个不需要的重定位。
    • 延迟绑定——依赖于全局偏移量表(Global Offset Table,GOT) 和 过程链接表(Procedure Linkage Table,PLT)

库打桩机制

库打桩(library interpositioning):允许用户截获对共享库函数的调用,取而代之执行自定义的代码。

  • 可以追踪某个特殊库函数的调用次数,验证和追踪它的输入和输出值,甚至可以替换成其他实现。

编译时打桩——访问程序的源代码

  • 使用C预处理器在编译时打桩,-I.参数告知C预处理器在搜索通常的系统目录之前,先在当前目录搜索malloc.h。然后,以本地的malloc.h指示预处理器用对相应包装函数的调用替换掉对应目标函数的调用
linux> gcc -DCOMPILETIME -c mymalloc.c 
linux> gcc -I. -o intc int.c mymalloc.o

linux> ./intc
malloc(32)=0x9ee010
free(0x9ee010)
1
2
3
4
5
6

链接时打桩——访问程序的可重定位对象文件

  • Linux静态链接器支持用--warp f 标志进行链接打桩。这个标志告知链接器:
    • 把对符号f的引用解析成__wrap_f
    • 把对符号__real_f的引用解析成f

运行时打桩——访问可执行目标文件

  • 基于动态链接器的LD_PRELOAD环境变量,动态链接器会先搜索LD_PRELOAD库,然后搜索其他库。

处理目标文件的工具

在Linux系统中有大量可用的工具可以帮助我们理解和处理目标文件。特别地,GNU binutils包尤其有帮助,而且可以运行在每一个Linux平台上

命令 说明
AR 创建静态库,插入、删除、列出和提取成员
STRING 列出一个目标文件中所有可以打印的字符串
STRIP 从目标文件中删除符号表信息
NM 列出一个目标文件中符号表中定义的符号
SIZE 列出目标文件中节的名字和大小
READELF 显示一个目标文件的完整结构,包括ELF头中编码的所有信息,包含SIZE和NM的功能
OBJDUMP 所有二进制工具之母,能够显示目标文件中的所有信息。它最大的作用是反汇编.text节中的二进制指令
LDD 列出一个可执行文件在运行时所需的共享库

总结

链接可以在编译时由静态编译器完成(静态库的链接),也可以在加载和运行时由动态链接器完成(动态库的链接)。

链接器处理的文件是目标文件,目标文件是一种二进制文件,有 3 种不同形式:

  1. 可重定位目标文件:
  2. 可执行目标文件:静态链接器将多个可重定位目标文件合并成一个可执行目标文件,它可以加载到内存中并执行。.exe 文件就是可执行目标文件。
  3. 共享目标文件(共享库):运行时由动态链接器链接和加载。

链接器的两个主要任务:

  1. 符号解析:将目标文件中的每个全局符号都绑定到一个唯一的定义。
  2. 重定位:确定每个符号的最终内存地址,并修改对那些目标的引用。

静态链接器是由 GCC 这样的编译驱动程序调用的。它们将多个可重定位目标文件合并成一个单独的可执行目标文件。多个目标文件可以定义相同的符号,链接器可以按照一定规则来解析这些相同的符号。

多个目标文件可以被连接到一个单独的静态库中。链接器用库来解析其他目标模块中的符号引用。许多链接器都是通过从左到右的顺序扫描库来解析符号引用。

加载器将可执行文件的内容映射到内存,并运行这个程序。链接器还可能生成部分链接的可执行目标文件,这样的文件中有对定义在共享库中的例程和数据的为解析的引用。在加载时,加载器将部分链接的可执行文件映射到内存,然后调用动态链接器,动态链接器通过加载共享库和重定位程序中的引用来完成链接任务。

被编译为位置无关代码的共享库可以加载到任何地方,也可以在运行时被多个进程共享。为了加载、链接和访问共享库的函数和数据,应用程序也可以在运行时使用动态链接器。