5、动态链接
初识动态链接
随着软件规模的逐渐增大,静态链接的缺点也逐渐暴露,有如下几个问题
- 1、由于每个elf本身包含静态库,同时加载使也需要加载到内存中,浪费内存和磁盘空间
- 2、不利于程序的开发和发布
为解决上述问题,引入了动态链接,其原理的基本思想是:在编译链接阶段,不对那些目标文件进行链接,而是将链接这个过程推迟到运行时再进行。在实现上基本思想是:将程序按模块拆分成几个独立的部分,在运行时才将它们链接成为一个完整的程序,即由动态链接器将动态库文件装载到进程的地址空间,并进行符号绑定和重定位,而不是像静态链接一样将所有目标文件直接链接成为一个独立的执行文件,相比于静态链接存在一定的性能损失,但在各种优化后损失在%5以下。
同时由于动态链接虚拟地址空间会比较复杂,需要系统的支持,linux中ELF动态链接文件被称为动态共享对象DSO(dynamic shared objects)。linux下编译一个动态库命令如下
gcc -fPIC -shared -o libxxx.so xxx.c
一个简单动态链接的例子如下图所示
gcc -o program1 program1.c ./xxx.so
上述编译过程介绍后,并不会把xxx.so链接到输出文件program1中。链接过程如下:链接器发现program1.o中引用的外部符号在一个动态库(动态库中保存由其到处的符号信息)中,此时链接器并不会对该符号进行重定位,而是标记其为动态链接符号,把真正的链接过程保留到装载时执行。
动态链接示例
地址无关代码
1、背景
上一节提到,动态链接在运行时将进程需要的动态库加载到进程的地址空间,那这个动态库的加载地址如何确定呢?是否和静态链接一样在编译时确定呢?本节将针对这个问题进行介绍。
- 1、动态库的加载地址在编译时确定将存在很多问题,例如一个程序存在多个模块,那就必须针对这个程序专门管理其使用的动态库的加载地址,同时一个模块还可能被多个程序管理,加载库的地址可能冲突,这样将导致动态库的加载地址管理异常麻烦,早期的静态共享库就采用这种方式;同时该方式下,动态库的升级受到各种限制。目前解决该问题的方式是假设动态库可以在任意地址进行加载,动态库编译时不能假定自己在进程虚拟地址空间的位置。
- 2、为实现上述方式,通常采用装载重定位技术load time relocation,即在链接时,针对代码中引用的动态库符号不进行重定位,而是在动态库装载后,地址确定时再进行被应用符号的重定位,相对而言,静态链接使用的是链接时重定位link time relocation。
- 3、但装载重定位技术还无法完全解决动态链接的问题,考虑如下场景,一个动态库可能被多个进程使用,在多个进程的不同的地址空间,这个时候代码段在多个进程是复用的,因此如果修改代码中符号的地址位置,必然不能兼容所有使用到该动态库的进程(当然数据段符号重定位,由于在每个进程中都各自维护因此可以采用装载重定位解决)。为解决该问题(即共享对象中代码段的绝对地址重定位问题),动态链接又采用了一种地址无关的代码技术PIC,position independent code。其基本的原理为:将代码段中需要重定位的部分抽离出来,和数据段放在一起,这样每个进程各自维护数据段,代码段也就可以在各个进程中保持不变了。
gcc -shared -fPIC hello.c -o hello.so
-shared表示采用装载重定位技术
-fPIC表示采用地址无关代码技术
总结一下:装载重定位技术,解决动态库加载地址的问题(重点解决共享库中数据段符号重定位问题),PIC技术解决共享库在多个进程中共用问题(重点解决共享库中代码段中绝对地址引用文件)
2、PIC实现方式
下面将详细介绍以下PIC技术的实现方式。针对动态库中符号的引用方式不同,实现方式有所区别。动态库的地址引用方式可以分为以下四种
动态库符号引用方式分类
- 1、模块内部函数调用
模块内部函数调用相对简单,通常采用相对偏移调用指令,即目的地址相对下一条指令的偏移。如果模块内部的符号的相对地址是固定的那么,这种方式就是地址无关的。但实际还不是采用这种方式,实际也是采用下面的GOT表间接访问的方式调用的,主要原因是链接过程中会将所有的共享库的符号合并到一张全局符号表中,合并过程中可能存在覆盖问题,为使代码中所有的引用位置唯一而放弃相对地址的跳转
相对便宜调用指令 - 2、模块内部数据访问
这种情况的问题也很明显,要做到地址无关,代码中就不能由数据的绝对地址,因为不同的进程其数据段地址必然存在一定的差异,因此只能采用相对地址进行访问。
一般而言,模块内部的代码段和数据的段的相对位置是固定的,即前面几页代码段,后面几页数据段,因此对于模块内部的数据访问就可以使用当前位置加上相应的偏移量即可。 - 3、模块间数据调用
由于模块加载地址在运行时才确定,因此模块间的数据引用需要在装载的时候在能确定。elf具体做法对每个动态库内部建立全局偏移表(GOT,global offset table),表中将被存放数据的实际地址,并把这个偏移表放在模块内部的数据段,代码中对数据的访问通过GOT表间接访问即可。由于GOT表在模块内部的代码段,因此查找GOT表可以采用当前位置+相对偏移即可。在程序加载的时候对GOT表的实际引用进行重定位即可。 - 4、模块间函数调用
模块间的函数调用采用的方式上面GOT表的方式一致,只不过此时GOT表将被存放的是实际的函数地址。
模块间数据和函数调用
总结上述四中调用方式,可以用如下表表示
数据 | 函数 | |
---|---|---|
模块内部 | 相对地址访问 | 相对跳转调用 |
模块间 | GOT表间接跳转 | GOT表间接跳转 |
tips:如何区分一个dso是否为PIC,PIC的共享库将没有代码段的重定位表,通过判断下面命令是否有输出即可,有输出则不是PIC
readelf -d foo.so | grep TEXTREL
3、共享对象中全局变量问题
上面四种方式,基本覆盖了模块的符号调用方式,但还有一种情况没有考虑,即模块内部的全局变量(处理方式不能采用上述的第一种方式当成模块内部的数据访问)原因如下
- 1、当模块代码中如下方式,使用全局变量时,编译过程无法判断这个变量是跨模块调用还是模块间调用
exten int nglobal;
- 2、执行文件中如果按找上述方式使用全局变量,由于执行文件部署PIC方式,因此必须在链接时确定nglobal的位置,这样执行文件的.bss段将存在nglobal的副本,当动态库中真的定义了该变量,将导致该变量存在多份。
为解决上面的问题,针对共享库内的全局变量全部当作定义在其他模块的全局变量,此时采用上面第三种方式进行访问,即GOT间接访问,在加载重定位时,如果发现可执行文件的代码段中存在该符号的副本,则将GOT表对应位置指向该副本,如果执行模块中无该符号,则将GOT表中对应位置指向模块内部的该变量副本,如果模块内部对该变量进行初始化也需要对主执行文件的该变量进行初始化。
4、数据段的地址无关性
上面主要是解决共享对象中代码段中存在的绝对地址引用,那数据段中是否也存在绝对地址引用呢?如果存在该如何解决,考虑如下代码
static int a = 1;
static int * p = &a;
由于直接引用了数据a的地址,这是一个绝对地址引用。由于数据段在每个进程中都是独立管理的,因此针对数据段中存在的绝对地址引用可以采用上述介绍的加载重定位进行解决。具体实现如下:如果发现共享对象的数据段存在绝对地址引用,编译链接时将生成一个针对此种引用的重定位表,在运行加载时动态链接器,如果发现此种重定位表,将对数据段的绝对地址引用进行重定位。
延迟绑定PLT,procedure linkage table
动态链接相比静态链接有很多优势,但其是以牺牲一部分性能换取的,性能损失主要在以下两个方面
1、全局和静态数据访问、模块间的调用都需通过GOT间接访问
2、链接工作是在程序运行时进行的
这里将主要介绍延迟绑定,这是一种优化动态链接性能的方法,其基本思想是减小上面第二个问题带来的性能损失,当函数被第一次调用的时候进行重定位,如果没有用到则不进行重定位。
PLT表是ELF实现延迟绑定的方式,具体来说则是在模块间的函数调用时本来是应该通过GOT表进行间接调转,但为了实现延迟绑定,在中间又加了一层PLT,动态库中每个导出符号在PLT表中的结构如下
***symble***@plt:
jmp * (***symble***@got)
push n
push moduleID
jump _dl_runtime_resolve
可以看到第一条指令是调到got表中进行间接调整,如果got表中已经重定位了,那就直接实现了符号的查找,但是为例实现延迟绑定,链接器在初始化阶段并没有将符号的真实地址填入got表对应位置,而是将第二调指令push n填入了got表,这样跳转到got表后,又将跳转到push n这条命令。后面这三条命令的含义是_dl_runtime_resolve(moduleID,n)即解析本模块的第n个符号的地址,进行真正的符号解析和重定位工作,如果找到则将对应地址填入got表对应位置。这样当第一次调用的时候将进行重定位工作,之后再使用就直接通过GOT表间接选址了。假设liba.so需要用到libc.so的第3个符号bar函数则其PLT项大致如下所示
bar@plt
jmp *(bar@got)
push 3
push liba.so_id
jmp _dl_runtime_resolve
上面只是延迟绑定的原理,其实际实现还有所差别,ELF动态库在实现延迟绑定时实际的GOT表和PLT表如下所示
ELF动态库延迟绑定实现
动态链接相关结构
在上一篇进程加载中由提到,在加载完可执行文件后,如果是静态链接就将控制权转交给执行文件入口地址,如果是动态链接则转交给动态链接器ld.so,动态链接器实际上本身是一个共享对象,可以被加载到任意进程的地址空间,但其本身是静态链接,不依赖任何其他共享对象。这一节简单介绍一下,针对动态链接ELF中存在哪些相关是结构。
- 1、.interp段,指定动态链接器的位置
- 2、.dynamic段,保存动态链接所需要的基本信息,例如依赖哪些共享对象,动态链接符号表位置,动态链接重定位表位置等
- 3、.dynsym动态符号表,只保存了与动态链接相关的符号,模块内部的符号不在这张表中,.symtab符号表,包含了共享对象的所有符号
- 4、动态链接重定位表,主要用于对动态对象的导入符号进行加载重定位工作,和数据段的绝对地址应用的重定位工作(对于以PIC方式编译的动态库,其代码段是不需要进行重定位的,因为地址无关)
- 5、辅助信息数组,保存动态链接过程所需的信息,通常在栈初始时保存在栈中。
动态链接的实现步骤
本节将简单介绍以下动态链接的实现步骤,通常包含以下三个步骤
- 1、动态链接器的自举
将动态链接器加载到进程地址空间后,动态链接器自身使用的全局和静态变量的重定位工作是需要自己完成的 - 2、装载共享对象
根据可执行文件所指示的依赖的共享对象,将它们逐个加载到进程的地址空间中(通常采用广度优先的方式),同时将所有共享对象的符号合并到一张全局符号表中,由于涉及合并全局符号表,因此会出现符号覆盖的问题,针对这这个问题,动态链接器采用的方式是:当一个符号被加入全局符号表时,如果已经由相同的符号名存在,则后续加载的将被忽略。 - 3、重定位和初始化
链接器遍历可执行文件和每个共享对象的重定位表,将它们的GOT/PLT中每个需要重定位的位置进行修正。初始化即链接器执行每个共享对象(并不是可执行文件)的.init段代码。
显示运行时链接
最后简单介绍显示运行时链接技术,这里运行时链接是特指执行文件在执行过程中,显示的执行加载一个共享对象,进行符号重定位的工作,并非程序在启动加载时动态链接器动态链接的过程。其实程序主要进行该动作也是通过调用动态链接器导出的相关函数完成的,主要包括以下几个函数
- 1、void * dlopen(const char * filename, int flag)
打开一个动态库将其加载到进程的地址空间中,并完成动态的库的初始化过程。特别说明如果filename=0那么dlopen将返回全局符号表的句柄,后续就可以查找执行全局符号。 - 2、void * dlsym(void * handle, char * symbol)
查找所需的符号,这里存在两种查找顺序,第一种是上面说到的如果dlopen的时全局符号表,那么查找将使用装载顺序(先装入的符号优先,后装入的同名符号被忽略);还有一种是dlopen一个特定的共享库,那么查找顺序则是基于这个特定的共享库和其依赖的库,进行广度优先遍历。 - 3、void * dlclose(void * handle)
与dlopen相反,通过引用计数控制一个模块的卸载,当计数为0时,先执行共享对象的.fini段代码,然后将符号从全局符号表中去除,取消进程空间和模块的映射关系,然后关闭模块文件。 - 3、char * dlerror()
指示上述函数的执行情况,出错返回char*,正常返回NULL。