【链接装载与库】动态链接(上)

动态链接

为什么要动态链接

静态链接使得不同的程序开发者和部门能够相对独立地开发和测试自己的程序模块,但静态链接的诸多缺点也逐步暴露出来,比如浪费内存和磁盘空间、模块更新困难等问题,使得人们不得不寻找一种更好的方式来组织程序的模块。

  • 内存和磁盘空间

静态连接的方式对于计算机内存和磁盘的空间浪费非常严重。特别是多进程操作系统情况下,静态链接极大地浪费了内存空间,想象一下每个程序内部除了都保留着printf()函数、 scanf()函数、strlen()等这样的公用库函数,还有数量相当可观的其他库函数及它们所需要的辅助数据结构。

如图所示的Program1 和 Program2 分别包含Programl.o和 Program2.o两个模块

【链接装载与库】动态链接(上)_第1张图片

当我们同时运行 Program1和Program2时,Lib.o在磁盘中和内存中都有两份副本。当系统中存在大量的类似于Lib.o的被多个程序共享的目标文件时,其中很大一部分空间就被浪费了。在静态链接中,C语言静态库是很典型的浪费空间的例子

  • 程序开发和发布

另一个问题是静态链接对程序的更新、部署和发布也会带来很多麻烦。
一旦程序中有任何模块更新,整个程序就要重新链接、发布给用户。 比如一个程序有20个模块,每个模块1MB, 那么每次更新任何一个模块,用户就得重新获取这个20MB的程序。

  • 动态链接

要解决空间浪费和更新困难这两个问题最简单的办法就是把程序的模块相互分割开来, 形成独立的文件,而不再将它们静态地链接在一起。
不对那些组成程序的目标文件进行链接,等到程序要运行时才进行链接。也就是说,把链接这个过程推迟到了运行时再进行,这就是动态链接的基本思想

【链接装载与库】动态链接(上)_第2张图片

这种做法解决了共享的目标文件多个副本浪费磁盘和内存空间的问题, 可以看到,磁盘和内存中只存在一份 Lib.o, 而不是两份。

其他优点:

  1. 它还可以减少物理页面的换入换出,也可以增加 CPU缓存的命中率,因为不同进程间的数据和指令访问都集中在了同一个共享模块上。
  2. 动态链接方案也可以使程序的升级变得更加容易,当我们要升级程序库或程序共 享的某个模块时,理论上只要简单地将旧的目标文件覆盖掉,而无须将所有的程序再重新链 接一遍。
  3. 当一个程序产品的规模很大的时候,往往会分割成多个子系统及多个模块,每个模块都 由独立的小组开发,甚至会使用不同的编程语言。动态链接的方式使得开发过程中各个模块 更加独立,耦合度更小,便于不同的开发者和开发组织之间独立进行开发和测试。
  • 程序可扩展性和兼容性

动态链接还有一个特点就是程序在运行时可以动态地选择加载各种程序模块,这个优点就是后来被人们用来制作程序的插件

  • 动态链接的基本实现

在Linux 系统中,ELF动态链接文件被称为动态共享对象(DSO), 简称共享对象,它们一般都是以“.so”为扩展名的一些文件;而在Windows系统中,动态链接文件被称为动态链接库, 它们通常就是我们平时很常见的以“.dll”为扩展名的文件。

在Linux中,常用的C 语言库的运行库glibc,它的动态链接形式的版本保存在“/lib”目录下,文件名叫做“libc.so"。当程序被装载的时候,系统的动态链接器会将程序所需要的所有动态链接库(最基本的就是libc.so) 装载到进程的地址空间,并且将程序中所有未决议的符号绑定到相应的动态链接库中,并进行重定位工作。

程序与 libc.so 之间真正的链接工作是由动态链接器完成的,而不是由我们前面看到过 的静态链接器ld完成的。也就是说,动态链接是把链接这个过程从本来的程序装载前被推迟到了装载的时候。

简单的动态链接例子

我们分别需要如下几个源文件:“Program1.c”、“Program2.c”、“Lib.c” 和“Lib.h”。它们的源代码如清单所示。

/*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 
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

两个程序的主要模块 Programl.c和 Program2.c分别调用了Lib.c里面的 foobar()函数,传进去一个数字,foobar()函数的作用就是打印这个数字

我们使用GCC将Lib.c编译成一个共享对象文件

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

这时候我们得到了一个Lib.so 文件,这就是包含了Lib.c的foobar()函数的共享对象文件。然后我们分别编译链接Program1.c和 Program2.c

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

这样我们得到了两个程序 Program1 和 Program2,这两个程序都使用了 Lib.so 里面的 foobar()函数。

【链接装载与库】动态链接(上)_第3张图片

Lib.c被编译成Lib.so共享对象文件,Program1.c被编译成Program1.o之后,链接成为可执行程序Programl

如果 foobar()是一个定义与其他静态目标模块中的函数,那么链接器将会按照 静态链接的规则,将Program1.o中的foobar地址引用重定位:如果foobar()是一个定义在某个动态共享对象中的函数,那么链接器就会将这个符号的引用标记为一个动态链接的符号, 不对它进行地址重定位,把这个过程留到装载时再进行。

把Lib.so 也作为链接的输入文件之一,链接器在解析符号时就可以知道:foobar是一个定义在Lib.so的动态符号。这样链接器就可以对 foobar 的引用做特殊的处理,使它成为一个对动态符号的引用。

  • 动态链接程序运行时地址空间分布

在Lib.c中的foobar()函数里面加入 leep函数然后就可以查看进程的虚拟地址空间分布
【链接装载与库】动态链接(上)_第4张图片

我们看到,整个进程虚拟地址空间中,多出了几个文件的映射。 Lib.so与Programl一样,它们都是被操作系统用同样的方法映射至进程的虚拟地址空间,只是它们占据的虚拟地 址和长度不同。 Programl除了使用Lib.so 以外,它还用到了动态链接形式的 C 语言运行库 libc-2.6.1.so。另外还有一个很值得关注的共享对象就是Id-2.6.so, 它实际上是Linux 下的动态链接器。 动态链接器与普通共享对象一样被映射到了进程的地址空间,在系统开始运行Program1 之前,首先会把控制权交给动态链接器,由它完成所有的动态链接工作以后再把 控制权交给 Program1, 然后开始执行。

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

地址无关代码

  • 固定装载地址的困扰

共享对象在被装载时,如何确定它在进程虚拟地址空间中的位置?

程序模块的指令和数据中可能会包含一些绝对地址的引用(.so中),我们在链接产生输出文件的时候,就要假设模块被装载的目标地址。
一个很简单的情况, 一个人制作了一个程序,该程序需要用到模块B, 但是不需要用到模块A, 所以他以为地址0x1000到0x2000是空闲的,于是分配给了另外一个模块C。这样C和原先的模块A的目标地址就冲突了,任何人以后将不能在同一个程序里面使用模块A和C。

为了解决这个模块装载地址固定的问题,我们设想是否可以让共享对象在任意地址加载?这个问题另一种表述方法就是: 共享对象在编译时不能假设自己在进程虚拟地址空间中的位置。

  • 装载时重定位

这个想法的基本思路就是,在链接时,对所有绝对地址的引用不作重定位,而把这一步 推迟到装载时再完成。

假设函数 foobar相对于代码段的起始地址是0x100, 当模块被 装载到 0x10000000 时,我们假设代码段位于模块的最开始,即代码段的装载地址也是 0x10000000, 那么我们就可以确定foobar的地址为0x10000100。这时候,系统遍历模块中的重定位表,把所有对foobar的地址引用都重定位至0x10000100。

可以想象,动态链接模块被装载映射至虚拟空间后,指令部分是在多个进程之间共享 的,由于装载时重定位的方法需要修改指令,所以没有办法做到同一份指令被多个进程共享, 因为指令被重定位后对于每个进程来讲是不同的。当然,动态连接库中的可修改数据部分对 于不同的进程来说有多个副本,所以它们可以采用装载时重定位的方法来解决。

我们前面在静态链接时提到过重定位,那时的重定位叫做链接时重定位, 而现在这种情况经常被称为装载时重定位, 在Windows 中,这种装载时重定位又被叫做基址重置

  • (对动态库文件产生)地址无关代码

装载时重定位是解决动态模块中有绝对地址引用的办法之一,但是它有一个很大的缺点是指令部分无法在多个进程之间共享,这样就失去了动态链接节省内存的一大优势。有一种更好的方法解决共享对象指令中对绝对地址的重定位问题。实现的基本想法就是把指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令 部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本。这种方案就是目前被称 为地址无关代码(PIC)的技术。

这里我们把共享对象模块中的地址引用按照是否为跨模块分成两类:模块内部引用和模块外部引用;按照不同的引用方式又可以分为指令引用和数据访问,这样我们就得到了4种情况

● 第一种是模块内部的函数调用、跳转等。
● 第二种是模块内部的数据访问,比如模块中定义的全局变量、静态变量。
● 第三种是模块外部的函数调用、跳转等。
● 第四种是模块外部的数据访问,比如其他模块中定义的全局变量。

【链接装载与库】动态链接(上)_第5张图片

● 第一种

模块内部的跳转、函数调用都可以是相对地址调用,或者是基于寄存器的相对调用,所以对于这种指令是不需要重定位的。

● 第二种

办法就是相对寻址。也就是说,任何一条指令与它需要访问的模块内部数据之间的相对位置是固定的,那么只需要相对于当前指令加上固定的偏移量就可以访问模块内部数据了。
【链接装载与库】动态链接(上)_第6张图片

● 第三种

模块间的数据访问目标地址要等到装载时才决定.要使得代码地址无关,基本的思想就是把跟地址相关的部分放到数据段里面,ELF的做法是在数据段里面建立一个指向这些变量的指针数组,也被称为全局偏移表(GOT),当代码需要引用该全局变量时,可以通过GOT中相对应的项间接引用
【链接装载与库】动态链接(上)_第7张图片

当指令中需要访问变量b 时,程序会先找到GOT, 然后根据 GOT 中变量所对应的项找 到变量的目标地址。

● 第四种

对于模块间调用和跳转,我们也可以采用上面类型四的方法来解决。与上面的类型有所 不同的是, GOT中相应的项保存的是目标函数的地址,当模块需要调用目标函数时,可以通过GOT中的项进行间接跳转
【链接装载与库】动态链接(上)_第8张图片

● 小结

\ 指令跳转、调用 数据访问
模块内部 (1)相对跳转和调用 (2)相对地址访问
模块外部 (3)间接跳转和调用(GOT) (4)间接访问(GOT)
  • 共享模块的全局变量问题

发现了吗?我们上面的情况中没 有包含定义在模块内部的全局变量的情况。可能你的第一反应就是,这不是很简单吗?跟模 块内部的静态变量一样处理不就可以了吗?的确,粗略一看模块内部的全局变量和静态变量 的地址都可以通过上面所列出的类型两种方法来解决。但是有一种情况很特殊,我们来看看 会产生什么问题。

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

当一个模块引用了一个定义在共享对象的全局变量的时候,它无法根据这个上下文判断 global 是定义在同一个模块的 的其他目标文件还是定义在另外一个共享对象之中,即无法判断是否为跨模块间的调用。

于是解决的办法只有一个,那就是所有的使用这个变量的指令都指向位于可执行文件中 的那个副本。ELF共享库在编译时,默认都把定义在模块内部的全局变量当作定义在其他模块的全局变量,也就是说当作前面的类型四,通过GOT来实现变量的访问。

Q: 如果一个共享对象lib.SO中定义了一个全局变量G, 而进程A 和进程B 都使用了lib.so, 那么当进程 A 改变这个全局变量G 的值时,进程 B 中的G 会受到影响吗?
A: 不会。因为当lib.SO被两个进程加载时,它的数据段部分在每个进程中都有独立的副本, 从这个角度看,共享对象中的全局变量实际上和定义在程序内部的全局变量没什么区 别。任何一个进程访问的只是自己的那个副本,而不会影响其他进程。那么,如果我 们把这个问题的条件改成同一个进程中的线程 A 和线程 B, 它们是否看得到对方对 lib.So 中的全局变量G 的修改呢?对于同一个进程的两个线程来说,它们访问的是同一 个进程地址空间,也就是同一个 lib.SO 的副本,所以它们对G的修改,对方都是看得到的。

=下篇=


你可能感兴趣的:(编译原理,c++,c语言,linux,汇编,数据结构)