动态链接的基本思想是把程序按照模块拆分成各个相对独立的部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接那样把所有的程序模块都链接成一个单独的可执行文件。动态链接涉及运行时的链接及多个文件的装载,必需要有操作系统的支持,因为动态链接的情况下,进程的虚拟地址空间的分布会比静态链接情况下更为复杂,还有一些存储管理、内存共享、进程线程等机制在动态链接下也会有一些微妙的变化。在Linux系统中,ELF动态链接文件被称为“动态共享对象(DSO,Dynamic Shared Objects),简称共享对象,它们一般都是以“.so”为扩展名的一些文件,而在Windows系统中,动态链接文件被称为动态链接库(Dynamical Linking Library),即平时见到的以“.dll”为扩展名的文件。
动态链接库的特点与优势
把函数库推迟到程序运行时加载的好处有几个:
- 可以实现进程之间的资源共享。就是说,某个程序的在运行中要调用某个动态链接库函数的时候,操作系统首先会查看所有正在运行的程序,看在内存里是否已有此库函数的拷贝。如果有,则让其共享那一拷贝(共享代码段,数据码各自独立一份);只有没有才链接载入。这种模式虽然会带来一些”动态链接“额外的开销,但却大大地节省了系统的内存资源,通过一定的优化,与静态链接相比,性能损失大约在5%以下。
- 程序升级变得简单。用户只需要升级动态链接库,而无需像静态链接那样重新编译其他原有的代码就可以完成整个程序的升级。
- 可以使链接载入由程序员在程序代码中控制,如dlopen、dlsym、dlclose等等。
装载重定位
静态链接是通过在链接过程对所有目标文件进行重定位的。而对于动态链接库而言,在链接时,可执行文件对所有绝对地址的引用不作重定位,而把这一步推迟到装载时再完成。一旦模块装载地址确定,即目标地址确定,那么系统对程序中所有的绝对地址引用进行重定位。负责完成这部分工作的是动态链接器(Dynamic Loader,其实也是一个共享对象,ld.so)。
动态链接模块被装载遇到至虚拟空间之后,指令部分是在多个进程之间共享的,由于装载时重定位的方法需要修改指令,所以没有办法做到同一份指令被多个进程共享,因为指令被重定位后对于每个进程来讲是不同的。当然,动态链接库中的可修改数据部分对于不同的进程来说有多个副本,所以它们可以采用装载时重定位的方法来解决。
因此,地址无关代码(PIC,Position-independent Code)就在这种需求的前提产生了。这种方案就是把指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本。
可以按是否跨模块和引用方式分为4类地址引用类型:
/* a.elf */
static int a;
extern int b;
extern void ext();
void bar()
{
a = 1; //类型2 模块内数据访问
b = 2; //类型3 模块间数据访问
}
void foo()
{
bar(); //类型1 模块内调用或跳转
ext(); //类型4 模块间调用或跳转
}
类型一 模块内部调用或跳转
对于现代的操作系统来讲,模块内部的跳转、函数调用都可以是相对地址调用,或者是基于寄存器相对调用,所以对于这种指令是不需要重定位的,这点从静态链接时的模块内部重定位可以看出。
类型二 模块内部数据访问
一个模块前面一般是若干个页的代码,后面紧跟着若干个页的代码,这些页之间的相对位置是固定的,也就是说,任何一条指令与它需要访问的模块内部数据之间的相对位置是固定的,那么只需要对于当前指令的地址加上固定的偏移量就可以访问模块内部数据了。现代的体系结构中,数据的相对寻址往往没有相对于当前指令地址(PC)的寻址方式,所以ELF用了一个很巧妙的方法来得到当前的PC值,然后再加上一个偏移量就可以达到访问相应变量的目的了。
其实就是在代码中调用了一个函数(__i686.get_pc_thunk.cx),在这个函数中把esp(堆栈)中存放的返回地址存到某个寄存器中,然后再原来的代码中从寄存器(此处为ecx)中取出来。反汇编出来的结果如下:
000004a7 <__i686 .get_pc_thunk.cx>: 4a7: 8b 1c 24 mov (%esp),%ecx 4aa: c3 ret 4ab: 90 nop
类型三 模块间数据访问
模块间的数据访问比模块内部稍微麻烦,因为模块间的数据访问目标地址要等到装载时才决定的。比如上面的变量b,它被定义在其他模块中,并且该地址在装载时才能确定,因此,这时就需要用到了在”a.elf”文件的数据段里面建立一个指向这些变量的指针数组(注意是在”a.elf”的数据段),也被称为全局偏移表(GOT,Global Offset Table)。
全局偏移表是将地址无关代码(PIC)通过计算转换成相应的绝对地址。当代码需要引用该全局变量时,可以通过GOT中相对应的项间接引用来获取到绝对地址。而在重定位项的类型是R_386_GLOB_DAT,其实就是对全局偏移表的引用。
在指令要访问变量b时,程序会先找到当前目标文件的GOT,然后根据GOT中变量所对应的项找到变量的绝对地址。每个变量都对应一个4个字节的地址,链接器在装载模块的时候会查找每个变量的绝对地址,然后填充GOT中的各个项,以确保每个指针所指向的地址正确。由于GOT本身是放在数据段的,所以它可以在模块装载时被修改,并且每个进程都可以有独立的副本,相互不受影响。
将上面的代码编译成so文件,可以看到got的属性是一个数据段:
pic.so: file format elf32-i386
Sections:
Idx Name Size VMA LMA File off Algn
18 .got 00000010 00001fe4 00001fe4 00000fe4 2**2
CONTENTS, ALLOC, LOAD, DATA
在动态链接器将控制权交给进程映像中任何的代码之前,会处理所有的全局偏移表重定位项,确保执行过程中绝对地址信息可用。这个过程可以大概描述如下:
- 动态链接器开始处理重定位项,处理到全局变量b。
- 从重定位表项中的r_info低8位判断重定位项的类型是R_386_GLOB_DAT,从r_info的高24位获取到重定位入口的符号表符号表的位置。
- 从重定位表项中的r_offset中获取到了全局变量b在”a.elf”全局偏移表GOT的位置。
- 根据从r_info的高24位获取到的下标在全局符号表中获取到了全局变量b的地址,经过重定位计算后将b的绝对地址更新到步骤3中得知的GOT位置。
- 当在”a.elf”中的代码指令要访问b时,程序在编译的时候已经在访问b的位置像类型一那样安插一段代码,也像类型一那样通过当前的PC值加上一偏移量计算出b在GOT中的位置,然后再从该位置获取到b的绝对地址。
类型四 模块间调用或跳转
这种完全可以使用类型三的方法可以解决,但是使用这种方法会使性能存在一些问题。
至于共享模块的全局变量冲突问题,ELF共享库在编译时,默认都把定义在模块内部的全局变量当作定义在其他模块的全局变量,也就是说当共享模块被装载时,如果某个全局变量在可执行文件中拥有副本,那么动态链接器就会把GOT中的相应地址指向该副本,这样该变量在运行时实际上最终就只有一个实例。
而对于取地址这种重定位入口:
static int a; static int* p = &a;
实际上这样在数据段中绝对地址的引用,相应地对于p的重定位项类型为”R_386_RELATIVE”。
延迟绑定
动态链接的确但静态链接灵活很多,但是同时也比静态链接慢,因为动态链接下的模块间变量访问和函数调用都要进行间接处理,而且在程序启动的时候要寻找并装载所需要的共享对象,并进行符号查找和地址重定位等工作势必减慢程序的启动速度。因此,ELF采用了一种叫延迟绑定(Lazy Binding)的做法,基本的思想就是当函数第一次被用到时才进行绑定(包括符号查找、重定位等),如果没有用到则不进行绑定。这样的做法可以大大加快程序的启动速度,特别有利于一些有大量函数引用和大量模块的程序。
ELF文件将GOT拆分成两个表叫做”.got”和”.got.plt”。其中”.got”用来保存全局变量引用的地址,”.got.plt”用来保存函数引用的地址,也就是说,所有对于外部函数的引用全部被分离出来放到了”.got.plt”。
当我们调用某个外部模块的函数时,如果按照通常的做法应该是通过GOT中相应的项进行间接跳转。PLT为了实现延迟绑定,在这个过程中间又增加了一层间接。调用函数并不直接通过”.got.plt”跳转,而是通过一个叫作”.plt”的结构来进行跳转。每个外部函数在”.plt”中都有一个相应的项,比如bar()函数在”.plt”中的项的地址可以称为”bar@plt”。”.plt”的基本结构大概如下(以bar@plt为例):
.PLT0: push *(.got.plt+4) jump *(.got.plt+8) .PLT1: jump *(bar@got.plt) push offset_of_rel jump .PLT0
延迟绑定的符号解析过程大概如下(以上面示例中的bar()为例):
- 代码中调用bar()的地方在编译时已经被替换成一条调用bar@plt的转移指令,即是调用的”.plt”中的bar所在的项,即”.PLT1″。
- 当首次解析的时候,虽然在”.got.plt”中有bar函数的项,但是在没有解析之前,该项存储的内容为0,而非bar函数的绝对地址,因此指令”jump *(bar@got.plt)”实际上没有任何的动作,只是直接跳转到下一条push指令(因为jump的偏移为0)。
- 指令”push offset_of_rel”是将bar()函数的重定位项的索引压栈,而该重定位项的类型为R_386_JMP_SLOT,offset_of_rel是重定位表”.rel.plt”中的下标。
- 接着指令”jump .PLT0″跳转到.PLT0,将”.got.plt”的第二表项压栈,然后再跳转到”.got.plt”第三个表项指定的地址。这里要简述一下”.got.plt”的结构,”.got.plt”和”.got”几乎是一样的,除了”.got.plt”前三项的值有特殊意义:(1)第一项保存”.dynamic”段的地址,这个段描述了本模块动态链接相关的信息(可暂忽略);(2)保存本模块的ID(用于在动态链接时查找本模块的信息);(3)保存的是_dl_runtime_resolve()函数的地址,用于启动动态链接器,加载模块,解析符号并重定位。。
- 此时,可以看到在堆栈上,已经存有bar()函数在重定位项的索引和当前要重定位的模块ID,那么_dl_runtime_resolve()函数就可以启动动态链接器,当动态链接器得到控制后,它恢复堆栈,获取在步骤3中入栈的重定位项索引和步骤4中入栈的本模块信息,通过这2个信息,动态链接器符号解析和计算绝对地址,将bar()的“真实”地址存储于bar()在全局偏移表”.got.plt”的表项(原来的值为0)。
- 动态链接器更新了”.got.plt”中的信息后,将执行的控制权传递给bar()函数。那么当再次调用bar()函数时,PC依然会跳转到”.PLT1″,但是此时”bar@got.plt”的值已经不再是0了,因为会正确地跳转到bar()函数的绝对地址。
动态链接相关的数据结构
在动态链接的情况下,可执行文件的装载与静态链接情况基本一样。首先操作系统会读取可执行文件的头部,检查文件合法性,然后从头部中的”Program Header”中读取每个”Segment”的虚拟地址、文件地址和属性,并将它们映射到进程虚拟空间的相应位置,这些步骤跟静态链接情况下基本一样。但不同的是,静态链接在完成以上工作后会将控制权直接转交给可执行文件的入口地址(e_entry),然后程序开始执行。而动态链接情况下,操作系统需要加载可执行文件依赖的所有共享对象,此时还有很多外部符号的引用还处于无效地址的状态,因此操作系统会启动一个动态链接器,可加载可执行文件所依赖的共享对象,最后才将控制权转交给可执行文件。
“.interp”段
这个段的内容是指定可执行文件所需要的动态链接器的路径,即是说动态链接器的位置即不是系统配置指定的,也不是由环境参数决定的,而是由ELF可执行文件决定的。
Contents of section .interp: 8048134 2f6c6962 2f6c642d 6c696e75 782e736f 8048144 2e3200 /lib/ld-linux.so.2
其实这个只是一个软链接。动态链接器在Linux下是Glibc的一部分,也就是属于系统库级别的,它的版本号往往跟系统中的Glibc库版本号一样的。
“.dynamic”段
动态链接ELF中最重要的结构应该是”.dynamic”段,这个段里面保存了动态链接器所需要的基本信息,比如依赖于哪些共享对象、动态链接符号表的位置、动态链接重定位表的位置、共享对象初始化代码的地址等,所以,”.dynamic”段可以看成是动态链接下ELF文件的”文件头“。
typedef struct {
Elf32_Sword d_tag;
union {
Elf32_Word d_val;
Elf32_Addr d_ptr;
} d_un;
} Elf32_Dyn;
“.dynamic”段的结构基本上是由一个类型值加上一个附加的数值或指针。而附加的union值可能是索引、下标和虚拟地址等等。而类型为DT_NEED类型则为动态链接的模块所依赖的模块路径。
“.dynsym”段
这个段称为动态符号表(Dynamic Symbol Table),用来表示动态链接这些模块之间的符号导入导出关系。与”.symtab”不同的是,”.dynsym”只保存了与动态链接相关的符号,对于那些模块内部的符号,比如模块私有变量则不保存。很多时候动态链接的模块同时拥有”.dynsym”和”symtab”两个表,”.symtab”中往往保存了所有符号,包括”.dynsym”中的符号。
动态链接的重定位表
在静态链接中,目标文件里面包含有专门用于表示重定位信息的重定位表,比如”.rel.text”表示是代码段的重定位表,”.rel.data”是数据段的重定位表。在相应在动态链接文件中,也有类似的重定位表分别叫做”.rel.dyn”和”.rel.plt”,它们分别相当于”.rel.data”和”.rel.plt”。
“.rel.dyn”是对数据引用进行修正,它所修正的位置位于”.got”以及数据段;而”.rel.plt”是对函数引用的修正,它所修正的位置位于”.got.plt”。
非常不错的分享,赞一个!期待更多精彩!