前面说了静态链接的流程,提到了静态链接与动态链接的不同之处以及各自的优势:静态链接的优势在于其优秀的可移植性,但是相对应的其所占空间大小也很大,且还有在对程序的更新、维护方面也有着问题。
动态链接则消除了这方面的问题,即使得空间不再浪费,更新一个程序也变得不再麻烦。
假设有两个程序a和b,如果两个都依赖于Libc.o这个模块,那么当我在系统中同时执行这两个程序时,静态链接的情况下就会在内存中产生两个Libc.o的副本,典型的浪费了空间
而动态链接将链接的过程推迟到了运行时就解决了这个问题,运行前先将可执行文件装入内存,然后查询所需要依赖的目标文件是否已存在于内存,如存在就不必再装入,如不存在就将其装入,直到所有依赖的目标文件装入完成,随即开始进行链接的过程
上面的方法不仅解决了空间浪费的问题,对于程序更新维护的问题也一并解决了。
例如a程序中使用的Lib.o由其他人提供,当此人对其进行了更新了以后,那么使用静态链接的情况下,a程序的开发者需要拿到最新的Lib.o,进行重新链接后将新的a程序发布给用户,这种情况下程序中任何一个小地方的改动都会导致程序重新下载,而对于动态链接来说,用户只需要下载最新的Lib.o就可以了,只要保证调用的函数接口不变,就可以直接在运行时链接。
动态链接的基本思想即将链接的过程推迟进行,在运行时加载至内存后再进行链接。
上面是两个简单程序a和b,都依赖于Lib.o这个目标文件,Lib.h是需要包含头文件
首先将Lib.c编译成一个共享对象文件
然后生成了Lib.so这个共享对象,之后分别编译连接a.c和b.c
从而生成a和b两个ELF文件
总结一下上面动态链接的流程
这里产生一个问题:Lib.so也参与了a.c文件的链接过程,但是前面说过动态链接的基本思想是将链接过程推迟到加载后再进行链接,这里貌似冲突了?
之前解释过编译的过程,在a.c文件的编译过程中foobar()函数编译器是不知道其地址的,于是将这个问题留给链接器处理。
这里链接器就对foobar()确定其性质,如果它在静态链接库中就使用静态链接的规则进行重定位,而如果foobar()位于动态共享对象中的话,就将这个符号标记为一个动态链接的符号,不进行重定位,留到加载时再进行。
这里参与链接过程的Lib.so实际上提供了符号信息,即用于说明foobar是一个定义在Lib.so中的动态符号,于是链接器不对其进行重定位,留待加载时进行。
对于一个静态链接文件,整个进程只需要映射一个文件即ELF文件本身,但是对于动态链接,除了可执行文件本身,还需要映射它所依赖的共享目标文件,那么进程地址空间如何分布?
由于需要查看虚拟地址空间分布,我们在前面Lib.c的基础上进行修改
将其编译成为共享对象文件(.so),利用动态链接的特性,我们不需要再去对原本的a.c及b.c进行链接,可以直接执行
我们可以看到整个虚拟地址空间中有着多个文件的映射,其中a是我们链接生成的ELF文件,Lib.so是我们定义的共享对象文件,不过还有两个文件,其中libc-2.19.so是动态链接形式的C语言运行库,另一个ld-2.19.so则是动态链接器。
从中不难发现动态链接与静态链接的链接器不同,动态链接的扩展名也为.so,分明也是一个共享对象,与静态链接不同,当系统开始运行a程序时,首先会将控制权交给动态链接器,当完成所有动态链接工作后再把控制权还给a程序,进而执行程序。
分别看a文件与Lib.so文件的装载属性
可以看到与静态链接文件相同,其中有两个装载的Segment,整个a文件的起始装载地址为0x08048000,与静态链接文件不同之处在于其多了一些Segment,这是为了实现动态链接而所需要的部分
接着来看Lib.so的装载属性
除了文件类型与普通程序不同以外,其他几乎与普通程序一样,不过还有一点不同,.so文件的装载地址从0x00000000开始,这个地址很明显是无效的,而且从前面查看进程的虚拟地址空间分布中可以看出Lib.so文件的实际装载地址也不是0x00000000,于是得出一个结论,共享对象的最终装载地址在编译时并不确定
1、装载时重定位与地址无关代码
前面说过,共享对象在装载时的地址不是指定好的,其实是装载器根据当前地址空间的空闲情况为其动态分配的,那么为什么要在任意地址空间为其分配呢?
动态链接的情况下,不同的模块装载地址一样是不行的。对于一个单个程序,我们可以指定各个模块的地址,但是对于某个模块被多个程序使用,或者是多个模块被多个程序使用,那么就会产生冲突的情况,比如1个人指定A模块为0x1000-0x2000,另一个人不使用B模块,而且指定B模块地址为0x1000-0x2000,那么很明显,A与B两个模块无法同时存在,任何人不能再同一个程序内使用模块A与B。
另外,此种情况下升级共享库也成了很大的问题,首先共享库必须保持其中全局函数与变量地址不变,因为链接时已经绑定了这些地址,如果改变就要重新链接,而且由于被分配的地址空间肯定有限制,所以对于共享库的升级,其大小不能增大太多,否则就会超过被分配空间。
因此共享对象必须要在任意地址加载,那么就不能假设自己在进程虚拟地址空间中的位置。
为了完成上面所说的共享对象在任意地址加载,那么如何解决共享对象地址的问题呢?程序运行时,指令所调用的函数地址或者变量地址都必须是确定的,对于共享对象这种任意地址加载的情况下,如果解决呢?
首先运用前面在静态链接中所学到的重定位的概念,那么稍加改变,在链接的过程中仍然不对程序中使用到的动态链接符号进行重定位,推迟到装载时再完成,即一旦模块装载地址确定,就对程序中所有绝对地址引用进行重定位。这样的方式叫做装载时重定位,而之前在静态链接中所提到的重定位叫做链接时重定位。
但是此种情况下还是有个问题,我们所希望的共享对象在内存中后可以被多个进程共享,即只要装载共享对象一次即可不像静态链接情况下需要多次装载(这里的装载共享对象一次是指物理内存中装载了共享对象),但是对于不同进程来说,物理内存中的共享对象在各自的虚拟地址动检还是需要各自进行映射,同时各自映射的地址也不同,这就造成了共享对象的变量及一些函数的地址在虚拟地址空间中的不同。对于装载时重定位来说,只要重定位就必须要改变指令中的具体地址,特别是绝对地址,势必造成各自进程中的共享对象的代码段不同,那么要使其能够正常运行,就必须有各自的副本,这就失去了和静态链接相比能够节省内存的优势,所以这种方法是不太适合的。
于是引出了第二种能够让共享对象任意地址加载的方法。
对于地址无关代码,需要分情况来讨论,共享对象模块中的地址引用可以按照是否跨模块分为两类:模块内与模块外,按照引用方式的不同可分为指令引用与数据访问,指令引用与数据访问实际上就是我们在静态链接中两种重定位入口的区别(指令引用使用相对地址引用而数据访问采用绝对地址引用)
以一个例子来实际的理解地址无关代码技术:
可以得到由上面的源文件得出的共享对象文件。
由于被调用函数与调用者位于同一个模块,于是相对位置固定,所以根本不要用到地址无关代码的技术,直接进行相对地址引用,不需要重定位。
图中其实地址位于5a3的语句即为调用bar()函数的语句,不过可以看到和想象中有些不同,调用的是bar@plt函数,关于这个牵扯到了延迟绑定(PLT)的内容,后面会说明。
对于模块内部的数据访问,使用的是绝对地址,因为前面说过指令中不能直接包含数据的绝对地址,所以我们要将其改为相对地址。
一个模块前面若干页是代码,后面若干页是数据,这些页之间的相对位置固定,那么就简单了,任何一个指令与其所需要访问的模块内部数据之间的相对位置固定,于是相对于当前命令加上偏移量即可。
图中的白底部分即访问模块内部变量a,并赋值为1的具体实现,其中第一句调用了__x86.get_pc_thunk.cx函数,那么这个函数是干什么的呢?
这就是这个函数的具体实现,可以看到只是将堆栈的栈顶指针esp的值赋值给ecx寄存器,那么目的是什么呢?
当处理器执行call指令后,下一条指令的地址会被压到栈顶,而esp即指向栈顶,于是这个函数能够将下条指令的地址存入ecx寄存器。
那么我们继续看下面的语句,将ecx寄存器中的值加上0x1a8d,我们可以计算一下:下一条指令地址+0x1a8d=0x0573+0x1a8d=0x2000,于是此时ecx寄存器中存储的地址应是0x2000,看看这个地址位于哪里
.got.plt段(延迟绑定中会讲到此段的具体作用)的起始地址就是0x2000,当然这是还没有装载时的地址,如果装载的话上面计算的地址都要加上共享对象装载的起始地址的,于是上面的两句实际上找到了.got.plt段的具体位置。
最后在这个地址的基础上加上了偏移量0x24,于是比对上一张图我们可以看到,实际上找到了.bss段中,而对于没有初始化的全局变量,确实存放于.bss段中。
ELF使用模块的实际装载地址+下条指令的地址+偏移量获得.got.plt段的地址,再根据具体变量与.got.plt段起始地址的偏移量找到数据变量。
于是模块内的调用与数据访问都是用相对地址完成。
由于共享对象的地址要到装载时才能确定,所以共享对象模块间的数据访问也需要等到装载时才能够决定,所以比较麻烦。
这里是模块外部的数据访问,无法计算模块与模块间的偏移量,那么相对地址引用就无法使用,必须牵扯到绝对地址的引用。此种情况下,要使地址代码无关,基本思想就是把跟地址相关的部分放到数据段中由于数据段每个进程都有各自的副本,所以不会影响到代码段的多进程共享。于是ELF在数据段中建立一个指向这些变量的指针数组,即全局偏移表(Global Offset Table),代码需要引用此全局变量时,通过GOT中相对应的项间接引用即可。
比如指令要访问b,就会先找到GOT,根据其中变量所对应的项找到目标地址,每个变量对应一个4字节的地址(32位)。装载模块时链接器会查找每个变量所在地址,充填GOT中的项。
图中是例子中bar( )函数对模块外部变量b进行数据访问并赋值2的语句,前面说过eax寄存器的值为.got.plt段的起始地址,此时第一句在此地址基础上减去偏移量0x14,实际上找到了0x1fec的位置
从图中可以看到,0x1efc的位于.got段中,且应为第二项,于是找到了变量b的绝对地址,从而给变量b赋值。
上面那种情况理解后,这种就很好理解,就是在GOT中符号所对应的并不再是变量地址,而是函数的入口地址,从而通过GOT中找到相应的项然后找到相应的入口地址,从而跳转执行
上面就是地址无关代码所使用的技术,可以看到通过这种方法我们解决了共享对象模块内以及其与其他模块之间的数据访问与函数调用问题,于是我们得以实现多个进程同时共享一个装载完成的共享对象的目的。
下面考虑一种情况:
如果在一个叫做module.c文件中使用了一个定义于其它模块(但不是共享对象)的整型变量global,那么如何声明?
这里应该已经发现了一个问题,这种情况下,链接器无法分辨这个变量到底是定义在哪里了,是使用了PIC的共享对象模块呢还是没有使用PIC技术的主模块中呢?前者需要在装载后才能够知道具体地址,那么在链接时候就无法重定位出正确地址,而后者则在链接时就可重定位正确地址。对于这种不知道的情况,链接器会在.bss段中定义一个global变量的副本,于是问题出现,.bss段中有个副本,而共享对象模块中还有其定义的那个部分,一个变量出现在了两个位置上,这在实际运行过程中是肯定不行的,那么如何处理?
解决的办法就是统一所有使用这个变量的指令都指向同一个位置,这个位置是.bss段中的那个副本。共享对象编译时,默认将所有定义于模块内部的全局变量当做定义在其他模块的全局变量,于是就像前面所说PIC中的类型三,通过GOT来进行数据的访问。在装载时,会对主模块中的变量进行判断,如果某个全局变量有副本,那么就把GOT中的相应项指向这个副本,于是得以使这个变量的位置统一,如果这个变量在共享对象中进行了初始化,那么就将这个初始化的值也放入副本中;如果没有这个变量的副本,那么就自然指向了共享对象模块内部的那个唯一的副本。
动态链接的确比起静态链接来说有许多优势,节省内存、更易更新维护等,但是它也因此付出了一定的代价,使得ELF程序在静态链接下摇臂动态链接稍微快些,根据前面所讲,这些代价来自于两方面:1、动态链接在装载后进行链接工作;2、动态链接对于全局和静态的数据访问要进行复杂的GOT定位,然后进行间接寻址,比静态链接麻烦的多。那么如何进行优化?
其实我认为这种性能优化的中心思想和动态链接的基本思想差不多,根本来说就是推迟,不需要马上用的函数就推迟对其的链接,这种方法就叫做延迟绑定(PLT),即当函数第一次被用到时才进行绑定(符号查找、重定位等操作),如果用不到就不进行绑定。
由于有了上面的技术,所以可以推断出程序开始执行时,模块间函数的调用都没有进行绑定,而是等到需要使用时才会由动态链接器负责绑定,于是大大加快了程序的启动速度。
那么延迟绑定如何实现?
前面说过,对于调用外部模块的函数时,由于地址无关代码的机制,我们需要通过GOT中相应的项来进行间接跳转,而PLT为了实现延迟绑定,在此基础上又加了一层间接跳转。
因为实际实验中的代码跟原理上有点不同,所以这里先说一下原理,之后再来看实际的情况
这是一个bar@plt的实现,即在GOT的基础上多加的一层间接跳转的具体代码。
首先第一个jmp语句是一个间接跳转,跳转的具体位置即后面标识的地方,这里就是GOT中保存bar()的相应的项,如果链接器已经初始化了该项(填入了正确的地址),那么就会直接跳转到bar()函数执行,但是为了实现PLT,链接器在初始化的阶段实际填的地址是下一条指令的地址,于是这条相当于执行下一句。当然,等到实现了绑定后,这里就实际上完成了跳转执行bar()函数的功能;
第二句push,相当于bar这个符号在重定位表“.rel.plt”中的下标,即函数的编号。这里考虑下,如果我们需要使用一个函数来绑定函数地址,那么我们需要哪些参数?答案是需要知道这个地址绑定发生在哪个模块中的哪个函数,这里的n实际上就是将哪个函数的参数进行压栈;
第三句push,上面已经压入了发生绑定的函数编号,下面就是模块了,于是压入模块ID;
最后跳转_dl_runtime_resolve,这个函数就是用来根据模块ID及函数编号进行绑定的函数,于是完成了绑定的功能。接下来第二次跳转函数的地址,就可以进而执行具体函数的内容,返回时根据堆栈中保存的EIP直接返回到调用者,不再继续执行bar@plt第二条开始的代码。
上面就是PLT间接调用的基本流程
接下来来看实际情况
图中即foo函数的反汇编代码,其中白底的两行代表调用bar( )及ext( )的语句。
先看上面的两句,其实这里很熟悉,前面讲地址无关代码时这里已经讲过,即执行白底两句时ebx寄存器中存储的地址是0x2000(.got.plt地址),实际的运用中,ELF将GOT拆分成了.got和.got.plt两部分,区经济中.got用来保存全局变量的地址,而.got.plt用于保存函数引用的地址,于是很明显这里调用函数,必须在.got.plt中找寻具体项从而找到具体地址。
这是第一句call 400
真实跳转的位置,可以看到第一句跳转到了.got.plt的第四项的位置(32位系统4字节是一项),根据前面的原理,这里的跳转的应该是bar( )的具体项从而具体找到了bar( )的地址,不过这是第一次执行,还没有绑定,于是跳转下一句;
第二句push $0x0,根据前面原理中,这里压入的应该是在重定位表“.rel.plt”中bar这个符号的下标,即表中第一个重定位入口(通过readelf -r 文件名
可以看到具体表中情况);
第三句跳转一个函数,这是因为在一个模块中,对于模块id肯定相同,且所要运行的函数的绑定地址也是相同的,那么就可以写成一个函数节省空间。
具体的函数中,第一句将模块id压入,第二句跳转到具体执行绑定所用的函数。
不过这里压入的模块id时0x4(%ebx),跳转的地址为0x8(%ebx),这是为什么?这里牵扯到了.got.plt的特殊之处,它的前三项有着特殊意义:
1、第一项保存“.dynamic”段的地址,这个段用于描述本模块汇总的动态链接相关信息
2、第二项保存本模块ID
3、第三项保存_dl_runtime_resolve( )的地址
这样就能懂了吧,压入的模块id是找到了.got.plt中的第二项中的模块ID,而跳转的地址是找到了.got.plt中第三项中保存的地址。
于是经过上面的步骤,PLT的延迟绑定技术得以实现,使得动态链接的性能得以提高。另外.plt段也是一个地址无关代码,所以可以跟代码段合并成一个可读可执行的Segment装载。
微信扫一扫
关注该公众号