链接器和加载器

作者:Sandeep Grover

链接是将各种代码和数据片段组合在一起,形成一个可以加载到内存中的可执行文件的过程。链接可以在编译时、加载时(由加载器)以及运行时(由应用程序)完成。链接过程可以追溯到 1940 年代后期,当时它是手动完成的。现在,我们有了 链接器,它们支持复杂的功能,例如动态链接共享库。本文简明扼要地讨论了链接的各个方面,从重定位和符号解析到支持位置无关的共享库。为了保持简单易懂,我将所有讨论都针对 x86 架构(Linux)上的 ELF(可执行和链接格式)可执行文件,并使用 GNU 编译器(GCC)和链接器(ld)。但是,链接的基本概念保持不变,无论使用的操作系统、处理器架构或目标文件格式如何。

编译器、链接器和加载器的操作:基础知识

考虑两个程序文件 a.c 和 b.c。当我们在 shell 提示符下调用 GCC a.c b.c 时,会发生以下操作

gcc a.c b.c
  • 对 a.c 运行预处理器,并将结果存储在中间预处理文件中。

    cpp other-command-line options a.c /tmp/a.i
    
  • 对 a.i 运行编译器 proper,并在 a.s 中生成汇编代码

    cc1 other-command-line options /tmp/a.i  -o /tmp/a.s
    
  • 对 a.s 运行汇编器,并生成目标文件 a.o

    as other-command-line options /tmp/a.s  -o /tmp/a.o
    

cpp、cc1 和 as 分别是 GNU 的预处理器、编译器 proper 和汇编器。它们是标准 GCC 发行版的一部分。

对文件 b.c 重复上述步骤。现在我们有了另一个目标文件 b.o。链接器的工作是获取这些输入目标文件(a.o 和 b.o)并生成最终可执行文件

   ld other-command-line-options /tmp/a.o /tmp/b.o -o a.out

然后,最终可执行文件 (a.out) 就可以加载了。要运行可执行文件,我们在 shell 提示符下键入其名称

./a.out

shell 调用加载器函数,该函数将可执行文件 a.out 中的代码和数据复制到内存中,然后将控制权转移到程序的开头。加载器是一个名为 execve 的程序,它将可执行目标文件的代码和数据加载到内存中,然后通过跳转到第一条指令来运行程序。

a.out 最初在 a.out 目标文件中被命名为汇编器输出 (Assembler OUTput)。从那时起,目标文件格式发生了各种变化,但该名称仍在继续使用。

链接器与加载器

链接器和加载器执行各种相关但概念上不同的任务

  • 程序加载。 这指的是将程序映像从硬盘复制到主内存,以便将程序置于就绪运行状态。在某些情况下,程序加载还可能涉及分配存储空间或将虚拟地址映射到磁盘页面。

  • 重定位。 编译器和汇编器为每个输入模块生成目标代码,起始地址为零。重定位是通过将相同类型的所有节合并为一个节来为程序的各个部分分配加载地址的过程。代码和数据节也会进行调整,以便它们指向正确的运行时地址。

  • 符号解析。 一个程序由多个子程序组成;一个子程序对另一个子程序的引用是通过符号进行的。链接器的工作是通过记录符号的位置并修补调用者的目标代码来解析引用。

因此,链接器和加载器的功能之间存在相当大的重叠。考虑它们的一种方式是:加载器执行程序加载;链接器执行符号解析;它们中的任何一个都可以进行重定位。

目标文件

目标文件有三种形式

  • 可重定位目标文件,其中包含二进制代码和数据,其形式可以在编译时与其他可重定位目标文件组合以创建可执行目标文件。

  • 可执行目标文件,其中包含二进制代码和数据,其形式可以直接加载到内存中并执行。

  • 共享目标文件,这是一种特殊类型的可重定位目标文件,可以加载到内存中并在加载时或运行时动态链接。

编译器和汇编器生成可重定位目标文件(也包括共享目标文件)。链接器将这些目标文件组合在一起以生成可执行目标文件。

目标文件因系统而异。第一个 UNIX 系统使用了 a.out 格式。System V 的早期版本使用了 COFF(通用目标文件格式)。Windows NT 使用 COFF 的变体,称为 PE(可移植可执行)格式;IBM 使用其自己的 IBM 360 格式。现代 UNIX 系统(如 Linux 和 Solaris)使用 UNIX ELF(可执行和链接格式)。本文主要关注 ELF。

ELF 头部

.text

.rodata

.data

.bss

.symtab

.rel.text

.rel.data

.debug

.line

.strtab

上图显示了典型的 ELF 可重定位目标文件的格式。ELF 头部以 4 字节的魔术字符串 \177ELF 开头。ELF 可重定位目标文件中的各个节是

  • .text,已编译程序的机器代码。

  • .rodata,只读数据,例如 printf 语句中的格式字符串。

  • .data,已初始化的全局变量。

  • .bss,未初始化的全局变量。BSS 代表块存储起始 (block storage start),此节实际上不占用目标文件中的任何空间;它只是一个占位符。

  • .symtab,符号表,其中包含程序中定义和引用的函数和全局变量的信息。此表不包含任何局部变量的条目;这些变量在堆栈上维护。

  • .rel.text,.text 节中需要修改的位置列表,当链接器将此目标文件与其他目标文件组合时需要修改这些位置。

  • .rel.data,全局变量的重定位信息,这些全局变量在本模块中被引用但未定义。

  • .debug,调试符号表,其中包含局部变量和全局变量的条目。仅当使用 -g 选项调用编译器时,此节才存在。

  • .line,原始 C 源代码程序中的行号与 .text 节中的机器代码指令之间的映射。调试器程序需要此信息。

  • .strtab,.symtab 和 .debug 节中符号表的字符串表。

符号和符号解析

每个可重定位目标文件都有一个符号表和关联的符号。在链接器的上下文中,存在以下类型的符号

  • 由模块定义并由其他模块引用的全局符号。所有非静态函数和全局变量都属于此类。

  • 由输入模块引用但在其他地方定义的全局符号。所有带有 extern 声明的函数和变量都属于此类。

  • 由输入模块独占定义和引用的局部符号。所有静态函数和静态变量都属于此类。

链接器通过将每个引用与来自其输入可重定位目标文件的符号表中的恰好一个符号定义相关联来解析符号引用。模块的局部符号解析很简单,因为一个模块不能有多个局部符号的定义。然而,解析对全局符号的引用比较棘手。在编译时,编译器将每个全局符号导出为强符号或弱符号。函数和初始化的全局变量获得强权重,而全局未初始化的变量是弱的。现在,链接器使用以下规则解析符号

  1. 不允许有多个强符号。

  2. 给定一个强符号和多个弱符号,选择强符号。

  3. 给定多个弱符号,选择任何一个弱符号。

例如,链接以下两个程序会产生链接时错误

/* foo.c */               /* bar.c */
int foo () {               int foo () {
   return 0;                  return 1;
}                          }
                           int main () {
                              foo ();
                           }

链接器将生成错误消息,因为 foo(作为全局函数的强符号)被定义了两次。

gcc foo.c bar.c
/tmp/ccM1DKre.o: In function 'foo':
/tmp/ccM1DKre.o(.text+0x0): multiple definition of 'foo'
/tmp/ccIhvEMn.o(.text+0x0): first defined here
collect2: ld returned 1 exit status

Collect2 是链接器 ld 的包装器,由 GCC 调用。

与静态库链接

静态库是类似类型的串联目标文件的集合。这些库存储在磁盘上的存档中。存档还包含一些目录信息,可以更快地搜索内容。每个 ELF 存档都以魔术八字符字符串 !<arch>\n 开头,其中 \n 是换行符。

静态库作为参数传递给编译器工具(链接器),链接器仅复制程序引用的目标模块。在 UNIX 系统上,libc.a 包含所有 C 库函数,包括 printf 和 fopen,这些函数被大多数程序使用。

gcc foo.o bar.o /usr/lib/libc.a /usr/lib/libm.a

libm.a 是 UNIX 系统上的标准数学库,其中包含诸如 sqrt、sin、cos 等数学函数的目标模块。

Linkers and Loaders

在使用静态库进行符号解析的过程中,链接器从左到右扫描命令行上的可重定位目标文件和存档作为输入。在此扫描期间,链接器维护一组 O(将进入可执行文件的可重定位目标文件)、一组 U(未解析的符号)和一组 D(先前输入模块中定义的符号)。最初,所有三个集合都是空的。

  • 对于命令行上的每个输入参数,链接器确定输入是目标文件还是存档。如果输入是可重定位目标文件,则链接器将其添加到集合 O,更新 U 和 D,然后继续处理下一个输入文件。

  • 如果输入是存档,它会扫描构成存档的成员模块列表,以匹配 U 中存在的任何未解析符号。如果某些存档成员定义了任何未解析的符号,则将该存档成员添加到列表 O,并根据存档成员中找到的符号更新 U 和 D。为所有成员目标文件迭代此过程。

  • 在通过上述两个步骤处理完所有输入参数后,如果发现 U 不为空,则链接器会打印错误报告并终止。否则,它会合并和重定位 O 中的目标文件以构建输出可执行文件。

这也解释了为什么静态库放置在链接器命令的末尾。在库之间存在循环依赖关系的情况下,必须特别小心。必须对输入库进行排序,以便每个符号都由存档的成员引用,并且命令行的符号的至少一个定义后面跟着对它的引用。此外,如果未解析的符号在多个静态库模块中定义,则从命令行中找到的第一个库中选择定义。

重定位

一旦链接器解析了所有符号,每个符号引用都只有一个定义。此时,链接器开始重定位过程,该过程包括以下两个步骤

  • 重定位节和符号定义。 链接器将所有相同类型的节合并到一个新的单节中。例如,链接器将所有输入可重定位目标文件的 .data 节合并为最终可执行文件的单个 .data 节。对 .code 节执行类似的过程。然后,链接器为新的聚合节、输入模块定义的每个节以及每个符号分配运行时内存地址。完成此步骤后,程序中的每个指令和全局变量都具有唯一的加载时地址。

  • 重定位节内的符号引用。 在此步骤中,链接器修改代码和数据节中的每个符号引用,以便它们指向正确的加载时地址。

每当汇编器遇到未解析的符号时,它都会为该对象生成一个重定位条目,并将其放置在 .relo.text/.relo.data 节中。重定位条目包含有关如何解析引用的信息。典型的 ELF 重定位条目包含以下成员

  • 偏移量,需要重定位的引用的节偏移量。对于可重定位文件,此值是从节的开头到受重定位影响的存储单元的字节偏移量。

  • 符号,修改后的引用应指向的符号。它是必须相对于其进行重定位的符号表索引。

  • 类型,重定位类型,通常为 R_386_PC32,表示 PC 相对寻址。R_386_32 表示绝对寻址。

链接器迭代可重定位目标模块中存在的所有重定位条目,并根据类型重定位未解析的符号。对于 R_386_PC32,重定位地址计算为 S + A - P;对于 R_386_32 类型,地址计算为 S + A。在这些计算中,S 表示来自重定位条目的符号的值,P 表示正在重定位的存储单元的节偏移量或地址(使用来自重定位条目的偏移量值计算得出),A 是计算可重定位字段的值所需的地址。

动态链接:共享库

上面的静态库有一些明显的缺点;例如,考虑标准函数,如 printf 和 scanf。几乎每个应用程序都使用它们。现在,如果一个系统正在运行 50-100 个进程,则每个进程都有其自己的 printf 和 scanf 可执行代码副本。这占用了内存中的大量空间。另一方面,共享库解决了静态库的缺点。共享库是一个目标模块,可以在运行时以任意内存地址加载,并且可以由内存中的程序链接。共享库通常被称为共享对象。在大多数 UNIX 系统上,它们用 .so 后缀表示;HP-UX 使用 .sl 后缀,Microsoft 将它们称为 DLL(动态链接库)。

要构建共享对象,可以使用特殊选项调用编译器驱动程序

gcc -shared -fPIC -o libfoo.so a.o b.o
Linkers and Loaders

上面的命令告诉编译器驱动程序生成一个共享库 libfoo.so,它由目标模块 a.o 和 b.o 组成。-fPIC 选项告诉编译器生成位置无关代码 (PIC)。

现在,假设主目标模块是 bar.o,它依赖于 a.o 和 b.o。在这种情况下,链接器使用以下命令调用

gcc bar.o ./libfoo.so

此命令创建一个可执行文件 a.out,其形式可以在加载时链接到 libfoo.so。这里 a.out 不包含目标模块 a.o 和 b.o,如果我们创建静态库而不是共享库,则会包含这些模块。可执行文件仅包含一些重定位和符号表信息,这些信息允许在运行时解析对 libfoo.so 中的代码和数据的引用。因此,这里的 a.out 是一个部分可执行文件,它仍然依赖于 libfoo.so。可执行文件还包含一个 .interp 节,其中包含动态链接器的名称,它本身是 Linux 系统上的共享对象 (ld-linux.so)。因此,当可执行文件加载到内存中时,加载器将控制权传递给动态链接器。动态链接器包含一些启动代码,这些代码将共享库映射到程序的地址空间。然后它执行以下操作

  • 将 libfoo.so 的文本和数据重定位到内存段;以及

  • 重定位 a.out 中对 libfoo.so 定义的符号的任何引用。

最后,动态链接器将控制权传递给应用程序。从那时起,共享对象的位置在内存中是固定的。

从应用程序加载共享库

即使在执行过程中,也可以从应用程序加载共享库。应用程序可以请求动态链接器加载和链接共享库,即使不将这些共享库链接到可执行文件。Linux、Solaris 和其他系统提供了一系列函数调用,可用于动态加载共享对象。Linux 提供了系统调用,例如 dlopen、dlsym 和 dlclose,可用于加载共享对象、查找该共享对象中的符号以及分别关闭共享对象。在 Windows 上,LoadLibrary 和 GetProcAddress 函数分别取代 dlopen 和 dlsym。

用于操作目标文件的工具

以下是可用于探索目标/可执行文件的 Linux 工具列表。

  • ar:创建静态库。

  • objdump:这是最重要的二进制工具;它可以用来显示目标二进制文件中的所有信息。

  • strings:列出二进制文件中的所有可打印字符串。

  • nm:列出目标文件符号表中定义的符号。

  • ldd:列出目标二进制文件所依赖的共享库。

  • strip:删除符号表信息。

建议阅读

链接器和加载器 作者:John Levine

来自 Sun 的链接器和库指南

Sandeep GroverQuickLogic Software (印度) Pvt. Ltd. 工作。

电子邮件: sgrover@quicklogic.com

加载 Disqus 评论