链接装载与库(五)动态链接

原图

为什么要动态链接

  • 内存和磁盘空间: /usr/bin 下就有数千个可执行文件,还有其他数以千计的库如果都需要静态链接,那么空间浪费无法想象

  • 程序开发和发布: 一旦程序中有任何模块更新,整个程序就要重新链接、发布给用户

  • 程序可扩展性和兼容性: 动态链接还可以加强程序的兼容性。一个程序在不同的平台运行时可以动态地链接到由操作系统提供的动态链接库

  • 存在的问题: 当程序所依赖的某个模块更新后,由于新的模块与旧的模块之间接口不兼容,导致了原有的程序无法运行

简单的动态链接例子

我们分别需要如下几个源文件:“Program1.c”、“Program2.c”、“Lib.c”和“Lib.h”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
清单 7-1 SimpleDynamicalLinking
/*Program1.c*/
#include "Lib.h"

int main()
{
foobar(1);
return 0;
}

/*Program2.c */
#include "Lib.h"

int main()
{
foobar(2);
return 0;
}

/*Lib.c*/
#include <stdio.h>

void foobar (int i)
{
printf("Printing from Lib.so d\n", i);
}

/*Lib.h */
#ifndef LIB_H
#define LIB_H

void foobar(int i);

#endif

程序很简单,两个程序的主要模块 Program1.c 和 Program2.c 分别调用了 Lib.c 里面的 foobar() 函数。然后我们使用 GCC 将 Lib.c 编译成一个共享对象文件:

1
gcc -fPIC -shared -o Lib.so Lib.c

上面 GCC 命令中的参数“-shared”表示产生共享对象。这时候我们得到了一个 Lib.so 文件,这就是包含了 Lib.c 的 foobar() 函数的共享对象文件。然后我们分别编译链接 Program1.c 和 Program2.c:

1
2
gcc -o Program1 Program1.c ./Lib.so
gcc -o Program2 Program2.c ./Lib.so

这样我们得到了两个程序 Program1 和 Program2,这两个程序都使用了 Lib.so 里面的 foobar() 函数。从 Program1 的角度看,整个编译和链接过程如下图所示。

关于模块(Module)

在动态链接下,一个程序被分成了若干个文件,有程序的主要部分,即可执行文件(Program1)和程序所依赖的共享对象(Lib.so),很多时候我们也把这些部分称为模块。

当链接器将 Program1.o 链接成可执行文件时,这时候链接器必须确定 Program1.o 中所引用的 foobar() 函数的性质。如果 foobar() 是一个静态目标模块中的函数,将 Program1.o 中的 foobar 地址引用重定位:如果 foobar() 是一个定义在某个动态共享对象中的函数,那么链接器就会将这个符号的引用标记为一个动态链接的符号,不对它进行地址重定位,把这个过程留到装载时再进行。

那么这里就有个问题,链接器如何知道 foobar 的引用是一个静态符号还是一个动态符号?这实际上就是我们要用到 Lib.so 的原因。Lib.so 中保存了完整的符号信息,把 Lib.o 也作为链接的输入文件之一,链接器在解析符号时就可以知道:foobar 是一个定义在 Lib.so 的动态符号。这样链接器就可以对 foobar 的引用做特殊的处理,使它成为一个对动态符号的引用。

动态链接程序运行时地址空间分布

我们还是以上面的 Program1 为例:

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

void foobar(int i)
{
printf("Printing from Lib.so &d\n",i);
sleep(-1);
}

然后就可以查看进程的虚拟地址空间分布:

Lib.so 与 Program1 一样,它们都是被操作系统用同样的方法映射至进程的虚拟地址空间,只是它们占据的虚拟地址和长度不同。Program1 除了使用 Lib.so 以外,它还用到了动态链接形式的 C 语言运行库 libc-2.6.1.so。另外还有一个很值得关注的共享对象就是 ld-2.6.so,它实际上是 Linux 下的动态链接器。动态链接器与普通共享对象一样被映射到了进程的地址空间,在系统开始运行 Program1 之前,首先会把控制权交给动态链接器,由它完成所有的动态链接工作以后再把控制权交给 Program1。

我们通过 readelf 工具来查看 Lib.so 的装载属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
readelf -1 Lib.so
Elf file type is DYN (Shared object file)
Entry point 0x390
There are 4 program headers,starting at offset 52
Program Headers:
Type offset VirtAddr PhysAddrFilesiz MemSiz Flg Align
LOAD 0x000000 0x00000000 0x00000000 0x004e0 0x004e0RE 0×1000
LOAD 0x0004e0 0x000014e0 0x000014e0 0x0010c 0x00110RW 0x1000
DYNAMIC 0x0004f4 0x000014f4 0x000014f4 0x000c8 0x000c8RW 0x4
GNU_STACK 0x000000 0×00000000 0×00000000 0x00000 0x00000RW 0×4

Section to Segment mapping:
Segment Sections..
00 .hash .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rel.dyn
.rel.plt .init .plt .text .fini
01 .ctors .dtors .jcr .dynamic .got .got.plt .data .bss
02 .dynamic
03

注意:动态链接模块的装载地址是从地址 0x00000000 开始的。我们知道这是无效地址,并且从上面的进程虚拟空间分布看到,Lib.so 的最终装载地址并不是 0x00000000,从这一点我们可以推断,共享对象的最终装载地址在编译时是不确定的,而是在装载时动态分配一块足够大小的虚拟地址空间给相应的共享对象。

地址无关代码

固定装载地址的困扰

如果不同的模块目标装载地址不一样是不行的,我们设想是否可以让共享对象在任意地址加载?

装载时重定位

在链接时,对所有绝对地址的引用不作重定位,而把这一步推迟到装载时再完成。一旦模块装载地址确定,即目标地址确定,那么系统就对程序中所有的绝对地址引用进行重定位。我们前面在静态链接时提到过重定位,那时的重定位叫做链接时重定位(Link Time Relocation),而现在这种情况经常被称为装载时重定位(Load Time Relocation)。

但是动态链接模块被装载映射至虚拟空间后,指令部分是在多个进程之间共享的,由于装载时重定位的方法需要修改指令,所以没有办法做到同一份指令被多个进程共享,因为指令被重定位后对于每个进程来讲是不同的。当然,动态连接库中的可修改数据部分对于不同的进程来说有多个副本,所以它们可以采用装载时重定位的方法来解决。

地址无关代码

其实我们的目的很简单,希望程序模块中共享的指令部分在装载时不需要因为装载地址的改变而改变,所以实现的基本想法就是把指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本。这种方案就是目前被称为地址无关代码(PIC,Position-independent Code)的技术。

产生地址无关的代码并不麻烦。主要分如下四种情况:

  • 第一种是模块内部的函数调用、跳转等都可以是相对地址调用,或者是基于寄存器的相对调用,所以对于这种指令是不需要重定位的。
  • 第二种是模块内部的数据访问、比如模块中定义的全局变量、静态变量。现代的体系结构中,数据的相对寻址往往没有相对与当前指令地址(PC)的寻址方式,所以 ELF 用了一个很巧妙的办法来得到当前的 PC 值,然后再加上一个偏移量就可以达到访问相应变量的目的了。
  • 第三种是模块外部的数据访问、比如其他模块中定义的全局变量。ELF 的做法是在数据段里面建立一个指向这些变量的指针数组,也被称为全局偏移表(Global Offset Table,GOT),当代码需要引用该全局变量时,可以通过 GOT 中相对应的项间接引用。
  • 第四种是模块外部的函数调用、跳转等。GOT 中相应的项保存的是目标函数的地址,当模块需要调用目标函数时,可以通过 GOT 中的项进行间接跳转。

共享模块的全局变量问题

1
2
3
4
5
extern int global:
int foo()
{
global = 1;
}

当编译器编译 module.c 时,它无法判断 global 是定义在同一个模块的的其他目标文件还是定义在另外一个共享对象之中。

假设 module.c 是程序可执行文件的一部分,那么在这种情况下,由于程序主模块的代码并不是地址无关代码,它引用这个全局变量的方式跟普通数据访问方式一样,编译器会产生这样的代码:

1
movl $Ox1,XXXXXXXX

XXXXXXXX 就是 global 的地址。由于可执行文件在运行时并不进行代码重定位,所以变量的地址必须在链接过程中确定下来。为了能够使得链接过程正常进行,链接器会在创建可执行文件时,在它的“.bss”段创建一个 global 变量的副本。那么问题就很明显了,现在 global 变量定义在原先的共享对象中,而在可执行文件的“.bss”段还有一个副本。如果同一个变量同时存在于多个位置中,这在程序实际运行过程中肯定是不可行的。

于是解决的办法只有一个,那就是所有的使用这个变量的指令都指向位于可执行文件中的那个副本。ELF 共享库在编译时,默认都把定义在模块内部的全局变量当作定义在其他模块的全局变量,也就是说当作前面的类型四,通过 GOT 来实现变量的访问。

延迟绑定(PLT)

动态链接的确有很多优势,比静态链接要灵活得多,但它是以牺牲一部分性能为代价的。据统计 ELF 程序在静态链接下要比动态库稍微快点,大约为 1%~5%。我们知道动态链接比静态链接慢的主要原因是动态链接下对于全局和静态的数据访问都要进行复杂的 GOT 定位,然后间接寻址;对于模块间的调用也要先定位 GOT,然后再进行间接跳转。另外一个减慢运行速度的原因是动态链接的链接工作在运行时完成,即程序开始执行时,动态链接器都要进行一次链接工作。

延迟绑定实现

在动态链接下,如果一开始就把所有函数都链接好实际上是一种浪费。所以 ELF 采用了一种叫做延迟绑定(Lay Binding)的做法,基本的思想就是当函数第一次被用到时才进行绑定(符号查找、重定位等),如果没有用到则不进行绑定。

PLT 在 ELF 文件中以独立的段存放,段名通常叫做“.plt”,因为它本身是一些地址无关的代码,所以可以跟代码段等一起合并成同一个可读可执行的“Segment”被装载入内存。

动态链接相关段

.interp 段

实际上,动态链接器的位置既不是由系统配置指定,也不是由环境参数决定,而是由 ELF 可执行文件决定。在动态链接的 ELF 可执行文件中,有一个专门的段叫做“.interp”段。“.interp”内容:

1
2
3
4
5
6
objdump -s a.out
a.out: file format elf32-i386
Contents of section .interp:
8048114 2f6c6962 2f6c642d 6c696e75 782e736f /lib/ld-1inux.so
8048124 2e3200
.2.

“.interp”的内容很简单,里面保存的就是一个字符串,这个字符串就是可执行文件所需要的动态链接器的路径,在 Linux 下,可执行文件所需要的动态链接器的路径几乎都是“/lib/ld-linux.so.2”。

.dynamic 段

动态链接 ELF 中最重要的结构应该是“.dynamic”段,这个段里面保存了动态链接器所需要的基本信息,比如依赖于哪些共享对象、动态链接符号表的位置、动态链接重定位表的位置、共享对象初始化代码的地址等。

.dynsym 段(动态符号表)

为了表示动态链接这些模块之间的符号导入导出关系,ELF 专门有一个叫做动态符号表(Dynamic Symbol Table)的段用来保存这些信息,这个段的段名通常叫做“.dynsym”(Dynamic Symbol)。与静态链接中的符号表“.symtab”不同的是,“.dynsym”只保存了与动态链接相关的符号。

与“.symtab”类似,动态符号表也需要一些辅助的表,比如用于保存符号名的字符串表。静态链接时叫做符号字符串表“.strtab”(String Table),在这里就是动态符号字符串表“.dynstr”(Dynamic String Table);由于动态链接下,我们需要在程序运行时查找符号,为了加快符号的查找过程,往往还有辅助的符号哈希表(“.hash”)。我们可以用 readelf 工具来查看 ELF 文件的动态符号表及它的哈希表:

1
readelf -sD Lib.so

动态链接重定位表

对于使用 PIC 技术的可执行文件或共享对象来说,虽然它们的代码段不需要重定位(因为地址无关),但是数据段还包含了绝对地址的引用,因为代码段中绝对地址相关的部分被分离了出来,变成了 GOT,而 GOT 实际上是数据段的一部分。除了 GOT 以外,数据段还可能包含绝对地址引用。

在静态链接中,目标文件里面包含有专门用于表示重定位信息的重定位表,比如“.rela.text”表示是代码段的重定位表,“.rela.data”是数据段的重定位表。动态链接的文件中,也有类似的重定位表分别叫做“.rela.dyn”和“.rela.plt'”。“.rela.dyn”实际上是对数据引用的修正,它所修正的位置位于“.got”以及数据段;而“.rela.plt”是对函数引用的修正,它所修正的位置位于“.got.plt”。

动态链接的步骤和实现

  • 首先操作系统会读取可执行文件的头部,检查文件的合法性,然后从头部中的“Program Header”中读取每个“Segment”的虚拟地址、文件地址和属性,并将它们映射到进程虚拟空间的相应位置。
  • 对于静态链接。 操作系统接着就可以把控制权转交给可执行文件的入口地址,然后程序开始执行,一切看起来非常直观
  • 对于动态链接。
    • 可执行文件里对于很多外部符号的引用还处于无效地址的状态。所以在映射完可执行文件之后,操作系统会先启动一个动态链接器(Dynamic Linker)
    • 将控制权交给动态链接器的入口地址,执行一系列自身的初始化操作,开始对可执行文件进行动态链接工作
    • 当所有动态链接工作完成以后,动态链接器会将控制权转交到可执行文件的入口地址,程序开始正式执行

动态链接器自举

我们知道动态链接器本身也是一个共享对象,但是事实上它有一些特殊性。对于普通共享对象文件来说,它的重定位工作由动态链接器来完成。可是对于动态链接器本身来说,它的重定位工作由谁来完成?动态链接器必须有些特殊性。

  • 首先是,动态链接器本身不可以依赖于其他任何共享对象。
  • 其次是动态链接器本身所需要的全局和静态变量的重定位工作由它本身完成。

对于第一个条件我们可以人为地控制,在编写动态链接器时保证不使用任何系统库、运行库;对于第二个条件,动态链接器必须在启动时有一段非常精巧的代码可以完成这项艰巨的工作而同时又不能用到全局和静态变量。这种具有一定限制条件的启动代码往往被称为自举(Bootstrap)。

动态链接器入口地址即是自举代码的入口,当操作系统将进程控制权交给动态链接器时,动态链接器的自举代码即开始执行。自举代码首先会找到它自己的 GOT。而 GOT 的第一个入口保存的即是“.dynamic”段的偏移地址,由此找到了动态连接器本身的“.dynamic”段。通过“.dynamic”中的信息,自举代码便可以获得动态链接器本身的重定位表和符号表等,从而得到动态链接器本身的重定位入口,先将它们全部重定位。从这一步开始,动态链接器代码中才可以开始使用白己的全局变量和静态变量。

实际上在动态链接器的自举代码中,除了不可以使用全局变量和静态变量之外,甚至不能调用函数,即动态链接器本身的函数也不能调用。其实我们在前面分析地址无关代码时已经提到过,实际上使用 PIC 模式编译的共享对象,对于模块内部的函数调用也是采用跟模块外部函数调用一样的方式,即使用 GOT/PLT 的方式,所以在 GOT/PLT 没有被重定位之前,自举代码不可以使用任何全局变量,也不可以调用函数。

装载共享对象

完成基本自举以后,动态链接器将可执行文件和链接器本身的符号表都合并到一个符号表当中,我们可以称它为全局符号表(Global Symbol Table)。然后链接器开始寻找可执行文件所依赖的共享对象。由此,链接器可以列出可执行文件所需要的所有共享对象,并将这些共享对象的名字放入到一个装载集合中。然后链接器开始从集合里取一个所需要的共享对象的名字,找到相应的文件后打开该文件,读取相应的 ELF 文件头和“.dynamic”段,然后将它相应的代码段和数据段映射到进程空间中。如果这个 ELF 共享对象还依赖于其他共享对象,那么将所依赖的共享对象的名字放到装载集合中。如此循环直到所有依赖的共享对象都被装载进来为止。

符号的优先级

在动态链接器按照各个模块之间的依赖关系,对它们进行装载并且将它们的符号并入到全局符号表时,会不会有这么一种情况发生,那就是有可能两个不同的模块定义了同一个符号?一个共享对象里面的全局符号被另一个共享对象的同名全局符号覆盖的现象被称为共享对象全局符号介入(Global Symbol Interpose)。

关于全局符号介入这个问题,实际上 Linux 下的动态链接器是这样处理的:它定义了一个规则,那就是当一个符号需要被加入全局符号表时,如果相同的符号名已经存在,则后加入的符号被忽略。

全局符号介入与地址无关代码

前面介绍地址无关代码时,对于第一类模块内部调用或跳转的处理时,我们简单地将其当作是相对地址调用/跳转。但实际上这个问题比想象中要复杂,结合全局符号介入,关于调用方式的分类的解释会更加清楚。还是拿前面“pic.c”的例子来看,由于可能存在全局符号介入的问题,foo() 函数对于 bar() 的调用不能够采用第一类模块内部调用的方法,因为一旦 bar 函数由于全局符号介入被其他模块中的同名函数覆盖,那么 foo 如果采用相对地址调用的话,那个相对地址部分就需要重定位,这又与共享对象的地址无关性矛盾。所以对于 bar() 函数的调用,编译器只能采用第三种,即当作模块外部符号处理,bar() 函数被覆盖,动态链接器只需要重定位“.got.plt”,不影响共享对象的代码段。 为了提高模块内部函数调用的效率,有一个办法是把 bar() 函数变成编译单元私有函数,即使用“static”关键字定义 bar() 函数,这种情况下,编译器要确定 bar() 函数不被其他模块覆盖,就可以使用第一类的方法,即模块内部调用指令,可以加快函数的调用速度。

重定位和初始化

当上面的步骤完成之后,链接器开始重新遍历可执行文件和每个共享对象的重定位表,将它们的 GOT/PLT 中的每个需要重定位的位置进行修正。此时动态链接器已经拥有了进程的全局符号表,这里就不再重复介绍了。

重定位完成之后,如果某个共享对象有“.init”段,那么动态链接器会执行“.init”段中的代码,用以实现共享对象特有的初始化过程,比如最常见的,共享对象中的 C++的全局/静态对象的构造就需要通过“.init”来初始化。相应地,共享对象中还可能有“.finit”段,当进程退出时会执行“.finit'”段中的代码,可以用来实现类似 C++全局对象析构之类的操作。

如果进程的可执行文件也有“.init”段,那么动态链接器不会执行它,因为可执行文件中的“.init”段和“.finit'”段由程序初始化部分代码负责执行。

当完成了重定位和初始化之后,所有的准备工作就宣告完成了,所需要的共享对象也都已经装载并且链接完成了,这时候动态链接器将进程的控制权转交给程序的入口并且开始执行。

Linux 动态链接器实现

关于动态链接器的实现的几个问题还是很值得思考的:

  • 动态链接器本身是动态链接的还是静态链接的? 动态链接器本身应该是静态链接的,它不能依赖于其他共享对象,动态链接器本身是用来帮助其他 ELF 文件解决共享对象依赖问题的,如果它也依赖于其他共享对象,那么谁来帮它解决依赖问题?所以它本身必须不依赖于其他共享对象。这一点可以使用 ldd 来判断:

    1
    2
    ldd /lib/ld-linux.so.2
    statically linked

  • 动态链接器本身必须是 PIC 的吗? 是不是 PIC 对于动态链接器来说并不关键,动态链接器可以是 PIC 的也可以不是,但往往使用 PIC 会更加简单一些。一方面,如果不是 PIC 的话,会使得代码段无法共享,浪费内存;另一方面也会使 ld.so 本身初始化更加复杂,因为自举时还需要对代码段进行重定位。实际上的 ld-linux.so.2 是 PIC 的

  • 动态链接器可以被当作可执行文件运行,那么他的装载地址应该是多少? ld.so 的装载地址跟一般的共享对象没区别,即为 0x00000000。这个装载地址是一个无效的装载地址,作为一个共享库,内核在装载它时会为其选择一个合适的装载地址

显式运行时链接

显式运行时链接(Explicit Run-time Linking)有时候也叫做运行时加载。也就是让程序自己在运行时控制加载指定的模块,并且可以将其卸载。这种共享对象往往被叫做动态装载库(Dynamic Loading Library),其实本质上它跟一般的共享对象没什么区别,只是程序开发者使用它的角度不同。

参考文献

《程序员的自我修养》