【Linux学习笔记】Linux动态共享对象(动态链接库)装载过程

来源:互联网 发布:usb端口禁用 编辑:程序博客网 时间:2024/05/13 15:05

       之前开发项目时,曾遇到一个问题:模块中用到的某so文件与模块某源码文件中存在同名函数(在链接器linker来看,是同名符号)时,so文件中同名函数被“覆盖”,从而导致模块启动时崩溃。当时曾专门做过实验,得到了避免出问题的一些小技巧(参见之前的某篇笔记),但其实对引发问题的底层原因并特别不清楚(当时由于这类问题对应的术语及其英文关键词一无所知,导致google不出干货)。最近,阅读《程序员的自我修养:链接、装载与库》一书,才明白这类问题背后的机理,作为笔记,记录于此。

1)共享对象

        关于dynamic shared object的介绍性内容(例如其基本概念、优缺点、基本使用方法等),可以google出很多经典资料,例如wikipedia在说明library topic时介绍的,这里不再赘述。

2)基本思想

        linux共享对象实现动态链接的基本思想是:把程序按照模块拆分成各个相对独立部分,在程序运行时(确切地说,是正式运行前的装载阶段)才将它们链接在一起形成完整的程序,而不是像静态链接那样事先把所有的程序模块都链接成一个单独的可执行文件。

3)装载过程及涉及的问题

        当程序被装载时,系统的动态链接器会将程序所需的所有动态链接库(例如最基本的libc.so)装载到进程的地址空间,且将程序中所有为决议的符号绑定到相应的动态链接库中,并进行重定位工作(术语叫装载时重定位-load time relocation,在windows中,又叫基址重置-rebasing,区别于静态链接的链接时重定位-link time relocation)。也即,动态链接是把链接过程从本来的程序装载前推迟到装载时。共享对象的最终装载地址在编译时是不确定的,而是在装载时,装载器根据当前地址空间的空闲情况,动态分配一块足够大小的虚拟地址空间给相应的共享对象。        

        装载时重定位的问题:so文件被load并映射至虚拟空间后,指令部分通常是多个进程间共享的,通常的装载时重定位是通过修改指令实现的(主要是根据情况修改指令中涉及到的地址),所以无法做到同一份指令被多个进程共享(因为指令被重定位后对每个进程来讲是不同的)。这样一来,就失去了动态链接节省memory的一大优势。

         为解决此问题,引入了地址无关代码(PIC,Position-independent Code,详细概念见wikipedia)的技术,基本思路是把指令中那些需要被修改的部分分离出来,跟数据部分放到一起,这样,剩下的指令就可以保持不变,而数据部分在每个进程中拥有一个副本。ELF针对各种可能的访问类型(模块内部指令调用、模块内部数据访问、模块间指令调用、模块间数据访问),实现了对应地址引用方式,从而实现了PIC。

        对应到实际应用中,我们可以在编译时指定-fPIC参数让gcc产生地址无关码。

4)延迟绑定

        影响动态链接性能的两个主要原因:

       a. 与静态链接相比,动态链接对全局和静态的数据访问都要进行GOT(Global Offset Table,实现PIC时引入的具体技术)定位,然后间接寻址;对于模块间的调用也要先定位GOT,然后间接跳转,如此,程序的运行速度就会减慢

       b. 程序装载时,动态链接器要进行一次链接工作,即查找并装载所需的共享对象,然后进行符号查找、地址重定位等工作,这会减慢程序启动速度

       一方面,程序模块往往包含了大量的函数调用,从而导致动态链接器在模块间函数引用的符号查找及重定位方面耗费时间;另一方面很多函数并不会在程序运行初期就用到(尤其是有些异常处理函数),由此,EFL采用延迟绑定(lazy binding)来对动态链接做优化,其基本思想是当函数第一次被调用时才进行绑定(符号查找、重定位等),若没有被调用则不进行绑定。这个思路可以大大加快程序启动速度,对于有大量函数引用的程序启动时,尤为明显。具体到实现,EFL采用PLT(procedure linkage table)来实现,具体过程很是精妙复杂,本文只是抛砖引玉,不再详述,有兴趣的同学可以用PLT英文关键字google相关资料。

5)动态链接的步骤和实现

       动态链接步骤基本分为3步:

       a. 动态链接器自举

       linux系统的动态链接器(通常为/lib/ld-linux.so)本身也是个共享对象。对于普通共享文件,其relocation由动态链接器来完成,若其依赖其它共享对象,则这些被依赖的共享对象由动态链接器负责链接和装载。而对于动态链接器,虽然是共享对象,但具有某些特殊性:首先,其本身不能依赖其它任何共享对象;其次,其本身所需的全局和静态变量的relocation由它本身来完成,这种启动代码的方式往往被称为自举(bootstrap,术语描述可参照此处)

       b. 装载共享对象

       完成基本自举后,动态链接器将可执行文件和链接器本身的符号表合并为全局符号表(global symbol table),然后链接器寻找可执行文件依赖的共享对象。由此,链接器可以列出可执行文件所需的所有共享对象并将其名字放入装载集合中,此后,链接器遍历该集合,根据每个共享对象的名字找到对应文件后打开,读取相应的ELF header和.dynamic段,然后将它对应的代码段和数据段映射到进程空间。如果把依赖关系看着图的话,这个装载过程就是图的遍历过程,可用深度优先或广度优先来遍历,这取决于链接器的实现,比较常见的算法一般是广度优先。

       装载过程中,当新的共享对象被load后,它的symbol table会被合并到全局符号表中,当所有被依赖的共享对象都装载完毕后,全局符号表中将包含进程中所有的动态链接所需的符号。

       上述装载过程中,若2个不同模块定义了同一个符号,会产生一个符号的优先级问题:在全局符号表中,最终存放的到底是哪个模块中定义的符号?

       这种一个共享对象中的全局符号被另一个共享对象的同名全局符号覆盖的现象又被称为共享对象全局符合介入(global symbol interpose)。对于这个问题,linux的动态链接器是这样处理的:它定义了一个规则,当一个符号需要被并入全局符号表时,若相同的符号名已存在,则后加入的符号被忽略。这种先装入的符号优先的优先级方式成为装载序列(load ordering)。

       WARNING:由于存在这种重名符号被直接忽略的问题,当程序使用大量共享对象时应特别注意符号的重名问题,若两个符号重名又执行不同功能,则程序运行时可能会将所有该符号名的引用解析到第一个被加入全局符号表的使用该符号名的符号,从而导致程序出现莫名其妙的错误!

       c. 重定位和初始化

       完成上面两步后,链接器开始重新遍历可执行文件和每个共享对象的重定位表,将它们的GOT/PLT中的每个需要重定位的位置进行修正(由于此时动态链接器已经建立起了进程的全局符号表,故修正过程也显得比较容易)。

       重定位完成后,若某共享对象有.init段,则动态链接器会执行该段中的代码,用以实现共享对象特有的初始化过程。相应地,共享对象可能还有.finit段,该段在进程退出时执行。

       当完成了重定位和初始化后,所有准备工作完成,进程所需的共享对象都装载完毕且完成链接,这时动态链接器就如释重负,将进程控制权转交给程序入库并开始执行程序。

6)显式动态链接

       主要通过4个api完成:dlopen、dlsym、dlerror、dlclose,这些api比较简单,具体用法man即可搞定,本文不再详述。

       这里需要引起注意的还是符号优先级问题:当进程有模块是由dlopen()显式装入的,这些后装入模块的符号可能会与之前已装入模块间有重名符号,此时,符号冲突如何解决?

       实际上,不论是之前由动态链接器装入还是之后由dlopen()装入的共享对象,动态链接器在进行符号解析及重定位时,都是采用装载序列的原则,即先装入的符号优先。

       那么,当使用dlsym()进行符号的地址查找时,这个函数是不是也按照装载序列的优先级进行符号查找呢?实际情况是,dlsym()对符号的查找优先级分两种类型:

      a. 若在全局符号表中进行符号查找,即dlopen()时,第1个参数filename传入NULL,那么由于全局符号表使用装载序列,此时dlsym()也采用装载序列。

       b. 若对某个由dlopen()打开的共享对象进行符号查找,则采用一种叫做依赖序列(dependency ordering)的优先级,它以被dlopen()打开的那个共享对象为root node,对它所有依赖的共享对象做广度优先遍历,直到找到符号为止。 


======================== EOF ===================

原创粉丝点击