为了解决空间浪费和更新困难问题,动态链接把程序的模块相互分割开,形成独立的文件。
即不对那些组成程序的目标文件进行链接,等到程序要运行时才进行链接。
------------------------------------------------------------------------------------
程序可扩展性和兼容性
程序在运行时可以动态地选择加载各种程序模块,这个特点被用来制作程序的插件。
------------------------------------------------------------------------------------
动态链接的基本实现
动态链接的基本思想是 把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,
而不是像静态链接一样把所有的程序模块都链接成一个单独的可执行文件。
在Linux系统中,ELF动态链接文件被称为动态共享对象(DSO),简称共享对象,以“.so”为扩展名;
在windows中,动态链接文件被称为动态链接库,以“.dll”为扩展名。
在使用动态链接库的情况下,程序本身被分成了程序主要模块(Program1)和动态链接库(Lib.so)。
当程序被装载时,系统的动态连接器会将程序所需要的所有动态链接库(最基本的即libc.so)装载到进程的地址空间,
并且将程序中所有未决议的符号绑定到相应的动态链接库中,并进行重定位工作。
两个程序的主模块Program1.c和Program2.c分别调用了Lib.c里面的foobar()函数。
使用gcc将Lib.c编译成一个共享对象文件:
"-shared"表示产生共享对象。使用"-fPIC"参数可产生地址无关代码。
在静态链接中,会将Program1.o和Lib.o链接产生可执行文件。
而动态链接参与的则是Lib.so,因为Lib.so中保存了完整的符号信息,链接器在解析.o文件符号时便可以得知foobar是一个定义在Lib.so的动态符号。
**********************
关于模块
**********************
在静态链接时,整个程序最终只有一个可执行文件,它是一个不可分割的整体;但在动态链接下,一个程序被分成若干文件,有程序的
主要部分,即可执行文件(Program1)和程序所依赖的共享对象(Lib.so),很多时候把这些部分称为模块,即动态链接下的可执行文件
和共享对象都可以看做是程序的一个模块。
当Program1.c被编译成Program1.o时,编译器不知道foobar()函数的地址;
当链接器将Program1.o链接成可执行文件时,链接器必须确定Program1.o中引用的foobar()函数的性质。
如果foobar()函数是一个定义在其他静态目标模块中的函数,链接器会将Program1.o中的foobar()函数地址引用重定位;
如果foobar()是一个定义在某动态共享对象中的函数,那么链接器就会将这个符号的引用标记为一个动态链接的符号,不对它进行地址重定位,留到装载时再进行。
-----------------------------------------------------------------------------------------------------------------------------------
动态链接程序运行时地址空间分布
修改Lib.c
查看进程的虚拟地址空间分布
OS用同样的方法将多个文件映射至进程的虚拟内存空间。
除了Program1,Lib.so以外,有动态链接形式的C语言运行库libc-2.19.so,动态连接器ld-2.19.so。
在系统开始运行Program1之前,首先会把控制权交给动态连接器,由他完成所有的动态链接工作后再将控制权交给Program1,然后开始执行
除了文件的类型与普通程序不同以外,其它几乎与普通程序一样。
动态链接模块的装载地址是从0x00000000开始的,即共享对象的最终装载地址在编译时是不确定的,而是在装载时由装载器临时动态分配的。
------------------------------------------------------------------------------------------------------------------------------
问题:共享对象在被装载时,如何确定它在进程虚拟地址空间中的位置?
1.可以手工指定,即静态共享库,将程序各个模块统一交给操作系统来管理,OS在某个特定的地址划分出一些地址块,为那些已知的模块预留足够的 空间。但会有地址冲突的问题,升级问题(必须保持共享库中全局函数和变量地址的不变)
2.共享对象在任意地址加载:共享对象在编译时不能假设自己在进程虚拟地址空间中的位置;而可执行文件基本可以确定自己在进程虚拟空间中的起 始位置,因为可执行文件往往是第一个被加载的文件。
3.装载时重定位:在链接时对所有绝对地址的引用不作重定位,而把这一步推迟到装载时再完成。一旦模块装载地址确定,即目标地址确定,那么系 统就对程序中所有的绝对地址引用进行重定位。产生共享对象时使用"-shared"且不用"-fPIC",则输出的共享对象就是使用装载时重定位的方法。 但装载时重定位会修改指令,使得无法共享指令代码部分;但数据部分是可以用装载时重定位的,因为进程们都有数据部分的私有副本。
4.地址无关代码:装载时重定位的缺点是指令部分无法在多个进程之间共享。
\||/
希望程序模块中共享的指令部分在装载时不需要因为装载地址的改变而改变
\||/
把指令中需要修改的部分分离出来,跟数据部分放在一起,指令部分保持不变,数据部分可以在每个进程中拥有一个副本
以上即:地址无关代码(PIC,Position-independent Code)的技术。 注:使用PIC的程序也需要重定位,只是代码段不需要而已。 -----------------------------------------------------------------------------------------------------------------------
模块中各种类型的地址引用方式:
1.模块内部的函数调用、跳转等
2.模块内部的数据访问,比如模块中定义的全局变量、静态变量
3.模块外部的函数调用、跳转等
4.模块外部的数据访问,比如其他模块中定义的全局变量
-----------------------------------------------------------
因为模块间访问的目标地址要等到装载时才确定,根据PIC思想,应将地址相关的部分放到数据段。
ELF的做法是在数据段里面建立一个指向这些变量的指针数组,也称为全局偏移表(GOT)【数据段的一部分】。
当程序需要访问某外部变量时,程序先找到GOT,然后根据GOT中变量所对应的项找到变量的目标地址。
链接器在装载模块时会查找每个变量所在的地址,然后填充GOT中的各个项。
如何区分一个DSO是否为PIC:readelf -d foo.so |grep TEXTREL
如果有输出则不是PIC的;TEXTREL表示代码段重定位表地址,而PIC的DSO是不会包含任何代码段重定位表的。
PIC与PIE
地址无关代码也可用于可执行文件。GCC参数为"-fPIE"。
--------------------------------------------------------------------------------------------------------------
共享模块的全局变量问题
缺省
-----------------------------
数据段地址无关性
对数据段来说,它在每个进程都有一份独立的副本,因此可以选择装载时重定位的方法来解决数据段中绝对地址引用问题。
对于共享对象,如果数据段中有绝对地址引用,那么编译器和链接器就会产生一个重定位表,这个重定位表里面包含了"R_386_RELATIVE"类型的重定位入口,用于解决数据段重定位问题。当动态连接器装载共享对象时,如果发现有这样的重定位入口,则对该共享对象进行重定位。
对可执行文件来说,默认情况下,如果可执行文件是动态链接的,那么GCC会使用PIC的方法来产生可执行文件的代码段部分。
---------------------------------------------------------------------------------------------------------------
延迟绑定(PLT,Procedure Linkage Table)
ELF采用了一种叫做延迟绑定(Lazy Binding)的做法,即当函数第一次被用到时才进行绑定(符号查找,重定位等)。
当调用某个外部模块函数时,如果按照通常的做法应通过GOT中相应的项进行间接跳转。
PLT为了实现延迟绑定,在这个过程中增加了一层间接跳转。
调用函数并不直接通过GOT跳转,而是通过一个叫PLT项的结构来进行跳转。
每个外部函数在PLT中都有一个相应的项,如bar()函数在PLT中的项的地址我们称之为bar@plt。
bar@plt:
jmp *(bar@GOT) 如果GOT中的该项已经初始化,则跳到bar();否则执行下一条指令
push n bar这个符号在".rel.plt"中的下标压栈
push moduleID 函数所在模块的ID入栈
jump _dl_runtime_resolve 完成符号的解析和重定位工作,将bar()的真正地址填入bar@GOT中,再次调用bar@plt
ELF将GOT拆成".got"和".got.plt"两个表。前者保存全局变量引用的地址,后者用来保存函数引用的地址。
PLT在ELF文件中以独立的段存放,段名为".plt",因为它本身是一些地址无关的代码,所以可以和代码段合并成同一个可读可执行的segment。
-----------------------------------------------------------------------------------------------------------------------------
动态链接相关结构
1.".interp"段:interpreter,内容时可执行文件所需要的动态连接器路径,查看方法:readelf -l a.out |grep interpreter
2.".dynamic"段:保存了动态链接器所需要的基本信息。用readelf -d Lib.so查看。
ldd可查看一个程序主模块或一个共享库依赖于哪些共享库
3.动态符号表:".dynsym"只保存了与动态链接相关的符号。辅助表有:动态符号字符串表".dynstr",符号哈希表".hash"。
4.动态链接重定位表:共享对象需要重定位的主要原因是导入符号的存在。除了GOT以外,数据段还可能包含绝对地址引用。".rel.dyn"是对数据引用 的修正,所修正的位置位于“.got”以及数据段;“.rel.plt”是对函数引用的修正,所修正的位置位于".got.plt"。
-----------------------------------------------------------------------------------------------------------------------------------
动态链接时进程堆栈初始化信息
-----------------------------------------------------------------------------------------------------------------------------------
动态链接的步骤和实现
1.启动动态连接器
2.装载共享对象
1)动态连接器将可执行文件和链接器本身的符号表合并到全局符号表
2)动态连接器开始寻找可执行文件所依赖的共享对象(".dynamic"段中),并将这些共享对象的名字放入一个装载集合中。
3)动态连接器开始从集合中取出一个名字,找到相应文件后打开并读取相应的ELF头文件和“.dynamic”段,将它相应的代码段和数据段映射到进程空间中。
4)如果这个ELF共享对象还依赖于其他共享对象,那么将所依赖的共享对象名字放到装载集合中。
5)当一个新的共享对象被装载进来时,它的符号表也会合并到全局符号表。【全局符号介入问题】
3.重定位和初始化
1)linker重新遍历可执行文件和每个共享对象的重定位表,将它们的GOT/PLT中的每个需要重定位的位置进行修正。
2)重定位完成后,如果某个共享对象有".init"段,linker会执行该段的代码。而可执行文件的".init"段则不用执行,那是由程序初始化代码负责的。
3)linker将进程的控制权交给程序的入口并开始执行。