共享目标文件(动态库)使用一种与可重定位目标文件不同的链接方法,称为动态链接。
一、为什么要引入动态链接
静态链接要求在程序运行之前,把静态库与可重定位文件合并成一个整体。
对于静态库来所,每个调用静态库的程序各自都有一份静态库的副本。
这就造成了两个问题:
- 1、空间浪费问题
如果内存中的两个进程都链接了同一静态库,那么这个静态库在两个进程各有一份拷贝,这就造成了内存空间浪费。 - 2、更新困难问题
一个库改动后,要通知所有使用它的程序更新库并重新编译链接。
使用程序的用户也要把编译好的程序全部重新下载才能使用。
动态链接的解决方案
静态链接是在编译时就把程序主要模块与库链接成一个整体,在运行时把这个整体加载到内存中去的。
和静态链接不同的时,在编译阶段,链接器仅仅完成部分符号解析工作,程序主要模块和库并不一个整体。运行时,把程序主要模块和库分别加载到内存,然后再做链接工作。
加载后的链接给程序增加了很多灵活性。
- (1)如果程序要用到的库已经在内存中,就不用重新加载。
- (2)如果更新了库,只需要把库加载到内存即可,下次启动程序时,就会链接新的库了。
二、动态链接的步骤
- 运行前:
生成动态库
与主要模块的第一次链接 - 运行时:
加载主要模块
运行主要模块
加载动态链接器(ld.so)
运行动态链接器
继续运行主要模块
正文将对加粗的部分进一步解释
三、生成动态链接库
生成共享目标文件的过程使用了静态链接,但做完静态链接后,还额外发生了一些事情,称为“地址无关代码(PIC)技术”。
为什么要使用这种技术?它是怎么做的?
这需要我们先理解一下操作系统中关于地址的概念。
1.操作系统中的地址
操作系统中有物理内存空间和虚拟地址空间两个概念。
当我们说两个进程共享一个动态库时,只是两个进程的动态库共享只一个物理空间。也就是说动态库在内存中实际上只有一份拷贝。
当我们说动态库以及主要模块中符号的地址时,指的是虚拟地址空间。每个进程独占3G的虚拟地址空间。
也就是说,即使是位于同一物理空间的动态库,在两个进程地址空间中所存在的虚拟地址都是不一样的。
同理,动态库引用主要模块中的符号,两个主要模块位于不同的进程空间,其符号地址也是不一样的。
2.要解决的问题
理解了操作系统中的地址之后,再来看看动态库的链接的解决的问题。
虽然是运行过程中在内存里做的链接,其实要解决的问题是差不多的。主要是访问内部数据、外部函数和外部数据的问题。
。
按照静态链接的做法,只有起始地址确定了,再做一下重定位就可以解决问题。
而动态链接的问题在于,对于不同的进程,动态链接库所引用的符号地址是不一样的。但动态库的指令又是所有进程共享的,不能为每个进程做适配。
正是基于这种原因,提出了PIC技术。其基本思想就是把要变的部分和不变的部分分离。其中要变的部分放到数据段(GOT)里去。
指令 ---> 数据/函数地址 ---> 数据/函数的第一条指令
------ ----------------
需要 与装载地址有关
修改
解决方案:
指令 ---> GOT下标 --->|
| ---> 数据/函数地址 ---> 数据/函数的第一条指令
GOT基址 --->|
3.地址无关代码
- 什么是地址无关代码?
希望程序模块中共享的指令部分在装载时不需要因为装载地址的改而改变,于是把指令中那些需要被修改的部分分离出来,跟数据部分放在一起。这种方案被称为地址无关代码(PIC)。
其具体做法是,把要修改地址的那些符号的地址存放到数据段的一张表GOT中,而要访问这个符号的地址时从这表里去查。因为符号在表里的地址是固定的,这样即使符号的地址修改了,符号在表里的索引没有改,那么代码段就不会受到影响。
修改后的指令不再直接存数据地地址,而是通过一个表去查询数据的地址。这个表GOT就是全局符号表。
每个数据在表中的下标是固定的,与装载地址无关。GOT基地的获取可以参考上方《文件内的数据访问》,也是与装载地址无关的。这样这个指令就成了地址无关代码了。
数据的实际地址存储在GOT表中。但GOT表是数据,每个进程各自有一份副本,不需要共用,也不需要满足地址无关。
剧透一下,在动态链接库的的重定向过程,不是更新指令中的地址,而是更新GOT中的地址。 - 地址无关代码与动态链接库什么关系?
没有使用PIC的动态链接库,能够在运行时链接,解决“更新困难”的问题。但每个进程使用它时都有一份自己的副本,不能解决“空间浪费”的问题。
使用了PIC的动态链接库,在运行时链接,能够解决“更新困难”的问题。各个进程共享库的指令部分,每个进程有一份库的数据的副本,能够减少“空间浪费”。
因此,在生成动态链接库时,通常都会使用PIC技术。
4.使用PIC生成动态链接库
不会对共享目标文件中所有的代码都使用PIC技术。只有那些会在运行时修改地址符号才有必要使用。
符号 | 访问方式 | 分析 | 是否使用PIC |
---|---|---|---|
文件内的static函数调用 | 文件内的函数调用使用的是相对地址跳转 | 不管库的装载地址是什么,指令之间的相对地址不会改变 | 否 |
文件内的static数据访问 | 访问文件内的数据使用相对寻址的方式(与静态链接不同) | 任何一条指令与它要访问的内部数据之间的相对位置是固定的,与装载地址无关 | 否 |
文件外的数据访问 (1)同一模块(动态库)其它文件的数据 (2)其它模块(别的动态库或包含main的主要模块)中的数据 (3)本文件定义的非static全局数据 |
这类数据的目标地址要等到装载时才能确定 | 是 | |
文件外的函数调用 (1)同一模块(动态库)其它文件的函数 (2)其它模块(别的动态库或包含main的主要模块)中的函数 (3)本文件定义的非static函数 |
这类函数与文件外的数据类型,那么向这类函数地址跳转的指令都是地址有关的。 | 是 |
四、主模块的第一次链接
动态库与主模块的链接是在运行时进行的。但是。。。
在编译生成可执行文件时,或是在ld时,如果有用到动态库,也是要写进来的。
这时的动态库起了什么作用?
在编译生成可执行文件的过程中,需要符号解析和重定向两个步骤。
符号解析即找到外部符号的地址。
重定向即更新指令中引用这些地址的部分。
如果符号解析过程中,外部符号找到不到,有两种可能:(1)确定没有定义(2)在动态库中定义
在ld过程中,动态库会准备一张表,包含它们能提供的所有符号。当主要模块找不到某个符号时会查看这张表。若找到,则对符号做特殊处理,符号解析与重定位时跳过这个符号。若没找到,则报错。
五、动态链接器的工作
加载完动态链接器以后,控制权就会交给动态链接器。
由动态链接器完成运行时动态链接的工作。
1.加载其它所需要的.so
动态链接器完成自举会开始加载别的动态库(广度优先)。
加载到内存后分配进程的虚拟地址空间。
有了虚拟地址空间的动态库,其符号的地址也就确定了。
2.符号解析
动态库加载并分配虚拟地址后后,动态链接器对动态库做符号解析。
这里把加载和符号解析分开讲,实际过程是连着的。一个动态库加载和解析后,再加载和解析另一个动态库。
动态链接器保存一个全局符号表GST。
动态链接器把主要模块与链接器本身的可引出符号主到GST中。
动态链接器把装载进来的动态库的符号表合并到GST中。
如果GST和动态库包含同一符号,则使用GST中的,丢弃动态库中的。(先来后到)
如果某个动态库找不到,则报错。
3.重定位和初始化
动态链接器再次遍历主要模块与其它动态库,依次对它们做重定向工作。
(1)修改主要模块的指令中引用外部符号的地址
(2)修改动态库GOT中引用外部符号的地址
这些外部符号的地址已经存储在GST中。
如果动态库有.init段,动态链接器会执行动态库的.init段
链接工作结束后,动态链接把控制权归还给主要模块。
六、运行时的调用
主要模块开始运行,当需要使用动态库中的符号时,就会跳转。
由于动态库“丢弃重复符号”的策略。有可能发现使用的符号不是自己期待的那个,但链接器并没有报错。
七、名词说明
主要模块:包含main的可执行程序
重定向:更新外部符号的地址