链接器链接过程及相关概念解析

Posted 清水寺扫地僧

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了链接器链接过程及相关概念解析相关的知识,希望对你有一定的参考价值。


链接(linking)是将各种代码和数据片段收集并组合为一个单一文件的过程,所得到的文件可以被加载(复制)到内存并执行。链接由链接器(linker)程序执行,链接执行的时机有:

  • 编译时(compile time):源代码被翻译成机器码。静态库,共享库(动态库),可重定位目标文件;
  • 加载时(load time):程序被加载器(loader)加载到内存并执行时。静态库,共享库(动态库),可重定位目标文件;
  • 运行时(run time):由应用程序来执行链接。共享库(动态库)/共享目标文件。


1. 编译器驱动程序

编译器驱动程序(compiler driver),代表用户在需要时调用语言预处理器、编译器、汇编器和链接器。若我们有main.c和sum.c两个文本源程序文件,其中main.c中调用了sum.c的函数,则生成可执行目标文件的命令(在shell中输入并执行)为gcc -Og -o prog main.c sum.c。其过程如下:

由源文件转换为可执行目标文件的编译过程
静态链接过程。链接器将可重定位目标文件组合,进而形成一个可执行目标文件prog

生成后得到可执行文件prog,在shell中输入./prog(因为prog 不是一个内置的shell 命令,所以shell 会认为prog 是一个可执行目标文件,通过调用某个驻留在存储器中称为加载器(loader)的操作系统代码来运行它。),即可运行prog这个程序。



2. 目标文件

目标文件有三种形式:

  • 可重定位目标文件:包含二进制代码和数据,其形式可以在编译时与其他可重定位目标文件合并起来,创建一个可执行目标文件。linux下 . o .o .o,ms下 . l i b .lib .lib
  • 可执行目标文件:包含二进制代码和数据,其形式可以被直接复制到内存并执行;
  • 共享目标文件:一种特殊类型的可重定位目标文件,可以在加载或者运行时被动态地加载进内存并链接,linux下 . s o .so .so,ms下 . d l l .dll .dll

文 本 源 文 件 ⟶ c c l & a s 可 重 定 位 文 件 ( 包 括 共 享 目 标 文 件 ) ⟶ l d 可 执 行 目 标 文 件 \\quad\\quad\\quad\\quad\\quad\\quad文本源文件\\stackrel{ccl \\& as }{\\longrightarrow}可重定位文件(包括共享目标文件)\\stackrel{ld}{\\longrightarrow}可执行目标文件 ccl&as()ld

目标模块(object module) 就是一个字节序列,而目标文件(object file) 就是一个以文件形式存放在磁盘中的目标模块。两者在许多情况下是可以互换的,一般来说目标文件可以包含多个目标模块。

目标文件有多种文件格式,从最早的a.out,到Windows使用的可移植可执行(Portable Executable, PE)格式,MacOS-X使用Mach-O格式,当下x86-64 Linux和Unix系统使用可执行可链接格式(Executable and Linkable Format, ELF)。以下讨论均围绕ELF,与其它格式相比概念均相似。


2.1 可重定位目标文件(.o)

下图是典型的 ELF 可重定位目标文件的格式:


2.2 可执行目标文件(无后缀)

在看过3. 链接器的任务之后,再看此节。

典型的 ELF 可执行目标文件的格式

即文本源程序通过编译器、汇编器和链接器之后得到的可执行目标文件(二进制文件),该文件包含加载程序到内存并运行的所需的所有信息。

其中,ELF可执行文件被设计得很容易加载到内存,可执行文件的连续的片(chunk)被映射到连续的内存段。程序头部表(program header table)描述了这种映射关系。

加载可执行目标文件

正如 1. 编译器驱动程序 中图片后内容所说shell调用某个驻留在存储器中的加载器的OS代码来执行可执行目标文件。任何Linux程序都可以通过调用 execve 函数来调用加载器。加载器将可执行目标文件中的代码和数据从磁盘复制到内存中,然后通过跳转到程序的第一条指令或入口点来运行该程序。这个将程序复制到内存并运行的过程叫做加载。

每个Linux程序运行时都会有一个内存映像(memory snapshot),如下图所示:


2.3 共享目标文件(.dll和.so)

静态库( . l i b .lib .lib . a .a .a)需要定期维护和更新,更新时需要重新进行编译和链接,费时费力;同时对于 l i b c . a libc.a libc.a 这样的标准C静态库被几乎所有的源程序所链接使用,其中的目标模块的代码会被复制到每个运行进程的文本块当中,将是对稀缺的内存系统资源的极大浪费。

针对静态库的这些缺陷,出现了 共享库(shared library) 这一产物,其也是一目标模块,在运行或加载时,可加载到任意的内存地址,并和一个在内存中的程序链接起来。这一过程称为 动态链接(dynamic linking),由动态链接器(dynamic linker)程序来执行。共享库也称为共享目标(shared object),Linux下后缀为 . s o .so .so,MS下后缀为 . d l l .dll .dll

共享库进行共享的两种方式:

  • 在任何给定的文件系统中,对于一个库只有一个 . s o .so .so 文件。所有引用该库的可执行目标文件共享这个文件中的代码和数据,而不是像静态库的内容那样被复制和嵌入到引用它们的可执行的文件中;
  • 在内存中,一个共享库的 . t e x t .text .text 节的一个副本可以被不同的正在运行的进程共享;

链接器动态链接共享库

链接器动态链接共享库的行为过程如下图所示:

创建共享库libvector.so的指令为:gcc -shared -fpic -o libvector.so addvec.c multvec.c,其中 − f p i c -fpic fpic 指示编译器生成与位置无关的代码(共享库的编译总是选择该选项,被编译为位置无关代码(Position Independent Code, PIC)的共享库可以加载到任何地方,也可以在运行时被多个进程共享), − s h a r e d -shared shared 指示链接器创建一共享目标文件。

再进一步使用gcc -o prog21 main2.c ./libvector.so创建出可执行目标文件prog21。创建prog21的过程时,先静态执行一些链接,再在程序加载时,动态完成链接过程,因为prog21的文件形式可以使其再运行时和libvector.so链接。运行时,没有任何共享库libvector.so的代码段和数据段被复制到prog21中,链接器只复制了一些重定位和符号表信息,使得可在运行时对libvector.so当中的代码和数据进行引用解析。

当加载器加载和运行可执行文件prog21时,先加载部分链接的可执行文件prog21。接着,它注意到prog21包含一个.interp 节,这一节包含动态链接器的路径名,动态链接器本身就是一个共享目标(如在Linux 系统上的 l d − l i n u x . s o ld-linux.so ldlinux.so),加载器不会像它通常所做地那样将控制传递给应用,而是加载和运行这个动态链接器。然后,动态链接器通过执行下面的重定位完成链接任务:

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

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

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

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

  • dlopen 函数加载和链接共享库filename;
  • dlsym 函数的输人是一个指向前面已经打开了的共享库的句柄和一个symbol 名字,如果该符号存在,就返回符号的地址,否则返回NULL;
  • dlerror 函数返回一个字符串,它描述的是调用dlopen、 dlsym 或者dlclose 函数时发生的最近的错误,如果没有错误发生,就返回NULL;

此外,Java当中的JNI(Java Native Interface)机制,允许Java程序调用“本地的”C和C++函数。JNI的基本思想是将本地C函数(如foo)编译到一个共享库中(如foo.so), 当一个正在运行的Java程序试图调用函数foo时,Java解释器利用dlopen接口(或者与其类似的接口)动态链接和加载foo.so然后再调用foo。



3. 链接器的任务

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

3.1 符号解析(symbol resolution)

目标文件定义和符号引用,每个符号应当对应于一个符号定义,或许是一个函数、全局变量或静态变量(即C语言中以static属性声明的变量),符号解析的目的是将每个符号引用和相应的符号定义关联起来

符号和符号表

每个可重定位目标模块 m 都有一个符号表 .symtab,它包含 m 定义和引用的符号的信息(该表不包含对应于本地非静态程序变量的任何符号,即不加static修饰的局部变量,这些变量保存在运行时栈当中进行管理,见 程序的机器级表示:7. 程序的运行过程 )。在链接器的上下文中,对于目标模块 m 而言有三种不同的符号:

  • 由模块 m 定义并能被其他模块引用的全局符号。全局链接器符号对应于非静态的 C 函数和全局变量。
  • 由其他模块定义并被模块 m 引用的全局符号。这些符号称为外部符号,对于在其他模块中定义的非静态 C 函数和全局变量。
  • 只被模块 m 定义和引用的局部符号。它们对应于带 static 属性的 C 函数和全局变量。这些符号在模块 m 中任何位置都可见,但是不能被其他模块引用。

符号表的数据结构如下图:

其中需要注意的是,每个符号都被分配到目标文件的某个节,由section字段表示,其也是到节头部表的索引。有三个特殊伪节(在节头部表没有条目):ABS代表不该被重定位符号;UNDEF表示未定义符号(在本模块使用,其他模块定义);COMMON代表还未分配位置的未初始化数据目标(COMMON表示的是伪初始化的全局变量,而.bss表示的是未初始化的静态变量,初始化为0的全局或静态变量)。

符号解析

  • 对局部符号的(包括定义和引用)解析(引用和定义均在相同模块中),符号(引用)解析相当明了,同时编译器只要确保它们拥有唯一名称即可;
  • 对全局符号和外部符号的引用解析就复杂得多。当编译器遇到一个不是在当前模块中定义的符号(变量或函数名)时,会假设该符号是在其他某个模块中定义的,生成一个链接器符号表条目,并把它交给链接器处理。如果链接器在它的任何输入模块中都找不到这个被引用符号的定义,就输出一条(通常很难阅读的)错误信息并终止;
  • 对全局符号和外部符号的(定义)解析很棘手,多个目标文件可能会定义相同名字的全局符号。在这种情况中,链接器必须要么标志一个错误,要么以某种方法选出一个定义并抛弃其他定义。(对于C++中的函数重载,编译器依据函数签名组合编码成一个对链接器而言唯一的名称,也称为重整(mangling)过程)。

①链接器解析多重定义的全局符号方式(三个规则)

每个全局符号都区分为强弱符号,其划分依据为:函数和已初始化的全局变量是强符号;未初始化的全局变量是弱符号。
根据强弱符合定义,有如下三个处理多重定义符号名的规则:

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

其中规则2,3容易引起难以察觉的跨文件错误或重定义错误,造成编译器不报错但结果与预期不符的可怕情况。

②与静态库( . a .a .a)链接(三种方式中的静态库方式)

在"1.编译器驱动程序"中所将的输出可执行文件的过程,其输入是一组可重定位目标文件。在所有的编译系统都提供将所有相关的目标模块打包成一单独文件,称为静态库(static library),也即存档文件,静态库也可作为 链接器 l d ld ld 的输入。静态库以一种称为存档(archive) 的特殊文件格式存放在磁盘中,其有头部来描述每个成员目标文件的大小和位置,文件名由后缀 . a .a .a标示。

当链接器构造一个输出的可执行文件时,它只复制静态库里被应用程序引用的目标模块。静态库提出以解决 让编译器认出对函数的调用造成的符号解析复杂的问题将所有标准C函数放在单独可重定位目标模块再引入造成的内存空间浪费的问题

静态库的创建使用ar工具,指令为ar rcs libvector.a addvec.o multvec.o,即使用addvec.o和mulvec.o两个可重定位目标文件创建libvector.a静态库。若使用静态库libvector.a和main2.o创建可执行目标文件,指令为gcc -static prog2c main2.o ./libvector.agcc -static -o prog2c main2.o -L -lvector(其中-lvector参数是libvector.a的缩写),(gcc的相关命令参数见:gcc简介和命令行参数说明:(三)库操作选项)

链接器链接可重定位目标文件和静态库的行为过程如下图所示:

③链接器怎样使用静态库解析引用(解析顺序与结果)

在符号解析阶段,链接器从左到右按照它们在编译器驱动程序命令行上出现的顺序来扫描可重定位目标文件和存档文件。在扫描中,链接器维护三个集合,初始时三个集合均为空:

  • 可重定位目标文件的集合E(Executable)(这个集合中的文件会被合并起来形成可执行文件);
  • 未解析的符号集合U(Unresolved)(即引用了但尚未定义的符号);
  • 已定义的符号集合D(Defined)(在前面输入文件已定义的符号);

有了三个集合之后,链接器使用如下规则解析静态库中的引用,先设定当前扫描到的文件为 f f f

  • 对于每个 f f f,链接器判断 f f f是目标文件还是存档文件:
    ① 若 f f f是目标文件,将 f f f添加到 E E E,修改 U U U D D D来反映 f f f的符号定义和引用;
    ② 若 f f f是存档文件,链接器尝试匹配 U U U中未解析的符号和存档文件中所定义的成员符号。若某个存档文件成员 m m m,定义了一符号解析 U U U中的一个引用,那么将 m m m加到 E E E中,修改 U U U D D D来反映 f f f的符号定义和引用。对 f f f中的所有成员目标文件均依次进行该过程,直至 U U U D D D都不再变化。此时将不包含在 E E E中的所有其他成员目标文件丢弃;
  • 如果当链接器完成对命令行上输人文件的扫描后, U U U是非空的,那么链接器就会输出一个错误并终止。否则,它会合并和重定位 E E E中的目标文件,构建输出的可执行文件。

需要注意的是,命令行上库和目标文件的顺序的排布应当顺应各个库之间的依赖关系,而不是任意排序。


3.2 重定位(relocation)

编译器和汇编器生成从地址0开始的代码和数据节(虚拟内存屏蔽掉物理内存)。符号解析是将符号引用和其定义关联起来,从而链接器就知道它的输入目标模块中的代码节和数据节的确切大小了。而重定位是把每个符号定义与一个内存位置关联起来,进而修改所有对这些符号的引用,使得它们指向这个内存位置。链接器使用汇编器产生的重定位条目(relocation entry)的详细指令,不加甄别地执行这样的重定位。

重定位条目(relocation entry)

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

ELF格式的重定位条目格式如下:

ELF定义了32中不同的重定位类型,这里只提两种最基本的:

  • R_X86_64_PC32: 重定位一个使用32 位PC(Process Counter) 相对地址的引用。当CPU 执行一条使用PC 相对寻址的指令时,它就将在指令中编码的32 位值加上PC 的当前运行时值,得到有效地址(如call 指令的目标);
  • R_X86_64_32: 重定位一个使用32 位绝对地址的引用。通过绝对寻址,CPU 直接使用在指令中编码的32 位值作为有效地址,不需要进一步修改;

重定位(relocation)

在重定位过程中,由两步组成:

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

在重定位算法当中,链接器遍历每个节,同时遍历每个节中的重定位条目,依据重定位条目中的类型(相对寻址/绝对寻址)决定如何对重定位内容进行运行时内存地址的指定。



4. 总结及归纳

  • 链接可以在编译时由静态编译器来完成,也可以在加载时和运行时由动态链接器来完成。链接器处理称为目标文件的二进制文件,它有3种不同的形式:可重定位的、可执行的和共享的。可重定位的目标文件由静态链接器合并成一个可执行的目标文件,它可以加载到内存中并执行。共享目标文件(共享库)是在运行时由动态链接器链接和加载的,或者隐含地在调用程序被加载和开始执行时,或者根据需要在程序调用dlopen库的函数时;
  • 链接器的两个主要任务是符号解析和重定位,符号解析将目标文件中的每个全局符号都绑定到一个唯一的定义,而重定位确定每个符号的最终内存地址,并修改对那些目标的引用;
  • 静态链接器是由像GCC这样的编译驱动程序调用的。它们将多个可重定位目标文件合并成一个单独的可执行目标文件。多个目标文件可以定义相同的符号,而链接器用来悄悄地解析这些多重定义的规则可能在用户程序中引人微妙的错误;
  • 多个目标文件可以被连接到一个单独的静态库中。链接器用库来解析其他目标模块中的符号引用。许多链接器通过从左到右的顺序扫描来解析符号引用,这是另一个引起令人迷惑的链接时错误的来源;
  • 加载器将可执行文件的内容映射到内存,并运行这个程序。链接器还可能生成部分链接的可执行目标文件(-fpic -shared),这样的文件中有对定义在共享库中的例程和数据的未解析的引用。在加载时,加载器将部分链接的可执行文件映射到内存,然后调用动态链接器,它通过加载共享库和重定位程序中的引用来完成链接任务;
  • 被编译为位置无关代码的共享库可以加载到任何地方,也可以在运行时被多个进程共享。为了加载、链接和访问共享库的函数和数据,应用程序也可以在运行时使用动态链接器。

以上是关于链接器链接过程及相关概念解析的主要内容,如果未能解决你的问题,请参考以下文章

Linux动态库(.so)静态库(.a)的制作和使用

DLL的相关理解

学习笔记 链接

计算机系统篇之链接:静态链接(中)——符号解析

链接一

链接