跳转至

动态链接

最近在读《程序员的自我修养:链接,装载与库》,其实这本书跟 Android 开发的联系还挺紧密的,无论是 NDK 开发,或者是性能优化中一些常用的 Native Hoook 手段,都需要了解一些链接,装载相关的知识点。本文为读书笔记。

为什么要动态链接

前面我们介绍了静态链接,那么在已经有了静态链接的情况下,我们为什么还需要动态链接?

空间浪费问题

静态链接会将依赖库的内容直接打包到可执行文件中,这很明显会带来来得的空间浪费问题。

想象一下每个程序内部除了都保留着 printf() 函数、 scanf() 函数、 strlen() 等这样的公用库函数,还有数量相当可观的其他库函数及它们所需要的辅助数据结构。在现在的Linux系统中,一个普通程序会使用到的C语言静态库至少在1 MB以上,那么,如果我们的机器中运行着100个这样的程序,就要浪费近100 MB的内存。

版本更新问题

静态链接的另一个问题是给程序的更新,部署和发布带来很多麻烦。一旦程序中有任何模块更新,整个程序就要重新链接、发布给用户。比如一个程序有20个模块,每个模块1 MB,那么每次更新任何一个模块,用户就得重新获取这个20 MB的程序。

因此如果程序都使用静态链接,那么通过网络来更新程序将会非常不便,因为一旦程序任何位置的一个小改动,都会导致整个程序重新下载

动态链接是什么?

要解决空间浪费和更新困难这两个问题最简单的办法就是把程序的模块相互分割开来,形成独立的文件,而不再将它们静态地链接在一起。简单地讲,就是不对那些组成程序的目标文件进行链接,等到程序要运行时才进行链接。也就是说,把链接这个过程推迟到了运行时再进行,这就是动态链接(Dynamic Linking)的基本思想。

比如我们有 Program1 与 Program2 两个程序,它们都依赖于 lib.o。当开始加载 Program1 时,系统发现其依赖 lib.o,就会将 lib.o 加载到内存中,然后开始链接工作。这个链接过程与静态链接非常相似,包括符号解析,地址重定位等。完成这些步骤后,系统把控制权交给 Program1 的程序入口处,程序开始执行。此时,当我们开始运行 Program2 时,不需要再次加载 lib.o,因为内存中已经有一份了,系统要做的只是将 Program2 与 lib.o 链接起来。

很明显,动态链接的方式解决了共享的目标文件多个副本浪费磁盘和内存空间的问题,磁盘和内存中只存在一份 lib.o,而不是两份。另外在内存中共享一个目标文件也可以减少物理页面的换入换出,同时增加CPU缓存的命中率,因为不同进程间的数据和指令访问都集中在了同一个共享模块上。

动态链接方案也可以使程序的升级变得更加容易,当我们要升级程序库或程序共享的某个模块时,理论上只要简单地将旧的目标文件覆盖掉,而无须将所有的程序再重新链接一遍。当程序下一次运行的时候,新版本的目标文件会被自动装载到内存并且链接起来,程序就完成了升级的目标。

动态链接将程序链接的过程由装载前推迟到了装载时,这当然会带来一些性能上的损失,但也可以通过延迟绑定(Lazy Binding)等方式进行优化,使性能损失达到最小。据估算,动态链接与静态链接相比,性能损失大约在5%以下。当然经过实践的证明,这点性能损失用来换取程序在空间上的节省和程序构建和升级时的灵活性,是相当值得的。

动态链接简单示例

接下来我们通过一个例子来看下动态链接的过程,它们的源代码如下所示

/** 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);
    sleep(-1);
}

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

void foobar(int i);

#endif

程序很简单,两个程序的主要模块Program1.cProgram2.c分别调用了Lib.c里面的foobar()函数,传进去一个数字,foobar()函数的作用就是打印这个数字

接下来我们开始编译链接这些代码

gcc -fPIC -shared -o Lib.so Lib.c // -shared 表示生成动态共享库,-fPIC 后面再解释
gcc -o Program1 Program1.c ./Lib.so // 编译链接生成 Program1
gcc -o Program2 Program2.c ./Lib.so // 编译链接生成 Program2

通过以上步骤,我们就得到了一个动态共享库Lib.so与两个程序:Program1Program2。从Program1的角度看,整个编译链接过程如下图所示:

动态链接地址空间分布

对于静态链接的可执行文件来说,整个进程只有一个文件要被映射,那就是可执行文件本身,我们在前面的章节已经介绍了静态链接下的进程虚拟地址空间的分布。但是对于动态链接来说,除了可执行文件本身之外,还有它所依赖的共享目标文件。那么这种情况下,进程的地址空间分布又会怎样呢?

我们运行程序,然后查看进程 maps 文件获取进程的虚拟地址空间分布

$ ./Program1 &
[3] 11067
Printing from Lib.so 1

 $ cat /proc/11067/maps
55b2e50da000-55b2e50db000 r--p 00000000 07:03 126060                     /workspaces/programmer-training/Program1
55b2e50db000-55b2e50dc000 r-xp 00001000 07:03 126060                     /workspaces/programmer-training/Program1
55b2e50dc000-55b2e50dd000 r--p 00002000 07:03 126060                     /workspaces/programmer-training/Program1
55b2e50dd000-55b2e50de000 r--p 00002000 07:03 126060                     /workspaces/programmer-training/Program1
55b2e50de000-55b2e50df000 rw-p 00003000 07:03 126060                     /workspaces/programmer-training/Program1
55b2e558d000-55b2e55ae000 rw-p 00000000 00:00 0                          [heap]
7f7d4f34a000-7f7d4f34d000 rw-p 00000000 00:00 0 
7f7d4f34d000-7f7d4f36f000 r--p 00000000 00:2e 525226                     /usr/lib/x86_64-linux-gnu/libc-2.31.so
7f7d4f36f000-7f7d4f4e7000 r-xp 00022000 00:2e 525226                     /usr/lib/x86_64-linux-gnu/libc-2.31.so
7f7d4f4e7000-7f7d4f535000 r--p 0019a000 00:2e 525226                     /usr/lib/x86_64-linux-gnu/libc-2.31.so
7f7d4f535000-7f7d4f539000 r--p 001e7000 00:2e 525226                     /usr/lib/x86_64-linux-gnu/libc-2.31.so
7f7d4f539000-7f7d4f53b000 rw-p 001eb000 00:2e 525226                     /usr/lib/x86_64-linux-gnu/libc-2.31.so
7f7d4f53b000-7f7d4f53f000 rw-p 00000000 00:00 0 
7f7d4f549000-7f7d4f54a000 r--p 00000000 07:03 126046                     /workspaces/programmer-training/Lib.so
7f7d4f54a000-7f7d4f54b000 r-xp 00001000 07:03 126046                     /workspaces/programmer-training/Lib.so
7f7d4f54b000-7f7d4f54c000 r--p 00002000 07:03 126046                     /workspaces/programmer-training/Lib.so
7f7d4f54c000-7f7d4f54d000 r--p 00002000 07:03 126046                     /workspaces/programmer-training/Lib.so
7f7d4f54d000-7f7d4f54e000 rw-p 00003000 07:03 126046                     /workspaces/programmer-training/Lib.so
7f7d4f54e000-7f7d4f550000 rw-p 00000000 00:00 0 
7f7d4f550000-7f7d4f551000 r--p 00000000 00:2e 525204                     /usr/lib/x86_64-linux-gnu/ld-2.31.so
7f7d4f551000-7f7d4f574000 r-xp 00001000 00:2e 525204                     /usr/lib/x86_64-linux-gnu/ld-2.31.so
7f7d4f574000-7f7d4f57c000 r--p 00024000 00:2e 525204                     /usr/lib/x86_64-linux-gnu/ld-2.31.so
7f7d4f57d000-7f7d4f57e000 r--p 0002c000 00:2e 525204                     /usr/lib/x86_64-linux-gnu/ld-2.31.so
7f7d4f57e000-7f7d4f57f000 rw-p 0002d000 00:2e 525204                     /usr/lib/x86_64-linux-gnu/ld-2.31.so
7f7d4f57f000-7f7d4f580000 rw-p 00000000 00:00 0 
7ffc08748000-7ffc0876a000 rw-p 00000000 00:00 0                          [stack]
7ffc087b5000-7ffc087b9000 r--p 00000000 00:00 0                          [vvar]
7ffc087b9000-7ffc087bb000 r-xp 00000000 00:00 0                          [vdso]
ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0                  [vsyscall]

可以看到,进程虚拟地址空间中多出了几个文件映射

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

同时我们也可以通过 readelf 命令查看 Lib.so 的装载属性

$ readelf -l Lib.so 

Elf file type is DYN (Shared object file)
Entry point 0x1080
There are 11 program headers, starting at offset 64

Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  LOAD           0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000560 0x0000000000000560  R      0x1000
  LOAD           0x0000000000001000 0x0000000000001000 0x0000000000001000
                 0x000000000000017d 0x000000000000017d  R E    0x1000
  LOAD           0x0000000000002000 0x0000000000002000 0x0000000000002000
                 0x00000000000000dc 0x00000000000000dc  R      0x1000
  LOAD           0x0000000000002e10 0x0000000000003e10 0x0000000000003e10
                 0x0000000000000220 0x0000000000000228  RW     0x1000
  DYNAMIC        0x0000000000002e20 0x0000000000003e20 0x0000000000003e20
                 0x00000000000001c0 0x00000000000001c0  RW     0x8

 Section to Segment mapping:
  Segment Sections...
   00     .note.gnu.property .note.gnu.build-id .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt 
   01     .init .plt .plt.got .plt.sec .text .fini 
   02     .rodata .eh_frame_hdr .eh_frame 
   03     .init_array .fini_array .dynamic .got .got.plt .data .bss 

除了文件的类型与普通程序不同以外,其他几乎与普通程序一样。还有有一点比较不同的是,动态链接模块的装载地址是从地址 0x00000000 开始的。我们知道这个地址是无效地址,并且从上面的进程虚拟空间分布看到,Lib.so 的最终装载地址并不是0x00000000。

从这一点我们可以推断,共享对象的最终装载地址在编译时是不确定的,而是在装载时,装载器根据当前地址空间的空闲情况,动态分配一块足够大小的虚拟地址空间给相应的共享对象。

那么问题来了,为什么要这样做,为什么不将每个共享对象在进程中的地址固定?

地址无关代码

固定装载地址的问题

为了实现动态链接,我们首先会遇到的问题就是共享对象地址的冲突问题。很明显,在动态链接的情况下,如果不同的模块目标装载地址都一样是不行的

而如果通过手动指定各个模块的地址,比如把 0x1000 到 0x2000 分配给模块 A,把地址 0x2000 到 0x3000 分配给模块 B。但是如果同一个模块被多个程序使用,或者多个模块被多个程序使用,这种手动指定的方式就非常繁琐和容易冲突了,在大型系统中几乎是不可行的。

很明显,固定装载地址在动态链接中是行不通的。为了解决这个模块装载地址固定的问题,我们设想是否可以让共享对象在任意地址加载?这个问题另一种表述方法就是:共享对象在编译时不能假设自己在进程虚拟地址空间中的位置

装载时重定位

为了使共享对象支持在任意地址装载,一个很容易想到的思路就是:共享对象中的符号在链接时不进行重定位,而把这一步推迟到装载时再完成。一旦模块装载地址确定,即目标地址确定,那么系统就对程序中所有的绝对地址引用进行重定位。

比如函数 foobar 相对于代码段的起始地址是 0x100,当模块被装载到 0x10000000 时,我们假设代码段位于模块的最开始,即代码段的装载地址也是 0x10000000,那么我们就可以确定 foobar 的地址为 0x10000100。这时候,系统遍历模块中的重定位表,把所有对 foobar 的地址引用都重定位至 0x10000100。

通过这种方式可以解决共享对象的地址在装载之后才能确定的问题,但是装载时重定位的方法并不适合用来解决上面的共享对象中所存在的问题。

这是因为装载时重定位方法需要修改指令,所以无法做到同一份指令被多个进程共享,因为指令被重定位后对于每个进程来讲是不同的。因此装载时重定位方式会指令部分也存在多个副本,会导致一定的内存浪费。

Linux 和 GCC 支持这种装载时重定位的方法,我们前面在产生共享对象时,使用了两个GCC参数-shared-fPIC,如果只使用-shared,那么输出的共享对象就是使用装载时重定位的方法。

地址无关代码

上面说到,装载时重定位方式指令部分无法在多个进程间共享,会失去动态链接节省内存的一大优势。因此我们还需要有一种更好的方法解决共享对象指令中对绝对地址的重定位问题。

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

接下来我们分析一下模块中各种类型的地址引用方式,根据是否为跨模块可以分成两类:模块内部引用和模块外部引用;同时区分数据与指令又可以分为指令引用和数据访问,这样就可以分为以下四种情况:

  • 第一种是模块内部的函数调用、跳转等。
  • 第二种是模块内部的数据访问,比如模块中定义的全局变量、静态变量。
  • 第三种是模块外部的函数调用、跳转等。
  • 第四种是模块外部的数据访问,比如其他模块中定义的全局变量。
#include "pic.h"
static int a;
extern int b;
extern void ext();

void bar()
{
    a = 1; // 模块内数据访问
    b = 2; // 模块间数据访问
}

void main()
{
    bar(); // 模块内指令调用
    ext(); // 模块间指令调用
}

类型一 模块内部调用或跳转

首先来看下第一种类型,模块内部调用,因为被调用的函数与调用者都处于同一个模块,它们之间的相对位置是固定的,所以这种情况比较简单。对于现代的系统来讲,模块内部的跳转、函数调用都可以是相对地址调用,或者是基于寄存器的相对调用,所以对于这种指令是不需要重定位的。

模块内部调用的方式其实与静态链接指令修正方式比较类似,如下图所示:

这条指令的第一个字节表示调用的指令码,后4个字节表示目的地址相对于当前指令的下一条指令的偏移。

比如上面的例子中main函数调用了bar函数,只要mainbar的相对地址不变,这条指令是地址无关的。即无论模块被装载到哪个位置,这条指令都是有效的。

类型二 模块内部数据访问

接着来看看第二种类型,模块内部的数据访问。很明显,指令中不能直接包含数据的绝对地址,那么唯一的办法就是相对寻址。我们知道,一个模块前面一般是若干个页的代码,后面紧跟着若干个页的数据,这些页之间的相对位置是固定的,也就是说,任何一条指令与它需要访问的模块内部数据之间的相对位置是固定的,那么只需要相对于当前指令加上固定的偏移量就可以访问模块内部数据了

类型三 模块间数据访问

模块间的数据访问比模块内部稍微麻烦一点,因为模块间的数据访问目标地址要等到装载时才决定,比如上面例子中的变量b,它被定义在其他模块中,并且该地址在装载时才能确定。

我们前面提到要使得代码地址无关,基本的思想就是把跟地址相关的部分放到数据段里面,很明显,这些其他模块的全局变量的地址是跟模块装载地址有关的。ELF的做法是在数据段里面建立一个指向这些变量的指针数组,也被称为全局偏移表(Global Offset Table,GOT),当代码需要引用该全局变量时,可以通过GOT中相对应的项间接引用,它的基本机制如图所示:

如上所示,当指令中需要访问变量 b 时,程序会先找到 GOT,然后根据 GOT 中变量所对应的项找到变量的目标地址。链接器在装载模块的时候会查找每个变量所在的地址,然后填充 GOT 中的各个项,以确保每个指针所指向的地址正确。由于 GOT 本身是放在数据段的,所以它可以在模块装载时被修改,并且每个进程都可以有独立的副本,相互不受影响。

类型四 模块间调用、跳转

对于模块间调用和跳转,我们也可以采用上面类型三的方法来解决。与上面的类型有所不同的是,GOT 中相应的项保存的是目标函数的地址,当模块需要调用目标函数时,可以通过GOT 中的项进行间接跳转,基本的原理如图所示:

小结

总得来说,模块内部数据与指令通过相对地址访问或跳转,模块间数据与指令通过 GOT 表间接访问或跳转

延迟绑定(PLT)

动态链接相比静态链接要更加灵活,但是会牺牲一部分性能。据统计 ELF 程序在静态链接下要比动态库稍微快点,大约为1%~5%。

动态链接比静态链接慢的原因主要在于:

  1. 动态链接下对于全局和静态的数据访问都要进行复杂的 GOT 定位,然后间接寻址,对于模块间的调用也要先定位GOT,然后再进行间接跳转
  2. 动态链接的链接工作在运行时完成,即程序开始执行时,动态链接器会寻找并装载所需要的共享对象,然后进行符号查找地址重定位等工作

下面我们介绍一些优化动态链接性能的方法

延迟绑定

在动态链接下,程序模块之间包含了大量的函数引用,所以在程序开始执行前,动态链接会耗费不少时间用于解决模块之间的函数引用的符号查找以及重定位

不过可以想象,在一个程序运行过程中,可能很多函数在程序执行完时都不会被用到,比如一些错误处理函数或者是一些用户很少用到的功能模块等,如果一开始就把所有函数都链接好实际上是一种浪费。

所有一种最直接的优化思路就是延迟绑定(Lazy Binding):基本的思想就是当函数第一次被用到时才进行绑定(符号查找、重定位等),如果没有用到则不进行绑定。所以程序开始执行时,模块间的函数调用都没有进行绑定,而是需要用到时才由动态链接器来负责绑定。这样的做法可以大大加快程序的启动速度,特别有利于一些有大量函数引用和大量模块的程序。

PLT 基本原理

ELF 使用 PLT(Procedure Linkage Table)来实现延迟绑定,当我们调用某个外部模块的函数时,如果按照通常的做法应该是通过 GOT 中相应的项进行间接跳转。PLT 为了实现延迟绑定,在这个过程中间又增加了一层间接跳转。调用函数并不直接通过 GOT 跳转,而是通过一个叫作 PLT 项的结构来进行跳转。每个外部函数在 PLT 中都有一个相应的项,比如我们要访问共享对象中的 bar() 函数, bar() 函数在 PLT 中的项的地址我们称之为 bar@plt。让我们来看看 bar@plt 的实现:

bar@plt: 
jmp *(bar@GOT) 
push n 
push moduleID 
jump _dl_runtime_resolve 
  1. bar@plt 的第一条指令是一条通过 GOT 间接跳转的指令。bar@GOT 表示 GOT 中保存 bar() 这个函数相应的项。如果链接器在初始化阶段已经初始化该项,并且将 bar() 的地址填入该项,那么这个跳转指令就会跳转到 bar(), 实现函数正确调用。但是为了实现延迟绑定,链接器在初始化阶段并没有将 bar() 的地址填入到该项,那么这条指令相当于没有进行任何操作,继续下面的指令。
  2. 第二条指令将一个数字 n 压入堆栈中,这个数字是 bar 这个符号引用在重定位表“.rel.plt”中的下标。
  3. 接着又是一条push指令将模块的ID压入到堆栈
  4. 最后跳转到 _dl_runtime_resolve:这条指令先将所需要决议符号的下标压入堆栈,再将模块 ID 压入堆栈,然后调用动态链接器的 _dl_runtime_resolve() 函数来完成符号解析和重定位工作 。_dl_runtime_resolve() 在进行一系列工作以后将 bar() 的真正地址填入到 bar@GOT 中
  5. 一旦 bar() 这个函数被解析完毕,当我们再次调用 bar@plt 时,第一条jmp指令就能够跳转到真正的 bar() 函数中, bar() 函数返回的时候会根据堆栈里面保存的 EIP 直接返回到调用者,而不会再继续执行后面的指令

PLT 具体实现

上面我们描述了 PLT 的基本原理,PLT 真正的实现要比它的结构稍微复杂一些。ELF 将 GOT 拆分成了两个表叫做“.got”和“.got.plt”。其中“.got”用来保存全局变量引用的地址,“.got.plt”用来保存函数引用的地址

“.got.plt”还有一个特殊的地方是它的前三项是有特殊意义的,分别含义如下:

  1. 第一项保存的是“.dynamic”段的地址,这个段描述了本模块动态链接相关的信息
  2. 第二项保存的是本模块的ID。
  3. 第三项保存的是 _dl_runtime_resolve() 的地址。

其中第二项和第三项由动态链接器在装载共享模块的时候负责将它们初始化。“.got.plt”的其余项分别对应每个外部函数的引用。PLT的结构也与我们示例中的PLT稍有不同,为了减少代码的重复,ELF把上面例子中的最后两条指令放到PLT中的第一项。并且规定每一项的长度是 16 个字节,刚好用来存放 3 条指令

实际的 PLT 基本结构如下所示:

PLT0: 
push *(GOT + 4) 
jump *(GOT + 8) 

... 

bar@plt: 
jmp *(bar@GOT) 
push n 
jump PLT0 ”

动态链接相关结构

动态链接情况下,可执行文件的装载与静态链接情况基本一样。首先操作系统会读取可执行文件的头部,检查文件的合法性,然后从头部中的“Program Header”中读取每个“Segment”的虚拟地址、文件地址和属性,并将它们映射到进程虚拟空间的相应位置。在静态链接情况下,操作系统接着就可以把控制权转交给可执行文件的入口地址,然后程序开始执行,一切看起来非常直观。

但是在动态链接情况下,操作系统还不能在装载完可执行文件之后就把控制权交给可执行文件,因为我们知道可执行文件依赖于很多共享对象。这时候,可执行文件里对于很多外部符号的引用还处于无效地址的状态,即还没有跟相应的共享对象中的实际位置链接起来。所以在映射完可执行文件之后,操作系统会先启动一个动态链接器(Dynamic Linker)。

在 Linux 下,动态链接器ld.so实际上是一个共享对象,操作系统同样通过映射的方式将它加载到进程的地址空间中。操作系统在加载完动态链接器之后,就将控制权交给动态链接器的入口地址(与可执行文件一样,共享对象也有入口地址)。当动态链接器得到控制权之后,它开始执行一系列自身的初始化操作,然后根据当前的环境参数,开始对可执行文件进行动态链接工作。当所有动态链接工作完成以后,动态链接器会将控制权转交到可执行文件的入口地址,程序开始正式执行.

.intrp 段

那么动态链接器的位置在哪儿?由什么决定的呢?

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

我们可以使用 objdump 工具来查看“.interp”内容”

$ objdump -s PicTest

PicTest:     file format elf64-x86-64

Contents of section .interp:
 0318 2f6c6962 36342f6c 642d6c69 6e75782d  /lib64/ld-linux-
 0328 7838362d 36342e73 6f2e3200           x86-64.so.2.    

“.interp”的内容很简单,里面保存的就是一个字符串,这个字符串就是可执行文件所需要的动态链接器的路径

.dynamic 段

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

使用 readelf 工具可以查看“.dynamic”段的内容

$ readelf -d pic.so

Dynamic section at offset 0x2e18 contains 24 entries:
  Tag        Type                         Name/Value
 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]
 0x000000000000000c (INIT)               0x1000
 0x000000000000000d (FINI)               0x1140
 0x0000000000000019 (INIT_ARRAY)         0x3e08
 0x000000000000001b (INIT_ARRAYSZ)       8 (bytes)
 0x000000000000001a (FINI_ARRAY)         0x3e10
 0x000000000000001c (FINI_ARRAYSZ)       8 (bytes)
 0x000000006ffffef5 (GNU_HASH)           0x2f0
 0x0000000000000005 (STRTAB)             0x3d8
 0x0000000000000006 (SYMTAB)             0x318
 0x000000000000000a (STRSZ)              120 (bytes)
 0x000000000000000b (SYMENT)             24 (bytes)
 0x0000000000000003 (PLTGOT)             0x4000
 0x0000000000000002 (PLTRELSZ)           24 (bytes)
 0x0000000000000014 (PLTREL)             RELA
 0x0000000000000017 (JMPREL)             0x540
 0x0000000000000007 (RELA)               0x480
 0x0000000000000008 (RELASZ)             192 (bytes)
 0x0000000000000009 (RELAENT)            24 (bytes)
 0x000000006ffffffe (VERNEED)            0x460
 0x000000006fffffff (VERNEEDNUM)         1
 0x000000006ffffff0 (VERSYM)             0x450
 0x000000006ffffff9 (RELACOUNT)          3

另外Linux还提供了一个命令用来查看一个程序主模块或一个共享库依赖于哪些共享库:

$ ldd PicTest
    linux-vdso.so.1 (0x00007ffcfc9bd000)
    ./pic.so (0x00007f4fc64b5000)
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f4fc62b9000)
    /lib64/ld-linux-x86-64.so.2 (0x00007f4fc64c1000)

动态符号表

为了完成动态链接,最关键的还是所依赖的符号和相关文件的信息。我们知道在静态链接中,有一个专门的段叫做符号表“.symtab”(Symbol Table),里面保存了所有关于该目标文件的符号的定义和引用。动态链接的符号表示实际上它跟静态链接十分相似。

为了表示动态链接这些模块之间的符号导入导出关系,ELF专门有一个叫做动态符号表(Dynamic Symbol Table)的段用来保存这些信息,这个段的段名通常叫做“.dynsym”(Dynamic Symbol)。与“.symtab”不同的是,“.dynsym”只保存了与动态链接相关的符号,对于那些模块内部的符号则不保存。很多时候动态链接的模块同时拥有“.dynsym”和“.symtab”两个表,“.symtab”中往往保存了所有符号,包括“.dynsym”中的符号。

我们可以用 readelf 工具来查看 ELF 文件的动态符号表

$ readelf -s pic.so

Symbol table '.dynsym' contains 8 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND _ITM_deregisterTMCloneTab
     2: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND printf@GLIBC_2.2.5 (2)
     3: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND __gmon_start__
     4: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND _ITM_registerTMCloneTable
     5: 0000000000000000     0 FUNC    WEAK   DEFAULT  UND __cxa_finalize@GLIBC_2.2.5 (2)
     6: 0000000000001119    39 FUNC    GLOBAL DEFAULT   14 ext
     7: 0000000000004028     4 OBJECT  GLOBAL DEFAULT   24 b

动态链接重定位表

共享对象在装载时需要重定位,动态链接的文件中,代码段与数据段的重定位表分别叫做“.rel.dyn”和“.rel.plt”,它们分别相当于 “.rel.text”和“.rel.data”。

“.rel.dyn”实际上是对数据引用的修正,它所修正的位置位于“.got”以及数据段;而“.rel.plt”是对函数引用的修正,它所修正的位置位于“.got.plt”。我们可以使用readelf 来查看一个动态链接的文件的重定位表:

$ readelf -r Lib.so

Relocation section '.rela.dyn' at offset 0x488 contains 7 entries:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
000000003e10  000000000008 R_X86_64_RELATIVE                    1130
000000003e18  000000000008 R_X86_64_RELATIVE                    10f0
000000004028  000000000008 R_X86_64_RELATIVE                    4028
000000003fe0  000100000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_deregisterTMClone + 0
000000003fe8  000300000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0
000000003ff0  000400000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_registerTMCloneTa + 0
000000003ff8  000600000006 R_X86_64_GLOB_DAT 0000000000000000 __cxa_finalize@GLIBC_2.2.5 + 0

Relocation section '.rela.plt' at offset 0x530 contains 2 entries:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
000000004018  000200000007 R_X86_64_JUMP_SLO 0000000000000000 printf@GLIBC_2.2.5 + 0
000000004020  000500000007 R_X86_64_JUMP_SLO 0000000000000000 sleep@GLIBC_2.2.5 + 0

在静态链接中我们已经碰到过两种类型的重定位入口 R_386_32 和 R_386_PC32,这里可以看到几种新的重定位入口类型:R_X86_64_RELATIVE、R_X86_64_GLOB_DAT和R_X86_64_JUMP_SLO

  • R_X86_64_JUMP_SLO: 该类型表示被修正的位置只需要直接填入符号的地址即可,它所修正的位置位于“.got.plt”
  • R_X86_64_GLOB_DAT: 与 R_X86_64_JUMP_SLO 类似,表示被修正的位置只需要直接填入符号的地址即可,但是它所修正的位置位于“.got”
  • R_X86_64_RELATIVE: 这种类型的重定位实际上就是基址重置,适用于共享对象的数据段这种无法做到地址无关的符号,它可能会包含绝对地址的引用,对于这种绝对地址的引用,我们必须在装载时将其重定位。

动态链接的步骤和实现

动态链接的步骤基本上分为3步:先是启动动态链接器本身,然后装载所有需要的共享对象,最后是重定位和初始化。

动态链接器自举

我们知道动态链接器本身也是一个共享对象,但是事实上它有一些特殊性。对于普通共享对象文件来说,它的重定位工作由动态链接器来完成;它也可以依赖于其他共享对象,其中的被依赖的共享对象由动态链接器负责链接和装载。可是对于动态链接器本身来说,它的重定位工作由谁来完成?它是否可以依赖于其他的共享对象?

这就要求动态链接器做到自举:动态链接器本身不可以依赖于其他任何共享对象;其次是动态链接器本身所需要的全局和静态变量的重定位工作由它本身完成。

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

装载共享对象

  1. 完成基本自举以后,动态链接器将可执行文件和链接器本身的符号表都合并到一个符号表当中,我们可以称它为全局符号表(Global Symbol Table)。
  2. “.dynamic”段中,有一种类型的入口是DT_NEEDED,它所指出的是该可执行文件(或共享对象)所依赖的共享对象,由此,链接器可以列出可执行文件所需要的所有共享对象,并将这些共享对象的名字放入到一个装载集合中
  3. 链接器开始从集合里取出一个所需要的共享对象的名字,找到相应的文件后打开该文件,读取相应的ELF文件头和“.dynamic”段,然后将它相应的代码段和数据段映射到进程空间中。
  4. 如果这个ELF共享对象还依赖于其他共享对象,那么将所依赖的共享对象的名字放到装载集合中。如此循环直到所有依赖的共享对象都被装载进来为止。
  5. 这个装载顺序类似图的遍历过程,链接器可能会使用深度优先或者广度优先或者其他的顺序来遍历整个图
  6. 当一个新的共享对象被装载进来的时候,它的符号表会被合并到全局符号表中,所以当所有的共享对象都被装载进来的时候,全局符号表里面将包含进程中所有的动态链接所需要的符号

符号的优化级

在动态链接器按照各个模块之间的依赖关系,对它们进行装载并且将它们的符号并入到全局符号表时,会不会有这么一种情况发生,那就是有可能两个不同的模块定义了同一个符号?

“/* a1.c */ 

#include <stdio.h> 

void a() 
{ 
   printf("a1.c\n"); 
} 

/* a2.c */ 
#include <stdio.h> 
void a() 
{ 
   printf("a2.c\n"); 

}

/* b1.c */ 
void a(); 

void b1() 
{ 
   a(); 
} 

/* b2.c */ 
void a(); 

void b2() 
{ 
   a(); 
}

可以看到 a1.c 和 a2.c 中都定义了名字为“a”的函数。那么由于 b1.c 和 b2.c 都用到了外部函数“a”,我们假设 b1.so 依赖于 a1.so,b2.so 依赖于 a2.so,将 b1.so 与 a1.so 进行链接,b2.so 与 a2.so 进行链接。

那么当有程序同时使用 b1.c 中的函数 b1 和 b2.c 中的函数 b2 会怎么样呢?

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

由于存在这种重名符号被直接忽略的问题,当程序使用大量共享对象时应该非常小心符号的重名问题

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

前面介绍地址无关代码时,对于第一类模块内部调用或跳转的处理时,我们简单地将其当作是相对地址调用/跳转。

但实际上这个问题比想象中要复杂,还是拿前面“pic.c”的例子来看,由于可能存在全局符号介入的问题,foo函数对于bar的调用不能够采用第一类模块内部调用的方法,因为一旦bar函数由于全局符号介入被其他模块中的同名函数覆盖,那么foo如果采用相对地址调用的话,那个相对地址部分就需要重定位,这又与共享对象的地址无关性矛盾。

所以对于bar()函数的调用,编译器只能采用第三种,即当作模块外部符号处理,bar()函数被覆盖,动态链接器只需要重定位“.got.plt”,不影响共享对象的代码段。

因此,实际上使用PIC模式编译的共享对象,对于模块内部的函数调用也是采用跟模块外部函数调用一样的方式,即使用 GOT/PLT 的方式,所以在 GOT/PLT 没有被重定位之前,自举代码不可以使用任何全局变量,也不可以调用函数。

重定位与初始化

当上面的步骤完成之后,链接器开始重新遍历可执行文件和每个共享对象的重定位表,将它们的 GOT/PLT 中的每个需要重定位的位置进行修正。因为此时动态链接器已经拥有了进程的全局符号表,所以这个修正过程也显得比较容易,跟我们前面提到的地址重定位的原理基本相同。

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

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

运行时加载

支持动态链接的系统往往都支持一种更加灵活的模块加载方式,叫做显式运行时链接(Explicit Run-time Linking),有时候也叫做运行时加载。也就是让程序自己在运行时控制加载指定的模块,并且可以在不需要该模块时将其卸载。

这种运行时加载使得程序的模块组织变得很灵活,当程序需要用到某个插件或者驱动的时候,才将相应的模块装载进来,而不需要从一开始就将他们全部装载进来,从而减少了程序启动时间和内存使用。并且程序可以在运行的时候重新加载某个模块,这样使得程序本身不必重新启动而实现模块的增加、删除、更新等,这对于很多需要长期运行的程序来说是很大的优势。

在 Linux 中,动态库可以通过动态链接器提供的一系列 API 运行时加载,具体地讲共有4个函数:打开动态库(dlopen)、查找符号(dlsym)、错误处理(dlerror)以及关闭动态库(dlclose)

在 Android 大型应用中,经常会使用上述 dl 函数打开系统动态库做一些 hook 操作

总结

本文主要介绍了为什么要使用动态链接,动态链接中的装载时重定位与地址无关代码技术的使用,Elf 文件中动态链接的结构,以及动态链接的步骤和实现等内容

动态链接中的 .got,.got.plt 表,运行时加载等内容是掌握 Native Hook 技术的基础,在 Android 性能优化中非常常用。


最后更新: November 17, 2024