0%

《程序员的自我修养》 Ch7 动态链接(上)

7.1 为什么要动态链接

首先,要明确一个问题:为什么要用动态链接?静态链接这么简单好用,不香吗?

主要出于以下两个原因:

  • 静态链接会造成硬盘与内存的空间浪费
  • 静态链接程序的更新麻烦

第一个问题:

因为,静态链接是在链接的时候就把程序依赖的所有目标文件都合并到最终的可执行程序里,而一个程序往往会用到许多公共的目标文件(库),例如C语言标准库(stdio.hstdlib.h等)。如果计算机中的每个程序都包含C语言标准库的目标文件,那么以当今计算机中程序的数量来看,你的硬盘空间是大概率不够的。同时,静态链接的程序是被整体加载到内存中的,因此,当多个程序都被加载到内存中,它们所包含的标准库部分也会被重复加载许多次,这就造成了内存空间的极大浪费,以今计算机操作系统中运行的程序数量来看,你有限的内存大小也是顶不住的。

7.1-静态链接时文件在内存中的副本

图7.1 静态链接时文件在内存中的副本

第二个问题:

如果一个程序(假设是Program1)使用的一个第三方公共目标文件(假设是Lib.o)进行了更新,那么Program1需要进行重新链接,再发布给用户,用户需要重新下载整个程序,如果这个程序用了100个模块(目标文件、库),每个假设是1MB,整个程序100MB,那么即使每次只改动了某个模块的一个地方,那么也要重新链接整个程序,用户也要重新下载,对用户非常不友好。

7.2-动态链接时文件在内存中的副本

图7.2 动态链接时文件在内存中的副本

动态链接

动态链接的原理就是把程序的模块分割开来,使之成为一个个相互独立的文件,并一直保持这种状态,直到程序被加载到内存中运行。当点击该程序运行时,该程序所依赖的所有模块(文件)都被加载到内存,然后由动态链接器进行链接操作,链接操作基本与静态链接一致。

这种把链接过程推迟到运行时进行的思想就是动态链接的基本原理。

第一,动态链接所加载的部分模块(公共模块)是可以被多个程序共用的,这就大大减少了内存浪费;第二,倘若程序中的某个模块更新了,理论上我们只需要覆盖掉老版本的模块目标文件就行(不过实际还要考虑得多一点,比如新旧接口兼容问题)。

程序可扩展性和兼容性

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

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

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

有一句话说得好,“在计算机领域,没有什么是不能通过添加一层中间层解决的”(狗头

比如操作系统A和操作系统B对于printf()的实现机制不同,如果我们的程序是静态链接的,那么程序就需要在A、B两个平台分别编译链接;如果用的是动态链接,那么程序只需要有一个版本,就可以在两个操作系统上运行,动态地选择printf()的实现版本(不过这仅仅是理想情况下,实际还要考虑其他东西,例如接口版本)。

动态链接也带来了一些问题,最常见的一个问题是,当程序所依赖的某个模块更新了,更新前后的接口并不兼容,那么就导致主程序不能运行。于是我们需要一种管理机制来管理动态链接库的版本。

动态连接的基本实现

动态链接的基本原理前面已经说过了,现在考虑一个事,我们能直接将静态链接用到的目标文件当作动态链接所用的模块文件吗?答案是不行。实际的动态链接文件与目标文件有一些区别(大体上一致),后面会介绍。

动态链接需要操作系统的支持,Linux下的动态链接文件称作动态共享对象(Dynamic Shared Object),简称共享对象,一般以.so为后缀;Windows下的动态链接文件称作动态链接库(Dynamic Linking Library),后缀一般为.dll

7.2 简单的动态链接例子

1
2
3
4
5
6
7
8
/* Program1.c */
#include"Lib.h"

int main()
{
foobar(1);
return 0;
}
1
2
3
4
5
6
7
8
/* Program2.c */
#include"Lib.h"

int main()
{
foobar(2);
return 0;
}
1
2
3
4
5
6
7
/* Lib.h */
#ifndef LIB_H
#define LIB_H

void foobar(int i);

#endif
1
2
3
4
5
6
7
8
/* Lib.c */
#include"stdio.h"

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

编译链接过程:

1
2
3
gcc -fPIC -shared -o Lib.so Lib.c
gcc -o Program1 Program1.c ./Lib.so
gcc -o Program2 Program2.c ./Lib.so

注意产生Lib.so的两个关键参数:-fPIC-shared-shared是必须的参数,-fPIC则是下文所要介绍的PIC技术。

7.3-动态连接过程

图7.3 动态连接过程

为什么在生成可执行文件的时候仍要Lib.so“参与链接工作”呢?其实这里的链接并不是真正的链接,而是为了让Program1知道其中引用的foobar 函数是一个动态链接的符号,从而不进行重定位,而是把这个过程留到装载时再进行。同时,如果Lib.so“不参与链接”,那么生成可执行文件的过程可能会产生找不到符号定义的问题。

然后运行其中一个可执行文件(以Program1为例),并查看它的虚拟地址空间映射,如下:

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
31
32
33
34
35
36
doa@LAPTOP-DOA:~/Document/Ch7$ ./Program1 &
[1] 75
doa@LAPTOP-DOA:~/Document/Ch7$ Printing from Lib.so 1
^C
doa@LAPTOP-DOA:~/Document/Ch7$ cat /proc/75/maps
7fffba250000-7fffba253000 rw-p 00000000 00:00 0
7fffba260000-7fffba285000 r--p 00000000 00:00 289858 /usr/lib/x86_64-linux-gnu/libc-2.31.so
7fffba285000-7fffba3fd000 r-xp 00025000 00:00 289858 /usr/lib/x86_64-linux-gnu/libc-2.31.so
7fffba3fd000-7fffba447000 r--p 0019d000 00:00 289858 /usr/lib/x86_64-linux-gnu/libc-2.31.so
7fffba447000-7fffba448000 ---p 001e7000 00:00 289858 /usr/lib/x86_64-linux-gnu/libc-2.31.so
7fffba448000-7fffba44b000 r--p 001e7000 00:00 289858 /usr/lib/x86_64-linux-gnu/libc-2.31.so
7fffba44b000-7fffba44e000 rw-p 001ea000 00:00 289858 /usr/lib/x86_64-linux-gnu/libc-2.31.so
7fffba44e000-7fffba452000 rw-p 00000000 00:00 0
7fffba460000-7fffba461000 r--p 00000000 00:00 23019 /home/doa/Document/Ch7/Lib.so
7fffba461000-7fffba462000 r-xp 00001000 00:00 23019 /home/doa/Document/Ch7/Lib.so
7fffba462000-7fffba463000 r--p 00002000 00:00 23019 /home/doa/Document/Ch7/Lib.so
7fffba463000-7fffba464000 r--p 00002000 00:00 23019 /home/doa/Document/Ch7/Lib.so
7fffba464000-7fffba465000 rw-p 00003000 00:00 23019 /home/doa/Document/Ch7/Lib.so
7fffba470000-7fffba472000 rw-p 00000000 00:00 0
7fffba480000-7fffba481000 r--p 00000000 00:00 289671 /usr/lib/x86_64-linux-gnu/ld-2.31.so
7fffba481000-7fffba4a3000 r-xp 00001000 00:00 289671 /usr/lib/x86_64-linux-gnu/ld-2.31.so
7fffba4a3000-7fffba4a4000 r-xp 00023000 00:00 289671 /usr/lib/x86_64-linux-gnu/ld-2.31.so
7fffba4a4000-7fffba4ab000 r--p 00024000 00:00 289671 /usr/lib/x86_64-linux-gnu/ld-2.31.so
7fffba4ab000-7fffba4ac000 r--p 0002b000 00:00 289671 /usr/lib/x86_64-linux-gnu/ld-2.31.so
7fffba4ad000-7fffba4ae000 r--p 0002c000 00:00 289671 /usr/lib/x86_64-linux-gnu/ld-2.31.so
7fffba4ae000-7fffba4af000 rw-p 0002d000 00:00 289671 /usr/lib/x86_64-linux-gnu/ld-2.31.so
7fffba4af000-7fffba4b0000 rw-p 00000000 00:00 0
7fffba4b0000-7fffba4b1000 r--p 00000000 00:00 23152 /home/doa/Document/Ch7/Program1
7fffba4b1000-7fffba4b2000 r-xp 00001000 00:00 23152 /home/doa/Document/Ch7/Program1
7fffba4b2000-7fffba4b3000 r--p 00002000 00:00 23152 /home/doa/Document/Ch7/Program1
7fffba4b3000-7fffba4b4000 r--p 00002000 00:00 23152 /home/doa/Document/Ch7/Program1
7fffba4b4000-7fffba4b5000 rw-p 00003000 00:00 23152 /home/doa/Document/Ch7/Program1
7fffbc820000-7fffbc841000 rw-p 00000000 00:00 0 [heap]
7fffc4172000-7fffc4972000 rw-p 00000000 00:00 0 [stack]
7fffc4bab000-7fffc4bac000 r-xp 00000000 00:00 0 [vdso]
doa@LAPTOP-DOA:~/Document/Ch7$

可以看到其中不止有Program1的映射,还有libc-2.31.soLib.sold-2.31.so 等共享对象,其中libc-2.31.so是动态链接的C语言运行库 ,ld-2.31.so是Linux下的动态链接器。再开始执行Program1之前,系统会把控制权交给动态链接器,并由它完成所有的动态链接工作,然后再把控制权交还给Program1,开始执行。

7.3 地址无关代码

动态链接需要在开始执行程序之前对各个模块中对数据和引用的地址进行确定,也就是静态链接时的重定位。动态链接实现重定位的方式有两种:

  • 装载时重定位
  • 地址无关代码(Position-Independent Code, PIC)

装载时重定位很简单,就是在模块装载时先确定装载地址(目标地址/基地址),然后依据这个装载地址修改其中的绝对地址引用进行重定位(因为模块是按照一个整体被装载的,所以代码和数据之间的相对位置是不会改变的)

这种方式的缺点就是,一个共享对象的指令部分不能被多个进程所共享,仍然起不到节省内存空间的作用。因为指令部分中的引用在装载时被修改后就与进程相关了,取决于这个共享对象在该进程虚拟空间中被分配的地址,是一个固定值,而对于不同的进程,该共享对象的虚拟地址都是不一样的,所以一份指令不能被多个进程所共享。

解决这个问题的方法就是地址无关代码技术,一开始听起来很nb,其实就是在指令部分和被引用的地址之间增加了一层,这一层称为全局偏移表(Global Offset Table,GOT),在共享对象文件中是名为.got的段。

我们在上一节中生成Lib.so的时候就使用了-fPIC参数,从而产生使用了PIC技术的动态共享库。

我们的使用这种技术的目的是希望共享模块的指令部分与装载地址无关,从而能被多个进程共享。于是,一个自然的想法就是把指令中那些需要修改的部分分离出来,放在数据部分,因为每个进程都有单独的一个数据部分的副本,从而可以满足我们的需求。PIC主要是针对,共享模块中对模块外部数据or函数的访问or调用(后简写访问),因为内部数据和函数的访问都可以使用相对跳转来实现,而外部数据和函数则不能(因为相对位置会变化)。

于是我们请出GOT,我们并不直接访问外部数据和函数,而是将指令中对外部数据和函数的直接访问改为访问一个GOT表中的固定位置的指针(这些指针是固定放在数据部分的某个位置的,且排序也固定,所以GOT本质上是一个指针数组)。

7.4-模块间数据and函数访问

图7.4 模块间数据and函数访问

每个指针在GOT中的位置是固定的,所以指令部分的地址也就是固定的了,是不是很巧妙?

代码中对函数和数据的访问大致可以分为以下四种:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static int a;	//注意这里是静态全局变量
extern int b;
extern void ext();

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

void foo()
{
bar(); //类型1 模块内部函数调用
ext(); //类型3 模块外部函数调用
}
表7.1 不同类型的函数、数据访问方式
指令跳转、调用 数据访问
模块内部 (1)相对跳转和调用 (2)相对地址访问
模块外部 (3)间接跳转和调用(GOT) (4)直接访问(GOT)

注意!对于模块内部的静态全局变量来说,是可以直接用相对寻址的方式访问的,而对于模块内部的非静态全局变量来说,却不能,因为编译器无法确定对全局变量的引用是跨模块的还是模块内部的。因此,无论是模块内部还是外部的全局变量,都只能使用GOT的方式来访问。可执行文件在生成代码的过程中,在链接过程中就要确定地址,这时,链接器会在.bss段创建一个该全局变量的副本,在之后的动态连接过程中,其他模块的GOT都会指向该副本,从而不会导致冲突。

7.3.5 数据段地址无关

我们之前讨论的都是指令(代码)部分的地址无关性,其实数据段里面也有绝对地址的引用问题,例如一个指针变量p指向一个全局/静态变量a,a的地址会随着不同进程的装载而不同,解决这个问题的方法是利用重定位表,其中包含a的重定位信息,在动态装载的时候就会被重定位。

装载时重定位和地址无关代码的优缺

装载时重定位 地址无关代码
优点 运行速度较PIC方案要快 代码部分能被共享
缺点 代码部分不能被共享 比装载时重定位的方法运行时多了计算当前地址以及间接寻址的过程

对于动态连接的可执行文件,GCC默认会使用PIC的方法产生可执行文件的代码段部分。