1.为什么动态链接
- 静态链接导致同一模块在多个进程中使用,在内存中存在多个副本,造成空间浪费。
- 静态链接给程序的更新、部署和发布造成麻烦。
动态链接的基本实现
基本思想是把程序按照模块拆分成各个相对独立的部分,在程序运行的时候才将它们链接在一起形成一个完整的程序。
Linux系统中,ELF动态链接文件被称为动态共享对象(DSO Dynamic Shared Objects)简称共享对象,一般以
.so
为拓展名。Windows系统中,动态链接文件被称为动态链接库(Dynamical Linking Library),即DLL文件
Linux中,常用的C语言库的运行库glibc,它的动态链接形式的版本保存在
/lib
目录下,文件名为libc.so
。所有的C语言编写的、动态链接程序都可以在运行时使用它。
2.简单的动态链接例子
- 创建以下文件
// dlib.c
#include<stdio.h>
void fooBar(int i){
print("echo from %d\n",i);
}
///test1.c
int main(){
fooBar(1);
return 0;
}
- 使用GCC将dlib.c编译成一个共享对象文件
gcc -fPIC -shared -o MyLib.so dlib.c
- 然后我们链接test1.c文件
gcc -o test1 test1.c ./MyLib.so
如果在dlib.c中的fooBar函数中写入sleep(-1);然后
./test1 &
查看PID,接着就可以查看进程的虚拟地址空间分布
cat /proc/12985/maps
kill 12985
我们还可以通过readelf工具查看MyLib.so的装载特性。
readelf -l MyLib.so
- 有一点和普通程序不一样的地方是:动态链接模块的装载地址是从0x00000000开始的。我们知道这个地址是无效地址,并且从得到的进程的虚拟空间分布看到,MyLib.so的最终装载地址并不是0x00000000.从这一点可以看到,共享对象的最终装载地址在编译时时不确定的
3.地址无关代码技术
装载时重定位
在链接时,对所有绝对地址的引用不作重定位,而把这一步推迟到装载时在完成。一旦模块装载地址确定,即目标地址确定,那么系统就对程序所有的绝对地址引用进程重定位。
静态链接时提到过重定位叫做链接时重定位
。现在这种情况被称为装载时重定位
,在Windows中,这种装载时重定位被称作基址重置
。
地址无关代码
装载时重定位时解决动态模块中有绝对地址引用的办法之一,但它有一个很大的缺点就是指令部分无法在多个进程之间共享,这样就失去了动态链接节省内存的一大优势。
目的:程序模块中共享的指令部分在装载时不需要因为装载地址的改变而改变。
基本想法:把指令部分需要被修改的部分分离出来,跟数据部分放一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本。这种方案被称为地址无关代码(PIC,
Position Independent Code)。
模块中各种地址引用方式可以分为以下几种:
- 模块内部函数调用、跳转等。
- 模块内部的数据访问。
- 模块外部的函数调用、跳转等。
- 模块外部的数据访问。
类型一 模块内部调用或跳转
模块内部的跳转、函数调用都可以是相对地址调用,或者是基于寄存器的相对调用,所以对于这种指令是不需要重定位的。
类型二 模块内部数据访问
指令中不能直接包含数据的绝对地址,那么唯一的办法就是相对寻址。
类型三 模块间数据访问
模块间的数据访问目标地址要等到装载时才决定。ELF的做法是在数据段里面建立一个指向这些变量的指针数组,也被称为全局偏移表(GlobalOffsetTable,GOT),当代码需要引用该全局变量时,可以通过GOT中相对的项间接引用。
类型四 模块间调用、跳转
GOT中相应的项保存的是目标函数的地址,当模块要调用目标函数时,可以通过GOT中的项进行间接跳转。
如何区分一个DSO是否为PIC
readelf -d MyLib.so |grep TEXTREL
PIE
一个以地址无关方式编译的可执行文件称为地址无关可执行文件(PIE,Position-Independent-Executable).
GCC产生PIE的参数为-fPIE
或者-fpie
-fPIC和-fpic
-fPIC和-fpic从功能上讲完全一样,但是-fPIC产生的代码大,但是没有在平台上的限制,而-fpic产生的代码小,而且较快,但是在某些平台上会有限制。所以常用-fPIC
延迟绑定
动态链接有很多优势,比静态链接要灵活,但它牺牲了一部分性能代价。
动态链接比静态链接慢的主要原因是动态里链接对于全局和静态的数据访问都要进行复杂的GOT定位,然后间接寻址;对于模块间的调用也要线定位GOT,然后进行跳转。另一个减慢运行速度的原因是动态链接工作运行时完成,程序开始执行时,动态链接器都要进行一次链接。
在一个程序运行过程中,很多函数在程序执行完时都不会被用到,如果一开始把所有函数都链接好实际是一种浪费。所有ELF采用一种叫做延迟绑定的做法,基本思想:当函数第一次被用到时才进行绑定,如果不用到,则不绑定。
ELF使用PLT
(Procedure Linkage Table)的方法来实现。
调用函数并不直接通过GOT跳转,而是通过一个叫做PLT项结构来进行跳转。每个外部函数在PLT中都有一个相对于的项,比如bar()函数在PLT中的项的地址我们称为bar@plt。
bar@plt实现:
bar@plt:
jmp *(bar@GOT)//通过GOT间接跳转的指令.连接器初始化阶段并没有将bar()地址填入该项,而是将下面的代码地址填入
push n//将决议符号下标压入堆栈
push moduleID//将模块ID压入堆栈
jump _dl_runtime_resolve//调用链接器_dl_runtime_resolve函数来完成符号解析和重定位工作
- ELF将GOT拆分成两张表叫做”.got”和”.got.plt”。
“.got”用来保存全局变量引用地址
“.got.plt”用来保存函数引用地址
.got.plt
表中,前三项分别是
-
.dynamic
段的地址 - 本模块的 ID
- _dl_runtime_resolve()的地址
与动态链接相关结构
.interp段
- 动态链接器的位置由ELF可执行文件决定。在动态链接的ELF可执行文件中,有一个专门的段叫做”.interp”段。
- “.interp”段的内容很简单,里面保存的就是一个字符串,这个字符串就是可执行文件所需要的动态链接器路径(通常实际上是一个软链接)。
动态链接器在Linux下是Glibc的一部分,也就是属于系统库级别。 - 可以使用
objdump -s a.out
查看.interp的内容 - 还可以使用
objdump -l a.out | grep interpreter
查看可执行文件所需要的动态链接器的路径。
.dynamic段
是动态链接ELF中最重要的结构,这个段里面保存了动态链接器所有需要的基本信息,比如:依赖与那些共享对象、动态链接符号表的位置、动态链接重定位表的位置、共享对象初始化代码的地址等。
“.dynamic”段可以看成动态链接下ELF文件的“文件头”。
使用
readelf -d MyLib.so
可以查看“.dynamic”段的内容另在Linux还提供了ldd命令还查看一个程序主模块或一个共享库依赖于哪些共享库。
$ ldd program
动态符号表
.dynsym 段
该段与 “.symtab”段类似,但只保存了与动态链接相关的符号,很多时候,ELF文件同时拥有 .symtab 与 .synsym段,其中 .symtab 将包含 .synsym 中的符号。该符号表中记录了动4 .dynstr 段
该段是 .dynsym 段的辅助段,.dynstr 与 .dynsym 的关系,类比与 .symtab 与 .strtab 的关系
.dynstr 段
(动态符号字符串表),用于保存符号名的字符串表。
静态链接时叫做符号字符表(.strtab)。
.hash 段
在动态链接下,需要在程序运行时查找符号,为了加快符号查找过程,增加了辅助的符号哈希表(.hash)
- 可以使用
readelf -sD MyLib.so
查看ELF文件的动态符号表和哈希表
动态链接重定位表
共享对象需要重定位的主要原因是导入符号的存在。
在动态链接中,导入符号的地址在运行时才确定,所以需要在运行时将这些导入符号引用修正,即需要重定位。
动态链接重定位相关结构
在动态链接的文件中,也有和静态文件类似的重定位的表,分别叫做”.rel.dyn”和”.rel.plt”
动态链接时进程堆栈初始化信息
当操作系统将控制权交给动态链接器时,它需要知道可执行文件和本进程的一些信息,这些信息由操作系统传递给动态链接器,保存在进程的堆栈里面。
堆栈里面还保存了一些辅助信息数组
操作系统传递给动态链接器的辅助信息有4个:(例)
- AT_PHDR,值为0x08048034,程序表头位于0x08048034
- AT_PHENT,值为20,程序表头中每个项的大小为20字节
- AT_PHNUM,值为7,程序表头共有7个项
- AT_ENTRY,0x08048320,程序入口地址为0x08048320
动态链接的步骤和实现
动态链接基本上分为3步:先是启动动态链接器本身,然后装载所有需要的共享对象,最后重定位和初始化。
1.动态链接器自举
2.装载共享对象
完成基本自举后,动态链接器将可执行文件和链接器本身的符号表都合并到一个符号表中,我们称为全局符号表。然后链接器开始寻找可执行文件所依赖的共享对象。在”.dynamic”段中,类型入口DT_NEEDED,它所指出的是该可执行文件所依赖的共享对象。链接器可以列出可执行文件所需要的所有共享对象,并将这些共享对象的名字放入一个装载集合中。然后链接器开始从集合里取一个所需要的共享对象名字,找到相对应的文件后打开该文件,读取相应的ELF文件头和”.dynalic”段,然后将它相应的代码段和数据段映射到进程空间。
当一个新的共享对象被装载进来的时候,它的符号表会被合并到全局符号表中,所以当所有共享对象都被装载进来的时候,全局符号表里面将包含进程中所有的动态链接器所需要的符号。
符号的优先级
两个不同模块定义了同一个符号会怎么样?
当一个共享对象里面的全局符号被另一个共享对象的同名全局符号覆盖的现象称为共享对象全局符号介入。
全局符号介入这个问题,在Linux下动态链接器是这样处理的:它定义了一个规则,那就是当一个符号需要被加入全局符号表时,如果相同的符号名已经存在,则后加入的符号被忽略。
重定位和初始化
当上面的步骤完成后,链接器开始重新遍历可执行文件和每个共享对象的重定位表,将它们的GOT/PLT中每个需要重定位的位置进行修正。
重定位完成之后,如果某个共享对象有”.init”段,那么动态链接器会执行”.init”段中代码,用以实现共享对象特有的初始化过程。共享对象中可能还有”.finit”段,当进程退出时,会执行”.finit”段中的代码,可以用来实现类似C++全局对象析构之类的操作。
如果进程的可执行文件也有”.init”段,那么动态链接器不会执行它,因为可执行文件中”.init”段和”.finit”段由程序初始化部分代码负责执行。
Linux动态链接器实现
- 内核在装载完ELF可执行文件以后就返回到用户空间,将控制权交给程序的入口。
- 对于动态链接器的可执行文件,内核会分析它的动态链接器地址,将动态链接器映射至进程地址空间,然后把控制权交给动态链接器。
- Linux动态链接器本身就是一个共享对象。共享对象其实也就是ELF文件,它也有跟可执行文件一样的ELF文件头。动态链接器是个非常特殊的共享对象,它不仅是个共享对象,还是个可执行的程序。
- Linux的内核在执行execve()时不关心目标ELF文件是否可执行,它只是简单按照程序头表里面的描述对文件进行装载然后把控制权交给ELF入口地址。
- windows系统中的EXE和DLL也是类似的区别,DLL也可以被当作程序来运行,WINDOWS提供一个叫做rundll32.exe的工具可以把一个DLL当作可执行文件运行。
- 动态链接器本身应该是静态链接的,它不能依赖于其他共享对象,动态链接器本身是用来帮助其他ELF文件解决共享对象依赖问题的。
显示运行时链接
支持动态链接的系统往往都支持一种更加灵活的模块加载方式,叫做显示运行时链接
,有时候也叫做运行时加载。
一般的共享对象不需要进行任何修改就可以进行运行时装载,这种共享对象往往叫做动态转载库。
动态库实际上跟一般的共享对象没有区别。主要区别是共享对象是由动态链接器在程序启动之前负责装载和链接的,这一系列步骤都由动态连接器自动完成,对于程序是透明的。而动态库的装载则是通过一系列由动态链接器提供的API,具体地讲共有4个函数:打开动态库(dlpen),查找符号(dlsym),错误处理(dlerror),关闭动态库(dlclose),程序可以通过这几个API对动态库进行操作。
dlopen()
void * dlopen(const char *filename,int flag)
- 第一个参数是被加载动态库的路径
- 第二个参数flag表示函数符号的加载方式,常量RTLD_LAZY表示使用延迟绑定,即PLT机制;常量RTLD_NOW表示当模块被加载时即完成所有的函数的绑定工作。或者RTLD_GLOBAL可以和前面的两个常量一起用(用或操作),表示将被加载的模块的全局符号合并到进程的全局符号表中。
- dlopen()函数用来打开一个动态库,并将其加载到进程的地址空间,完成初始化过程。返回被加载模块的举兵,这个句柄在后面使用dlsym()或者dlclose()的时候会用到。如果模块及加载失败,则返回NULL
dlsym()
void * dlsym(void *handle,char *symbol);
- handle是dlopen返回的句柄
- symbol是要查找的符号的名字,以\0结尾的C字符串
dlsym函数基本上是运行时装载的核心部分,可以通过这个函数找到所需要的符号。
dlerror()
每次调用dlopen(),dlsym(),dlclose(),以后,我们都可以调用dlerror()函数来判断上次调用是否成功。
dlclose()
dlclose()的作用跟dlopen()刚好相反,它的作用是将一个已经加载的模块卸载。系统会维持一个加载引用计数器,每次使用dlopen()加载某模块时,相应的的计数器加一;每次使用dlclose()卸载模块时,相应的计算器减一。只有当计数器减到0时,模块才被真正卸载掉。