为什么要动态链接
在静态链接的情况下,假如多个程序包含相同的模块Lib.o,那么当同时运行多个程序时在磁盘和内存中会存在多个Lib.o 的副本,这样就造成了空间的浪费。
另外一个问题是静态链接对程序的更新、部署和发布页会带来很多麻烦。程序中一旦有任何模块更新,整个程序就要重新连接。发布给用户。
要解决空间浪费和更新困难这两个问题最简单的办法就是把程序的模块互相分割开来,形成独立的文件,而不是再将它们静态地链接在一起。简单来说就是不对那些组成程序的目标文件进行链接,等到程序要运行时才进行链接。也就是说吧链接这个过程推迟到了运行时再进行,这就是动态链接(Dynamic Linking) 的基本思想。
目前主流的操作系统都支持动态链接这种方式,在Linux 系统中,ELF动态链接文件被称为动态共享对象(DSO,Dynamic Shared Objects),简称共享对象,它们一般都是以.so 为扩展名的一些文件;而在Windows 系统中,动态链接文件被称为动态链接库(Dynamic Linking Library),它们通常就是我们平时常见的以.dll 为扩展名的文件。
简单的动态链接例子
我们先以ELF 作为例子来描述动态链接的过程,我们分别需要如下几个源文件:
/* Prpgram1.c */
#include "Lib.h"
int main()
{
foobar(1);
return 0;
}
/* Prpgram2.c */
#include "Lib.h"
int main()
{
foobar(2);
return 0;
}
/* Lib.c */
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
我们使用$gcc -fPIC -shared -o Lib.so Lib.c 将Lib.c 编译成一个共享对象文件Lib.so。Lib.so 中保存了完整的符号信息,在链接时链接器就可以知道foobar 是定义在Lib.so 中的动态符号。我们再使用Lib.so 来分别链接Program1.c、Program2.c。$gcc -o Program1 Program1.c ./Lib.so $gcc -o Program2 Program1.c ./Lib.so整个编译和链接过程如图:
我们可以看到在链接时会把Program.o 和Lib.so 链接到一起产生可执行文件Program1。但是这里Lib.so 并没有被链接进来。如果foobar() 是定义在静态目标文件中的函数,那么链接时会按静态链接的规则,将Program1.o 中的foobar 地址引用重定位。如果foobar() 是定义在一个动态共享对象中的函数,那么链接时链接器会把这个符号的引用标记为一个动态链接的符号,不对它进行地址重定位,把这个过程留到装载时再进行。共享对象的最终装载地址在编译时是不确定的,而是在装载时,装载器根据当前地址空间的空闲情况,动态分配一块足够大的虚拟地址空间给相应的共享对象。
地址无关代码
固定装载地址的困扰
为了实现动态链接,我们首先遇到的问题就是共享对象地址冲突问题。对一个简单的程序来说我们可以手动指定模块的地址,比如0x1000 ~ 0x2000分配给模块A,把0x2000 ~ 0x3000 分配给B。但是如果模块被多个程序使用,某个程序不需要A,所以这个程序以为地址0x1000 ~ 0x2000 是空闲的,于是分配给了另一个模块C。这样地址就冲突了。不幸的是再起有些系统采用了这种做法,这种做法叫静态共享库(Static Shared Library),静态共享库的做法就是将程序的各种模块统一交给操作系统来管理,操作系统在某个特定的地址划分出一些地址块,为那些已知的模块预留足够的空间。
静态共享库除了上面提到的地址冲突问题,还有库升级也很成问题,因为升级后的共享库必须保持全局函数和变量地址不变,如果程序在链接时已经绑定了这些地址,一旦更改就必须重新连接程序。
为了解决模块装载地址固定的问题,我们设想是否可以让共享对象在任意地址加载,也就是说共享对象在编译时不能假设自己在进程虚拟地址空间中的位置。
装载时重定位
早在没有虚拟存储概念的情况下,程序时直接被装载进物理内存的。当同时有多个程序运行的时候,操作系统根据内存空闲情况,动态分配一块大小合适的物理内存给程序,所以程序被装载的地址是不确定的。程序中的指令和数据中的绝对地址引用需要重定位。我们前面在静态链接时提到的重定位是链接时重定位,而这种情况被称为装载时重定位(Load Time Relocation)。但是装载时重定位并不适合用来解决共享对象中所存在的问题。装载时重定位需要修改指令,所以没办法做到同一份指令可以被多个进程共享。
前面在生成共享对象时,使用了两个GCC 参数-shared 和 -fPIC,如果只用-shared 那么输出的共享对象就是使用装载时重定位的方法。
地址无关代码
那么使用-fPIC 这个参数有什么效果呢?
装载时重定位有个很大的缺点就是指令无法在多个进程之间共享,这样就失去了动态链接节省内存的一大优势。我们还需要解决共享对象指令中绝对地址的重定位问题。基本想法就是把指令中那些需要被修改的部分分离出来,跟数据放到一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本。这种方案目前被称为**地址无关代码(PIC,Position-independent Code )**技术。
首先我们先来分析模块中各种类型的地址引用方式:
模块内部调用或者跳转
这种类型是最简单的,调用的函数与调用者处于同一模块,它们之间的相对位置是固定的。这种指令时不需重定位的。
模块内部数据访问
模块内部的数据访问,指令中不能直接包含数据的绝对地址,那么唯一的办法就是相对寻址。一个模块内前面一般是若干个页的代码,后面紧跟着若干个页的数据,这些页的相对位置是固定的。通过获取当前指令地址加上偏移量就可以访问到相应的数据了。
模块间数据访问
模块间数据访问比模块内部稍微麻烦一点,相当于加了一个中间层。ELF 文件会在数据段里建立一个指向这些变量的指针数组,被称为全局偏移表(Global Offset Table,GOT),链接器在装载模块时会查找每个变量所在的地址,然后填充GOT 中的各个项,以确保每个指针所指向的地址正确。由于GOT 是放在数据段的,所以他可以在模块装载时被修改,并且每个进程都有独立的副本,互相不受影响。在程序中访问变量时,会先找到GOT,这一步和模块内部数据访问一样。
模块间调用、跳转
对于模块间调用和跳转,我们可以采用模块间数据访问的方法来解决。不同的是GOT 中保存的是目标函数的地址。但是这会存在一些性能问题,后面会介绍ELF 采用的一种更复杂和精巧的方法。
共享模块的全局变量问题
上面的情况没有包含定义在模块内部的全局变量的情况,这种情况连接器会在.bbs 段创建一个全局变量的副本,所有使用到这个变量的指令都指向位于可执行文件中的副本。也就是当共享模块被装载时,如果某个全局变量在可执行文件中有副本,那么动态链接器就会把GOT 中的相应地址指向该副本,这样该变量在运行时实际只有一个实例。如果变量在共享模块中被初始化,那么动态链接器还需要将该初始化值复制到程序主模块中的变量副本;如果该全局变量在主程序中没有副本,那么GOT 中相应的地址就指向模块内部该变量副本。
数据段地址无关性
对于数据段来说,它在每个进程中都有一份独立的副本,所以并不担心被进程改变。从这点来看,我们可以选择装载时重定位的方法来解决数据段中绝对地址引用问题。对于共享对象来说,如果数据段中有绝对地址引用,那么编译器和链接器就会产生一个重定位表,这个重定位表里包含了R_386_RELATIVE 类型的重定位入口,用于解决上述问题。
延迟绑定
动态链接比静态链接要灵活得多,但是会牺牲一部分性能。动态链接对于全局和静态数据的访问需要进行复杂的GOT 定位,模块间也需要先定位GOT 然后在进行跳转。另外动态链接的链接是在运行时完成的,即程序开始执行时,动态链接器都要进行一次链接工作。这些势必会减慢程序的启动速度。下面我们将介绍优化动态链接的一些方法。
程序运行过程中可能很多的函数在程序执行完也不会被用到,所以ELF 采用了一种叫做**延迟绑定(Lazy Binding)**的做法,基本思想就是函数第一次被用到时才进行绑定,如果没有用到则不进行绑定。ELF 使用PLT(Procedure Linkage Table)的方法来实现。PLT 为了实现延迟绑定,在通过GOT间接跳转这个过程中间又增加了一层间接跳转。利用符号重定位表和模块ID来完成符号解析和重定位。一旦符号被解析完毕,当我们再次调用时就可知直接跳转到正确位置。
动态链接相关结构
在Linux 下,动态链接器ld.so 实际上是一个共享对象,操作系统通过映射的方式将它加载到进程空间中。操作系统在加载完动态链接器后就将控制权交给动态链接器的入口地址。当动态连接器完成工作后,控制权转交给可执行文件的入口地址。程序开始正式执行
.interp 段
在动态链接的ELF文件中,有一个.interp 段,这个段保存了可执行文件所需要的动态链接器的路径。
.dynamic 段
动态链接ELF 中最重要的结构,这个段里面保存了动态链接器所需的基本信息。.dynamic 段结构如下:
typedef struct {
Elf32_Sword d_tag;
union {
Elf32_Word d_val;
Elf32_Addr d_ptr;
} d_un;
} Elf32_Dyn;
Elf32_Dyn 结构由一个类型值加上附加的数值或指针,不同类型值后面附加的数值或者指针有不同的含义。下面列举几个常见的类型值:
| d_tag 类型 | d_un 的含义 |
|---|---|
| DT_SYMTAB | 动态链接符号表的地址,d_ptr 表示.dynsym 的地址 |
| DT_STRTAB | 动态链接字符串表地址,d_ptr 表示.dynstr 的地址 |
| DT_STRSZ | 动态链接字符串表大小,d_val 表示大小 |
| DT_HASH | 动态链接哈希表地址,d_ptr 表示.hash 的地址 |
| DT_SONAME | 本共享对象的SO_NAME,我们会在后面介绍 |
| DT_RPATH | 动态链接共享对象搜索路径 |
| DT_INIT | 初始化代码地址 |
| DT_FINIT | 结束代码地址 |
| DT_NEED | 依赖的共享对象文件,d_ptr 表示所依赖的共享对象文件名 |
| DT_REL | |
| DT_RELA | 动态链接重定位表地址 |
| DT_RELENT | |
| DT_RELAENT | 动态重读位表入口数量 |
动态符号表
比如程序依赖共享对象引用到了某个函数,那么对于程序可以说是导入了某个函数,对于共享对象来说则是导出了某个函数。这种发导入导出关系放在静态链接下,可以看成普通函数的定义和引用。
为了表示动态链接这些模块之间的符号导入导出关系,ELF 专门有个叫动态符号表(Dynamic Symbol Table)的.dynsym 段来保存这些信息。与.symtab 不同的是.dynsym 只保存了与动态链接相关的符号。动态符号表也需要一些辅助的表,比如保存符号名的字符串变,在这里就是动态符号字符串表.dynstr(Dynamic String Table);还有为了加快符号查找过程的符号哈希表(.hash)。
动态链接重定位表
动态链接重定位相关结构
动态链接文件中重定位表分别叫做.rel.dyn 和.rel.plt 相当于静态链接文件中的.rel.text 和.rel.data。我们使用$readelf -r Lib.so 查看一个动态链接问价的重定位表:
这里可以看到有几种重定位入口类型:R_386_RELATIVE、R_386_GLOB_DAT 和R_386_JUMP_SLOT。比如我们看下printf 这个重定位入口,它的类型为R_386_JUMP_SLOT,它的偏移值为0x000015d8,可以看到R_386_JUMP_SLOT 这些类型的重定位入口按顺序存放在.got.plt 中。当动态链接器需要重定位时,它先查找printf 的地址,拿到地址之后直接放入0x000015d8 中,从而实现了重定位。R_386_GLOB_DAT 是对.got 的重定位原理和R_386_JUMP_SLOT 一样。
麻烦一点的就是R_386_RELATIVE 类型了,这种类型实际上就是基址重置(Rebasing)。例如下面的代码:
static int a;
static int* p = &a;
在编译时共享对象是从0开始的,我们假设静态变量a 相对于起始地址的偏移为B 所以p 的值为B。一旦共享对象被装载到地址A。那么实际上变量a 的地址为A+B 即p 的值需要加上一个装载地址A。R_386_RELATIVE 类型的重定位入口就是专门用来重定位指针变量p 这种类型的。
动态链接时进程堆栈初始化信息
操作系统会在堆栈里保存动态链接器所需的一些辅助信息数组(Auxiliary Vector)。辅助信息的格式也是一个结构数组
typedef struct
{
uint32_t a_type;
union
{
uint_32_t a_val;
} a_un;
} Elf32_auxv_t;
我们介绍几个常见的类型值,并且是动态链接器在启动时所需要的:
| a_type 定义 | a_type 值 | a_val 的含义 |
|---|---|---|
| AT_NULL | 0 | 表示辅助信息数组结束 |
| AT_EXEFD | 2 | 表示可执行文件的句柄 |
| AT_PHDR | 3 | 可执行文件中程序头表 |
| AT_PHENT | 4 | 可执行文件中程序头表中每一个入口(Entry)的大小 |
| AT_PHNUM | 5 | 可执行文件中程序头表中入口(Entry)的数量 |
| AT_BASE | 7 | 表示动态链接器本身的装载地址 |
| AT_ENTRY | 9 | 可执行文件入口地址,即启动地址 |
它们在进程堆栈位于环境变量指针的后面:
动态链接的步骤和实现
动态链接步骤基本上分为3步:先是启动动态链接器本身,然后装载所有需要的共享对象,最后是重定位和初始化。
动态链接器自举
对于普通共享对象来说,它的重定位由动态链接器完成,也可以依赖其他共享对象。我们知道动态链接器本身也是一个共享对象,和普通共享对象不同的是,动态链接器不可以依赖其它任何共享对象,其次是动态链接器的重定位工作由他本身完成。所以动态链接器在启动时必须有一段精巧的代码可以完成这项艰巨的工作同时又不能用到全局和静态变量。这种具有一定限制条件的启动代码往往被称为自举(Bootstrap)。
动态链接器入口地址即自举代码的入口,自举代码首先会找到他自己的GOT。而GOT 的第一个入口是.dynamic 段的偏移地址,通过.dynamic 中的信息,自举代码便可以获得动态链接器本身的重定位表和符号表等,从而得到动态链接器本身的重定位入口,先将它们全部重定位。从这一步开始动态链接器代码才可以使用自己的全局变量和静态变量。
装载共享对象
完成自举后,动态链接器将可执行文件和链接器本身的符号都合并到全局符号表,然后链接器通过.dynamic 段中DT_NEED 类型的入口找到可执行文件依赖的所有共享对象,并将这些对象放入一个装载集合中,然后把这些对象映射到进程中,如果这些共享对象还依赖其他共享对象,那么将所依赖的共享对放到装载集合中。如此反复知道所有依赖的共享对象都被装载进来。
重定位和初始化
上面步骤完成后,链接器开始重新遍历可执行文件和每个共享对象的重定位表,将他们GOT/PLT 中的每个需要重定位的位置修正。重定位完成后如果某个共享对象有.init 段,那么动态链接器会执行.init 段中的代码,用以实现动态共享对象特有的初始化过程。
显示运行时连接
支持动态链接的系统往往都支持一种更加灵活的某块加载方式,叫做现实运行时连接,有时候也叫运行时加载,它就是让程序自己在运行时控制加载指定的模块,并且可以在不需要该模块时将其卸载。