程序员的自我修养——动态链接

程序员的自我修养——动态链接

趁着借的图书馆的书还没还,赶紧看。写博客记录一下学习的内容便于以后反复看

静态链接使得不同的程序开发者和部门能够相对独立地开发和测试自己的模块。当随着时间,静态链接的问题也暴露出来:如浪费内存和磁盘空间,模块更新困难,这使得人们不得不找一种更好的方式来组织程序的模块。

动态链接

传统的静态链接存在一个问题,如果多个进程需要使用同一个模块时,会造成一个函数在不同进程中存在多个相同的副本。同时,静态链接对程序的更新部署和发布也带来了很多的麻烦。一旦一个 .o 文件更新了,那么程序就需要重新的链接。对于程序的发布商来说,一旦有任何的模块的更新,就需要重新的链接,然后发布给用户,带来很大的不便。

要解决这样的问题,最简单的方法就是把程序的模块相互分割开来,形成独立的文件,而不再将它们静态链接到一起。简单的来说就是,不对那些组成程序的目标文件进行链接,等到程序运行时才进行链接。也就是说,把链接这个过程推迟到运行时再进行,这就是**动态链接(Dynamic Linking)**的基本思想。

假如我们要运行 Program_a, 系统加载 Program_a.o,然后发现里面用到了 lib.o,系统就加载 lib.o,如果 lib.o 里又依赖别的目标文件,系统会安照这样的方法将它们全部加载进内存,这个链接的工作原理和静态链接非常相似,包括符号解析,地址重定位等。完成这些步骤后,系统开始把控制权交给 Program_a.o 的程序入口处,程序开始运行。这时如果我们需要运行另外一个 Program_b,如果其使用到了 lib.o,则就不需要再重新加载,系统要做的只是将它们链接起来。

程序可扩展和兼容性

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

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

动态链接也可以增强程序的兼容性。一个程序在不同的平台运行时可以动态地链接到由操作系统提供的动态链接库,这些动态链接库相当于在程序和操作系统之间增加了一个中间件,从而消除程序对不同平台之间的依赖的差异性。比如操作系统 A 和操作系统 B 对于 printf 函数的实现机制不同,如果我们的程序是静态链接的,那么程序需要分别链接成能够在操作系统 A 和 B 上运行的 2 个版本并且发布,但如果操作系统 A 和 B 都能提供一个动态链接库包含 printf(),并且这个函数使用相同的接口,那么程序只需要有一个版本,在不同的操作系统上运行会动态选择相应的版本。当然这只是理论可行,实际会存在各种问题,动态链接模块之间的兼容性问题也需要考虑。

动态链接的基本实现

动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序。

动态链接涉及运行时的链接及多个文件的装载,必须要有操作系统的支持。因为动态链接的情况下,进程的虚拟地址空间分布会比静态链接情况下更为复杂,还有一些存储管理,内存共享,进程线程等机制在动态链接下也会有一些微妙的变化。目前主流的操作系统几乎都支持动态链接这种方式,在 Linux 中,ELF 动态链接文件被称为动态共享文件(DSO,Dynamic Shared Objects),简称共享对象,他们一般都是以 .so 为扩展名的一些文件;而在 Windows 系统中,动态链接文件被称为动态链接库(Dynamical Linking Library),它就是通常我们平时很常见的 .dll 文件

在 Linux 中,常用的 C 语言的运行库 glibc,它的动态链接形式的版本保存在 “/lib” 目录下,文件名为 “libc.so”,整个系统只保留一份 C 语言的动态链接文件,而所有的 C 语言编写的,动态链接的程序都可以在运行的时候使用它。当程序被装载的时候,系统的动态链接器会将程序所需要的动态链接库装载到进程的地址空间,并将程序中所有的未决议符号绑定到相应的动态链接库中,并进行重定位工作。

简单的动态链接的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// prog1.c
# include "Lib.h"

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

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

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

void foo(int i){
printf("Printing from lib.so %d\n", i);
getchar(); // 阻塞,防止过快结束无法查看进程地址空间
}
// Lib.h

#ifndef LIB_H
#define LIB_H

void foo(int i);

#endif

使用 gcc -fPIC -shared -o Lib.so Lib.c 编译生成一个共享对象文件
使用 gcc -o prog1 prog1.c ./Lib.so, gcc -o prog2 prog2.c ./Lib.so。分别编译链接两个文件

整个编译和链接的过程可以看做是这样的:

当我们的模块 prog1.c 被编译成为了 .o 文件时,编译器还不知道 foo() 函数的地址,当链接器将 prog1.o 链接生成可执行文件时,这时候就必须确定 foo() 函数的性质。如果它是定义在其他静态目标模块中的函数,链接器按照静态链接的规则将地址引用重定位。如果是一个动态共享对象的函数,那么链接器就会将这个符号的引用标记为一个动态链接的符号,不对他进行重定位,把这个过程留到装载时再进行。

地址空间分布

cat /proc/120/maps
7f83e65f0000-7f83e67d7000 r-xp 00000000 00:00 161323 /lib/x86_64-linux-gnu/libc-2.27.so
7f83e67d7000-7f83e67e0000 —p 001e7000 00:00 161323 /lib/x86_64-linux-gnu/libc-2.27.so
7f83e67e0000-7f83e69d7000 —p 000001f0 00:00 161323 /lib/x86_64-linux-gnu/libc-2.27.so
7f83e69d7000-7f83e69db000 r–p 001e7000 00:00 161323 /lib/x86_64-linux-gnu/libc-2.27.so
7f83e69db000-7f83e69dd000 rw-p 001eb000 00:00 161323 /lib/x86_64-linux-gnu/libc-2.27.so
7f83e69dd000-7f83e69e1000 rw-p 00000000 00:00 0
7f83e69f0000-7f83e69f1000 r-xp 00000000 00:00 345391 xx/xx/Lib.so
7f83e69f1000-7f83e69f2000 —p 00001000 00:00 345391 xx/xx/Lib.so
7f83e69f2000-7f83e6bf0000 —p 00000002 00:00 345391 xx/xx/Lib.so
7f83e6bf0000-7f83e6bf1000 r–p 00000000 00:00 345391 xx/xx/Lib.so
7f83e6bf1000-7f83e6bf2000 rw-p 00001000 00:00 345391 xx/xx/Lib.so
7f83e6c00000-7f83e6c26000 r-xp 00000000 00:00 161135 /lib/x86_64-linux-gnu/ld-2.27.so

7f83e7000000-7f83e7001000 r-xp 00000000 00:00 345392 xx/xx/prog1
7f83e7200000-7f83e7201000 r–p 00000000 00:00 345392 xx/xx/prog1
7f83e7201000-7f83e7202000 rw-p 00001000 00:00 345392 xx/xx/prog1

$ kill 120

  • 程序部分的内存被分为3页,一页为4kb
  • 可以看到总共用了 3 个动态链接库,一个是我们写的,还有 C 语言运行时库(libc-2.27.so)和动态链接器(ld-2.27.so)

我们可以用 readelf -l Lib.so 来查看 Lib.so 的装载属性

$ readelf -l Lib.so
Elf file type is DYN (Shared object file)

Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
LOAD 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x00000000000006f4 0x00000000000006f4 R E 0x200000
LOAD 0x0000000000000e10 0x0000000000200e10 0x0000000000200e10
0x0000000000000218 0x0000000000000220 RW 0x200000
DYNAMIC 0x0000000000000e20 0x0000000000200e20 0x0000000000200e20
0x00000000000001c0 0x00000000000001c0 RW 0x8

  • 文件类型不同,其他和普通的程序很相似,需要注意的是动态链接模块的装载地址是从地址 0x00000000 开始,而在最终运行时的装载地址并不是这个。所以共享对象的最终装载地址在编译时是不确定的,在装载时根据当前地址的空闲情况来动态分配一块足够大的虚拟地址空间给共享对象。

地址无关代码

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

固定装载地址的问题

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

很明显,在动态链接的情况下,如果不同的模块目标装载地址都一样是不行的。而对于单个程序来说,我们可以手工指定各个模块的地址,比如说把 0x1000 到 0x2000 分配给模块 A, 把地址 0x2000 到 0x3000 分配给模块 B。但是,管理这些模块会变成一件无比繁琐的事情。比如一个人制作了一个程序,该程序使用模块 B,但不需要模块 A,所以他以为 0x1000-0x2000 是空闲的,就分给了 C 模块。这就产生了冲突,任何人以后将不能在同一个程序里使用模块 A 和 C。

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

这样导致了很多的问题,除了地址冲突外,静态共享库的升级也成了问题,因为升级后的共享库必须保持共享库中全局函数和变量地址的不变。如果应用程序已经链接了这些地址,一旦更改,就必须重新链接应用程序。而且不能增加过多的内容,否则有可能超出被分配的空间。

装载时重定位

为了解决上面的问题,我们就需要让共享对象可以在任意的地址装载。我们首先可以想到的就是静态链接中的重定位。这个想法的基本思路就是,在链接时,对所有绝对地址的引用不做重定位,而把这一步推迟到装载时再完成。一旦模块装载地址确定,即目标地址确定,那么系统就对程序中所有的绝对地址引用进行重定位。

我们在静态链接时提到了重定位,那时的重定位叫做链接时重定位(Link Time Relocation),而现在的这种情况是装载时重定位(Load Time Relocation)。但是,装载时重定位的方法并不适合解决上面的问题。可以想象,动态链接模块被装载映射至虚拟空间后,指令部分是在多个进程之间共享的,由于装载时重定位需要修改指令,所以无法做到同一份指令被多个进程共享,因为指令被重定位后对于每个进程来说是不同的

当然,动态链接库中的可修改数据部分对于不同的进程来说有很多的副本,就可以用装载时重定位来解决。

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

地址无关代码(PIC)

什么是 -fPIC 参数,它又有什么效果呢?

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

对现代的机器来说,产生地址无关的代码并不麻烦。我们先来分析模块中各种类型的地址引用方式。这里我们把共享对象模块中的地址引用按照是否为跨模块分成两类:模块的内部引用和模块的外部引用;按照不同的引用方式又可以分为指令引用和数据访问。这样就总共 4 种情况:

  1. 模块内部的函数调用,跳转等
  2. 模块内部的数据访问,比如模块中定义的全局变量,静态变量
  3. 模块外部的函数调用,跳转等
  4. 模块外部的数据访问,比如其他模块中定义的全局变量
1
2
3
4
5
6
7
8
9
10
11
12
13
static int a;
extern int b;
extern void ext();

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

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

模块内部调用或跳转

相对位置固定,这种指令一般都不需要重定位。生成的汇编代码中 call,或者 jmp 后面跟的就是相对地址,只要相对位置不变,这条指令就是地址无关的。对于全局符号介入的问题,后面会进行讨论。

模块内部数据访问

很明显,指令中不能直接包含数据的绝对地址,那么唯一的方法就是相对寻址。我们知道,一个模块前面一般都是若干个页的代码,后面跟着若干的页的数据,这些页之间的相对位置是固定的,也就是说,任何一条指令与它需要访问的模块内部数据之间的相对位置是固定的,那么只需要相对于当前指令加上固定的偏移量就可以访问模块内部数据了。现代的体系结构中,数据的相对寻址往往没有相对于当前指令地址 PC 的寻址方式,所以 ELF 用了一个很巧妙的方法来得到 PC,最常用的就是调用一个叫__xxx.get_pc_thunk.xx 的函数(如__x86.get_pc_thunk.bx),这个函数的作用就是把返回地址放到 ecx 中(mov (%esp), %ecx)。由于 call 时栈顶就是 PC 的下一条,这样操作我们就获得了 PC

模块间数据访问

模块间的数据访问比模块内部稍微麻烦一点,因为模块间的数据访问目标地址要等到装载时才决定。我们前面提到的要是代码地址无关,基本的思想就是把跟地址有关的部分放到数据段里面,很明显,这些其他模块的全局变量的地址是跟模块装载地址相关的。ELF 的做法是在数据段里面建立一个指向这些变量的指针数组,也被称为全局偏移表(Global Offset Table, GOT),当代码需要引用该全局变量的时候,可以通过 GOT 中相应的项间接引用,它的基本机制如图:

模块间数据访问.png

当指令要访问变量的时候,会先去找 GOT,然后会根据 GOT 中变量对应的项找到变量的目标地址。每个变量对应一个4字节的地址,链接器在装载模块的时候会查找每个变量所在的地址,然后填充 GOT 中的各个项以确保每个指针所指向的地址正确,而 GOT 表在数据段,每个进程都有自己的副本而相互不影响。

我们可以使用 objdump -h xx.so 来查看 GOT 表的位置,使用 objdump - R xx.so 来查看动态链接时的重定位项

模块间调用和跳转

可以调用上面的方法,即使用 GOT 表,不同的是,GOT 表中相应的项保存的是目标函数的地址,当模块要调用目标函数时,可以通过 GOT 中的项进行间接跳转。这种方法很简单,但是存在一些性能问题,实际上 ELF 采用了一种更加复杂和精巧的方法,我们将在后面的动态链接优化中进行介绍

小结

各种地址引用方式如下:

|–|–|–|
||指令跳转和调用|数据访问|
|模块内部|相对跳转和调用|相对地址访问|
|模块外部|间接跳转和调用(GOT)|间接访问(GOT)|

PIC 与 PIE

地址无关代码除了可以用在共享对象上面,也可以用在可执行文件,一个以地址无关方式编译的可执行文件被称为地址无关的可执行文件(PIE, Position-Independent Executable),与 GCC 的-fPIC-fpic 参数类似,产生 PIE 的参数为-fPIE-fpie

-fpic 和 -fPIC

这两个参数从功能上来看是完全一样的,都是指示 GCC 产生地址无关的代码,唯一的区别是, -fPIC 产生的代码要大而 -fpic 产生的代码相对较小,而且较快。我们使用 -fPIC 是因为 -fpic 生成的地址无关代码都是和硬件平台相关,比如全局符号的数量或者代码的长度等,而 -fPIC 则没有这样的限制,为了方便起见,绝大多数情况下我们都使用 -fPIC 参数来产生地址无关代码。

共享模块的全局变量问题

有一种很特殊的情况我们目前还没有解决,就是当一个模块引用了一个定义在共享对象的全局变量的时候,比如一个共享对象定义了一个全局变量 global,而在模块 module.c 中是这么引用的:

1
2
3
4
extern int global;
int foo(){
global = 1;
}

当编译器处理 module.c 时,它无法根据这个上下文判断 global 是定义在同一个模块的目标文件还是定义在另一个共享对象之中,即无法判断是否为跨模块间的调用。

假设 module.c 是程序可执行文件的一部分,那么在这种情况下,由于程序主模块的代码并不是地址无关代码,也就是说不会使用这种类似 PIC 的机制,它引用这个全局变量的方式就和普通的一样,在链接过程中就去确立这个变量的地址。为了能使链接过程正常进行,链接器会在创建可执行文件时在 .bss 段创建一个 global 变量的副本。那么现在 global 变量定义在原先的共享对象中,而可执行文件的 .bss 段中还有一个副本,一个变量同时存在于多个位置,就无法确定具体的位置。

解决方法只有一个,就是把所有的使用这个变量的指令都指向位于可执行文件的那个副本。 ELF 共享库在编译时,默认都把定义在模块内部的全局变量当作定义在其他模块的全局变量,也就是说是模块外部的数据访问,通过 GOT 表来实现。当共享模块被装载时,如果某个全局变量在可执行文件文件中拥有副本,那么动态链接器就会把 GOT 中的相应地址指向副本,这样,该变量在运行时就会只有一个实例。如果变量在共享模块中被初始化,那么链接器需要将该初始化的值复制到程序主模块中的变量副本;如果该全局变量在程序主模块中没有副本,那么 GOT 中相应地址就指向模块内部的该变量副本。

数据段地址无关地址

通过上面的方法,我们可以保证共享对象中的代码地址无关,但是数据部分也有绝对地址引用的问题。如: static int a; static int *p = &a,如果某个共享对象中有这样一段代码,那么 p 指针的地址就是一个绝对地址,它指向变量 a, 而变量 a 的地址会随共享对象的装载地址的改变而改变,应该怎么解决?

对于数据段来说,它在每个进程中都有一份独立的副本,所以并不担心被进程改变。从这点来看,我们可以选择装载时重定位的方法来解决数据段中绝对地址引用的问题。对于共享对象来说,如果数据段中有绝对地址引用,那么编译器和链接器就会产生一个重定位表,这个表中包含重定位的入口(类型为 “R_386_RELATIVE” ),当动态链接器装载共享对象时,如果发现该共享对象有这样的重定位入口,那么动态链接器就会对该共享对象进行重定位。

程序员的自我修养——动态链接

http://cyx0706.github.io/2020/04/16/Linkers-Loaders-7/

Author

Ctwo

Posted on

2020-04-16

Updated on

2020-10-25

Licensed under

Comments