为什么需要动态链接?
静态链接的方法很简单,在早期,绝大部分系统采用这种方案。随着计算机软件的发展,这种方案的缺点很快也就暴露出来。
静态链接对磁盘和内存空间的浪费
静态链接对于计算机的内存和磁盘空间浪费严重,特别是多进程的环境下。因为每个可执行的目标文件和进程都会有print()
,scanf()
等函数。一个普通的程序假设使用静态库1MB,那么运行100个程序,就要浪费100MB内存,相应的存放可执行文件的磁盘也会浪费很多空间。程序的更新困难
假设Program1所使用的Lib.o是由第三方厂商提供,当该厂商更新了Lib.o,那么Program1的厂商就要拿到最新的Lib.o,然后将其与Program1.o链接后,将新的Program1整个发布给用户。
这样做的缺点很明显,即一旦程序中有任何模块的更新,整个程序就要重新链接,发布给用户。
动态链接
要解决空间浪费和更新困难这两个问题最简单的办法就是把程序中的模块相互分割开来,形成独立的文件,而不是再将它们静态地链接在一起。简单的说,就是不对那些组成程序的目标文件进行链接,等到程序要运行时才进行链接,即把整个过程推迟到了运行时再进行。
程序与*.so之间正真的链接工作是由动态链接器完成的,而不是前面看到过的静态链接器ld
完成的。
在Linux系统中,ELF动态链接文件被称为动态共享对象,它们一般都是以.so
为扩展名的文件。
在Windows系统中,动态链接文件被称为动态链接库,它们一般以.dll
为扩展名的文件。
简单动态链接例子
用一个例子简单演示动态链接过程,需要4个源文件分别是: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);
sleep(-1); // 方便后面看内存映射maps
}
/******Lib.h******/
#ifndef LIB_H
#define LIB_H
void foobar(int i);
#endif
# gcc -fPIC -shared Lib.c -o Lib.so
# gcc -c Program1.c -o Program1.o
# gcc -c Program2.c -o Program2.o
# gcc ./Lib.so Program1.o -o Program1 ./Lib.so
# gcc ./Lib.so Program2.o -o Program2 ./Lib.so
-shared
表示生成共享对象。
-fPIC
表示生成地址无关代码。
以Program1为例,整个编译链接过程如下:
当链接器将Program1.o 链接成可执行文件时,这时链接器必须确定Program1.o中所引用的foobar()
函数的性质。
如果foobar()
是一个定义在其它静态模块目标模块中的函数,那么链接器将会按照静态链接规则,将Program1.o中的foobar()
地址重定位,如果foobar()
是一个定义在其他动态共享对象中的函数,那么链接器就会将这个符号的引用标记为一个动态链接的符号,而不必对它进行重定位,把这个过程保留到装载过程。
那么怎么知道foobar
的引用时一个静态符号,还是动态符号呢?实际上Lib.so保存了完整的符号信息,将Lib.so 作为链接的输入文件时,链接器就能知道foobar
是一个定义在Lib.so的动态符号。
动态链接程序运行时地址空间分布
对于静态链接的可执行文件来说,整个进程只有一个文件要被映射,那就是可执行文件本身。但是对于动态链接来说,除了可执行文件本身之外,还有它所依赖的共享目标文件。那么它的内存布局是怎么样的呢?
# ./Program1 &
# cat /proc/`ps -aux | grep Program1 | grep -v grep | cut -d' ' -f6`/maps
从上面输出可以看出,整个进程的虚拟地址空间中,多出了几个文件的映射,Lib.so
,ld-2.17.so
,libc-2.17.so
。
libc-2.17.so
是C语言运行库
ld-2.17.so
是动态链接器
动态链接器与普通对象一样被映射到了进程的地址空间,在系统开始运行Program1之前,首先会把控制权交给动态链接器,由它完成所有的动态链接工作以后再把控制权交给Program1,然后开始执行。
# readelf -l Lib.so # -l display program-headers/segments
Elf file type is DYN (Shared object file)
Entry point 0x620
There are 7 program headers, starting at offset 64
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
LOAD 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x00000000000007e4 0x00000000000007e4 R E 200000
LOAD 0x0000000000000df8 0x0000000000200df8 0x0000000000200df8
0x0000000000000240 0x0000000000000248 RW 200000
DYNAMIC 0x0000000000000e18 0x0000000000200e18 0x0000000000200e18
0x00000000000001c0 0x00000000000001c0 RW 8
NOTE 0x00000000000001c8 0x00000000000001c8 0x00000000000001c8
0x0000000000000024 0x0000000000000024 R 4
GNU_EH_FRAME 0x0000000000000760 0x0000000000000760 0x0000000000000760
0x000000000000001c 0x000000000000001c R 4
GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 RW 10
GNU_RELRO 0x0000000000000df8 0x0000000000200df8 0x0000000000200df8
0x0000000000000208 0x0000000000000208 R 1
Section to Segment mapping:
Segment Sections...
00 .note.gnu.build-id .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt .init .plt .text .fini .rodata .eh_frame_hdr .eh_frame
01 .init_array .fini_array .jcr .data.rel.ro .dynamic .got .got.plt .bss
02 .dynamic
03 .note.gnu.build-id
04 .eh_frame_hdr
05
06 .init_array .fini_array .jcr .data.rel.ro .dynamic .got
从上面可以看出,动态链接模块的装载地址是从地址0x00000000
开始的,但是从上面的实际进程虚拟地址空间可以看出,Lib.so的最终装载地址并不是0x00000000
。
所以说明:共享对象的最终装载地址在编译时是不确定的,而是在装载时,装载器根据当前地址空间的空闲情况,动态的分配一块足够大小的虚拟地址空间给相应的共享对象。
装载时的重定位
地址无关代码
我们希望程序模块中共享的指令部分在装载时不需要因为装载地址的改变而改变,所以实现的基本想法就是把指令中那些需要被修改的部分分离出来,和数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本。这种方案就是目前被称为地址无关代码。
对于模块的地址引用一共由4种情况:
- 第一种是模块内部的函数调用,跳转等。
- 第二种是模块内部的数据访问,比如模块种定义的全局变量,静态变量。
- 第三种是模块外部的函数调用,跳转等。
- 第四种是模块外部的数据访问,比如其他模块中定义的全局变量。
static int a;
extern int b;
extern void ext();
void bar()
{
a = 1; // 模块内部数据访问
b = 2; // 模块外部数据访问
}
void foo()
{
bar(); // 模块内部函数调用
ext(); // 模块外部函数调用
}
模块内部调用或跳转
这种类型比较简单,因为被调用的函数和调用者都处于同一个模块,它们之间的位置相对固定。对于现代的系统而言,模块内部的跳转,函数调用都可以时相对地址调用,或者基于寄存器的相对调用,所以这种指令是不需要重定位