我们在上文中研究的静态库解决了许多关于如何让大量相关函数对应用程序可用的问题。然而,静态库仍然有一些明显的缺点。静态库和所有的软件一样,需要定期维护和更新。如果应用程序员想要使用一-个库的最新版本,他们必须以某种方式了解到该库的更新情况,然后显式地将他们的程序与更新了的库重新链接。
另一个问题是几乎每个C程序都使用标准I/О函数,比如printf和 scanf。在运行时,这些函数的代码会被复制到每个运行进程的文本段中。在一个运行上百个进程的典型系统上,这将是对稀缺的内存系统资源的极大浪费。
共享库(shared library)是致力于解决静态库缺陷的一个现代创新产物。共享库是一个目标模块,在运行或加载时,可以加载到任意的内存地址,并和一个在内存中的程序链接起来。这个过程称为动态链接(dynamic linking),是由一个叫做动态链接器(dynamic linker)的程序来执行的。共享库也称为共享目标(shared object),在 Linux系统中通常用.so后缀来表示。微软的操作系统大量地使用了共享库,它们称为DLL(动态链接库)。
共享库是以两种不同的方式来“共享”的。首先,在任何给定的文件系统中,对于一个库只有一个.so 文件。所有引用该库的可执行目标文件共享这个.so文件中的代码和数据,而不是像静态库的内容那样被复制和嵌入到引用它们的可执行的文件中。其次,在内存中,一个共享库的.text节的一个副本可以被不同的正在运行的进程共享。在我们学习虚拟内存时将更加详细地讨论这个问题。
win平台下的PE动态链接机制与LInux下的ELF动态链接稍有不同,ELF比PE从结构上更简单。我们以ELF为例来描述动态链接的过程。
先看一个简单的例子:
//program.c
#include "lib.h"
int main()
{
foobar(1);
return 0;
}
//lib.c
#include<stdio.h>
void foobar(int i)
{
printf("printing from lib.so %d\n", i);
}
//lib.h
#ifndef LIB_H
#define LIB_H
void foobar(int i);
#endif
程序很简单,program.c调用lib.c中的foobar()函数,传进数字,再由foobar函数打印输出。我们使用gcc将lib.c编译成一个共享对象文件:
gcc -fPIC -shared -o lib.so lib.c
-fpic选项指示编译器生成与位置无关的代码。-shared选项指示链接器创建一个共享的目标文件。
这时我们得到了一个lib.so文件,然后编译链接program.c
gcc -o program program.c ./lib.so
整个编译和链接的过程如图所示
这样就创建了一个可执行目标文件Prpgram,而此文件的形式使得它在运行时可以和Lib.so链接。基本的思路是当创建可执行文件时,静态执行一些链接,然后在程序加载时,动态完成链接过程。认识到这一点是很重要的:此时,没有任何 Lib.so的代码和数据节真的被复制到可执行文件Program中。反之,链接器复制了一些重定位和符号表信息,它们使得运行时可以解析对lib.so中代码和数据的引用。
当加载器加载和运行可执行文件 Program时,,它注意到Program包含一个.interp节,这一节包含动态链接器的路径名,动态链接器本身就是一个共享目标(如在Linux系统上的ld-linux.so)加载器不会像它通常所做地那样将控制传递给应用,而是加载和运行这个动态链接器。然后,动态链接器通过执行下面的重定位完成链接任务:
最后,动态链接器将控制传递给应用程序。从这个时刻开始,共享库的位置就固定了,并且在程序执行的过程中都不会改变。
通过上一节的介绍我们已经基本了解了动态链接的概念,同时,我们也得到了一个问题,那就是:共享对象在被装载时,如何确定它在进程虚拟地址空间中的位置?
为了实现动态链接,我们首先会遇到的问题就是共享对象地址的冲突问题,由于程序模块的指令和数据中可能会包含一些绝对地址的引用,我们在链接产生输出文件的时候,就要假设模块被装载的目标地址。
在早期,我们使用动态库的时候曾经有过一种名为静态共享库的解决方案。 这与当前使用的动态库不太一样。其主要特点是将模块统一交给操作系统管理,使用的库会被固定加载到某个地址。这样我们就可以在编译的时候确定函数的地址。因为每个函数由于其库是确定的,所以其库在内存中的装载地址也是确定的,由此就可以得到其地址。
不幸的是,这种方案有很多问题。除了上面提到的地址冲突的问题,静态共享库的升级也很成问题,因为升级后的共享库必须保持共享库中全局函数和变量地址的不变,如果应用程序在链接时已经绑定了这些地址,一旦更改,就必须重新链接应用程序,否则会引起应用程序的崩溃。即使升级静态共享库后保持原来的函数和变量地址不变,只是增加了一些全局函数或变量,也会受到限制,因为静态共享库被分配到的虚拟地址空间有限,不能增长太多,否则可能会超出被分配的空间。
为了能够使共享对象在任意地址装载,我们首先能想到的方法就是静态链接中的重定位。这个想法的基本思路就是,在链接时,对所有绝对地址的引用不作重定位,而把这一步推迟到装载时再完成。一旦模块装载地址确定,即目标地址确定,那么系统就对程序中所有的绝对地址引用进行重定位。
简而言之就是对其他模块的函数的引用在链接时只填写函数相对于其模块的起始地址,到装载时模块基地址确定后再修改为相对地址+基地址。因为整个程序是按照一个整体被加载的,程序中指令和数据的相对位置是不会改变的。
例如foo相对于模块a的代码段起始地址是0x100,先在链接阶段填入,之后模块a装载后确定基地址为0x600000,则修改所有调用foo处地址为0x600100。
我们前面在静态链接时提到过重定位,那时的重定位叫做链接时重定位(Link TimeRelocation),而现在这种情况经常被称为装载时重定位(Load Time Relocation),在Windows 中,这种装载时重定位又被叫做基址重置(Rebasing).
不幸的是,这种方案仍然存在一个问题:
可以想象,动态链接模块被装载映射至虚拟空间后,指令部分是在多个进程之间共享的, 由于装载时重定位的方法需要修改指令, 所以没有办法做到同一份指令被多个进程共享,因为指令被重定位后对于每个进程来讲是不同的。当然,动态连接库中的可修改数据部分对于不同的进程来说有多个副本,所以它们可以采用装载时重定位的方法来解决。
Linux和 GCC支持这种装载时重定位的方法,我们前面在产生共享对象时,使用了两个GCC参数“-shared”和“-fPIC",如果只使用“-shared”,那么输出的共享对象就是使用装载时重定位的方法。
为了解决装载时重定位无法共享指令的问题,我们自然的希望程序模块中共享的指令部分在装载时不需要因为装载地址的改变而改变。所以实现的基本想法就是把指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本。这种方案就是目前被称为地址无关代码(PIC,Position-independent Code)的技术。
这里我们把共享对象模块中的地址引用按照是否为跨模块分成两类:
这样我们就得到了下面的4种情况:
类型一 模块内部调用或跳转
这种情况可以说是最简单的,由于被调用的函数与调用者处于同一模块,两者之间相对位置固定。对于现代的系统来讲,模块内部的跳转、函数调用都可以是相对地址调用,或者是基于寄存器的相对调用,所以对于这种指令是不需要重定位的。比如上面例子中 foo对bar的调用可能产生如下代码:
8048344 <bar> :
8048344: 55 push %ebp
8048345: 89 e5 mov %esp,%ebp
8048347: 5d pop %ebp
8048348: c3 ret
8048349 <foo> :
...
8048357: e8 e8 ff ff ff call 8048344 <bar>
804835c: b8 00 00 00 00 mov $0x0,%eax
foo中对bar 的调用的那条指令实际上是一条相对地址调用指令,,相对偏移调用指令如下所示。
//相对偏移调用指令call的指令码
E8 E8 FF FF FF
这条指令中的后4个字节是目的地址相对于当前指令的下一条指令的偏移,即OxFFFFFFE8(Little-endian)。OxFFFFFFE8是-24的补码形式,即 bar的地址为Ox804835c +(-24)= 0x8048344。那么只要bar和 foo的相对位置不变,这条指令是地址无关的。即无论模块被装载到哪个位置,这条指令都是有效的。这种相对地址的方式对于jmp指令也有效。
类型二 模块内部数据访问
很明显,指令中不能直接包含数据的绝对地址,那么唯一的办法就是相对寻址。我们知道,一个模块前面一般是若干个页的代码,后面紧跟着若干个页的数据,这些页之间的相对位置是固定的,也就是说,任何一条指令与它需要访问的模块内部数据之间的相对位置是固定的,那么只需要相对于当前指令加上固定的偏移量就可以访问模块内部数据了。不幸的是,现代的体系结构中,数据的相对寻址没有相对当前指令地址(PC)的寻址方式,所以ELF用了一个很巧妙的办法来得到当前的PC值,
我们来看看最常用的一种,也是现在ELF的共享对象里面用的一种方法:
0000044c <bar>:
44c: 55 push %ebp
44d: 89 e9 mov %esp,%ebp
44f: e8 40 00 00 00 call 494 <__i686.get_pc__thunk.cx>
454: 81 c1 8c 11 00 00 add 0x118c, %ecx
45a: c7 81 28 00 00 00 01 movl $ox1,ox28《%ecx) //a = 1
461: 00 00 00
464: 8b 81 f8 ff ff ff mov 0xfffffff8(%ecx) , %eax
46a: c7 00 02 00 00 00 movl $0x2,(%eax) //b = 2
470: 5d pop %ebp
471: c3 ret
00000494 <_.i686.getpc_thunk .cx> :
494 : 8b 0c 24 mov (%esp),%ecx
497: c3 ret
这是对上面的例·子中的代码先编译成共享对象然后反汇编的结果。用粗体表示的是bar()函数中访问模块内部变量a 的相应代码。从上面的指令中可以看到,它先调用了一个叫“_i686.get_pc_thunk.cx”的函数,这个函数的作用就是把返回地址的值放到ecx寄存器,即把call的下一条指令的地址放到ecx寄存器。
我们知道当处理器执行call指令以后,下一条指令的地址会被压到栈顶,而esp寄存器就是始终指向栈顶的,那么当“_i686.get_pc_thunk.cx”执行“mov (%esp), %ecx"的时候,返回地址就被赋值到ecx寄存器了。
接着执行一条add 指令和一条mov指令,可以看到变量 a的地址是add指令地址(保存在ecx寄存器)加上两个偏移量0x118c和0x28,即如果模块被装载到0x10000000这个地址的话,那么变量a的实际地址将是0x10000000 +0x454+0x118c + 0x28 =0x10001608.
类型三 模块间数据访问
模块间的数据访问比模块内部得多, 因为模块间的数据访问目标地址要等到装载时才决定,比如上面例子中的变量b,它被定义在其他模块中,并且该地址在装载时才能确定。
我们前面提到要使得代码地址无关,基本的思想就是把跟地址相关的部分放到数据段里面,很明显,这些其他模块的全局变量的地址是跟模块装载地址有关的。
ELF的做法是在数据段里面建立一个指向这些变量的指针数组,也被称为全局偏移表(Global Offset Table.GOT),当代码需要引用该全局变量时,可以通过GOT中相对应的项间接引用,它的基本机制如下图所示。
当指令中需要访问变量b时,程序会先找到GOT,然后根据GOT中变量所对应的项找到变量的目标地址。每个变量都对应一个4个字节的地址,链接器在装载模块的时候会查找每个变量所在的地址,然后填充GOT中的各个项,以确保每个指针所指向的地址正确。由于GOT本身是放在数据段的,所以它可以在模块装载时被修改,并且每个进程都可以有独立的副本,相互不受影响。
GOT如何做到指令的地址无关性 ?
我们可以在编译时确定GOT相对于当前指令的偏移。确定GOT的位置跟上面的访问变量a的方法基本一样,通过得到PC值然后加上一个偏移量,就可以得到GOT的位置。然后我们根据变量地址在GOT中的偏移就可以得到变量的地址。
类型四 模块间调用、跳转
对于模块间调用和跳转,我们也可以采用上面类型四的方法来解决。与上面的类型有所不同的是,GOT中相应的项保存的是目标函数的地址,当模块需要调用目标函数时,可以通过GOT中的项进行间接跳转。
原文:https://www.cnblogs.com/Redwarx008/p/14181212.html