第六章第一节 动态链接基础
动态链接的意义
静态链接的不足:浪费计算机内存与磁盘空间(许多公用库函数在内存&可执行文件中有多份,这是没有必要的);极大降低程序开发的效率(一旦程序中有任一模块更新,整个程序就要重新链接,然后发布给用户)。
为了避免这些不足,出现了动态链接——将程序需要的模块相互分割开,等运行时再进行链接。使得程序具有可扩展性,在运行时可以动态地选择加载各种程序模块。
ELF动态链接文件以.so为扩展名,在Windows系统中动态链接库以.dll为扩展名。
地址无关代码
显然,动态链接必然要求共享对象在被装载前无法得知其装载地址。与链接时重定位不同,由于指令是多个进程之间共享的,共享对象映射到每个进程的地址空间中的虚拟地址是不同的,从而指令中对绝对地址的引用也是不同的,这就导致指令部分无法在多进程之间共享。
解决办法是将指令中会因装载地址改变而改变的部分分离出来,与数据部分放在一起。于是多个进程可以共享指令部分,而数据部分每个进程都有一个副本。这就是地址无关代码。
那么编译器如何编写地址无关的代码,先考虑在源代码中有哪些地址引用方式。按照是否跨模块与指令引用OR数据访问分成如下。
- 模块内部的函数调用、跳转;
- 模块内部的数据访问;
- 模块外部的函数调用、跳转;
- 模块外部的数据访问;
对于第一种地址引用方式 被调用函数与调用指令的相对位置固定,可使用相对地址调用或基于寄存器的相对调用,对于这种指令无需重定位。
对于第二种地址引用方式 被访问变量与访问指令的相对位置固定,但是,现代的计算机体系结构中没有相对于当前指令地址的寻址数据方式,因此在ELF文件中用如下巧妙的方式来得到当前指令地址;
在需要知道当前指令地址的指令前调用__i686.get_pc_thunk.cx
函数如下所示;
1 | 00000494 <__i686.get_pc_thunk.cx>: |
这时因为CPU执行call指令时会将下一指令的地址压栈,而esp寄存器始终指向栈顶,因此__i686.get_pc_thunk.cx
函数的作用就是将需要知道的指令地址存入ecx寄存器。之后加上偏移得到数据的绝对地址即可访问变量。
对于第四种地址引用方式 被访问变量所在模块的装载地址在装载前不确定,因此被访问变量的绝对地址在装载前也不确定。为保证代码的地址无关性,ELF在数据段建立一个指向这些变量的指针数组——全局偏移表GOT,代码通过GOT中对应项间接访问变量。链接器在装载模块时会查找每个变量所在地址,并填充.got表中各项。而访问.got表中对应项即第二种地址引用方式。
对于第三种地址引用方式 为加快程序的启动速度,ELF对于模块外部的函数采用延迟绑定——当函数第一次被调用时将其绑定。ELF将GOT拆分为.got与.got.plt两个表,分别保存全局变量的地址与外部函数的地址;其中,.got.plt的前三项分别为.dynamic段的地址、本模块的ID、_dl_runtime_resolve函数的地址,之后就是外部函数的地址。不同的是,链接器在装载模块时并不会将外部函数进行绑定。
使用readelf查看所有段的信息。
1 | objdump -s -d Lib.so |
延迟绑定的实现
为了实现延迟绑定,ELF在间接访问外部函数的过程中增加了一次间接跳转,调用外部函数gfun时先跳转到外部函数在.plt表中对应项gfun@plt,gfun@plt的实现如下。
1 | gfun@plt: |
其中,PLT0为.plt表中第一项,它的实现为
1 | push *(GOT+4) #GOT为.got.plt表的地址,而根据.got.plt表的前三项内容可知,该指令是将本模块的ID压栈 |
要使gcc编译时产生地址无关代码,加上-fPIC选项即可。要想判断一个so文件是否是地址无关代码,可使用如下命令。
1 | readelf -d lib.so | grep TEXTREL |
共享对象的全局变量
由于共享对象的全局变量可能在模块外部修改,因此编译器在编译共享对象时,默认将模块内部的全局变量当作模块外部的全局变量。采用上述第四种地址引用方式访问全局变量。共享对象被装载时,对其中的全局变量var:若var在可执行文件中被分配了空间,则var在GOT中的地址指向可执行文件中的var(若var在共享对象中被初始化,还需要将初始化的值复制到可执行文件的var);否则,var在GOT中的地址指向共享对象内的var。
动态链接相关结构
.interp段
.interp段中保存可执行文件所需动态链接器的路径。通常为/lib64/ld-linux-x86-64.so.2,这是一个软链接,从而动态链接器更新版本时只需要将/lib64/ld-linux-x86-64.so.2指向新的动态链接器,而不用改动可执行文件的.interp段。
.dynamic段
.dynamic段保存动态链接器所需的基本信息(依赖的共享对象、动态链接符号表地址、动态链接重定位表地址,共享对象初始化代码地址等)。.dynamic段就是一个结构体数组,结构体定义如下。
1 | typedef struct{ |
d_tag代表后面的d_un的含义,常见的d_tag类型及其含义如下。
使用readelf工具可查看.dynamic段的内容。
1 | readelf -d Lib.so |
动态符号表
保存动态链接的共享模块间符号的导入导出关系。动态符号表在可执行文件中为.dynsym段。
动态链接重定位表
.rel.dyn段修正的地址位于.got段与数据段,.rel.plt修正的地址位于.got.plt(也存在.got未被拆分的情况,此时.rel.plt修正的符号也位于.got)。使用readelf工具可查看可执行文件的重定位表。
1 | readelf -r Lib.so |
其中有如下三种类型的重定位入口:R_386_RELATIVE、R_386_GLOB_DAT、R_386_JUMP_SLOT。R_386_GLOB_DAT与R_386_JUMP_SLOT。被修正位置只需要直接填入符号的地址即可;R_386_RELATIVE入口的重定位方式为基址重置,这是由于编译器在编译共享对象时,为其分配的地址空间从0开始,假设一个静态变量的地址为B,则对此静态变量的地址重定位时,再加上共享对象的装载地址A,即A+B。
进程堆栈初始化
同样地,操作系统在将控制权交给动态链接器之前,会在进程的堆栈中保存动态链接器所需信息,以辅助信息结构体数组的形式存放,结构体定义如下。
1 | typedef struct{ |
该结构体定义与.dynamic段的结构体定义类似,a_type为类型值,a_val为数值,常见的类型及其含义如下。
辅助信息数组存放在环境变量指针之后。