动态链接

静态链接使得不同的程序开发者和部门能够相对独立地开发和测试自已的程序模块,从某种意义上来讲大大促进了程序开发的效率,原先限制程序的规模也随之扩大.但是慢慢地静态链接的诸多缺点也逐步暴露出来,比如浪费内存和磁盘空间/模块更新困难等问题,使得人们不得不寻找一种更好的方式来组织程序的模块.

为什么要动态链接

动态链接有诸多好处, 事实上绝大部分 ELF 包括编译器的默认编译选项都会生成动态链接的可执行文件

  1. 内存和磁盘空间

    静态链接的方式对于计算机内存和磁盘的空间浪费非常严重.特别是多进程操作系统情况下,静态链接极大地浪费了内存空间

    想象一下每个程序内部除了都保留着printf/scanf/strlen等这样的公用库函数,还有数量相当可观的其他库函数及它们所需要的辅助数据结构.在现在的Linux系统中,一个普通程序会使用到的C语言静态库至少在1MB以上,那么,如果我们的机器中运行着100个这样的程序,就要浪费近100MB的内存:如果磁盘中有2000个这样的程序,就要浪费近2GB的磁盘空间,很多Linux的机器中,/usr/bin下就有数千个可执行文件.

    20240116231621

  1. 程序开发和发布

    空间浪费是静态链接的一个问题,另一个问题是静态链接对程序的更新/部署和发布也会带来很多麻烦.比如程序Program1所使用的Lib.o是由一个第三方厂商提供的,当该厂商更新了Lib.o的时候(比如修正了lib.o里面包含的一个Bug),那么Program1的厂商就需要拿到最新版的Lib.o,然后将其与Programl.o链接后,将新的Program1整个发布给用户.这样做的缺点很明显,即一旦程序中有任何模块更新,整个程序就要重新链接/发布给用户.比如一个程序有20个模块,每个模块1MB,那么每次更新任何一个模块,用户就得重新获取这个20MB的程序.如果程序都使用静态链接,那么通过网络来更新程序将会非常不便,因为一旦程序任何位置的一个小改动,都会导致整个程序重新下载.

    20240116232432

动态链接概念

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

模块的好处有很多:

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

程序可扩展性和兼容性

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

比如某个公司开发完成了某个产品,它按照一定的规则制定好程序的接口,其他公司或开发者可以按照这种接口来编写符合要求的动态链接文件.该产品程序可以动态地载入各种由第三方开发的模块,在程序运行时动态地链接,实现程序功能的扩展.

实际上游戏 mod 很多就是利用了 dll 的技术来实现的, 开放接口给 mod 制作者

动态链接还可以加强程序的兼容性.一个程序在不同的平台运行时可以动态地链接到由操作系统提供的动态链接库,这些动态链接库相当于在程序和操作系统之间增加了一个中间层,从而消除了程序对不同平台之间依赖的差异性.

比如操作系统A和操作系统B对于 printf 的实现机制不同,如果我们的程序是静态链接的,那么程序需要分别链接成能够在A运行和在B运行的两个版本并且分开发布:但是如果是动态链接,只要操作系统A和操作系统B都能提供一个动态链接库包含printf(),并且这个printf使用相同的接口,那么程序只需要有一个版本,就可以在两个操作系统上运行,动态地选择相应的printf的实现版本.

当然这只是理论上的可能性,实际上还存在不少问题

动态链接的基本实现

动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有的程序模块都链接成一个个单独的可执行文件.

那么我们能不能直接使用目标文件进行动态链接呢? 这个问题的答案是:理论上是可行的,但实际上动态链接的实现方案与直接使用目标文件稍有差别.

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

目前主流的操作系统几乎都支持动态链接这种方式,在Linux系统中,ELF动态链接文件被称为动态共享对象(DSO,Dynamic Shared Objects),简称共享对象,它们一般都是以 .so 为扩展名的一些文件;而在Windows系统中,动态链接文件被称为动态链接库(Dynamical Linking Library),它们通常就是我们平时很常见的以 .dll 为扩展名的文件.

在Linux中,常用的C语言库的运行库glibc,它的动态链接形式的版本 /lib/x86_64-linux-gnu/libc.so.6 保存在"/lib", 系统只保留一份C语言库的动态链接文件"libc.so",而所有的C语言编写的/动态链接的程序都可以在运行时使用它.当程序被装载的时候,系统的动态链接器会将程序所需要的所有动态链接库(最基本的就是libc.so)装载到进程的地址空间,并且将程序中所有未决议的符号绑定到相应的动态链接库中,并进行重定位工作

20240117224153

操作系统只需要装载一次 libc.so, 其余所有程序都可以共享使用. 或者上图换一种视角, libc.so 只在物理内存中被加载一次, 然后被映射到多个进程的虚拟内存空间

libc.so 的装载位置不固定, 会有一个随机的地址偏移

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

这样的做法的确很灵活,但是程序每次被装载时都要进行重新进行链接,是不是很慢? 的确,动态链接会导致程序在性能的一些损失,但是对动态链接的链接过程可以进行优化,比如我们后面要介绍的延迟绑定(Lazy Binding)等方法,可以使得动态链接的性能损失尽可能地减小.据估算,动态链接与静态链接相比,性能损失大约在5%以下.当然经过实践的证明,这点性能损失用来换取程序在空间上的节省和程序构建和升级时的灵活性,是相当值得的

简单的动态链接例子

// program1.c
#include "Lib.h"

int main() {
    foobar(1);
    return 0;
}
// program2.c
#include "Lib.h"

int main() {
    foobar(2);
    return 0;
}
// Lib.h
#ifndef LIB_H
#define LIB_H

void foobar(int i);

#endif
// Lib.c
#include <stdio.h>

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

执行如下指令编译动态库, 分别编译链接 program1/2

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

gcc -o program1 program1.o ./Lib.so
gcc -o program2 program2.o ./Lib.so

上面GCC命令中的参数"-shared"表示产生共享对象,"-fPIC"我们稍后还会详细解释,这里暂且略过.

这时候我们得到了一个Lib.so文件,这就是包含了Lib.c的foobar函数的共享对象文件.然后我们分别编译链接Program1.c和Program2.c. 整个编译链接的过程如下图所示:

20240117103721

其中有一个步骤与静态链接不一样,那就是 program1.o 被链接成可执行文件的这一步.在静态链接中,这一步链接过程会把 program1.o和 Lib.o 链接到一起,并且产生输出可执行文件.

但是在动态链接的过程中Lib.o没有被链接进来, 链接器仅使用了 Lib.so 中的符号, 因为 Lib.so 中保存了完整的符号信息(因为运行时进行动态链接还须使用符号信息). 链接器在解析符号时就可以知道: foobar是一个定义在 Lib.so 的动态符号.这样链接器就可以对foobar的引用做特殊的处理,使它成为一个对动态符号的引用.

当链接器将Program1.o链接成可执行文件时,这时候链接器必须确定Program1.o中所引用的foobar函数的性质.

运行时地址分布

假设此时我们修改 Lib.c 中的代码将 foobar 函数改为永久休眠, 然后重新编译动态链接库

// Lib.c
#include <stdio.h>
#include <unistd.h>

void foobar(int i) {
    printf("Printing from Lib.so %d\n", i);
    sleep(-1);
}
$ gcc -fPIC -shared -o Lib.so Lib.c
$ ./program1 &
$ cat /proc/6804/maps

这里不需要重新链接 program1/2, 修改 Lib.so 之后就完成了更改

20240117110714

相比静态链接, 动态链接的进程虚拟地址空间中多出了几个文件的映射.

关于动态链接器的部分我们稍后提到, 这里只是通过 maps 介绍一下相关 .so

如果使用 readelf 查看 Lib.so 文件, 可以发现除了文件的类型(DYN)与普通程序不同以外,其他几乎与普通程序一样.

除此之外我们发现动态链接模块以及程序的装载地址是从地址 0 开始的.我们知道这个地址是无效地址,并且从上面的进程虚拟空间分布看到,Lib.so的最终装载地址并不是这里.

前文静态链接中的起始地址会在 program header 的 VMA 中记录, 操作系统装载完成后只需要将 CPU 的 IP 修改为代码段的起始地址 0x401000 即可.

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

地址无关代码

固定装载地址的问题

我们已经基本了解了动态链接的概念, 同时我们也得到了一个问题: 共享对象在被装载时,如何确定它在进程虚拟地址空间中的位置?

为了实现动态链接,我们首先会遇到的问题就是共享对象地址的冲突问题. 程序模块的指令和数据中可能会包含一些绝对地址的引用,我们在链接产生输出文件的时候,就要假设模块被装载的目标地址.很明显,在动态链接的情况下,如果不同的模块目标装载地址都一样是不行的.

对于单个程序来讲,我们可以手工指定各个模块的地址,比如把0x1000到0x2000分配给模块A,把地址0x2000到0x3000分配给模块B.但是,如果某个模块被多个程序使用,甚至多个模块被多个程序使用,那么管理这些模块的地址将是一件无比繁琐的事情.比如一个很简单的情况,一个人制作了一个程序,该程序需要用到模块B,但是不需要用到模块A,所以他以为地址0x1000到0x2000是空闲的,于是分配给了另外一个模块C.这样C和原先的模块A的目标地址就冲突了,任何人以后将不能在同一个程序里面使用模块A和C.

20240117175819

早期的确有些系统采用了这样的做法,这种做法叫做静态共享库(Static Shared Library), 它跟静态库(Static Library)有很明显的区别.静态共享库的做法就是将程序的各种模块统一交给操作系统来管理,操作系统在某个特定的地址划分出一些地址块,为那些已知的模块预留足够的空间

静态共享库的目标地址导致了很多问题:

种种限制和弊端导致了静态共享库的方式在现在的支持动态链接的系统中已经很少见,而彻底被动态链接取代

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

装载时重定位

为了能够使共享对象在任意地址装载,我们首先能想到的方法就是静态链接中的重定位.这个想法的基本思路就是,在链接时,对所有绝对地址的引用不作重定位,而把这一步推迟到装载时再完成.一旦模块装载地址确定,即目标地址确定,那么系统就对程序中所有的绝对地址引用进行重定位, 如下图所示:

20240117183455

装载后确定了 dll_load_addr 就可以计算出指令的实际位置, 再去更新原先重定位表中对应指令的跳转距离

这里的 inst_offsetfoobar_offset 是确定的, .text_addr 对于操作系统来说也是确定的, 其实不确定的只有 dll_load_addr 的动态库实际装载的偏移量

装载时重定位确实解决了任意地址装载的问题, 但与此同时又带来了新的问题. 因为除了程序中会有绝对地址访问, 动态库中同样可能存在绝对地址访问. 假设 libc.so 中存在一条 jmp 0x2000 的跳转指令, 那么当期被装载到 0x1000 位置时我们需要修正其绝对路径为 0x3000, 但是当其被装载到 0x2000 位置时我们又需要将其修正为 0x4000. 如下图所示

20240117225828

正常来说动态链接模块被装载映射至虚拟空间后, 我们希望指令部分是在多个进程之间共享的, 可修改的数据部分每个进程保留自己的一份副本, 但是由于不同进程的动态链接模块的起始装载地址不同, 都需要对应的修改绝对地址的指令, 所以对于没有办法做到同一份指令被多个进程共享.

地址无关代码

Linux和GCC支持这种装载时重定位的方法,我们前面在产生共享对象时,使用了两个GCC参数 -shared-fPIC, 如果只使用 -shared,那么输出的共享对象就是使用装载时重定位的方法.

那么什么是 -fPIC 呢? 使用这个参数会有什么效果呢?

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

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

  1. 模块内部的函数调用/跳转等.
  1. 模块内部的数据访问,比如模块中定义的全局变量/静态变量.
  1. 模块外部的函数调用/跳转等.
  1. 模块外部的数据访问,比如其他模块中定义的全局变量.

20240117232342

延迟绑定(PLT)

动态链接相关结构

动态链接的步骤和实现

显式运行时链接

动态链接器与普通共享对象一样被映射到了进程的地址空间,在系统开始运行Program1之前,首先会把控制权交给动态链接器,由它完成所有的动态链接工作以后再把控制权交给Program1,然后开始执行.

参考

zood