动态链接(一)

1. 为什么要动态链接

静态链接的缺点:

(1)内存和磁盘空间:

比如有两个程序,目标文件分别为Program1.o,Program2.o,并且都用到Lib.o这个模块。静态链接生成可执行文件Program1,Program2时,它们都分别存有Lib.o模块的一个副本。当同时运行Program1和Program2时,Lib.o在磁盘和内存中都有两个副本。可见会造成内存和磁盘空间的浪费。

(2)程序开发和发布:

在静态链接下,如果某个模块发生了改变,整个程序需要重新链接,然后再重新发布。对于用户来说,每次更新都需要重新下载整个程序。

动态链接的基本思想就是将链接这个过程推迟到运行时再进行。

以Program1和Program2为例,假设现在有Program1.o,Program2.o和Lib.o,当运行Program1时,系统首先会加载Program.o,然后发现它依赖于Lib.o,于是加载Lib.o,按照同样的方法将需要的所有目标文件都加载至内存,接着进行链接工作,和静态链接类似,包括符号解析,地址重定位等,最后系统把控制权交给Program1.o的程序入口处,程序开始运行。如果现在需要运行Program2,系统则加载Program2.o,发现Program2.o依赖的Lib.o已经在内存中了,因此系统接着直接执行链接工作。

可以看到动态链接情况下,当某个模块发生改变时,无需重新链接一遍,只需要简单地将目标文件覆盖掉,程序下次运行时,新版本的目标文件会自动被加载并链接,程序自动完成升级。动态链接使各个模块耦合度更小。

动态链接还有一个特点就是程序在运行时可以动态地选择加载各种程序模块,这就是插件(Plug-in)的原理。如某个公司开发完成了某个产品,并且给出了指定好的程序接口,第三方开发者可以按照这种接口来编写符合要求的动态链接文件。该产品程序可以动态地载入各种由第三方开发的模块,实现程序功能的扩展。

动态链接还可以加强程序的兼容性。一个程序在不同的平台下运行时可以动态地链接到由操作系统提供的动态链接库,这些动态链接库相当于在程序和操作系统之间增加一个中间层。对于静态链接,程序需要分别链接成能够在系统A和系统B下运行的两个版本就分开发布,对于动态链接,只要系统提供了动态链接所需要的接口,则程序即可在该系统下运行,理论上只需要一个版本。

动态链接文件和目标文件的结构会有所不同。Linux下ELF动态链接文件被称为动态共享对象(DSO,Dynamic Shared Objects),扩展名为“.so”,Windows下,动态链接文件被称为动态链接库(Dynamical Linking Library),扩展名为“.dll”。

2. 简单的动态链接例子

Windows下的PE动态链接机制和Linux下的ELF稍有不同,这里先以ELF作为例子。例子源码如下:

Program1.c

#include"Lib.h"

int main() {
	foobar(1);
	return 0;
}

Program2.c

#include"Lib.h"

int main() {
	foobar(2);
	return 0;
}

Lib.c

#include<stdio.h>

void foobar(int i) {
	printf("Printing from Lib.so %d\n",i);
	sleep(-1);
}

Lib.h

#ifndef LIB_H
#define LIB_H

void foobar(int i);

#endif

将Lib.c编译成一个共享对象文件:

gcc -fPIC -shared -o Lib.so Lib.c

-shared表示产生共享对象。然后分别编译链接Program1.c和Program2.c:

gcc -o Program1 Program1.c ./Lib.so
gcc -o Program2 Program2.c ./Lib.so

整个编译和链接过程如下:

在命令行中可以看到Lib.so也参与了链接过程,但实际上链接的输入目标文件只有Program1.o(当然还有C语言运行库,这里暂时忽略)。在链接过程中,对于一个定义于其他静态目标模块的符号,链接器会按照静态链接的规则将符号地址重定位,而如果该符号定义于动态共享对象,则链接器会将这个符号的引用标记为一个动态链接的符号,把重定位过程留到装载时再进行。那么如何知道一个符号的引用属于静态符号还是动态符号呢,这就是前面命令中需要加上Lib.so的原因。Lib.so中保存了完整的符号信息(因为动态链接时需要用到这些信息),链接器可以通过这些信息确定那些符号属于动态符号。因此在这里,Lib.so只是起提供符号信息的作用,并没有链接进最终可执行文件。

与静态链接不同,动态链接下,除了可执行文件本身之外,所依赖的共享对象文件也需要映射到进程的虚拟地址空间。

查看进程的虚拟地址空间分布:

可以看到Lib.so也被映射到进程的虚拟地址空间,还有动态链接形式的C语言运行库libc-2.23.so,可以看到还有一个是ld-2.23.so,实际上这是Linux下的动态链接器。首先系统会把控制权交给动态链接器,由它完成所有的动态链接工作之后再把控制权交给Program1,然后开始执行。

还有一点是,共享对象的最终装载地址在编译时是不确定的,而是在装载时,装载器根据当前地址空间的空闲情况,动态分配一块足够大小的虚拟地址空间给相应的共享对象。

3. 地址无关代码

3.1 装载时重定位

这个想法的基本思路是,在链接时,对所有绝对地址的引用不作重定位,而把这一步推迟到装载时再完成。一旦模块装载地址确定,那么系统就对程序中所有的绝对地址引用进行重定位。

这种方法存在缺点,在动态链接模块被装载映射至虚拟空间后,指令部分理论上是可以在多个进程共享的。但由于装载时重定位的方法需要修改指令,所以没有办法做到同一份指令被多个进程共享,因为指令被重定位后对于每个进程来讲是不同的。当然对于可修改数据部分,这部分在每个进程中都有一个副本,所以这部分可以使用装载时重定位的方法来解决。前面的编译命令使用了-shared和-fPIC参数,如果只使用-shared,那么输出的共享对象就是使用装载时重定位的方法。

3.2 地址无关代码

对于装载时重定位的缺点,所以目的是希望程序模块中共享的指令部分在装载时不需要因为装载地址的改变而改变。地址无关代码(PIC,Position-independent Code)技术的基本想法是把指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分可以保持不变,而数据部分可以在每个进程中拥有一个副本。

把共享对象模块中的地址引用按照是否跨模块分成模块内部引用和模块外部引用,按不同的引用方式分为指令引用和数据引用,因此有4种情况:

(1)模块内部的函数调用,跳转。

(2)模块内部的数据访问。

(3)模块外部的函数调用,跳转。

(4)模块外部的数据访问。

pic.c

static int a;
extern int b;
extern void ext();

void bar() {
	a=1; 	//type 2
	b=2; 	//type 4
}

void foo() {
	bar(); 	//type 1
	ext(); 	//type 3
}

实际上编译器并不能确定b和ext是模块外部的还是模块内部的,因为它们有可能是被定义在同一个共享对象的其他目标文件中。因此统一当作模块外部来处理。

(1)模块内部调用跳转

被调用函数和调用者处于同一个模块,它们之间的相对位置是固定的。模块内部的跳转,函数调用都可以是相对地址调用,或者是基于寄存器的相对调用。相对地址调用指令是指指令中的数据部分代表被调函数相对于调用指令下一条指令的偏移,因为这个偏移是固定不变的,因此对于这种指令是不需要重定位的。无论模块被装载到哪个位置,这条指令都是有效的。

(2)模块内部数据访问

在一个模块内,页之间的相对位置是固定的。即任何一条指令与它需要访问的模块内部数据之间的相对位置是固定的,那么只需要相对于当前指令的下一条指令的地址加上固定的偏移量就可以访问模块内部数据了。rip寄存器存放的正是下一条指令的地址:

可以看到%rip加上偏移0x200916就是变量a的地址,即0x71e+0x200916=0x201034。即如果模块被装载到0x10000000这个地址的话,则a的地址为0x10000000+0x71e+0x200916=0x10201034。

(3)模块间数据访问

基本思想是把地址相关的部分放到数据段里面。ELF的做法是在数据段里面建立一个指向这些变量的指针数组,称为全局偏移表(Global Offset Table ,GOT),当代码需要引用该全局变量时,可以通过GOT中相对应的项间接引用。

链接器在装载模块的时候会查找每个变量所在的地址,然后填充GOT中的各个项,以确保每个指针指向的地址正确。GOT是存放在数据段的,每个进程都可以有独立的副本,相互不受影响。从第二种类型中可以看到数据段里的模块内部变量相对于当前指令的偏移是固定的,GOT也是存放在数据段的,所以GOT相对于当前指令的偏移也是固定的。通过这个偏移可以找到GOT,再根据变量地址在GOT中的偏移,可以得到变量的地址。当然变量地址在GOT中的偏移是由编译器确定的。

看到上面的反汇编码,先把变量b的地址的偏移赋值给eax,即0x2008b3+0x725=0x200fd8。再通过寄存器间接寻址给变量b赋值。使用objdump -R pic.so查看pic.so在动态链接时需要重定位的项,发现b的地址的偏移确实是0x200fd8。因为还没有进行动态连接,所以可以看到0x200fd8处的内容都是0。

使用objdump -h pic.so查看GOT的位置:

可以知道b的地址在GOT中的偏移是8。如果指针用8字节表示,则表示b在第二项,如果指针用4字节表示,则表示b在第三项。

(4)模块间调用,跳转

这种情况也可以用GOT,GOT中相应的项保存的是目标函数的地址。

调用ext的汇编代码:

调用地址是0x5f0,是ext@plt的地址。ext@plt内容如下,其中plt是什么后面会介绍。

第一条指令是跳转指令,跳转地址是0x5f6+0x200a2a=0x201020,这就是ext函数的地址的真正偏移,查看重定位项发现确实如此。

GCC使用-fpic和-fPIC都可以产生地址无关代码,其中-fpic产生的代码相对较小,而且较快,但在某些平台上会有限制,而-fPIC则没有。

以下命令可以判断某个DSO是否为PIC,没有输出就代表是PIC。

readelf -d pic.so | grep TEXTREL

3.3 共享模块的全局变量问题

对于定义在模块内部的全局变量,实际上并不能简单地按第一种类型来解决。比如一个模块module.c如下:

extern int global;
int foo() {
    global=1;
}

global是定义在其他共享对象的全局变量。当编译module.c时,无法判断global是否属于模块间调用。

假设module.c是可执行文件的一部分,即程序的主模块,如果编译该文件时没有使用类似PIC的机制,那么该程序的主模块代码并不是地址无关代码。它引用这个全局变量就和普通数据访问方式一样,会产生类似下面的代码:

movl $0x1, xxxxxxxx

xxxxxxxx是global的地址,因此变量的地址必须在链接过程中(静态链接)确定下来。为了使链接过程顺利进行,链接器会在可执行文件的.bss创建一个global的副本。xxxxxxxx就是该副本的地址。但实际上global是定义在共享对象中,这样程序运行时会发现global有多个副本。因此解决方法是ELF共享库在编译时,默认把定义在自身模块内部的全局变量当作定义在其他模块的全局变量处理,也就是第三种类型,通过GOT实现数据访问。当共享模块被装载时,发现某个全局变量在可执行文件中存在一个副本,那么动态链接器会把GOT中的相应地址指向该副本,那么程序运行时该变量只有一个实例。如果变量在共享模块被初始化,动态链接器还会将初始值拷贝到主模块的副本中。如果主模块不存在副本,那么共享对象的GOT中相应地址自然就指向自身模块内部的该变量。

如果module.c是一个共享对象的一部分,那么在参数-fPIC的情况下,会把global的调用按照模块间数据访问的方式产生代码。因为即使global属于模块内引用,但它也有可能被主模块可执行文件引用,从而使共享对象中对global的引用要执行可执行文件中的global副本。

3.4 数据段地址无关性

数据段也会存在有绝对地址引用的问题:

static int a;
static int *p=&a;

a的地址随着装载地址的改变而改变,因此这段代码并不是地址无关的。但因为数据段每个进程都有一个副本,因此可以简单地使用装载时重定位的方式来解决数据段中绝对地址引用问题。

4. 延迟绑定(PLT)

动态链接比静态链接灵活,但性能会稍微差一些,主要有两个原因:

(1)对于模块间的调用或数据访问,都需要进行复杂的GOT定位,即先定位GOT,再进行间接跳转。

(2)程序开始执行时,动态连接器需要进行一次链接工作。

如果一个程序有很多个模块,包含很多函数,但其中很大一部分在程序执行完毕都没有被调用,因此为这些函数进行重定位其实是没必要的。因此ELF采用了一种叫做延迟绑定(Lazy Binding)的做法,基本思想就是当函数第一次被用到时进行绑定。

ELF使用PLT(Procedure Linkage Table)的方法来实现。假设liba.so需要调用libc.so中的bar()函数,那么当第一次调用时,需要调用动态连接器中的某个函数来完成地址绑定工作,假设这个函数为lookup()。lookup()至少要知道地址绑定发生在哪个模块,是哪个函数。假设原型为lookup(module,function)。这个例子就分别是liba.so和bar。其实这里lookup()函数真正的名字是_dl_runtime_resolve()。

当调用某个外部函数时,按照通常的做法是通过GOT中相应的项进行间接跳转,PLT为了实现延迟绑定,在这个过程中增加了一层间接跳转,调用函数是通过一个叫作PLT项的结构进行跳转。如ext()函数在PLT中的项的地址为ext@plt。

第一条指令是通过GOT间接跳转的指令。跳转地址是0x201020,这是ext函数在GOT中相应的项的地址,这个项保存的理应是ext函数的真正地址,但因为实现了延迟绑定,因此这个项还没有填入真正的地址,而是下一条指令的地址,可以看到0x201020的内容如下:

内容为0x5f6,正是下一条指令的地址。因此当第一次进入ext@plt时候,第一条指令相当于什么都没做。下一条指令是把ext这个符号引用在重定位表.rel.plt中的索引入栈,可以通过readelf -r pic.so查看:

接着跳转到0x5d0处

第一条指令就是把当前模块ID入栈,第二条就是调用_dl_runtime_resolve()函数来完成符号解析和重定位工作。0x201008和0x201010处的值是由运行时动态链接器初始化的,所以现在看到都是0。一旦解析完毕,下次调用时,ext@plt的第一条指令就直接跳转到了ext函数的入口。并且ext在返回时,根据堆栈里保存的EIP直接返回到调用者,而不会执行ext@plt之后的指令。

以上是PLT的基本原理,实际实现回复杂些。ELF将GOT拆分成了两个表,分别是.got和.got.plt。.got用来保存全局变量引用地址,.got.plt用来保存函数引用地址。其中.got.plt前三项有特殊含义:

(1)第一项保存.dynamic段的地址。(2)第二项保存的是本模块的ID。(3)第三项保存的是_dl_runtime_resolve()的地址。

之后的便是外部函数引用的地址。

这里每一项占8个字节,可以看到第二项和第三项都是0,因为还没初始化。可以看到第四项和第五项的地址初始化都是对应函数的plt中的第二条指令的地址。

关于PLT结构数组,每个外部函数引用都对应PLT结构数组中的一项。因为每个函数的plt中都有两个同样的操作:(1)将当前模块ID入栈。(2)跳转到_dl_runtime_resolve()。因此为了减少代码重复,把这两条指令统一放到了PLT结构数组的第一项,即PLT0。

现在回头看,地址0x5d0就是PLT0,接着是PLT1和PLT2。PLT结构长度是16字节,保证能刚好存放三条指令。PLT在ELF文件中以独立的段存放,段名为.plt,因为本身是地址无关的代码,所以可以和代码段合并成一个Segment被装载。

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值