凌云的博客

行胜于言

链接

分类:os| 发布时间:2026-08-18 23:08:00

概述

链接(linking) 是将各种代码和数据部分收集起来并组合成为一个单一文件的过程, 这个文件可被加载(或被拷贝) 到存储器并执行。 链接可以执行于编译时(compile time), 也就是在源代码被翻译成机器代码时; 也可以执行于加载时(load time), 也就是在程序被加载器(loader) 加载到存储器并执行时; 甚至执行于运行时(run time), 由应用程序来执行。 在早期的计算机系统中, 链接是手动执行的。 在现代系统中, 链接是由叫做链接器(linker) 的程序自动执行的。

链接器在软件开发中扮演着一个关键的角色, 因为它们使得分离编译(separate compilation)成为可能。 我们不用将一个大型的应用程序组织为一个巨大的源文件, 而是可以把它分解为更小、更好管理的模块, 可以独立地修改和编译这些模块。 当我们改变这些模块中的一个时, 只需简单地重新编译它, 并重新链接应用, 而不必重新编译其他文件。

  • 理解链接器将帮助你构造大型程序。 构造大型程序的程序员经常会遇到由于缺少模块、缺少库或者不兼容的库版本引起的链接器错误。 除非你理解链接器是如何解析引用、什么是库以及链接器是如何使用库来解析引用的, 否则这类错误将令你感到迷惑和挫败。
  • 理解链接器将帮助你避免一些危险的编程错误。 Linux 链接器解析符号引用时所做的决定可以不动声色地影响你程序的正确性。 在默认情况下, 错误地定义多个全局变量的程序将通过链接器, 而不产生任何警告信息。 由此得到的程序会产生令人迷惑的运行时行为, 而且非常难以调试。 我们将向你展示这是如何发生的, 以及该如何避免它。
  • 理解链接将帮助你理解语言的作用域规则是如何实现的。 例如, 全局和局部变量之间的区别是什么? 当你定义一个具有 static 属性的变量或者函数时, 到底实际意味着什么?
  • 理解链接将帮助你理解其他重要的系统概念。 链接器产生的可执行目标文件在重要的系统功能中扮演着关键角色, 比如加载和运行程序、虚拟存储器、分页和存储器映射。
  • 理解链接将使你能够利用共享库。 多年以来, 链接都被认为是相当简单和无趣的。 然而, 随着共享库和动态链接在现代操作系统中重要性的日益加强, 链接成为一个复杂的过程, 它为知识丰富的程序员提供了强大的能力。 比如, 许多软件产品在运行时使用共享库来升级压缩包装的(shrink-wrapped) 二进制程序。 还有, 大多数 Web 服务器都依赖于共享库的动态链接来提供动态内容。

为了使描述具体和可理解, 我们的讨论是基于这样的环境: 一个运行 Linux 的 x86-64 系统, 使用标准的 ELF-64 目标文件格式(后面简称为 EIF)。 然而, 无论是什么样的操作系统、ISA或者目标文件格式, 基本的链接概念是通用的, 认识到这一点是很重要的。 细节可能不尽相同, 但是概念是相同的。

编译器驱动程序

考虑图 1 中的 C 语言程序。 它包含两个源文件: main.csum.c。 这是一个简单的例子, 但是它将作为贯穿本文的一个小的运行示例, 来帮助我们说明关于链接是如何工作的一些重要知识点。

/* code/link/main.c */
int sum(int *a, int n);

int array[2] = { 1, 2 };

int main()
{
    int val = sum(array, 2);
    return val;
}
/* code/link/sum.c */
int sum(int *a, int n)
{
    int i, s = 0;

    for (i = 0; i < n; i++) {
        s += a[i];
    }
    return s;
}

图 1 示例程序 1

大多数编译系统提供编译驱动程序(compiler driver), 它代表用户在需要时调用语言预处理器、编译器、汇编器和链接器。 比如, 要用 GNU 编译系统构造示例程序, 我们就要通过在外壳中输入下列命令行来调用 GCC 驱动程序:

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

如果需要保留编译过程中生成的临时文件和输出详细的编译过程可以使用如下命令:

linux> gcc -save-temps -v -Og -o prog main.c sum.c

图 2 概括了驱动程序在将示例程序从 ASCII 码源文件翻译成可执行目标文件时的行为。(如果你想看看这些步骤, 用-v选项来运行GCC。) 驱动程序首先运行C预处理器(cpp), 它将 C 源程序 main.c 翻译成一个 ASCII 码的中间文件 main.i:

cpp [other arguments] main.c /tmp/main.i

接下来, 驱动程序运行 C 编译器(cc1), 它将 main.i 翻译成一个 ASCII 汇编语言文件 main.s

cc1 /tmp/main.i -Og [other arguments] -o /tmp/main.s

然后, 驱动程序运行汇编器(as), 它将 main.s 翻译成一个可重定位目标文件(relocatable object file) main.o:

as [other arguments] -o /tmp/main.o /tmp/main.s

链接器程序驱动程序经过相同的过程生成 sum.o。 最后, 它运行 ld, 将 main.osum.o 以及一些必要的系统目标文件(executable object) 组合起来, 创建一个可执行目标文件 prog:

ld -o prog [system object files and args] /tmp/main.o /tmp/sum.o

要运行可执行文件 prog, 我们在 Linux 外壳的命令行上输入它的名字:

linux> ./prog

外壳调用操作系统中一个叫做加载器的函数, 它拷贝可执行文件 prog 中的代码和数据到存储器, 然后将控制转移到这个程序的开头。

静态链接

像 Linux ld 程序这样的静态链接器(static linker) 以一组可重定位目标文件和命令行参数作为输入, 生成一个完全链接的可以加载和运行的可执行目标文件作为输出。 输入的可重定位目标文件由各种不同的代码和数据节(section) 组成。 指令在一个节中, 初始化的全局变量在另一个节中, 而未初始化的变量又在另一个节中。

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

  • 符号解析(symbol resolution)。 目标文件定义和引用符号。 符号解析的目的是将每个符号引用刚好和一个符号定义联系起来。
  • 重定位(relocation)。 编译器和汇编器生成从地址 0 开始的代码和数据节。 链接器通过把每个符号定义与一个存储器位置联系起来, 然后修改所有对这些符号的引用, 使得它们指向这个存储器位置, 从而重定位这些节。

目标文件纯粹是字节块的集合。 这些块中, 有些包含程序代码, 有些则包含程序数据, 而其他的则包含指导链接器和加载器的数据结构。 链接器将这些块连接起来, 确定被连接块的运行时位置, 并且修改代码和数据块中的各种位置。 链接器对目标机器了解甚少。 产生目标文件的编译器和汇编器已经完成了大部分工作。

目标文件

目标文件有三种形式:

  • 可重定位目标文件。 包含二进制代码和数据, 其形式可以在编译时与其他可重定位目标文件合并起来, 创建一个可执行目标文件。
  • 可执行目标文件。 包含二进制代码和数据, 其形式可以被直接拷贝到存储器并执行。
  • 共享目标文件。 一种特殊类型的可重定位目标文件, 可以在加载或者运行时被动态地加载到存储器并链接。

编译器和汇编器生成可重定位目标文件(包括共享目标文件)。 链接器生成可执行目标文件。 从技术上来说, 一个目标模块(object module) 就是一个字节序列, 而一个目标文件(object file) 就是一个存放在磁盘文件中的目标模块。

各个系统之间, 目标文件格式都不相同。 从贝尔实验室诞生的第一个Linux系统使用的是 a.out 格式(直到今天, 可执行文件仍然被称为 a.out 文件)。 System V Linux 的早期版本使用的是一般目标文件格式(Common Object File Format,COFF)。 Windows NT 使用的是 COFF 的一个变种, 叫做可移植可执行(Portable Executable, PE)格式。 Mac OS-X 使用的是 Mach-O 格式。 现代 Linux 系统(如 Linux, 还有 System V Linux 后来的版本, 各种 BSD Linux, 以及 Sun Solaris)使用的是 可执行和可链接格式(Executable and Linkable Format, ELF)。 尽管我们的讨论集中在 ELF 上, 但是不管是哪种格式, 基本的概念是相似的。

可重定位目标文件

图 3 展示了一个典型的 ELF 可重定位目标文件的格式。ELF 头(ELF header)以一个 16 字节的序列开始, 这个序列描述了生成该文件的系统的字的大小和字节顺序。 ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。 其中包括ELF头的大小、目标文件的类型(如可重定位、可执行或者是共享的)、机器类型(如x86-64)、节头部表(section header table)的文件偏移, 以及节头部表中的条目大小和数量。 不同节的位置和大小是由节头部表描述的, 其中目标文件中每个节都有一个固定大小的条目(entry)。

夹在ELF头和节头部表之间的都是节。 一个典型的 ELF 可重定位目标文件包含下面几个节:

  • .text: 已编译程序的机器代码。
  • .rodata: 只读数据, 比如 printf 语句中的格式串和 switch 语句的跳转表。
  • .data: 已初始化的全局C变量。 局部 C 变量在运行时保存在栈中, 既不出现在 .data 节中, 也不出现在 .bss 节中。
  • .bss: 未初始化的全局 C 变量。 在目标文件中这个节不占据实际的空间, 它仅仅是一个占位符。 目标文件格式区分初始化和未初始化变量是为了空间效率: 在目标文件中, 未初始化变量不需要占据任何实际的磁盘空间。
  • .symntab:一个符号表, 它存放在程序中定义和引用的函数和全局变量的信息。 一些程序员错误地认为必须通过 -g 选项来编译程序才能得到符号表信息。 实际上, 每个可重定位目标文件在 .symtab 中都有一张符号表。 然而, 和编译器中的符号表不同, .symtab 符号表不包含局部变量的条目。
  • .rel.data: 被模块引用或定义的任何全局变量的重定位信息。 一般而言, 任何已初始化的全局变量, 如果它的初始值是一个全局变量地址或者外部定义函数的地址, 都需要被修改。
  • .debug: 一个调试符号表, 其条目是程序中定义的局部变量和类型定义, 程序中定义和引用的全局变量, 以及原始的 C 源文件。 只有以 -g 选项调用编译驱动程序时才会得到这张表。
  • .line: 原始 C 源程序中的行号和 .text 节中机器指令之间的映射。 只有以 -g 选项调用编译驱动程序时才会得到这张表。
  • .strtab: 一个字符串表, 其内容包括 .symtab.debug 节中的符号表, 以及节头部中的节名字。 字符串表就是以 null 结尾的字符串序列。

为什么未初始化的数据称为 .bss?
用术语 .bss 来表示未初始化的数据是很普遍的。 它起始于 IBM 704 汇编语言(大约在1957年)中 “块存储开始"(Block Storage Start)指令的首字母缩写, 并沿用至今。 一个记住区别 .data.bss 节的简单方法是把“bss”看成是“更好地节省空间"(Better Save Space)!的缩写。

符号和符号表

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

  • m 定义并能被其他模块引用的全局符号。 全局链接器符号对应于非静态的 C 函数以及被定义为不带 C static 属性的全局变量。
  • 由其他模块定义并被模块 m 引用的全局符号。 这些符号称为外部符号(external), 对应于定义在其他模块中的C函数和变量。
  • 只被模块 m 定义和引用的本地符号。 有的本地链接器符号对应于带 static 属性的 C 函数和全局变量。 这些符号在模块 m 中随处可见, 但是不能被其他模块引用。 目标文件中对应于模块 m 的节和相应的源文件的名字也能获得本地符号。

认识到本地链接器符号和本地程序变量的不同是很重要的。 .symtab 中的符号表不包含对应于本地非静态程序变量的任何符号。 这些符号在运行时在栈中被管理, 链接器对此类符号不感兴趣。

有趣的是, 定义为带有 C static 属性的本地过程变量是不在栈中管理的。 相反, 编译器在 .data.bss 中为每个定义分配空间, 并在符号表中创建一个有唯一名字的本地链接器符号。

符号表是由汇编器构造的, 使用编译器输出到汇编语言 .s 文件中的符号。 .symtab 节中包含 ELF 符号表。 这张符号表包含一个条目的数组。 图 4 展示了每个条目的格式。

typedef struct {
    int name;       /* String table offset */
    char type:4,    /* Function or data (4 bits) */
         binding:4; /* Local or global (4 bits) */
    char reserved;  /* Unused */
    short section;  /* Section header index */
    long value;     /* Section offset or absolute address */
    long size;      /* Object size in bytes */
} Elf64_Symbol;

图 4 ELF符号表条目。typebinding 都是4位的

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

每个符号都和目标文件的某个节相关联, 由 section 字段表示, 该字段也是一个到节头部表的索引。

有三个特殊的伪节(pseudo section), 它们在节头部表中是没有条目的:

  • ABS 代表不该被重定位的符号
  • UNDEF 代表未定义的符号, 也就是在本目标模块中引用但是却在其他地方定义的符号
  • COMMON 表示还未被分配位置的未初始化的数据目标。 对于 COMMON 符号, value 字段给出对齐要求, 而 size 给出最小的大小。

COMMON.bss 之间的区别十分微妙。 现代版本的 gcc 按照以下惯例, 将可重定位目标文件中的符号分配给 COMMON.bss

  • COMMON 未初始化的全局变量
  • .bss 未初始化的静态变量, 以及初始化为零的全局或静态变量

这种看似随意的区分, 其原因在于链接器执行符号解析的方式——我们将在后面对此进行解释。

可以通过 GNU READELF 工具打印出 .symtab ELF 符号表中的内容, 以图 1 的代码为例:

readelf -s main.o

Symbol table '.symtab' contains 11 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND
     1: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS main.c
     2: 0000000000000000     0 SECTION LOCAL  DEFAULT    1
     3: 0000000000000000     0 SECTION LOCAL  DEFAULT    3
     4: 0000000000000000     0 SECTION LOCAL  DEFAULT    4
     5: 0000000000000000     0 SECTION LOCAL  DEFAULT    6
     6: 0000000000000000     0 SECTION LOCAL  DEFAULT    7
     7: 0000000000000000     0 SECTION LOCAL  DEFAULT    5
     8: 0000000000000000    24 FUNC    GLOBAL DEFAULT    1 main
     9: 0000000000000000     8 OBJECT  GLOBAL DEFAULT    3 array
    10: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND sum

这里我们重点关注最后三个条目, 前面的条目都是链接器内部使用的本地符号。

在这个例子中, 我们看到全局符号 main 的定义条目, 这是一个 24 字节的函数, 位于 .text 段的偏移量(即值)为零处。 紧接着是全局符号 array 的定义, 这是一个 8 字节的对象, 位于 .data 段的偏移量为零处。 最后一个条目来自对外部符号 sum 的引用。 readelf 使用整数索引来标识每个段。 Ndx=1 表示 .text 段, Ndx=3 表示 .data 段。

符号解析

链接器解析符号引用的方法是将每个引用与它输入的可重定位目标文件的符号表中的一个确定的符号定义联系起来。 对那些和引用定义在相同模块中的本地符号的引用, 符号解析是非常简单明了的。 编译器只允许每个模块中每个本地符号只有一个定义。 编译器还确保静态本地变量, 它们也会有本地链接器符号, 拥有唯一的名字。

不过, 对全局符号的引用解析就棘手得多。 当编译器遇到一个不是在当前模块中定义的符号(变量或函数名)时, 它会假设该符号是在其他某个模块中定义的, 生成一个链接器符号表条目, 把它交给链接器处理。 如果链接器在它的任何输入模块中都找不到这个被引用的符号, 它就输出一条(通常很难阅读的)错误信息并终止。

对全局符号的符号解析很棘手, 还因为多个目标文件可能会定义相同的符号。 在这种情况中, 链接器必须要么标志一个错误, 要么以某种方法选出一个定义并抛弃其他定义。 Linux 系统采纳的方法涉及编译器、汇编器和链接器之间的协作, 这样也可能给不警觉的程序员带来一些麻烦。

C++ 和 Java 中的链接器符号名称修饰(Mangling)
C++ 和 Java 都支持方法重载, 即在源代码中名称相同但参数列表不同的方法。 那么, 链接器如何区分这些不同的重载函数呢? C++ 和 Java 中的重载函数之所以能够正常工作, 是因为编译器会将每个“方法名与参数列表”的独特组合编码成一个供链接器识别的唯一名称。 这种编码过程被称为“名称修饰”(mangling), 而其逆过程则被称为“名称还原”(demangling)。
值得庆幸的是, C++ 和 Java 采用了兼容的名称修饰方案。 修饰后的类名由类名字符数(整数)加上原始类名构成。 例如, 类 Foo 被编码为 3Foo。 方法的编码格式为:原始方法名, 后跟 __, 再后跟修饰后的类名, 最后是各参数的单字母编码。 例如, Foo::bar(int, long) 被编码为 bar__3Fooil。 全局变量和模板名称的修饰也采用了类似的方案。

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

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

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

  • 规则1: 不允许有多个强符号。
  • 规则2: 如果有一个强符号和多个弱符号, 那么选择强符号。
  • 规则3: 如果有多个弱符号, 那么从这些弱符号中任意选择一个。

与静态库链接

迄今为止, 我们都是假设链接器读取一组可重定位目标文件, 并把它们链接起来, 成为一个输出的可执行文件。 实际上, 所有的编译系统都提供一种机制, 将所有相关的目标模块打包成为一个单独的文件, 称为静态库(staticibrary), 它可以用做链接器的输人。 当链接器构造一个输出的可执行文件时, 它只拷贝静态库里被应用程序引用的目标模块。

为什么系统要支持库的概念呢? 以 ANSI C 为例, 它定义了一组广泛的标准 I/O、字符串操作和整数数学函数, 例如 atoiprintfscanfstrcpyrand。 它们在 libc.a 库中, 对每个 C 程序来说都是可用的。 ANSI C 还在 libm.a 库中定义了一组广泛的浮点数学函数, 如 sincossqrt

让我们来看看如果不使用静态库, 编译器开发人员会使用什么方法来向用户提供这些函数。 一种方法是让编译器辨认出对标准函数的调用, 并直接生成相应的代码。 Pascal (只提供了一小部分标准函数)采用的就是这种方法, 但是这种方法对 C 而言是不合适的, 因为 C 标准定义了大量的标准函数。 这种方法将给编译器增加显著的复杂性, 而且每次添加、删除或修改一个标准函数时, 就需要一个新的编译器版本。 然而, 对于应用程序员而言, 这种方法会是非常方便的, 因为标准函数将总是可用的。

另一种方法是将所有的标准 C 函数都放在一个单独的可重定位目标模块中(如 libc.o 中), 应用程序员可以把这个模块链接到他们的可执行文件中:

linux> gcc main.c /usr/lib/libc.o

这种方法的优点是它将编译器的实现与标准函数的实现分离开来, 并且仍然对程序员保持适度的便利。 然而, 一个很大的缺点是系统中每个可执行文件现在都包含着一份标准函数集合的完全拷贝, 这对磁盘空间是很大的浪费。 (在一个典型的系统上, libc.a 大约是 8MB, 而 libm.a 大约是 1MB。) 更糟糕的是, 每个正在运行的程序都将它自己的这些函数的的拷贝放在存储器中, 这又是极度浪费存储器的。 另一个大的缺点是, 对任何标准函数的任何改变, 无论多么小的改变, 都要求库的开发人员重新编译整个源文件, 这是一个非常耗时的操作, 使得标准函数的开发和维护变得很复杂。

我们可以通过为每个标准函数创建一个独立的可重定位文件, 把它们存放在一个为大家都知道的目录中来解决其中的一些问题。 然而, 这种方法要求应用程序员显式地链接合适的目标模块到它们的可执行文件中, 这是一个容易出错而且耗时的过程:

linux> gcc main.c /usr/lib/printf.o /usr/lib/scanf.o ...

静态库概念被提出来, 以解决这些不同方法的缺点。 相关的函数可以被编译为独立的目标模块, 然后封装成一个单独的静态库文件。 然后, 应用程序可以通过在命令行上指定单独的文件名字来使用这些在库中定义的函数。 比如, 使用标准 C 库和数学库中函数的程序可以用形式如下的命令行来编译和链接:

linux> gcc main.c /usr/lib/libm.a /usr/lib/libc.a

在链接时, 链接器将只拷贝被程序引用的目标模块, 这就减少了可执行文件在磁盘和存储器中的大小。 另一方面, 应用程序员只需要包含较少的库文件的名字(实际上, C 编译器驱动程序总是传送 libc.a 给链接器, 所以前面提到的对 libc.a 的引用是不必要的)。

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

/* (a) addvec.o */
/* code/link/addvec.c */
int addcnt = 0;

void addvec(int *x, int *y,
            int *z, int n)
{
    int i;

    addcnt++;

    for (i = 0; i < n; i++)
        z[i] = x[i] + y[i];
}
/* code/link/addvec.c */
/* (b) multvec.o */
/* code/link/multvec.c */
int multcnt = 0;

void multvec(int *x, int *y,
             int *z, int n)
{
    int i;

    multcnt++;

    for (i = 0; i < n; i++)
        z[i] = x[i] * y[i];
}
/* code/link/multvec.c */

图 5 libvector.a 中的成员目标文件

为了创建该库, 我们将使用 AR 工具, 具体如下:

linux> gcc -c addvec.c multvec.c
linux> ar rcs libvector.a addvec.o multvec.o

为了使用这个库, 我们可以编写一个应用, 比如图 6 中的 main2.c,它调用 addvec 库例程。 (包含(头)文件 vector.h 定义了 libvector.a 中例程的函数原型)。

/* code/link/main2.c */
#include <stdio.h>
#include "vector.h"

int x[2] = {1, 2};
int y[2] = {3, 4};
int z[2];

int main()
{
    addvec(x, y, z, 2);
    printf("z = [%d %d]\n", z[0], z[1]);
    return 0;
}

图 6 示例程序2: 这个程序调用了静态 libvector.a 库中的成员函数

为了创建这个可执行文件, 我们要编译和链接输人文件 main.olibvector.a:

linux> gcc -c main2.c
linux> gcc -static -o prog2c main2.o -L. -lvector

图 7 概括了链接器的行为。 -static 参数告诉编译器驱动程序, 链接器应该构建一个完全链接的可执行目标文件, 它可以加载到存储器并运行, 在加载时无需更进一步的链接。 当链接器运行时, 它判定 addvec.o 定义的 addvec 符号是被 main.o 引用的, 所以它拷贝 addvec.o 到可执行文件。 因为程序不引用任何由 multvec.o 定义的符号, 所以链接器就不会拷贝这个模块到可执行文件。 链接器还会拷贝 libc.a 中的 printf.o 模块, 以及许多 C 运行时系统中的其他模块。

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

虽然静态库是很有用而且重要的工具, 但是它们同时也是程序员迷惑的源头, 因为 Linux 链接器使用它们解析外部引用的方式是令人困惑的。 在符号解析的阶段, 链接器从左到右按照它们在编译器驱动程序命令行上出现的相同顺序来扫描可重定位目标文件和存档文件。 (驱动程序自动将命令行中所有的 .c 文件翻译为 .o 文件。) 在这次扫描中, 链接器维持一个可重定位目标文件的集合 E (这个集合中的文件会被合并起来形成可执行文件), 一个未解析的符号(即引用了但是尚未定义的符号)集合 U, 以及一个在前面输人文件中已定义的符号集合 D。 初始时, E、U 和 D 都是空的。

  • 对于命令行上的每个输人文件 f, 链接器会判断 f 是一个目标文件还是一个存档文件。 如果 f 是一个目标文件, 那么链接器把 f 添加到 E, 修改 UD 来反映 f 中的符号定义和引用, 并继续下一个输人文件。
  • 如果 f 是一个存档文件, 那么链接器就尝试匹配 U 中未解析的符号和由存档文件成员定义的符号。 如果某个存档文件成员 m, 定义了一个符号来解析 U 中的一个引用, 那么就将 m 加到 E 中, 并且链接器修改 UD 来反映 m 中的符号定义和引用。 对存档文件中所有的成员目标文件都反复进行这个过程, 直到 UD 都不再发生变化。 在此时, 任何不包含在 E 中的成员目标文件都简单地被丢弃, 而链接器将继续处理下一个输人文件。
  • 如果当链接器完成对命令行上输人文件的扫描后, U 是非空的, 那么链接器就会输出一个错误并终止。 否则, 它会合并和重定位 E 中的目标文件, 从而构建输出的可执行文件。

不幸的是, 这种算法会导致一些令人困扰的链接时错误, 因为命令行上的库和目标文件的顺序非常重要。 在命令行中, 如果定义一个符号的库出现在引用这个符号的目标文件之前, 那么引用就不能被解析, 链接会失败。 比如, 考虑下面的命令行发生了什么:

linux> gcc -static ./libvector.a main2.c
/tmp/cc9XH6Rp.o: In function ‘main’:
/tmp/cc9XH6Rp.o(.text+0x18): undefined reference to ‘addvec’

在处理 libvector.a 时, U 是空的, 所以没有 libvector.a 中的成员目标文件会添加到 E 中。 因此, 对 addvec 的引用是绝不会被解析的, 所以链接器会产生一条错误信息并终止。

关于库的一般准则是将它们放在命令行的结尾。 如果各个库的成员是相互独立的(也就是说没有成员引用另一个成员定义的符号), 那么这些库就可以按照任何顺序放置在命令行的结尾处。

另一方面, 如果库不是相互独立的, 那么它们必须排序, 使得对于每个被存档文件的成员外部引用的符号 s, 在命令行中至少一个 s 的定义是在对 s 的引用之后的。 比如, 假设 foo.c 调用 libx.alibz.a 中的函数, 而这两个库又调用 liby.a 中的函数。 那么, 在命令行中 libx.alibz.a 必须处在 liby.a 之前:

linux> gcc foo.c libx.a libz.a liby.a

如果需要满足依赖需求, 可以在命令行上重复库。 比如, 假设 foo.c 调用 libx.a 中的函数, 而该库又调用 liby.a 中的函数, 而 liby.a 又调用 libx.a 中的函数。 那么 libx.a 必须在命令行上重复出现:

linux> gcc foo.c libx.a liby.a libx.a

作为另一种方法, 我们可以将 libx.aliby.a 合并成一个单独的存档文件。

重定位

一旦链接器完成了符号解析这一步, 它就把代码中的每个符号引用和确定的一个符号定义(即它的一个输人目标模块中的一个符号表条目)联系起来。 在此时, 链接器就知道它的输人目标模块中的代码节和数据节的确切大小。 现在就可以开始重定位了, 在这个步骤中, 将合并输人模块, 并为每个符号分配运行时地址。 重定位由两步组成:

  • 重定位节和符号定义。 在这一步中, 链接器将所有相同类型的节合并为同一类型的新的聚合节。 例如, 来自输人模块的 .data 节被全部合并成一个节, 这个节成为输出的可执行目标文件的 .data 节。 然后, 链接器将运行时存储器地址赋给新的聚合节, 赋给输人模块定义的每个节, 以及赋给输人模块定义的每个符号。 当这一步完成时, 程序中的每个指令和全局变量都有唯一的运行时存储器地址了。
  • 重定位节中的符号引用。 在这一步中, 链接器修改代码节和数据节中对每个符号的引用, 使得它们指向正确的运行时地址。 为了执行这一步, 链接器依赖于称为重定位条目(relocation entry)的可重定位目标模块中的数据结构, 我们接下来将会描述这种数据结构。

重定位条目

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

图 8 展示了 ELF 重定位条目的格式式。 offset 是需要被修改的引用的字节偏移。 symbol标识被修改的引用应该指向的符号。 type 告知链接器如何修改新的引用。 addend 是一个有符号常数, 某些类型的重定位利用它来对修改后的引用值进行偏移调整。

偏移量(offset)是指需要修改的引用的节内偏移量;符号(symbol)标识了修改后的引用应指向的目标符号;类型(type)指示链接器如何修改该引用;加数(addend)是一个有符号常数, 某些类型的重定位利用它来对修改后的引用值进行偏移调整。

/* code/link/elfstructs.c */
typedef struct {
    long offset;     /* Offset of the reference to relocate */
    long type:32,    /* Relocation type */
         symbol:32;  /* Symbol table index */
    long addend;     /* Constant part of relocation expression */
} Elf64_Rela;
/* code/link/elfstructs.c */

图 8 ELF 重定位条目。每个条目表示一个必须被重定位的引用。

ELF 定义了 32 种不同的重定位类型, 有些相当隐秘。 我们只关心其中两种最基本的重定位类型:

  • R_X86_64_PC32: 重定位一个使用 32 位 PC 相对地址的引用。 一个PC相对地址就是距程序计数器(PC)的当前运行时值的偏移量。 当CPU执行一条使用PC相对寻址的指令时, 它就将在指令中编码的32位值上加上PC的当前运行时值, 得到有效地址, PC值通常是存储器中下一条指令的地址。
  • R_X86_64_PC32: 重定位一个使用 32 位绝对地址的引用。 通过绝对寻址, CPU 直接使用在指令中编码的 32 位值作为有效地址, 不需要进一步修改。

可执行目标文件

我们已经看到链接器是如何将多个目标模块合并成一个可执行目标文件的。 我们的 C 程序, 开始时是一组 ASCII 文本文件, 已经被转化为一个二进制文件, 且这个二进制文件包含加载程序到存储器并运行它所需的所有信息。 图 9 概括了一个典型的 ELF 可执行文件中的各类信息。

可执行目标文件的格式类似于可重定位目标文件的格式。 ELF 头部描述文件的总体格式。 它还包括程序的入口点(entrypoint), 也就是当程序运行时要执行的第一条指令的地址。 .text.rodata.data 节和可重定位目标文件中的节是相似的, 除了这些节已经被重定位到它们最终的运行时存储器地址以外。 .init 节定义了一个小函数, 叫做 _init, 程序的初始化代码会调用它。 因为可执行文件是完全链接的(已被重定位了), 所以它不再需要 .rel 节。 ELF 可执行文件被设计得很容易加载到存储器, 可执行文件的连续的片(chunk) 被映射到连续的存储器段。 段头部表(segment header table) 描述了这种映射关系。

加载可执行目标文件

要运行可执行目标文件 prog, 可以在 Linux 外壳的命令行中输入它的名字:

linux> ./prog

因为 prog 不是一个内置的外壳命令, 所以外壳会认为 prog 是一个可执行目标文件, 通过调用某个驻留在存储器中称为加载器(loader) 的操作系统代码来运行它。 任何 Linux 程序都可以通过调用 execve 函数来调用加载器。 加载器将可执行目标文件中的代码和数据从磁盘拷贝到存储器中, 然后通过跳转到程序的第一条指令或入口点(entry point) 来运行该程序。 这个将程序拷贝到存储器并运行的过程叫做加载(loading)。

每个 Linux 程序都有一个运行时存储器映像, 类似于图 10 中所示的那样。 在 Linux x86-64 系统中, 代码段总是从地址 0x400000 开始, 接下来是数据段。 运行时堆位于数据段之后, 并通过调用 malloc 库向上增长。 接下来是为共享模块保留的区域。 用户栈总是从最大的合法用户地址(248 - 1)开始, 向下增长的(向低存储器地址方向增长)。 从栈的上部开始的段(从地址 248)是为操作系统驻留存储器的部分(也就是内核)的代码和数据保留的。

为简化起见, 我们将堆段、数据段和代码段绘制成彼此相邻, 并将栈顶置于最大的合法用户地址处。 实际上, 由于 .data 段的对齐要求, 代码段和数据段之间存在间隙。 此外, 链接器在为栈段、共享库段和堆段分配运行时地址时, 会使用地址空间布局随机化。 尽管这些区域的位置每次程序运行时都会改变, 但它们的相对位置保持不变。

当加载器运行时, 它创建如图 10 所示的存储器映像。 在可执行文件中段头部表的指导下, 加载器将可执行文件的相关内容拷贝到代码和数据段。 接下来, 加载器跳转到程序的入口点, 也就是符号 _start 的地址。 在 _start 地址处的启动代码(startup code) 是在目标文件 ctrl.o 中定义的, 对所有的 C 程序都是一样的。 _start 函数调用系统启动函数, __libc_start_main, 该函数定义在 libc.so 中。 它初始化执行环境, 调用用户级 main 函数, 处理其返回值, 并在必要时将控制权返回给内核。

动态链接共享库

我们在前面研究的静态库解决了许多关于如何让大量相关函数对应用程序可用的问题。 然后, 静态库仍然有一些明显的缺点。 静态库和所有的软件一样, 需要定期维护和更新。 如果应用程序员需要使用一个库的最新版本, 他们必须以某种方式了解到该库的更新情况, 然后显式地将它们的程序与更新了的库重新连接。

另一个问题是几乎每个 C 程序都使用标准 I/O 函数, 如 printfscanf。 在运行时, 这些函数代码会被复制到每个运行程序的文本段。 在一个运行超过 100 个进程的典型系统上, 这将是对稀缺的存储器系统资源的极大浪费。 (存储器的一个有趣属性就是无论系统中有多大的存储器, 它总是一种稀缺的资源。 磁盘空间和厨房的垃圾桶同样有这种属性。)

共享库(shared library) 是致力于解决静态库缺陷的一个现代创新产物。 共享库是一个目标模块, 在运行时, 可以加载到任意的存储器地址, 并和一个在存储器中的程序链接起来。 这个过程称为动态链接(dynamic linking), 是由一个叫做动态链接器(dynamic linker) 的程序来执行的。

共享库也称为共享目标(shared object), 在 Linux 系统中通常用 .so 后缀来表示。 微软的操作系统大量地利用了共享库, 它们称为 DLL(动态链接库)。

共享库是以两种不同的方式来 “共享” 的。 首先, 在任何给定的文件系统中, 对于一个库只有一个 .so 文件。 所有引用该库的可执行目标文件共享这个 .so 文件中的代码和数据, 而不是像静态库的内容那样被拷贝和嵌入到引用它们的可执行的文件中。 其次, 在存储器中, 一个共享库的 .text 节的一个副本可以被不同的正在运行的进程共享。

图 11 概括了 图 6 中示例程序的动态链接过程。 为了构造图 5 中向量运算示例程序的共享库 libvector.so, 我们会调用编译器, 给链接器如下特殊指令:

linux> gcc -shared -fpic -o libvector.so addvec.c multvec.c

-fPIC 选项指示编译器生成与位置无关的代码。 -shared 选项指示链接器创建一个共享的目标文件。

一旦创建了这个库, 我们随后就要将它链接到图 6 的示例程序中:

linux> gcc -o prog2l main2.c ./libvector.so

这样就创建了一个可执行目标文件 prog21, 而此文件的形式使得它在运行时可以和 libvector.so 链接。 基本的思路是当创建可执行文件时, 静态执行一些链接, 然后在程序加载时, 动态完成链接过程。

认识到这一点是很重要的: 此时, 没有任何 libvector.so 的代码和数据节真的被拷贝到可执行文件 prog21 中。 反之, 链接器拷贝了一些重定位和符号表信息, 它们使得运行时可以解析对 libvector.so 中代码和数据的引用。

当加载器加载和运行可执行文件 prog21 时, 它利用 加载可执行目标文件 节中讨论过的技术, 加载部分链接的可 执行文件 prog21。 接着, 它注意到 prog21 包合一个 .interp 节, 这个节包含动态链接器的路径名, 动态链接器本身就是一个共享目标(比如, 在Linux系统上的ld-linux.so)。 加载器不再像它通常那样将控制传递给应用, 而是加载和运行这个动态链接器。

然后, 动态链接器通过执行下面的重定位完成链接任务:

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

最后, 动态链接器将控制传递给应用程序。 从这个时刻开始, 共享库的位置就固定了, 并且在程序执行的过程中都不会改变。

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

到此刻为止, 我们已经讨论了在程序执行之前, 即应用程序被加载时, 动态链接器加载和链接共享库的情景。 然而, 应用程序还可能在它运行时要求动态链接器加载和链接任意共享库, 而无需在编译时链接那些库到应用中。 动态链接是一项强大有用的技术。 下面是一些现实世界中的例子:

  • 分发软件。微软 Windows 应用的开发者常常利用共享库来分发软件更新。 他们生成一个共享库的新版本, 然后用户可以下载, 并用它替代当前的版本。 下一次他们运行应用程序时, 应用将自动链接和加载新的共享库。
  • 构建高性能 Web 服务器。 许多 Web 服务器生成动态内容, 比如个性化的 Web 页面、账户余额和广告标语。 早期的 Web 服务器通过使用 fork 和 execve 创建一个子程, 并在该子进程的上下文中运行 CGI 程序 来生成动态内容。 然而, 现代高性能的 Web 服务器可以使用基于动态链接的更有效和完善的方法来生成动态内容。
    其思路是将生成动态内容的每个函数打包在共享库中。 当一个来自 Web 浏览器的请求到达时, 服务器动态地加载和链接适当的函数, 然后直接调用它, 而不是使用 forkexecve 在子进程的上下文中运行函数。 函数会一直缓存在服务器的地址空间中, 所以只要一个简单的函数调用的开销就可以处理随后的请求了。 这对一个繁忙的网站来说是有很大影响的。 更进一步考虑, 在运行时无需停止服务器, 就可以更新已存在的函数, 以及添加新的函数。

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

#include <dlfcn.h>

void *dlopen(const char *filename, int flag);
/* 返回:若成功则为指向句柄的指针, 若出错则为NULL。*/

dlopen 函数加载和链接共享库 filename。 filename 中的外部符号将使用之前使用 RTLD_GLOBAL 标志打开的库进行解析。 如果当前可执行文件是带 -rdynamic 选项编译的, 那么对符号解析而言, 它的全局符号也是可用的。 flag 参数必须要么包括 RTLD_NOW, 该标志告诉链接器立即解析对外部符号的引用, 要么包括 RTLD_LAZY 标志, 该标志指示链接器推迟符号解析直到执行来自库中的代码。 这两个值中的任意一个都可以和 RTLD_GLOBAL 标志取或。

#include <dlfcn.h>

void *dlsym(void *handle, char *symbol);
/* 返回: 若成功则为指向符号的指针, 若出错则为 NULL。*/

dlsym 函数的输人是一个指向前面已经打开共享库的句柄和一个符号名字, 如果该符号存在, 就返回符号的地址, 否则返回NULL。

#include <dlfcn.h>

int dlclose (void *handle);
/* 返回: 若成功则为 0, 若出错则为 -1。*/

如果没有其他共享库正在使用这个共享库, dlclose 函数就卸载该共享库。

#include <dlfcn.h>

const char *dlerror(void);
/* 返回:如果前面对 dlopen、dlsym 或 dlclose 的调用失败, 则为错误消息, 如果前面的调用成功, 则为 NULL。*/

dlerror 函数返回一个字符串, 它描述的是调用 dlopendlsym 或者 dclose 函数时发生的最近的错误, 如果没有错误发生, 就返回 NULL。

与位置无关的代码 (PIC)

共享库的一个主要目的就是允许多个正在运行的进程共享存储器中相同的库代码, 因而节约宝贵的存储器资源。 那么, 多个进程是如何共享程序的一个拷贝的呢? 一种方法是给每个共享库分配一个事先预备的专用的地址空间片(chunk), 然后要求加载器总是在这个地址加载共享库。 虽然这种方法很简单, 但是它也造成了一些严重的问题。 首先, 它对地址空间的使用效率不高, 因为即使一个进程不使用这个库, 那部分空间还是会被分配出来。 其次, 它也难以管理。 我们将不得不保证没有片会重叠。 每次当一个库修改了之后, 我们必须确认它的已分配的片还适合它的大小。 如果不适合了, 必须找一个新的片。 并且, 如果我们创建了一个新的库, 还必须为它寻找空间。 随着时间的进展, 假设在一个系统中有了成百个库和各种版本的库, 就很难避免地址空间分裂成大量小的、未使用而又不再能使用的小洞。 甚至更糟的是, 对每个系统而言, 库在存储器中的分配都是不同的, 这就引起了更多令人头痛的管理问题。

一种更好的方法是编译库代码, 使得不需要链接器修改库代码就可以在任何地址加载和执行这些代码。 这样的代码叫做与位置无关的代码 (Position-Independent Code,PIC)。 用户对 GCC 使用 -fPIC 选项指示 GNU 编译系统生成 PIC 代码。

在一个 x86-64 系统中, 对同一个目标模块中过程的调用是不需要特殊处理的, 因为引用是 PC 相对的, 已知偏移量就已经是 PIC 了。 然而, 对外部定义的过程调用和对全局变量的引用通常不是 PIC, 因为它们都要求在链接时重定位。

PIC 数据引用

编译器通过运用以下这个有趣的事实来生成对全局变量的PIC引用: 无论我们在存储器中的何处加载一个目标模块(包括共享目目标模块), 数据段总是被分配成紧随在代码段后面。 因此, 代码段中任何指令和数据段中任何变量之间的距离都是一个运行时常量, 与代码段和数据段的绝对存储器位置是无关的。

为了运用这个事实, 编译器在数据段开始的地方创建了一个表, 叫做全局偏移量表(Global Offset Table, GOT)。 在 GOT 中,每个被这个目标模块引用的全局数据对象都有一个条目。 编译器还为 GOT 中每个条目生成一个重定位记录。 在加载时, 动态链接器会重定位 GOT 中的每个条目, 使得它包含正确的绝对地址。 每个引用全局数据的目标模块都有自己的 GOT。

图 12 展示了示例 libvector.so 共享模块中的 GOT。 GOT[3] 间接加载全局变量 addcnt 的地址, 然后递增内存中的 addcnt。 这里的关键在于, 指向 GOT[3] 的 PC 相对引用的偏移量是一个运行时常量。 由于 addcntlibvector.so 模块定义, 编译器本可以利用代码段和数据段之间的恒定距离, 生成指向 addcnt 的直接 PC 相对引用, 并添加一个重定位指令, 供链接器在构建共享模块时解析。 然而, 如果 addcnt 由另一个共享模块定义, 则必须通过 GOT 进行间接访问。 在这种情况下, 编译器选择使用最通用的解决方案, 即 GOT, 用于所有引用。

PIC 函数调用

假设一个程序调用了一个由共享库定义的函数。 编译器无法预测该函数的运行时地址, 因为定义该函数的共享模块可以在运行时加载到任何位置。 通常的做法是为该引用生成一个重定位记录, 然后动态链接器可以在程序加载时解析该记录。 然而, 这种方法并非完全可预测, 因为它需要链接器修改调用模块的代码段。 GNU 编译系统使用一种称为延迟绑定的巧妙技术解决了这个问题, 该技术将每个过程地址的绑定延迟到该过程首次被调用时。

延迟绑定的动机在于, 典型的应用程序只会调用共享库(例如 libc.so)导出的成百上千个函数中的一小部分。 通过延迟解析函数地址, 直到实际调用时才进行解析, 动态链接器可以避免在加载时进行成百上千次不必要的重定位。 函数首次调用时会产生一定的运行时开销, 但之后每次调用只需一条指令和一个用于间接寻址的内存访问。

延迟绑定是通过两个数据结构(全局对象表 (GOT) 和过程链接表 (PLT))之间简洁但略显复杂的交互来实现的。 如果一个目标模块调用了共享库中定义的任何函数, 那么它就拥有自己的 GOT 和 PLT。 GOT 是数据段的一部分。 PLT 是代码段的一部分。

图 13 展示了 PLT 和 GOT 如何协同工作, 在运行时解析函数的地址。 首先, 让我们来看一下每个表的内容。

  • 过程链接表 (PLT)。 PLT 是一个包含 16 字节代码项的数组。 PLT[0] 是一个特殊条目, 它会跳转到动态链接器。 可执行文件调用的每个共享库函数都有其自身的 PLT 条目。 每个条目负责调用一个特定的函数。 PLT[1](此处未显示)调用系统启动函数 (__libc_start_main), 该函数初始化执行环境, 调用 main 函数, 并处理其返回值。 从 PLT[2] 开始的条目调用用户代码调用的函数。 在我们的示例中, PLT[2] 调用 addvec 函数, PLT[3](此处未显示)调用 printf 函数。
  • 全局偏移表 (GOT)。 如我们所见, GOT 是一个 8 字节的地址条目数组。 当与 PLT 结合使用时, GOT[0]GOT[1] 包含动态链接器在解析函数地址时使用的信息。 GOT[2] 是动态链接器在 ld-linux.so 模块中的入口点。 其余条目分别对应于一个需要在运行时解析地址的被调用函数。 每个条目都有一个匹配的 PLT 条目。 例如, GOT[4]PLT[2] 对应于 addvec 函数。 最初, 每个 GOT 条目指向对应 PLT 条目中的第二条指令。

图 13(a) 展示了 GOT 和 PLT 如何协同工作, 以实现延迟解析函数 addvec 首次调用时的运行时地址:

  • 步骤 1. 程序不直接调用 addvec, 而是调用 PLT[2], PLT[2]addvec 的 PLT 条目。
  • 步骤 2. 第一条 PLT 指令通过 GOT[4] 进行间接跳转。 由于每个 GOT 条目最初都指向其对应 PLT 条目中的第二条指令, 因此间接跳转只是将控制权转移回 PLT[2] 中的下一条指令。
  • 步骤 3. 将 addvec 的 ID (0x1) 压入堆栈后, PLT[2] 跳转到 PLT[0]
  • 步骤 4. PLT[0] 通过 GOT[1] 间接压入动态链接器的参数, 然后通过 GOT[2] 间接跳转到动态链接器。 动态链接器使用两个栈条目来确定 addvec 的运行时位置, 用该地址覆盖 GOT[4], 并将控制权传递给 addvec

图 13(b) 显示了后续每次调用 addvec 的控制流:

  • 步骤 1. 控制流如前所述传递给 PLT[2]
  • 步骤 2. 但是, 这次通过 GOT[4] 的间接跳转将控制流直接传递给 addvec

库插桩

Linux 链接器支持一种强大的技术, 称为库插桩(library interpositioning), 它允许你拦截对共享库函数的调用, 并转而执行你自己的代码。 利用插桩技术, 你可以跟踪某个特定库函数被调用的次数, 验证并记录其输入和输出值, 甚至可以用一个完全不同的实现来替换它。

其基本思想如下:给定一个需要被插桩的目标函数, 你创建一个包装函数(wrapper function), 其原型与目标函数完全相同。 然后, 通过某种特定的插桩机制, 让系统误以为应该调用包装函数而非目标函数。 包装函数通常先执行自身的逻辑, 再调用目标函数, 并将其返回值传回给调用者。

插桩可以发生在编译时、链接时, 或程序加载与运行阶段。 为了探讨这些不同的机制, 我们将使用图 14(a) 中的示例程序作为运行示例。 它调用了 C 标准库 (libc.so) 中的 mallocfree 函数。 调用 malloc 会从堆中分配一个 32 字节的内存块, 并返回指向该内存块的指针。 调用 free 会将该内存块释放回堆, 供后续的 malloc 调用使用。 我们的目标是利用插桩技术, 在程序运行过程中跟踪对 mallocfree 的调用情况。

/* (a) Example program int.c
    code/link/interpose/int.c */
#include <stdio.h>
#include <malloc.h>

int main()
{
    int *p = malloc(32);
    free(p);
    return(0);
}
/* code/link/interpose/int.c */

/* (b) Local malloc.h file
code/link/interpose/malloc.h */
#define malloc(size) mymalloc(size)
#define free(ptr) myfree(ptr)

void *mymalloc(size_t size);
void myfree(void *ptr);
/* code/link/interpose/malloc.h */

/* (c) Wrapper functions in mymalloc.c
    code/link/interpose/mymalloc.c */
#ifdef COMPILETIME
#include <stdio.h>
#include <malloc.h>

/* malloc wrapper function */
void *mymalloc(size_t size)
{
    void *ptr = malloc(size);
    printf("malloc(%d)=%p\n",
           (int)size, ptr);
    return ptr;
}

/* free wrapper function */
void myfree(void *ptr)
{
    free(ptr);
    printf("free(%p)\n", ptr);
}
#endif
/* code/link/interpose/mymalloc.c */

图 14 使用 C 预处理器进行编译时插桩。

编译时插桩

图 14 展示了如何利用 C 预处理器在编译时进行插桩。 mymalloc.c(图 14(c))中的每个包装函数都会调用目标函数, 打印一条跟踪记录, 然后返回。 本地的 malloc.h 头文件(图 14(b))指示预处理器将对目标函数的每次调用替换为对其包装函数的调用。 以下是编译和链接该程序的方法:

linux> gcc -DCOMPILETIME -c mymalloc.c
linux> gcc -I. -o intc int.c mymalloc.o

插桩之所以能够生效, 是因为使用了 -I. 参数, 它告诉 C 预处理器先在当前目录中查找 malloc.h, 然后再到常规的系统目录中查找。 注意, mymalloc.c 中的包装函数在编译时使用的是标准的 malloc.h 头文件。 运行该程序会得到如下跟踪输出:

linux> ./intc
malloc(32)=0x9ee010
free(0x9ee010)

链接时插桩

Linux 静态链接器支持使用 --wrap f 标志进行链接时插桩。 该标志指示链接器将对符号 f 的引用解析为 __wrap_f(带双下划线前缀), 并将对符号 __real_f(带双下划线前缀)的引用解析为 f。 图 15 展示了我们示例程序的包装函数。

以下是将源文件编译为可重定位目标文件的方法:

linux> gcc -DLINKTIME -c mymalloc.c
linux> gcc -c int.c
#ifdef LINKTIME
#include <stdio.h>

void *__real_malloc(size_t size);
void __real_free(void *ptr);

/* malloc wrapper function */
void *__wrap_malloc(size_t size)
{
    void *ptr = __real_malloc(size); /* Call libc malloc */
    printf("malloc(%d) = %p\n", (int)size, ptr);
    return ptr;
}

/* free wrapper function */
void __wrap_free(void *ptr)
{
    __real_free(ptr); /* Call libc free */
    printf("free(%p)\n", ptr);
}
#endif
/* code/link/interpose/mymalloc.c */

图 15 使用 --wrap 标志进行链接时插桩。

以下是将目标文件链接为可执行文件的方法:

linux> gcc -Wl,--wrap,malloc -Wl,--wrap,free -o intl int.o mymalloc.o

-Wl,option 标志用于将 option 传递给链接器。 option 中的每个逗号都会被替换为空格。 因此, -Wl,--wrap,malloc 会将 --wrap malloc 传递给链接器, -Wl,--wrap,free 的作用也类似。 运行该程序会得到如下跟踪输出:

linux> ./intl
malloc(32) = 0x18cf010
free(0x18cf010)

运行时插桩

编译时插桩需要访问程序的源文件。 链接时插桩需要访问其可重定位目标文件。 然而, 还有一种运行时插桩机制, 仅需访问可执行目标文件即可。 这一精妙的机制基于动态链接器的 LD_PRELOAD 环境变量。

如果将 LD_PRELOAD 环境变量设置为共享库路径名列表(以空格或冒号分隔), 那么在加载并执行程序时, 动态链接器(ld-linux.so)在解析未定义引用时, 会优先搜索 LD_PRELOAD 指定的库, 然后才搜索其他共享库。 借助这一机制, 在加载并执行任何可执行文件时, 你都可以对任意共享库(包括 libc.so)中的任何函数进行插桩。

图 16 展示了 mallocfree 的包装函数。 在每个包装函数中, 对 dlsym 的调用会返回指向目标 libc 函数的指针。 随后, 包装函数调用该目标函数, 打印跟踪信息, 然后返回。 以下是构建包含这些包装函数的共享库的方法:

linux> gcc -DRUNTIME -shared -fpic -o mymalloc.so mymalloc.c -ldl

下面是编译主程序的方法:

linux> gcc -o intr int.c

bash shell 中运行该程序的方法如下:

linux> LD_PRELOAD="./mymalloc.so" ./intr
malloc(32) = 0x1bf7010
free(0x1bf7010)
/* code/link/interpose/mymalloc.c */
#ifdef RUNTIME
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <dlfcn.h>

/* malloc wrapper function */
void *malloc(size_t size)
{
    void *(*mallocp)(size_t size);
    char *error;

    mallocp = dlsym(RTLD_NEXT, "malloc"); /* Get address of libc malloc */
    if ((error = dlerror()) != NULL) {
        fputs(error, stderr);
    exit(1);
    }
    char *ptr = mallocp(size); /* Call libc malloc */
    printf("malloc(%d) = %p\n", (int)size, ptr);
    return ptr;
}

/* free wrapper function */
void free(void *ptr)
{
    void (*freep)(void *) = NULL;
    char *error;

    if (!ptr)
        return;

    freep = dlsym(RTLD_NEXT, "free"); /* Get address of libc free */
    if ((error = dlerror()) != NULL) {
        fputs(error, stderr);
        exit(1);
    }
    freep(ptr); /* Call libc free */
    printf("free(%p)\n", ptr);
}
#endif

图 16 使用 LD_PRELOAD 进行运行时插桩。

处理目标文件的工具

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

  • ar: 创建静态库, 插入、删除、列出和提取成员。
  • strings: 列出一个目标文件中所有可打印的字符串。
  • strip: 从目标文件中删除符号表信息。
  • nm: 列出一个目标文件的符号表定义的符号。
  • size: 列出目标文件中节的名字和大小。
  • readelf: 显示一个目标文件的完整结构, 包括 ELF 头中编码的所有信息。 包含 sizenm 的功能。
  • objdump: 所有的二进制工具之母。 能够显示一个目标文件中所有的信息。 它最大的作用是反汇编 .text 节中的二进制指令。

Linux 系统为操作共享库还提供了 ldd 程序:

  • ldd: 列出一个可执行文件在运行时所需要的共享库。

小结

链接可以在编译时由静态编译器来完成, 也可以在加载时和运行时由动态链接器来完成。 链接器处理称为目标文件的二进制文件, 它有三种不同的形式:可重定位的、可执行的和共享的。 可重定位的目标文件由静态链接器合并成一个可执行的目标文件, 它可以加载到存储器中并执行。 共享目标文件(共享库) 是在运行时由动态链接器链接和加载的, 或者隐含地在调用程序被加载和开始执行时, 或者根据需要在程序调用 dlopen 库的函数时。

链接器的两个主要任务是符号解析和重定位, 符号解析将目标文件中每个全局符号都绑定到一个唯一的定义, 而重定位确定每个符号的最终存储器地址, 并修改对那些目标的引用。

静态链接器是由像 GCC 这样的编译驱动器调用的。 它们将多个可重定位目标文件合并成一个单独的可执行目标文件。 多个目标文件可以定义相同的符号, 而链接器用来悄悄地解析这些多重定义的规则可能再用户程序中引入的微妙错误。

多个目标文件可以被连接到一个单独的静态库中。 链接器用库来解析其他目标模块中的符号引用。 许多链接器通过从左到右的顺序扫描来解析符号引用, 这是另一个引起令人迷惑的链接时错误的来源。

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

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