动态链接
命令:
gcc:
- -static:产生静态库
- -shared:产生共享库
readelf:
ldd:
查看一个程序主模块或一个共享库依赖于哪些共享库
一.静态链接和动态链接的优缺点:
静态链接:
-
空间的浪费:静态链接,程序最后都会链接成一个可执行文件,那么功能相同的模块(可以用来共享),在每一个需要使用的程序中都有一个份,这样就会对计算机的内存和磁盘空间造成浪费。
-
更新、部署、发布困难:当需要更新功能相同的模块(可以用来共享)时,所有的程序需要重新连接。
动态链接:
不对那些组成程序的目标文件进行链接,等到程序要运行时才进行链接。
-
节省空间,提供缓存命中率:在内存中共享一个目标文件模块的好处不仅仅是节省内存,还可以减少物理页面的换入换出,也增加CPU缓存的命中率。
-
加强兼容性:动态链接库相当于在程序和操作系统之间增加了一个中间层,从而消除了程序对不同平台之间依赖的差异性。
-
时间换取空间:动态链接是把链接这个过程从本来的程序装载前被推迟到装载的时候,这样的做法很灵活,但是程序每次被装载时都要进行重新链接,会有性能的损失。
-
DLL Hell:当一个程序所依赖的某个模块更新后,由于新的模块与旧的模块之间接口不兼容,导致其他使用了这个模块的程序无法正常运行。在早起的Windows版本中经常会遇到这样的问题,这个问题也经常被称为Dll Hell;
二.动态链接:
2.1 动态链接过程中的外部符号引用:
链接器在链接生成可执行文件时,如果一个使用了的函数定义在动态共享库中,由于动态库共享库保存了完整的符号信息(因为运行时进行动态链接还需要使用符号信息),链接器可以得知该符号是定义在动态库,就会标记其为一个动态链接符号,不对它进行重定位,把重定位过程留到装载时进行。
2.2 地址空间分布:
对于静态链接的可执行文件来说,整个进程只有一个文件要被映射,那就是可执行文件本身。但是对于动态链接来说,除了可执行文件本身之外,还有它依赖的共享目标文件。
通过readelf -l xx.so可以发现共享对象的装载地址是0x00000000,是一个无效地址,因此共享对象的最终转载地址在编译时是不确定的。
2.3 地址无关代码:
2.3.1 固定装载地址的困扰:
程序模块的指令和数据中可能会包含一些绝对地址的引用,在链接产生输出文件的时候,就要假设模块被装载的目标地址。
如果共享对象的装载地址是固定,那么共享对象的装载地址不能由每个程序来决定了,而是由系统来管理。比如程序1使用了共享对象A和B,如果程序1将A和B的地址分配在0x1000-0x2000,0x2000-0x3000,当程序2编译的时候,用到了共享对象A和C,在地址分配的时候,由于不知道0x2000-0x3000已被分配给了共享对象B,然后把共享对象C分配在0x2000-0x3000。那么任何一个程序将不能同时使用共享对象B和C,因为它们的装载地址是一样的。
早起有些系统就采用了这种的做法,这种做法叫做“静态共享库”,静态共享库的做法是将程序的各种模块统一交给操作系统来管理,操作系统在某个特定的地址划分出一些地址块,为那些已知的模块预留足够的空间。
静态共享库的问题:
- 上面提到的地址冲突问题;
- 静态共享库升级,必须保持共享库中的全局函数和变量地址不变,因为程序在链接的时候就已经绑定了这些地址,否则需要重新链接程序;
- 由于静态共享库在一开始分配空间的时候是有限空间,所以新增的函数和变量会受到空间的限制。如果超出了之前预留的空间,就会出现与其他静态共享库地址冲突问题。
2.3.2 装载时重定位:
为了解决静态共享库的问题,共享对象在编译时不能假设自己的装载地址,那么程序链接的时候也就不能确定那些绝对地址是什么。这样共享对象需要能够在任意地址装载,基本思路是:在链接时,对所有绝对地址的引用不作重定位,把这一步推迟到装载时再完成,一旦模块装载地址确定,即目标地址确定,那么系统就对程序中所有的绝对地址引用进行重定位。
在链接时的重定位叫做链接时重定位,装载时的重定位叫做装载时重定位,在Windows中也叫基址重置。
共享对象也就是动态链接库在被装载到物理内存后,始终是只有一份的,不管有多少个进程使用它。但是对于每一个进程,共享对象会映射一次到虚拟地址空间,也就是每个进程空间都有一份共享对象的映射,此时,对于不同的进程,映射的地址(基址)是不一样的(大部分情况下)。紧接着,进行装载时重定位。装载时重定位由动态链接器完成,动态链接器会被一起映射到进程空间中。它根据共享对象在虚拟内存空间中的地址修改在物理内存中的共享对象中的指令,为什么会修改指令,原因在于绝对地址访问(如模块内的变量访问)是直接用mov指令完成的,也就是直接将地址打入寄存器,所以,此时的重定位会直接修改指令。进一步,共享对象中修改的指令是根据共享对象被映射到虚拟空间中的地址(基址)决定的,而每个进程对共享对象的映射不可能都是在相同地址。所以也就无法完成这一部分代码的共享。
2.3.4 地址无关代码:
装载时重定位解决了应用程序对引用的共享对象的全局函数和变量寻址的问题,也解决了共享对象对引用的其他共享对象的全局函数和变量寻址的问题。
装载时重定位需要修改指令,如果共享对象中的部分指令需要在装载时修改时,那么同一份指令就不能被多个进程共享了,因为指令被重定位后对于每个进程来说是不同的,那么就失去了动态链接节省内存(指令共享)的一大优势。这是对于共享对象引用其他共享对象的全局函数和变量的情况。虽然装载时重定位失去了共享的特点,但是运行速度还是要比地址无关代码要快。
为了解决这个问题,基本思路:将指令中需要修改的部分分离出来,跟数据部分放在一起(动态链接库中的可修改数据部分对于不同进程来说有各自的副本),这种技术叫做“地址无关代码”。GCC产生地址无关代码只需要使用“-fPIC”参数即可,注意:“-fPIC”是针对共享对象的,而不是可执行文件的。
共享对象模块中的四种地址引用方式:
-
模块内部的函数调用、跳转等:
调用者与被调用者处于同一模块,所以模块内部的跳转、函数调用、都可以是相对地址调用,或者基于寄存器的相对调用,这种指令不需要重定位。也就是说不管模块是装载到哪个位置,这种指令都是有效的。当然还有个“全局符号介入”的问题,后面会讲到。
-
模块内部的数据访问,比如全局变量、静态变量:
一个模块前面一般是若干个页的代码,后面紧跟着若干个页的数据,这些页之间的相对位置是固定的,也就是说任何一条指令与它需要访问的模块内部数据之间的相对地址是固定的,那么只需要相对于当前指令加上固定的偏移量就可以访问模块内部数据了。
-
模块外部的函数调用、跳转等:
模块间的数据访问目标地址要等到装载时才决定。ELF的做法是在数据段里面建立一个指向这些变量的指针数组,称为全局偏移表(Global Offset Table,GOT),链接器在装载模块的时候会查找每个变量所在的地址,然后填充到GOT中的各个项,由于GOT本身是在数据段,所以它可以在模块装载时被修改,且每个进程都有独立的副本。当指令需要访问这个变量(外部变量)时,由于模块内,指令与GOT的偏移量在编译期就确定了,所以当前指令通过加上一个固定的偏移量可以访问到GOT的位置,然后根据变量在GOT里的偏移量可以得到变量的地址,然后通过这个地址可以访问到变量的值。
-
模块外部的数据访问,比如定义在其他模块的全局变量:
模块间调用和跳转,采用类似于上面呢类型3的方法,只是与之不同的是GOT中相应的项保存时目标函数的地址。
如何区分一个DSO是否为PIC:
readelf -d foo.so | grep TEXTREL
如果上面的指令有任何输出,那么foo.so就不是PIC的,否则就是PIC的。因为PIC的DSO是不会包含代码段重定位表的,TEXTREL表示代码段重定位表地址。
PIC与PIE:
地址无关代码可以用在共享对象和可执行文件,产生地址无关可执行文件的方法,GCC添加-fPIE参数。
共享模块的全局变量问题:
extern int global;
int foo()
{
global = 1;
}
当编译器编译module.c时,它无法根据这个上下文判断global是定义在同一个模块的其他目标文件还是定义在另外一个共享对象之中,即无法判断是否为跨模块间的调用。
假设module.c是程序可执行文件的一部分,程序主模块的代码并不是地址无关代码,也就是说代码不会使用这种类似于PIC机制,它引用这个全局变量的方式跟普通数据访问方式一样,编译器会产生这样的代码:
movl $0x1, XXXXXXXX
XXXXXXXX就是global的地址。由于可执行文件在运行时并不进行代码重定位,所以变量的地址必须在链接过程中确定下来。为了能够使得链接过程正常进行,链接器会在创建可执行文件时,在它的“.bss”段创建一个global变量的副本。那么问题就很明显了,现在global变量定义在原先的共享对象中,而在可执行文件的“.bss”段还有一个副本,如果同一个变量同时存在多个位置中,这在程序实际运行过程中肯定不可行的。
解决的办法只有一个,所有的使用这个变量的指令都指向位于可执行文件中的那个副本。ELF共享库在编译时,默认都把定义在模块内部的全局变量当做定义在其他模块的全局变量,也就是说当作前面的类型四,通过GOT来实现变量的访问。
-
全变变量在可执行文件中拥有副本:动态链接器会把GOT中的相应地址指向该副本;
-
变量在共享模块初始化:动态链接器需要将初始化值复制到程序主模块中的变量副本中;
-
全局变量在程序主模块中没有副本:GOT中的相应地址指向模块内部的该变量副本。
2.4 延迟绑定实现:
一般在动态链接中,程序模块之间函数引用比较多(全局变量如果多的话,会使得模块之间耦合度比较高)。延迟绑定的基本思想:当函数第一次被用到时才进行绑定(符号查找、重定位等)。这么一来大大加快了程序的启动速度。ELF采用**PLT(Procedure Linkage Table)**的方法来实现。
ELF将GOT拆分成了两个表叫做“.got”和".got.plt"。其中“.got”用来保存全局变量引用的地址,“.got.plt”用来保存函数引用的地址。PLT在ELF文件中以独立的段存放,段名通常叫做“.plt”,因为它本身是一些地址无关的代码,所以可以跟代码段等一起合并成同一个可读可执行的“Segment”被装载入内存。
2.5 动态链接相关结构:
动态链接的基本步骤:
装载可执行文件 -> 启动动态链接器本身 -> 装载所有需要的共享对象 -> 动态链接器的重定位操作和初始化操作 -> 将控制权交给可执行文件的入口地址,程序开始执行。
2.5.1 “.interp”段:
interp是interpreter(解释器)的缩写,里面保存的就是一个字符串,这个字符串就是可执行文件所需要的动态链接器的路径。
2.5.2 “.dynamic”段:
动态链接ELF中最重要的结构就是“.dynamic”段。这个段里面保存了动态链接器所需要的基本信息:依赖于哪些共享对象、动态链接符号表的位置、动态链接重定位表的位置、共享对象初始化代码的地址等。可以通过readelf -d xx.so来查看“.dynamic”段的内容。Linux还提供了一个命令用来查看一个程序主模块或一个共享库依赖于哪些共享库:
ldd Program1
.dynamic结构:
typedef struct{
ELF32_Sword d_tag;
union{
ELF32_Word d_val;
ELF32_Addr d_ptr;
}
}
d_tag类型 |
d_un的含义 |
DT_SYMTAB |
动态链接符号表的地址,d_ptr表示“.dynsym”的地址 |
DT_STRTAB |
动态连你姐字符串表地址,d_ptr表示“.dynstr”的地址 |
DT_STRSZ |
动态链接字符串表大小,d_val表示大小 |
DT_HASH |
动态链接哈希表地址,d_ptr表示“.hash”的地址 |
DT_SONAME |
本共享对象的“SO-NAME” |
DT_RPATH |
动态链接共享对象的搜索路径 |
DT_INIT |
初始化代码地址 |
DT_FINIT |
结束代码地址 |
DT_NEED |
依赖的共享对象文件,d_ptr表示所依赖的共享对象文件名 |
DT_REL、DT_RELA |
动态链接重定位表地址 |
DT_RELENNT、DT_RELAENT |
动态重定位表入口数量 |
2.5.3 动态符号表(.dynsym):
对于Program1程序依赖于Lib.so,引用了里面的foobar()函数,那么称Program1导入(Import)了foobar函数,foobar是Program1的导入函数,其实该符号就叫做“符号引用”。对于Lib.so来说,Lib.so导出(Export)了foobar函数,foobar是Lib.so的导出函数,其实该符号就叫做”符号“。
.dynsym只保存了与动态链接相关的符号,而.symtab则保存了所有符号。
2.5.4 动态链接重定位表:
共享对象需要重定位表的主要原因是导入符号的存在。在动态链接中,导入符号的地址在运行时才确定,所以需要在运行时将这些导入符号的引用修正,即需要重定位。
如果一个共享对象不是以PIC模式编译的,那么需要在装载时被重定位。如果一个共享对象是PIC模式编译的,也需要重定位。
动态链接的文件中,也有类似的重定位表分别叫做”.rel.dyn“和”.rel.plt“,”rel.dyn“实际上是对数据引用的修正,它修正的位置位于”.got“以及数据段;而”.rel.plt“是对函数引用的修正,它所修正的位置位于”.got.plt“。如果某个ELF文件是以PIC模式编译的,并调用了外部函数bar,则bar会出现在”.rel.plt“中;而如果不是以PIC模式编译,则bar将出现在”.rel.dyn“中。
2.5.5 全局符号介入:
当一个可执行文件需要依赖两个库a.so、b.so,且两个共享对象中有名称相同的全局函数,那么当一个符号需要被加入全局符号表时,如果相同的符号名已经存在,则后加入的符号被忽略,这就叫做全局符号介入。
2.6 显示运行时链接:
从文件本身的格式上来看,动态库实际上跟一般的共享对象没有区别,主要的区别是:共享对象是由动态链接器在程序启动之前负责装载和链接的,这一系列步骤都由动态链接器自动完成,对于程序本身是透明的。而动态库的装载则是通过一系列由动态链接器提供的API:打开动态库(dlopen)、查找符号(dlsym)、错误处理(dlerror)、关闭动态库(dlclose)。
2.6.1 dlopen():
void * dlopen(const char *filename, int flag);
dlopen的返回值是被加载的模块的句柄,这个句柄可以在其他API中使用。如果模块已经通过dlopen被加载过了,那么返回的是同一个句柄。
参数:
2.6.2 dlsym:
void * dlsym(void *handle, char *symbol);
dlsym()找到了相应的符号,则返回该符号的值;没有找到相应的符号,则返回NULL。
(1)如果查找的符号是个函数,那么它返回的是函数的地址;
(2)如果查找的符号是个变量,那么它返回变量的地址;
(3)如果这个符号是个常量,那么它返回的是该常量的值。
参数:
- handle:dlopen()返回的动态库句柄;
- symbol:需要查找的符号的名字,一个以“\0”结尾的C字符串;
注意:如果常量的值刚好是NULL或者0,那么需要通过dlerror()函数可以判断是常量本身是NULL或者0,还是说dlsym()返回错误。
2.6.3 dlerror():
返回值类型是char*,如果返回NULL,则表示上一次调用成功;如果不是,则返回相应的错误消息。
2.6.4 dlclose():
dlclose()的作用跟dlopen()刚好相反,它的作用是将一个已经加载的模块卸载。系统会维持一个加载引用计数器,每次使用dlopen()加载某模块时,相应的计数器加一;每次使用dlclose()卸载某模块时,相应计数器减一。只有当计数器值减到0时,模块才被真正地卸载掉。卸载的过程跟加载刚好相反,先执行“.finit”段的代码,然后将相应的符号从符号表中去除,取消进程空间跟模块的映射关系,然后关闭模块文件。
2.7 共享库构造和析构函数:
-
_attribute_((constructor)):指定该函数为共享库构造函数,拥有这种属性的函数会在共享库加载时被执行,即在程序的main函数之前执行。如果我们使用dlopen()打开共享库,共享库构造函数会在dlopen()返回之前被执行。如果有多个这样的构造函数,那么可以指定优先级来决定函数执行的先后顺序,否则多个构造函数的执行顺序不定。指定优先级:
void \__attribute\__((constructor(n))) innit_func1(void);
对于构造函数来说,n越小函数的优先级越大。
-
_attribute_((destructor)):指定该函数为共享库析构函数。这种函数会在main()函数执行完毕之后执行(或者是程序调用exit()时执行)。如果使用dlclose来卸载共享库时,析构函数会在dlclose()返回之前执行。如果有多个这样的析构函数,那么可以指定优先级来决定函数执行的先后顺序。
void \__attribute\__((destructor(n))) finit_func1(void);
对于析构函数来说,刚好相反,n越大函数的优先级越大,这样有利于与构造函数一一对应(数字对应)。
注意:
如果我们使用了这种构造或析构函数,那么必须使用系统默认的标准运行库和启动文件,即不可以使用GCC的“-nostartfiles”或“-nostdlib”这两个参数。因为这些构造和析构函数是在系统默认的标准运行库或启动文件里面被运行的,如果没有这些辅助结构,它们可能不会被执行。