参考:
《程序员的自我修养—链接、装载与库》
《深入理解计算机系统》

动态链接

在动态链接出现之前,可执行文件的生成都是使用静态链接的方式来生成。静态链接方式需要在程序运行前将所有的可重定位文件全部链接到一起,形成一个整体后才能加载到内存中运行,这种机制存在一些弊端:

  • 空间浪费:程序在使用静态库的时候需要将使用到的公用库函数的目标文件全部链接到程序中,这就导致不同程序会存在相同库文件的多个副本,造成空间浪费;
  • 模块更新困难:对于程序中任何参与链接的可重定位文件发生更新,整个程序都需要重新进行编译和发布。

为了解决静态链接的问题,动态链接不再从一开始就将程序的所有模块静态链接在一起,而是等到程序运行时才进行链接。

动态链接的实现

动态链接的基本思想是将程序中的部分独立模块作为动态共享库实现,在链接生成可执行文件时,动态库只提供重定位和符号信息,而不实际参与链接;等到程序运行时,由动态链接器将程序依赖的动态共享库加载到进程的虚拟地址空间中,形成一个完整的程序后执行。动态链接的基本实现过程示意如下:

image.png

动态链接的核心工作由动态链接器完成,在Linux平台下,使用的动态链接器一般为ld-linux.so。动态链接器需要完成的任务一般包括:

  • 动态链接器自举:动态链接器自身也是一个动态共享库文件,在加载动态链接的可执行文件时,必须要先加载动态链接器,并由动态链接器完成自身的初始化,即自举;
  • 加载动态库:可执行文件中记录了依赖的符号信息,动态链接器会根据依赖信息,读取包含依赖符号的动态库文件,并映射动态库的代码段和数据段到进程地址空间中;
  • 重定位和初始化:动态链接器遍历可执行文件和所有共享对象的重定位表,然后依据记录在 GOT/PLT 表中的重定位信息,对外部符号的引用进行修正;在完成重定位后,动态链接器调用动态库中 .init 中代码,以实现动态库特有的初始化流程。

动态共享库

从动态链接器的几个主要任务中可以看到,动态链接器大部分的工作都是在处理动态共享库。动态共享库是一个目标文件,在运行时,可以被加载到任意的地址,并与一个在内存中的程序进行链接。动态共享库包含两个关键的特性:

  • 动态共享库的指令部分是在多个进程之间共享的,但数据是进程独立的,即数据在每个进程中拥有独立的副本;

  • 动态共享库的最终加载地址在编译时是不确定的,而是等到加载时,由加载器从当前进程的虚拟地址空间中动态分配。

在Linux平台下,动态共享库主要使用ELF文件格式进行存储,一般以.so为后缀名。

编译 Hello World

Hello World程序是最简单的一个使用动态共享库的例子:

1
2
3
4
5
6
7
8
#include <stdio.h>

int main(int argc, char *argv[])
{
printf("Hello World!\n");

return 0;
}

默认情况下,当我们编译一个Hello World程序时,编译器以动态链接的方式将程序依赖的库加入进来,在Linux下可以查看生成Hello World可执行程序依赖的动态库信息:

image.png

动态链接机制将整个程序被分成两个部分:可执行文件以及程序所依赖的动态共享库。在编译生成可执行文件时,链接器只会复制动态库中的重定位和符号表信息,而对于动态库的任何代码和数据节都不会复制到可执行文件中。在实际运行程序时,动态链接器会依据可执行文件中记录的信息,加载依赖的动态库文件,并在内存中完成动态链接;最后,动态链接器将控制转移给应用程序。

位置无关代码

动态库的一个主要目的就是允许多个正在运行的进程共享内存中的库代码,以节约内存资源。现代系统使用了一种称为位置无关代码(Position-Indepent Code, PIC)的技术来编译动态库,使用这种技术,可以将动态库加载到内存的任何位置而无需链接修改,所有进程都可以共享动态库中代码的单一副本。

PIC 的基本思想是将指令中那些需要进行重定位的部分剥离出来和数据部分放在一起,这样指令部分就可以保持不变,而数据部分在每个进程中都可以拥有一个副本。为了实现PIC,关键在于如何处理动态库中的各种符号引用。下列代码中涵盖了在动态库中会遇到到的各种符号引用类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// foo.c

extern int e_val;
extern void e_func();

static int s_val;

static void s_func() {
s_val = 2; // 模块内部的数据访问
e_val = 1; // 模块外部的数据访问
}

void foo() {
s_func(); // 模块内部的函数调用
e_func(); // 模块外部的函数调用
}

通常,对于模块内部符号的引用,可以利用 PC 相对寻址,然后由静态链接在构造目标文件时进行重定位,就可以使之成为 PIC;然而对动态库定义的外部函数以及对全局变量的引用,还需要编译器进行一些特殊的处理。现在围绕这些类型的引用,我们可以看看现代编译系统是如何为其生成 PIC 的代码。

  1. 模块内部的数据访问:动态库在被加载到内存时,库文件中任何一条指令与其要访问的模块内部数据之间的相对位置是固定的,因此使用当前指令地址加上固定的偏移量就能访问模块内部数据。x86_64 体系结构下,数据寻址已经支持相对当前PC指针寄存器的寻址方式。

  2. 模块内部的函数调用:由于被调用的函数与调用者都处于同一个模块中,它们之间的相对位置是固定的,因此使用相对地址调用就可以解决问题。在现代系统中,模块内的函数调用都是相对地址调用或基于寄存器的相对调用,该种指令无需重定位。

  3. 模块外部的数据访问:由于模块间的数据访问目标地址要等到装载时才能决定,为了实现 PIC,编译系统使用了一些新的数据结构:全局偏移表(Global Offset Table,GOT)。GOT 放置在数据段中,因此对于每个进程都有独立的副本,表中存放了所有外部变量的地址,由动态链接器在加载模块的时候,通过查找外部变量的地址进行填充;当指令需要引用外部变量时,可以通过 GOT 中相对应的项间接引用。

  4. 模块外部的函数调用:模块间的函数调用原理可以使用与数据访问类似的实现,只需要在GOT表中存放目标函数的地址即可。

全局符号介入

一个共享目标文件里面的全局符号被另一个共享目标文件的同名全局符号覆盖的现象,称作共享对象全局符号介入。Linux下动态链接器处理全局符号介入问题的规则是这样的:当一个符号需要被加入全局符号表时,如果相同的符号名已经存在,则后加入的符号被忽略

全局符号介入对PIC的影响

共享对象全局符号介入引入了这样的一个问题:如果在共享对象A中定义了一个全局符号global,共享对象B同样定义了global全局符号,并且在本模块中也使用了这个全局符号,那么在最后可执行文件依序加载A和B的时候,B中的相同符号就会被A覆盖掉,那么共享对象B和外部模块在访问符号时就会出现不一致。相同问题对于函数引用也有可能出现。

为了解决这个问题,一个思路就是将共享对象内定义的需要被外部模块引用的符号作为模块外部符号进行处理,包括全局变量和函数。如果使用更直白的表述就是,只有对于模块内使用static关键字进行修饰的符号(即文件内作用域)才视为模块内的符号进行处理,即类型一和类型二中使用的处理;而非static修饰的符号使用类型三和类型四中的情况进行处理。

共享模块的全局变量问题

当一个可执行文件引用了一个定义在共享对象的全局变量的时候,由于可执行文件不使用 PIC 技术,它会以访问普通数据的方式来引用这个全局变量。于是,在链接生成可执行文件的时候,就需要进行重定位工作。为了使链接过程可以正常执行,链接器会在创建可执行文件时,在其.bss段创建该变量的副本,并使用该副本的地址进行重定位。

ELF 共享库在编译时,默认将定义模块内部的全局变量当作定义在其它模块的全局变量,使用GOT来实现变量访问。当共享库被装载时,某个全局变量在可执行文件中存在副本,则动态链接器会使用该副本的地址来填充GOT的对应表项。

延迟绑定

动态链接将链接工作由编译时推迟到了运行时,在每次程序运行时,动态链接器都要寻找并加载依赖的动态库,然后进行符号查找和重定位工作,这导致动态链接的程序在加载时会带来一些额外的开销。为了提升程序的加载速度,编译系统使用了一种称为延迟绑定(Lazy Binding)的技术。

使用延迟绑定是基于这样一个前提:在动态链接下,程序加载的模块中包含了大量的函数调用,因此动态链接器会耗费很多的时间用于解决模块之间的函数引用的符号查找以及重定位,而实际上只有很少的一部分符号会被立刻访问。延迟绑定通过将函数地址的绑定推迟到第一次调用这个函数时,从而避免动态链接器在加载时处理大量函数引用的重定位。延迟绑定的实现使用了两个特殊的数据结构:全局偏移表(Global Offset Table,GOT)和过程链接表(Procedure Linkage Table,PLT)。

全局偏移表(GOT)

全局偏移表在 ELF 文件中以独立的节区存在,共包含两类,对应的节区名为.got.got.plt,其中,.got存放所有对于外部变量引用的地址;.got.plt保存所有对于外部函数引用的地址,对于延迟绑定主要使用.got.plt表。.got.plt表的基本结构如下图所示:

image.png

其中,.got.plt 的前三项存放着特殊的地址引用:

  • GOT[0]:保存 .dynamic 段的地址,动态链接器利用该地址提取动态链接相关的信息;
  • GOT[1]:保存本模块的ID;
  • GOT[2]:存放了指向动态链接器 _dl_runtime_resolve 函数的地址,该函数用来解析共享库函数的实际符号地址。

过程链接表

为了实现延迟绑定,当调用外部模块的函数时,程序并不会直接通过 GOT 跳转,而是通过存储在 PLT 表中的特定表项进行跳转。对于所有的外部函数,在 PLT 表中都会有一个相应的项,其中每个表项都保存了16字节的代码,用于调用一个具体的函数。

过程链接表中除了包含编译器为调用的外部函数单独创建的 PLT 表项外,还有一个特殊的表项,对应于 PLT[0],它用于跳转到动态链接器,进行实际的符号解析和重定位工作。

延迟绑定过程

考虑经典 Hello world 的实现:

1
2
3
4
5
6
7
8
#include <stdio.h>

int main(int argc, char *argv[])
{
printf("Hello World!\n");

return 0;
}

默认情况下,编译器通过动态链接的方式来使用C标准库,因此printf函数实际上就是一个存在于外部动态库的实现,通过观察printf的执行,即可以了解到在程序中延迟绑定的运作过程。我们将上述代码编译后进行反汇编:

image.png

main 函数对 printf 的调用流程如下:

  • main 函数不会直接调用 printf 函数,而是调用 puts@plt。注意,这里编译器会优化对 printf 的调用为对库函数 puts 的调用;
  • puts@plt 的第一条指令通过 GOT[3] 进行跳转。由于每个 GOT 表项初始化时都指向对应 PLT 条目的第二条指令,因此这个间接跳转会将控制转移到 puts@plt的第二条指令继续执行;
  • puts@plt 的第二条指令会将 puts 的 ID 压入栈中之后,然后跳转到 PLT[0] 中的指令;
  • .plt 中指令继续压入全局偏移表表中第二个表项所存放的地址,即本模块ID,最后跳转到动态链接器的入口 _dl_runtime_resolve,执行符号解析;
  • 完成符号解析后,_dl_runtime_resolve 会将解析出来的puts函数的地址,填入 GOT[3] 中,到达这一步后,对 puts 函数的符号绑定工作就完成了。

image.png

动态链接信息

操作系统在执行动态链接的可执行文件时,会首先加载动态链接器,然后由动态链接器根据保存在可执行文件中的动态链接信息,完成依赖动态库的加载、符号解析以及重定位等工作。这些动态链接信息包括但不限于:

  • 动态链接器路径;
  • 动态链接导入导出的符号定义及引用;
  • 动态链接符号重定位描述。

为了保存这些信息,ELF 文件格式针对动态链接使用了一些的特殊的节。

  1. 解释器节:使用动态链接的可执行文件在加载时,需要通过动态链接器帮助加载可执行文件依赖的动态库,为了指明动态链接器的位置,动态链接的可执行文件使用了一个专门的节叫做 .interp,用以保存可执行文件依赖的动态链接器的路径:

  2. 动态节:动态节 .dynamic 节是动态链接可执行文件特有的,保存了动态链接器所必需的一些信息,包括运行时依赖的共享库信息、动态链接符号表的位置、动态链接重定位表的位置以及共享库初始化代码的地址等。在 Linux 下,使用 readelf -d 可查看 .dynamic 节的内容。

  3. 动态符号表:静态链接中,ELF文件使用符号表 .symtab 节保存了关于该文件定义的符号以及引用,类似的,动态链接使用了动态符号表 .dynsym 来保存动态链接过程中各个模块之间的符号导入导出关系;与符号表 .symtab 不同,动态符号表只保存与动态链接相关的符号。

动态链接重定位

与静态链接类似,动态链接使用时同样需要对符号进行重定位。共享库需要重定位的原因是因为导入符号的存在。动态链接下,无论是可执行文件或共享库,一旦其需要依赖其他共享对象,即有导入的符号存在时,那么它的代码或数据中就会有对导入符号的引用。在最终程序运行时,需要对所有导入的符号进行重定位。

动态链接重定位表用于在程序运行时对导入符号进行重定位。动态链接的文件中,存在两类重定位表:.rel.dyn.rel.plt

  • .rel.dyn 用于对数据引用进行修正,其修正的位置位于 .got 以及数据段;
  • .rel.plt 用于对函数引用进行修正,其修正的位置位于 .got.plt