静态库暴露有以下缺点

  1. 内存和磁盘空间占用大。如果一个lib.o被多个程序使用,那么每个程序内都会有 lib.o的一个副本,如果多个程序同时运行,在内存中还会也会有多个副本。比如 C 语言静态库,一个普通程序使用到 C 语言静态库至少 1M 以上,如果有 100 个这样的程序,那么就至少要 100M 空间。很多 linux 机器上,/bin/下甚至有上千个程序,那么就要浪费几个 G 的空间。
  2. 程序更新与发布。每次更新一个静态库,整个程序都要重新编译更新。

动态链接

简单的说,动态链接就是链接过程推迟到运行时在进行,这就是动态链接的基本思想。

系统检查程序所需要的库及其依赖,并将其依次加载到内存中,当把所有库都加载完时,检查依赖关系是否满足,然后再进行链接工作,链接工作和静态链接非常相似,包括符号解析地址重定位等。

后续再加载其他程序时也一样,依次加载库和依赖,如果依赖的库在之前加载其他程序时已经被加载进去了,那么就不用再加载了,只需要链接即可。

很明显,动态链接解决了静态链接库浪费磁盘空间和内存的问题。除此之外它还减少了物理页面的换入换出,也可以增加 CPU 缓存命中率。因为不同进程间的数据和指令访问都集中在了一个共享模块上

动态链接的基本实现

动态链接的基本思想是将程序按照模块分割成各个相对独立的部分,在程序运行时才将它们链接成一个完整的程序。那么我们能不能直接使用目标文件进行动态链接呢。理论上是可行的,但实际上动态链接的实现方案和直接使用目标文件稍有差别。

动态链接涉及运行时的链接,以及多个文件的装载,必须有操作系统的支持。因为动态链接的情况下,进程的虚拟地址分布也会比静态链接更为复杂,还有一些存储管理,内存共享,进程线程也会有一些微妙的变化。

程序真正的链接工作是由动态链接器来完成的,动态链接是把链接的这个过程从程序装载前,推迟到了装载的时候。 程序每次装载都要进行重新链接,会有一定的性能损失。但是动态链接过程可以优化,比如延迟绑定。可以使得动态链接的性能损失降到最小。

据估算,动态链接相比于静态链接,性能损失约 5%。

简单动态链接例子

program1.c

1
2
3
4
5
#include "lib.h"
int main(){
foobar(1);
return 0;
}

lib.c

1
2
3
4
#include <stdio.h>
void foobar(int i){
printf("Print %d\n",i);
}

lib.h

1
2
3
4
5
6
#ifndef LIB_H_
#define LIB_H_

void foobar(int i);

#endif

当 program1.c 编编译成 program.o 时,编译器还不知道 foobar 的地址,当链接器将program.o链接成可执行文件时候,它必须知道 foobar 函数的性质

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

那么链接器是如何知道 foobar 是一个静态符号还是一个动态符号呢。这就是我们链接是需要指定动态库的一个原因了。动态库中包含了完整的符号信息。链接器在解析符号时就知道它是一个动态符号还是一个静态符号。

共享对象的最终装载地址在编译时是不确定的

地址无关代码

共享对象在被装载时,如何确定它在虚拟地址空间中的位置? 为了实现动态链接,我们首先遇到的问题就会是共享对象地址的冲突问题。 程序模块的指令和数据中可能会包含一些绝对地址的引用,我们在链接产生输出文件时,就要假设模块被装载的目标地址。很明显,如果不同的模块目标装载地址都是相同的显然是不行的。

我们能不能让共享对象在任意位置加载?换个说法就是: 共享对象在编译时能不能假设自己在进程虚拟地址空间的位置。 与此不同的是,可执行文件基本上知道自己在进程虚拟地址空间的其实位置,因为可执行文件往往是第一个被加载的文件。它可以选择一个固定的地址。

装载时重定位

一旦模块装载地址确定,即目标地址确定,那么系统对程序中所有的引用地址进行重定位。比如 foobar 相对于代码段的地址是 0x100,当模块被装载到 0x10000000时,那么就可以确定 foobar 的地址为 0x10000100, 这时候,系统遍历模块化中的重定位表,把所有对 foobar 的地址引用都重定位到 0x10000100

装载时重定位需要修改指令。那么多个进程就无法共享这些指令了。动态链接库中将指令中可修改的部分提取出来和数据部分放在一起,每个进程各自拥有一个副本。这样大部分指令就可以共享了。 这个方案就称为 地址无关代码(PIC, Position-independent Code 技术

地址引用方式

按照是否跨模块分为两类,模块内引用,模块外引用
按照不同的引用方式分为,指令引用,数据引用

1
2
3
4
5
6
7
8
9
10
11
12
13
static int a;
extern in b;
extern void ext();

void bar(){
a = 1; // 2.模块内部数据访问
b = 2; // 4.模块间数据访问
}

void foo(){
bar(); // 1.模块内部调用或跳转
ext(); // 3.模块外函数调用
}

编译器在编译上述内容时候,并不能确定 b 和函数 ext() 是模块外部还是模块内部的。 因为他们可能定义在同一个共享对象的其他目标文件中。由于没法确定,编译器只能把他们都当做模块外部的函数和变量来处理。

模块内部调用或跳转

这种是最简单的,模块内部的函数跳转都可以是相对地址调用,所以这种指令是不需要重定位的。只要 bar 和 foo 的相对位置不变,那么 bar() 的调用就是地址无关的,即无论模块被装载到什么位置,指令都是有效的。

模块内部数据访问

模块内各个任何一条指令和它要访问的内部数据之间的相对位置也是固定的。通过访问当前位置加上固定的偏移量就可以访问模块内部数据了。

模块间数据访问

模块间的数据访问目标地址要等到装载时才确定。要使得代码地址无关,基本思想是把跟地址相关的部分放到数据里面。动态库的做法是在数据段里建立一个指向这些变量的指针数组,也被称为 全局偏移表。当代码需要访问全局变量时,可以通过 GOT 中对应的项间接引用。GOT 本身是放在数据段的,它可以在模块装载时被修改。并且每个进程都可以有独立的副本,相互不受影响。

模块间调用跳转

和模块间数据访问类似,只不过 GOT 中保存的是目标函数地址。这种方法和简单,但是存在一些性能问题。实际上 ELF 采用了一种更加复杂和精巧的方法。见 延迟绑定