动态链接
动态链接的诞生:
动态链接产生最主要的原因就是静态链接空间浪费过于巨大,更重要的是现阶段各种软件都是模块化开发,不同模块都是由不同厂商开发的,一旦一个模块发生改变,整个软件就需要重新编译(静态链接的情况下)。
动态链接主要思想:
把链接这个过程推迟到了运行时再运行,这就是动态链接(Dynamic Linking)的基本思想。
动态链接的好处:
1.动态链接将共享对象放置在内存中,不仅仅节省内存,它还可以减少物理页面的换进换出,也可以提高CPU缓存的命中率,因为不同进程间的数据与指令都集中在同一个共享模块上。
2.当一个软件模块发生改变的时,只需要覆盖需要更新的文件,等程序下一次运行时自动链接更新那么,就算是跟新完成了。
3.增加程序的可扩展性和兼容性,它可以在运行时动态的加载各种程序模块,就是后来的插件(plug-in).
动态链接的基本实现:
动态链接的基本思想就是把程序按照模块拆分成各个相对独立部分,在程序运行时才将他们链接在一起形成一个完成的程序,而不是像静态链接把所有的模块都链接成一个单独的可执行文件。再Linux下ELF动态链接被称为动态共享库(DSO)。动态共享文件的制作参照之前的一片博文,后边将会对这个制作参数——FPIC 进行详细的介绍。
动态链接的数据结构:
使用readelf 工具可以详细的查看动态链接文件(.so)文件,发现动态链接模块的装载地址是从0x0000000000开始的,但是这个地址是无效的地址,所以共享对象的最终装载地址在编译时是不确定的,而是在装载时根据当前的地址空间的空闲情况,动态分配一块足够大小的虚拟地址空间给相应的共享对象。
地址无关代码(fPIC)
首先看看固定装载地址的困扰,如果都是装载地址都是固定好的,那么它的地址就是在编译时候确定的,那么当这个程序再需要增加模块或者程序段的时候,地址就会发生冲突,过去有人提出了改进这种问题的方法,然而这并不是一种好方法,叫做静态共享库,这种方法将分配的指空间的任务交给了操作系统,操作系统在适当的时候给需要使用的模块预留地址空间,这样导致了很多其它的问题产生,这样就需要一直保持全局变量的地址不变,升级了软件后全局变量的地址依然不能变化,如果增加代码,变量等东西,会导致地址不够用。
简而言之,共享对象在编译时不能假设自己在进程虚拟地址空间中的为位置。
重定位是一个解决问题的好方法,以重定位为基础开发了地址无关代码的技术,就是编译生成共享对象的那个参数,-fPIC 。
首先我们分析下模块中各种类型的地址引用方式。我们把共享对象模块中的地址引用按照是否需要跨模块分成两类:模块内部引用,模块外部引用。按照不同的引用方式又可以分为指令引用和数据访问。
1.第一种是模块内部的函数调用,调转等。
2.第二种是模块内部的数据访问,比如模块内部的全局变量,静态变量等。
3.第三种是模块外部的函数调用,调转等。
4.第四种是模块外部的数据访问,比如其它模块中定义的全局变量。
下面详细描述一下这四种访问方式:
第一种类型:
第一种类型是最简单的就是模块内部的调用或调转,因为被调函数与调用函数都在函数内部,这在编译链接时他们的相对位置时确定的,这是相对地址调用,或者是基于寄存器的相对调用,所以这种调用是不需要重定位的。
Call 指令地址:对于32位来说,第一个字节是指令的地址码,后边的四个字节是目的地址相对于当前指令的下一条指令的偏移。
第二种类型:
第二种类型是数据模块内部的数据访问,很明显,指令中不能直接包含数据的绝对地址,那么唯一的办法就是相对寻址。但是如何获得当前指令的地址是一个问题,因为现代操作系统,数据的相对寻址往往没有相对于当前指令的寻址方式,所以ELF 采用了一种巧妙的方法来获取当前指令地址(PC)。在汇编层面会调用一个_i686.get_pc_thunk.cx 的函数,作用是把返回地址的值放在ecx寄存器就是把call 的下一条指令地址放到ecx寄存器中。使用这个这种方法可以确定模块内部的变量以及函数的入口。
第三种类型:
模块间的数据访问要更加复杂一些,首先还是需要地址无关的属性,毕竟是在多个模块中调用函数,变量。所以最好只让它拥有一个父本,但是多个部分都能引用的上,所以ELF 在数据段里面建立一个指向这些变量的指针数组(*),也被称之为全局偏移表(GlobalOffset Table, GOT),当代码需要引用全局变量的时候通过GOT中相应的项间接引用。
当程序需要访问变量的时候,首先通过第二种方式得到PC的值,然后根据偏移量(偏移量在编译链接时就是已知的)找到GOT 段,在GOT中偏移就可以得到变量的地址,每一个变量对应一个一个地址(4字节)至于对应的顺序是由编译器决定的。
第四种类型:
模块间的函数调转与调用,和第三种类型相似,GOT段同样保存着各个函数的地址,同样运用第二种方法首先找到PC 地址,然后加上一个偏移得到函数地址在GOT中的偏移,然后得到一个间接的调用。
另:
-fpic与 -fPIC ,这两个参数都是gcc 产生地址无关代码,唯一的区别是“-fPIC”产生的代码要大,而“-fpic”产生的代码相对较小,而且较快,但是fPIC在某些硬件平台上会有一些限制,比如全局符号的数量或者代码长度等而是“-fPIC”则没有这样的限制,所以为了方便都使用“-fPIC”参数来产生地址无关代码。
Ps: 区分一个共享对象,是否为PIC:
使用工具readelf 可以辨识。
Readelf -d foo.so | grep TEXTREL
如果有输出那就不是PIC 的,否则就是PIC 的。PIC 的DSO 是不会包含任何代码段重定位表的,TEXTREL 表示代码段重定位表地址。
地址无关代码技术还可以使用在可执行文件上,一个以地址无关可执行方式产生参数位fpie.
延迟绑定(PLT):
动态链接虽然节省了内存,但是牺牲了一些性能,所以为了改进性能就使用了新的技术延迟绑定技术(PLT),基本思想是:当函数第一次被使用时才进行绑定.
基本原理:当我们调用外部函数时,一般是通过访问GOT段查找相应的项,进行调转。PLT(ProcedureLinkage Table)为了实现延时绑定,在中间又添加了一层调转。调用函数并不通过GOT调转,而是通过一个叫PLT的项进行调转,每个外部函数在PLT中都有一个对应的项称之为test@plt,假设由一个外部函数叫test( );那么在就会有这几行代码:
jmp*(test@plt)
push n
push moduleID
jump dl_runtime_resolve
test@plt:表示GOT中保存的test中的项。
这个项的第一条指令jmp指令是一条通过GOT之间调转的指令。
如果连接器在初始化的时候已经初始化了这个项,那么目标函数的地址已经填入该项,这就是我们期望的,直接调用就好还省的我们去GOT中找了,但是为了实现延时绑定,连接器并没有将函数地址放进去,而是将第二条指令push n 的地址加入到第一条指令中,这个步骤不需要查找任何一个符号表,代价很低,也很值的。
第二条指令将一个数字N(test 这个符号在rel.plt中的下标)压入栈中。
第三条指令将模块ID 压入堆栈。
第四条指令:调转到dl_runtime_resolve
这就是实现lookup(module,fun)函数的调用。
先将决议符号下标压入栈中,再将模块ID压入堆栈,然后调用连接器的dl_runtime_resolve()函数完成符号解析和重定位工作。这个函数在一系列的操作之后将test()函数的真正地址填入test@GOT,当中。此时GOT中才有了函数真正的函数,解也就解答了当时的一个问题,既然连接器可以把函数的地址填入GOT,为什么还有延时绑定,其实只有经历这些过程后函数地址才能被绑定。这就是PLT方式的基本实现,实际延时绑定的工作要比这样复杂的多。
GOT段的一些 信息:
ELF 将GOT 段分成了两个部分,其中一个叫 . got 段,另一个为.got.plt .其中got 段用来保存全局变量引用的地址,got.plt 用来保存函数引用的地址,对于外部函数的引用全部被分离出来放到了got.plt 段表中。另外还有一个特殊的地方,是它的前三项是有特殊意义的:
第一项保存的是.dynamic 的地址,这个段描述了本模块动态链接相关信息。
第二项保存的是本模块的ID。
第三项保存的是_dl_runtime_reslove()函数的地址。
其中二三项是由动态链接器在装载共享模块的时候将负责将他们初始化。
动态链接相关结构:
动态链接器:在interp 段保存了一些字符串,这些字符串就是动态链接器的地址。
其中动态链接结构中还包含dynamic段,动态符号表,动态链接重定位表,辅助信息组等信息,(初始化堆栈时初始化的一些信息,包括程序头,大小,类型,入口地址等)。
动态连接的步骤和实现:
基本分成三步:先是启动动态链接器本身。然后装载所有需要的共享对象,最后是重定为位和初始化。
首先是启动动态连接器本身,这就是动态连接器自举:
动态链接器本身就是一个共享对象,它自己需要启动自己,别人启动靠他,但是他启动靠谁呢?,答案是只能靠他自己,动态链接器不能依赖任何其它共享对象,其次它本身的全局变量,局部变量的重定位工作都由他自己完成,完成这些要求的代码非常精细与精巧,这种代码往往被为自举。动态连接器的入口地址就是字句代码的入口,当操作系统将控制权给动态连接器后,自举代码开始执行,首先访问自己的GOT段。然后找到dynamic段, 找到自身的符号表,重定位表等从而得到动态链接器本身的重定为入口,先将他们重定位。从这一步开始时用自己的全局变量和静态变量。实际上在动态连接器在自举的过程中,除了不可以使用全局变量和静态变量之外甚至不能调用函数,时用PIC 模式编译的共享对象,对于模块内部函数调用和外部函数调用的方式是一样的,时用GOT/PLT方式,所以在GOT/PLT没有被重定位之前,自举代码不可以使用任何全局变量。
装载共享对象:
完成基本的自举以后,动态连接器将可执行文件和链接器本身的符号都合并到一个符号表当中,我们可以称之为全局变量。如果将这些装载的过程看作是一个图的遍历过程,使用的算法一般都是广度优先。
当一个符号表需要被加入全局符号表时,如果相同的符号已经存在,则后加入的符号被忽略。
重定位和初始化:
首先进行GOT/PLT重点定位完成后,如果某个段有init 段,那么动态链接器就会执行init段中的代码,用以实现共享对象的初始化过程,比如C++的全局变量和静态对象,就需要通过init 来初始化,相应的可能还有 finit 段,当进程执行完成后就会执行实现C++全局对象析构之类的操作。
Q&A:
动态链接器本身是静态还是动态?
其实动态链接器本身是静态的。
动态链接器本身必须是IPC的吗?
是否是IPC 的对于动态链接器并不影响,但是如果是IPC 的就会更加的简单,实际上动态链接器就是PIC 的。
动态链接器可以被当成可执行文件,那么它的装载地址是多少?
动态链接器和普通的文件都是一样的,加载地址都是0x0000000000,这个地址是一个无效的地址,作为一个共享库,内核在装载后会为其选择一个合适的装载地址。
运行时加载:
其实还有一种更加灵活的加载方式,叫做显示运行时链接,有时候也叫做运行时加载,以共有四个函数。实际上是以库的形式调用的,基本是先打开库然后根据需要加载的函数符号加载相应的函数。所以一共有这四个函数。
Dlopen( ) :用来打开一个动态库,并将其加载到进程的在地址空间
dlsym( ):这是加载的核心,我们可以通过这个函数找到所需要的符号
dlerror( ):错误处理,都可以用来判断上一次调用是否成功。
dlclose( ):将一个已经加载的模块卸载。系统会维持一个加载引用技术器,每次使用加载模块时,相应计数器加一,当使用这个函数卸载一个模块时数字就减一。