正文
7.1 为什么要动态链接
静态链接使得不同的程序开发者和部门能够相对独立的开发和测试自己的程序模块,从某种意义上来讲大大促进了程序开发的效率,原先现在程序规模也随之扩大。但静态链接的缺点也暴露出来:浪费内存、磁盘空间、模块更新困难。
内存和磁盘空间
静态链接的方式对于计算机内存和磁盘的空间浪费非常严重。特别是多线程操作系统情况下,静态链接极大的浪费了内存空间。
程序开发和发布
静态链接堆程序的更新、部署和发布也会带来麻烦。如果程序都使用静态链接库,那么一旦程序有一点点改动,都会导致整个程序重新下载。
动态链接
要解决空间浪费和更新困难问题最简单的办法就是把程序的模块相互分割开来,形成独立的文件,而不再将它们静态地链接在一起。简单的讲,就是不对那些组成程序的目标文件进行链接,等到程序要运行时才进行链接。把链接推迟到运行时在进行,这就是动态链接。
当程序要升级程序库或程序共享的某个模块时,理论上只要简单的将旧的目标文件覆盖掉,而无须将所有程序在重新链接一遍。
程序可扩展性
动态链接还有一个特点就是程序在运行时可以动态地选择加载各种程序模块,这个优点被用来制作插件。
动态链接还可以加强程序的兼容性。一个程序在不同的平台运行时可以动态地链接到由操作系统提供的动态链接库,这些动态链接库相当于在程序和操作系统之间增加一个中间层,从而消除了程序对不同平台之间依赖的差异性。
动态链接的基本实现
动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起,形成一个完整程序,而不是像静态链接一样把所有的程序模块链接成一个单独的可执行文件。
动态链接涉及运行时的链接及多个文件的转载,必需要有操作系统的支持,因为动态链接的情况下,进程的虚拟地址空间的分布会比静态链接情况下更为复杂,还有一些存储管理、内存共享、进程线程等机制在动态链接下也会有一些微妙变化。
Linux系统中,ELF动态链接文件被称为动态共享对象,简称共享对象,它们一般是”.so”文件。在windows系统中,动态链接被称为动态链接库,它们通常就是我们常见的”.dll”为扩展名的文件。
当程序被转载的时候,系统的动态链接器会将程序所需要的所有动态链接库装载到进程的地址空间,并将程序中所有未决议的符号绑定到相应的动态链接库中,并进行重定位工作。
7.2 地址无关代码
7.2.1 固定装载地址的困扰
在动态链接的情况下,如果不同模块目标装载地址都一样是不行的。而对于单个程序来讲,我们可以手工指定各个模块地址,但在多模块被多个程序使用的时候,管理这些模块的地址讲非常繁琐。
但早期的有些系统确实使用了这种做法,叫做静态共享库。但是它导致很多问题:地址冲突,静态共享库的升级。
7.2.2 装载时重定位
在链接时,对所有绝对地址的引用不作重定位,而把这一步推迟到装载时在完成。一旦模块装载地址确定,即目标地址确定,那么系统就对程序所有的绝对地址引用进程重定位。
静态链接时提到过重定位叫做链接时重定位。现在这种情况被称为装载时重定位,在Windows中,这种装载时重定位被称作基址重置。
7.2.3 地址无关代码
装载时重定位时解决动态模块中有绝对地址引用的办法之一,但它有一个很大的缺点就是指令部分无法在多个进程之间共享,这样就失去了动态链接节省内存的一大优势。
目的:程序模块中共享的指令部分在装载时不需要因为装载地址的改变而改变。
基本想法:把指令部分需要被修改的部分分离出来,跟数据部分放一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本。这种方案被称为地址无关代码。
模块中各种地址引用方式可以分为以下几种:
- 模块内部函数调用、跳转等。
- 模块内部的数据访问。
- 模块外部的函数调用、跳转等。
- 模块外部的数据访问。
类型一 模块内部调用或跳转
模块内部的跳转、函数调用都可以是相对地址调用,或者是基于寄存器的相对调用,所以对于这种指令是不需要重定位的。
类型二 模块内部数据访问
指令中不能直接包含数据的绝对地址,那么唯一的办法就是相对寻址。
类型三 模块间数据访问
模块间的数据访问目标地址要等到装载时才决定。ELF的做法是在数据段里面建立一个指向这些变量的指针数组,也被称为全局偏移表,当代码需要引用该全局变量时,可以通过GOT中相对的项间接引用。
类型四 模块间调用、跳转
GOT中相应的项保存的是目标函数的地址,当模块要调用目标函数时,可以通过GOT中的项进行间接跳转。
7.3 延迟绑定
动态链接有很多优势,比静态链接要灵活,但它牺牲了一部分性能代价。
动态链接比静态链接慢的主要原因是动态里链接对于全局和静态的数据访问都要进行复杂的GOT定位,然后间接寻址;对于模块间的调用也要线定位GOT,然后进行跳转。另一个减慢运行速度的原因是动态链接工作运行时完成,程序开始执行时,动态链接器都要进行一次链接。
在一个程序运行过程中,很多函数在程序执行完时都不会被用到,如果一开始把所有函数都链接好实际是一种浪费。所有ELF采用一种叫做延迟绑定的做法,基本思想:当函数第一次被用到时才进行绑定,如果不用到,则不绑定。
ELF使用PLT的方法来实现。
调用函数并不直接通过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”用来保存函数引用地址
7.4 动态链接相关结构
动态链接下,可执行文件的转载与静态链接情况基本一样。首先操作系统会读取可执行文件的头部,检查文件的合法性,然后从头部中的”Program Header”中读取每个”Segment”的虚拟地址、文件属性和地址,并将它们映射到进程虚拟空间的相应位置。
在静态链接情况下,操作系统接着就可以把控制权转交给可执行文件的入口地址,然后程序开始执行。但在动态链接情况下,操作系统还不能在装载完可执行文件就把控制权交给可执行文件,这个时候,可执行文件对于很多外部符号的引用还是处于无效地址状态,所在在映射完可执行文件之后,操作系统会先启动一个动态链接器。
操作系统加载完动态链接器后,将控制权交给动态链接器入口地址,当动态链接器得到控制权之后,它开始执行一系列自身初始化操作,然后根据环境参数,开始对可执行文件进行动态链接工作。当所有工作都完成后,动态链接器会将控制权转交到可执行文件的入口地址,程序正式开始执行。
7.4.1 “.interp”段
动态链接器的位置由ELF可执行文件决定。在动态链接的ELF可执行文件中,有一个专门的段叫做”.interp”段。
“.interp”段的内容很简单,里面保存的就是一个字符串,这个字符串就是可执行文件所需要的动态链接器路径。
动态链接器在Linux下是Glibc的一部分,也就是属于系统库级别。
7.4.2 “.dynamic”段
这是动态链接ELF中最重要的结构,这个段里面保存了动态链接器所有需要的基本信息,比如:依赖与那些共享对象、动态链接符号表的位置、动态链接重定位表的位置、共享对象初始化代码的地址等。
“.dynamic”段可以看成动态链接器下ELF文件的文件头。
7.4.4 动态链接重定位表
共享对象需要重定位的主要原因是导入符号的存在。
在动态链接中,导入符号的地址在运行时才确定,所以需要在运行时将这些导入符号引用修正,即需要重定位。
动态链接重定位相关结构
在动态链接的文件中,也有和静态文件类似的重定位的表,分别叫做”.rel.dyn”和”.rel.plt”。
7.4.5 动态链接时进程堆栈初始化信息
当操作系统将控制权交给动态链接器时,它需要知道可执行文件和本进程的一些信息,这些信息由操作系统传递给动态链接器,保存在进程的堆栈里面。
堆栈里面还保存了一些辅助信息数组
操作系统传递给动态链接器的辅助信息由4个:
- AT_PHDR,值0x08048034,程序表头位于0x08048034
- AT_PHENT,值为20,程序表头中每个项的大小为20字节
- AT_PHNUM,值为7,程序表头共有7个项
- AT_ENTRY,0x08048320,程序入口地址为0x08048320
7.5 动态链接步骤和实现
动态链接基本上分为3步:先是启动动态链接器本身,然后装载所有需要的共享对象,最后重定位和初始化。
7.5.1 动态链接器自居
动态链接器本身不可以依赖于其他任何共享对象;其次时动态链接器本身所需要的全局和静态变量的重定位工作由它本身完成。编写动态链接器时保证不使用任何系统库、运行库;对于第二个条件动态链接器必须在启动时有一段代码可以完成这项工作同时又不能用到全局和静态变量。这种具有一定限制条件的启动代码往往被称为自举。
7.5.2 装载共享对象
完成基本自举后,动态链接器将可执行文件和链接器本身的符号表都合并到一个符号表中,我们称为全局符号表。然后链接器开始寻找可执行文件所依赖的共享对象。在”.dynamic”段中,类型入口DT_NEEDED,它所指出的是该可执行文件所依赖的共享对象。链接器可以列出可执行文件所需要的所有共享对象,并将这些共享对象的名字放入一个装载集合中。然后链接器开始从集合里取一个所需要的共享对象名字,找到相对应的文件后打开该文件,读取相应的ELF文件头和”.dynalic”段,然后将它相应的代码段和数据段映射到进程空间。
当一个新的共享对象被装载进来的时候,它的符号表会被合并到全局符号表中,所以当所有共享对象都被装载进来的时候,全局符号表里面将包含进程中所有的动态链接器所需要的符号。
符号的优先级
两个不同模块定义了同一个符号会怎么样?
当一个共享对象里面的全局符号被另一个共享对象的同名全局符号覆盖的现象称为共享对象全局符号介入。
全局符号介入这个问题,在Linux下动态链接器是这样处理的:它定义了一个规则,那就是当一个符号需要被加入全局符号表时,如果相同的符号名已经存在,则后加入的符号被忽略。
7.5.3 重定位和初始化
当上面的步骤完成后,链接器开始重新遍历可执行文件和每个共享对象的重定位表,将它们的GOT/PLT中每个需要重定位的位置进行修正。
重定位完成之后,如果某个共享对象有”.init”段,那么动态链接器会执行”.init”段中代码,用以实现共享对象特有的初始化过程。共享对象中可能还有”.finit”段,当进程退出时,会执行”.finit”段中的代码,可以用来实现类似C++全局对象析构之类的操作。
如果进程的可执行文件也有”.init”段,那么动态链接器不会执行它,因为可执行文件中”.init”段和”.finit”段由程序初始化部分代码负责执行。
7.5.4 Linux动态链接器实现
内核在装载完ELF可执行文件以后就iu返回到用户空间,将控制权交给程序的入口。
对于动态链接器的可执行文件,内核会分析它的动态链接器地址,将动态链接器映射至进程地址空间,然后把控制权交给动态链接器。
Linux动态链接器本身就是一个共享对象。共享对象其实也就是ELF文件,它也有跟可执行文件一样的ELF文件头。动态链接器是个非常特殊的共享对象,它不仅是个共享对象,还是个可执行的程序。
Linux的内核在执行execve()时不关心目标ELF文件是否可执行,它只是简单按照程序头表里面的描述对文件进行装载然后把控制权交给ELF入口地址。
windows系统中的EXE和DLL也是类似的区别,DLL也可以被当作程序来运行,WINDOWS提供一个叫做rundll32.exe的工具可以把一个DLL当作可执行文件运行。
动态链接器本身应该是静态链接的,它不能依赖于其他共享对象,动态链接器本身是用来帮助其他ELF文件解决共享对象依赖问题的。
7.6 显示运行时链接
支持动态链接的系统往往都支持一种更加灵活的模块加载方式,叫做显示运行时链接,有时候也叫做运行时加载。
一般的共享对象不需要进行任何修改就可以进行运行时装载,这种共享对象往往叫做动态转载库。
动态库实际上跟一般的共享对象没有区别。主要区别是共享对象是由动态链接器在程序启动之前负责装载和链接的,这一系列步骤都由动态连接器自动完成,对于程序是透明的。而动态库的装载则是通过一系列由动态链接器提供的API,具体地讲共有4个函数:打开动态库(dlpen),查找符号(dlsym),错误处理(dlerror),关闭动态库(dlclose),程序可以通过这几个API对动态库进行操作。
7.6.1 c
dlopen()函数用来打开一个动态库,并将其加载到进程的地址空间,完成初始化过程。
7.6.2 dlsym()
dlsym函数基本上是运行时装载的核心部分,可以通过这个函数找到所需要的符号。
7.6.3 dlerroe()
每次调用dlopen(),dlsym(),dlclose(),以后,我们都可以调用dlerroe()函数来判断上次调用是否成功。
7.6.4 dlclose()
dlclose()的作用跟dlopen()刚好相反,它的作用是将一个已经加载的模块卸载。系统会维持一个加载引用计数器,每次使用dlopen()加载某模块时,相应的的计数器加一;每次使用dlclose()卸载模块时,相应的计算器减一。只有当计数器减到0时,模块才被真正卸载掉。