1.为什么要动态链接
静态链接缺点:
1.静态链接的方式对于计算机内存和磁盘的空间浪费非常严重。特别是多进程操作系统情况下,静态链接极大的浪费了内存空间。
2.静态链接对程序的更新,部署和发布也会带来很大的麻烦。一旦程序中有模块更新,整个程序就要重新链接,发布给用户
要解决空间浪费和更新困难这2个问题最简单的办法就是把程序的模块互相分割开来,形成独立的文件,而不再将它们静态的链接在一起。
简单的说,就是不对哪些组成程序的目标文件进行链接,等到程序要运行时才进行链接。也就是说把这个过程推迟到了运行时再进行。这就是
动态链接的基本思想。
多个程序共享一个目标文件模块的好处是,节省内存,还可以减少物理页面的换入换出,也可以增加 CPU 缓存的命中率,因为不同的进程间的
数据和指令都集中子啊了同一个共享模块上。
动态链接也使得程序的升级变得很简单。当我们要升级程序库或程序共享的某个模块时,理论上只要简单的将旧的目标文件覆盖,而无需将所有的
程序再重新链接一遍。当程序下一次运行的时候,新版本的目标文件会自动装载到内存并链接起来。程序就完成了升级。
动态链接还有一个特点是,程序在运行时可以动态的选择加载各种程序模块,这个优点就是被人们后来用来制作程序的插件。比如某个公司开发了某个
产品,它按照一定的规则制定好程序的接口,其他公司或开发者可以按照这种接口来编写符合要求的动态链接文件。该产品程序可以动态的载入各种第三方
开发的模块,在程序运行的时候动态的链接,实现程序功能的扩展。
动态链接还可以加强程序的兼容性。一个程序在不同的平台运行时可以动态的链接到由操作系统提供的动态链接库,这些动态链接库相当于在程序和操作系统
之间增加了一个中间层,从而消除程序对不同平台之间的依赖。
动态库的基本思想是把程序按照模块拆分成各个独立的部分,在程序运行时才将它们链接成一个完整的程序,而不是像静态链接一样,把所有的程序模块都链接成
一个单独的文件。
动态链接涉及运行时的链接以及多个文件的装载,必须要有操作系统的支持,因为动态链接的情况下,进程的虚拟地址空间的分布比静态链接的情况更为复杂,还有
一些存储管理,内存共享,进程线程等机制在动态链接的情况下也会有些微妙的变化。
当程序被装载的时候,系统的动态链接器会将程序所需要的所有动态库装载到进程的地址空间,并且将程序中所有未决的符号绑定到相应的动态链接库中,并进行
重新定位。
程序与 libc.so 之间真正的链接工作是由动态链接器完成的,而不是我们前面看到的静态链接器 ld 完成的。也就是说,动态链接把链接的这个过程从本来的程序
装载被推迟到了装载的时候。动态链接会导致程序在性能的一些损失,性能损失大概在 5% 一下,这点性能损失能换来程序在空间上的节省和程序构建和升级时候的灵活。
2.简单的动态链接例子
gcc -fPIC -shared -o Lib.so Lib.c
gcc -o program1 program1.c ./Lib.so
gcc -o program2 program2.c ./Lib.so
图7-3 中有一个步骤与静态链接不一样,那就是 program.o 被连接成可执行文件这一步。在静态链接中,这一步链接过程会把 program1.o 和 Lib.o 链接
到一起,并产生输出可执行文件 program1。但是在这里,Lib.o 没有被连接进来,连接的输入目标文件只有 program1.so(当然还有C语音运行库)。但是从前面
的命令行看到,Lib.so 也参与了链接过程。
关于模块:
在静态链接时,整个程序最终只有一个可执行文件,它是一个不可分割的整体;但是在动态链接下,一个程序被分成了若干个文件,有程序的主要部分,即可执行文件(program1)
和程序所依赖的共享对象(Lib.so),很多时候我们也把这些部分称为模块,即动态链接下的可执行文件和共享对象都可以看做是程序的一个模块。
当程序模块 program1.c 被编译成 program.o 时,编译器还不知道函数 foobar() 函数的地址。当链接器将 program.o 链接成可执行文件时,
这个时候链接器必须确定 program1.o 中所引用的 foobar()函数的性质。如果 foobar() 是一个定义与其他静态目标模块中的函数,那么链接器将会按照
静态链接的规则,将 program1.o 中的 foobar 地址引用重定位;如果 foobar() 是一个定义在某个动态共享对象中的函数,那么链接器就会将这个符号的引用标记为一个动态链接
的符号,不对它进行重定位,把这个过程留到装载时再进行。
这里有个问题,链接器如何知道 foobar() 的引用是一个静态符号还是一个动态符号?这实际上就是我们要用到 Lib.so 的原因。Lib.so 中保存了完整的符号信息(因为运行时进行
动态链接还须使用符号信息),把 Lib.so 也作为链接的输入文件之一,链接器在符号解析时就可以知道:foobar 是一个定义在 Lib.so 的动态符号。这样链接器就可以对 foobar 的
引用做特殊的处理,使它成为一个对动态符号的引用。
动态链接程序运行时的地址分布:
对于静态链接的可执行文件来说,整个进程只有一个文件要被映射,那就是可执行文件本身。但是对于动态链接来说,除了可执行文件本身,还有它依赖的共享目标文件,那么这种情况下,
进程的地址空间分布又是怎样的?
[root@sfytest2 daodao]# cat /proc/31597/maps
00400000-00401000 r-xp 00000000 fd:01 1441794 /home/daodao/program1
00600000-00601000 r--p 00000000 fd:01 1441794 /home/daodao/program1
00601000-00602000 rw-p 00001000 fd:01 1441794 /home/daodao/program1
7f8374009000-7f83741bf000 r-xp 00000000 fd:01 132799 /usr/lib64/libc-2.17.so
7f83741bf000-7f83743bf000 ---p 001b6000 fd:01 132799 /usr/lib64/libc-2.17.so
7f83743bf000-7f83743c3000 r--p 001b6000 fd:01 132799 /usr/lib64/libc-2.17.so
7f83743c3000-7f83743c5000 rw-p 001ba000 fd:01 132799 /usr/lib64/libc-2.17.so
7f83743c5000-7f83743ca000 rw-p 00000000 00:00 0
7f83743ca000-7f83743cb000 r-xp 00000000 fd:01 1441793 /home/daodao/Lib.so
7f83743cb000-7f83745ca000 ---p 00001000 fd:01 1441793 /home/daodao/Lib.so
7f83745ca000-7f83745cb000 r--p 00000000 fd:01 1441793 /home/daodao/Lib.so
7f83745cb000-7f83745cc000 rw-p 00001000 fd:01 1441793 /home/daodao/Lib.so
7f83745cc000-7f83745ec000 r-xp 00000000 fd:01 132791 /usr/lib64/ld-2.17.so
7f83747dc000-7f83747df000 rw-p 00000000 00:00 0
7f83747e9000-7f83747eb000 rw-p 00000000 00:00 0
7f83747eb000-7f83747ec000 r--p 0001f000 fd:01 132791 /usr/lib64/ld-2.17.so
7f83747ec000-7f83747ed000 rw-p 00020000 fd:01 132791 /usr/lib64/ld-2.17.so
7f83747ed000-7f83747ee000 rw-p 00000000 00:00 0
7ffc041fc000-7ffc0421d000 rw-p 00000000 00:00 0 [stack]
7ffc0421e000-7ffc04220000 r-xp 00000000 00:00 0 [vdso]
ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0 [vsyscall]
我们可以看到,整个进程虚拟地址空间中,多出了几个文件的映射。Lib.so 与 program1 一样,它们都是被操作系统用同样的方法映射至进程的虚拟地址空间,只是它们占据
的虚拟地址和长度不同。program1 除了使用 Lib.so 以外,它还用到了动态链接形式的 C 语言运行库 libc-2.6.1.so。另外,还有一个值得关注的共享对象是 ld-2.6.so,
它实际上是 Linux 下的动态链接器。动态链接器与普通共享对象一样被映射到了进程的地址空间,在系统开始运行 program1 之前,首先会把控制器交给动态链接器,由它完成
所有的动态链接工作以后再把控制器交给 program1,然后开始执行。
动态链接模块的装载地址是从 0x00000000 开始的。我们知道这个地址是无效的,并且从上面的进程虚拟地址空间分布可以看到,Lib.so 的最终装载地址不是 0x00000000.
从这一点我们可以推断,共享对象的最终装载地址在编译时是不确定的,而是在装载时,装载器根据当前地址空间的空闲情况,动态分配一块足够大小的虚拟地址空间给相应的共享
对象。
3.地址无关代码
1.固定装载地址的困扰
共享对象在被装载时,如何确定它在进程虚拟地址空间中的位置?
在动态链接的情况下,如果不同模块目标装载地址都是一样是不行的。
静态共享库,请注意,它跟静态库有很明显的区别。静态共享库的做法就是将程序的各个模块统一交给操作系统来管理,操作系统在某个特定的地址划分出
一些地址块,为那些已知的模块预留足够的空间。
静态共享库的目标地址导致了很多问题,除了上面提到的地址冲突的问题,静态共享库的升级也很成问题,因为升级后的共享库必须保持共享库中全局函数和
变量地址的不变,如果应用程序在链接时已经绑定了这些地址,一旦更改,就必须重新链接应用程序,否则会引起应用程序的崩溃。即使升级静态共享库后保持
原来的函数和变量地址不变,只是增加了一些全局函数或变量,也会受到限制,因为静态共享库被分配到的虚拟地址空间有限,不能增长太多,否则可能会超出被分配
的空间。种种限制导致了静态共享库的方式在现在的支持动态链接的系统中已经很少见,而彻底被动态链接取代。
为了解决这个模块装载地址固定的问题,我们设想是否可以让共享对象在任意地址加载?这个问题的另外一种表述方法就是:共享对象在编译时不能假设自己在进程虚拟
地址空间中的位置。与此不同的是,可执行文件基本可以确定自己在进程虚拟空间的起始位置,因为可执行文件往往是第一个被加载的文件,它可以选择一个固定空闲的地址,
比如 Linux 下一般是 0x08040000,Windows 下一般是 0x004000。
2.装载时重定位
为了能够使共享对象在任意地址装载,我们首先能想到的方法是静态链接中的重定位。这个想法的基本思路是,在链接时,对所有的绝对地址的引用不作重定位,而把这一步推迟
到装载时再完成。一旦模块装载地址完成,即目标地址确定,那么系统就对程序中所有的绝对地址引用进行重定位。
早在没有虚拟存储概念的情况下,程序是直接被装载进物理内存的。当同时有多个程序运行的时候,操作系统根据当时内存空闲情况,动态分配一块大小合适的物理内存给程序,所以程序
被装载的地址是不确定的。系统在装载程序的时候需要对程序的指令和数据中对绝对地址的引用进行重定位。但这种重定位比前面提到过的静态链接的重定位简单的多,因为整个程序是按照一个
整体被加载的,程序中指令和数据的相对位置是不会被改变的。比如一个程序在编译时假设被装载的目标地址为 0x1000, 但是在装载时操作系统发现 0x1000 整个地址已经被别的程序使用了,
从 0x4000 开始有一块足够大的空间可以容纳该程序,那么该程序就可以被装载到 0x4000,程序指令或数据中的所有绝对引用只要加上 0x3000 的偏移量就可以了。
我们前面再静态链接时提到过重定位,那时的重定位叫做链接时重定位,而现在这种情况叫做装载时重定位,在 Windows 中,这种装载时重定位又被叫做基址重置。
这种情况与我们碰到的问题很相似,都是程序模块在编译时目标地址不确定而需要在装载时将模块重定位。但是装载时重定位的方法并不适合用来解决上面的共享对象中所存在的问题。可以想象,
动态链接模块被装载映射至虚拟空间后,指令部分是在多个进程之间共享的,由于装载时重定位的方法需要修改指令,所以没办法做到同一份指令被多个进程共享,因为指令被重定位后对每个进程来讲
是不同的。当然,动态链接库中的可以修改的数据部分对不同的进程来说有多个副本,所以它们可以采用装载时重定位的方法来解决。
Linux 和 GCC 支持这种装载时重定位的方法,我们前面在产生共享对象时,使用了两个 gcc 参数 "-shared" 和 "-fPIC",如果只使用 "-shared",那么输出的共享对象就是使用装载时重定位
的方法。
3.地址无关代码
装载时重定位是解决动态模块中有绝对地址引用的办法之一,但是它有个很大的缺点是指令部分无法在多进程之间共享,这样就失去了动态链接省内存的一大优势。我们还需要有一种更好的方法解决共享
对象指令中对绝对地址的重定位问题。其实我们的目的很简单,希望程序模块中共享的指令部分在装载时不需要因为装载地址的改变而改变,所以实现的基本思想就是把指令中那些需要被修改的部分分离出来,
跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本。这种方案就是目前被称为 地址无关代码的技术。
我们把共享对象模块中的地址引用按照是否跨模块分成2类:模块内部引用和模块外部引用;按照不同的引用方式又可以分为指令引用和数据访问。这样我们就得到了如下4种情况:
1.模块内部的函数调用,跳转等
这种是最简单的。因为被调用的函数与调用者都处于同一个模块,它们之间的相对位置是固定的,所以这种情况比较简单。对于现代的系统来讲,模块内部的跳转,函数调用都可以是相对的调用,或者是
基于寄存器的相对调用,所以对于这种指令是不需要重定位的。
2.模块内部的数据访问,比如模块中定义的全局变量,静态变量
指令中不能直接包含数据的绝对地址,那么唯一的办法就是相对寻址。我们知道,一个模块前面一般是若干个页的代码,后面紧跟着若干个页的数据,这些页之间的相对位置是固定的,也就是说,任何一条
指令与它需要访问的模块内部数据之间的相对位置是固定的,那么只需要相对于当前指令加上固定的偏移量就可以访问模块内部的数据了。现代的体系结构中,数据的相对寻址往往没有相对与当前指令地址(PC)
的寻址方式,所以 ELF 用了一个很巧妙的办法来得到当前的 PC 值,然后加上一个偏移量就可以达到访问相应变量的目的了。
3.模块外部的函数调用,跳转等
模块间的数据访问比模块内稍微麻烦一点,因为模块间的数据访问目标地址要等到装载时才能决定。我们前面提到要使得代码地址无关,基本的思想就是把地址相关的部分放到数据段里面,很明显,这些其他
模块的全局变量的地址是跟模块装载地址有关的。ELF 的做法是在数据段里面建立一个指向这些变量的指针数组,也被称为全局偏移量(GOT),当代码需要引用该全局变量时,可以通过 GOT 中相对应的项间接
引用。
当指令中需要访问变量 b 时,程序会先找到 GOT,然后根据 GOT 中变量所对应的项找到变量的目标地址。每个变量都对应一个4字节的地址,链接器在装载模块的时候会查找每个变量所在的地址,然后填充
GOT 中的各个项,以确保每个指针所指向的地址正确。由于 GOT 本身是放在数据段的,所以它可以在模块装载时被修改,并且每个进程都可以有独立的副本,互补影响.
模块在编译时可以确定模块内部变量与当前指令的偏移,那么我们也可以在编译时确定 GOT 相对于当前指令的偏移。确定 GOT 的位置跟上面访问变量 a 的方法基本一样,通过得到 PC 值然后加上一个偏移量,
就可以得到 GOT 的位置。然后我们根据变量地址在 GOT 中的偏移就可以得到变量的地址,当然 GOT 中每个地址对应于哪个变量是由编译器决定的。
4.模块外部的数据访问,如其他模块中定义的全局变量
对于模块间调用和跳转,我们也可以采用上面的方法来解决。与上面的类型有所不同的是, GOT 中相应的项保存的是目标函数的地址,当模块需要调用目标函数时,可以通过 GOT 中的项进行间接跳转。
各种地址引用方式:
指令跳转,调用 数据访问
模块内部:(1)相对跳转和调用 (2)相对地址访问
模块外部: (3)间接跳转和调用(GOT) (4)间接访问(GOT)
-fpic 和 -fPIC
使用 gcc 产生地址无关代码很简单,我们只需要使用 '-fPIC' 参数即可。实际上 gcc 还提供了另外一个类似的参数叫做 '-fpic',即 'PIC' 3个字母的小写,这2个参数从功能上来讲是完全一样的,都是指示
gcc 产生地址无关的代码。唯一的区别是,'-fPIC'产生的代码要大,而 '-fpic' 产生的代码相对较小,而且快。那么我们为什么不适用 '-fpic'而要使用 '-fPIC'呢?原因是,由于地址无关代码都是跟平台硬件
相关的,不同的平台有着不同的实现,'-fpic'在某些平台上会有一些限制,比如全局符号的数量或者代码的长度等,而 '-fPIC'没有这样的限制。
PIC 与 PIE
地址无关代码技术除了可以用在共享对象上面,它还可以用于可执行文件,一个以地址无关方式编译的可执行文件称作 地址无关可执行文件(PIE)。与 gcc 的 '-fPIC'类似,产生 PIE 的参数为 '-fPIE'或'-fpie'。
4.共享模块的全局变量问题
所有使用这个变量的指令都指向位于可执行文件中的那个副本。ELF 共享库在编译时,默认把定义在模块内部的全局变量当作定义在其他模块的全局变量,也就是说当作前面的类型4,通过 GOT 来实现访问。
当共享模块被装载时,如果某个全局变量在可执行文件中拥有副本,那么动态链接器就会把 GOT 中的相应地址指向该副本,这样该变量在运行时实际最终就只有一个实例。如果变量在共享模块中被初始化,那么
动态链接器还需要将该初始化值复制到程序主模块中的变量副本;如果该全局变量在主程序模块中没有副本,那么 GOT 中的相应地址就指向模块内部的该变量副本。
如果一个共享对象 lib.so 中定义了一个全局变量 G, 而进程A和进程B都使用了 lib.so,那么当进程A改变这个全局变量G的值时,进程B中的G会受到影响吗?
答:不会。因为当 lib.so 被两个进程加载时,它的数据段部分在每个进程中都有独立的副本,从这个角度上看,共享对象中的全局变量实际上和定义在程序内部的全局变量没有区别。任何一个进程访问的只是
自己的那个副本,而不会影响其他进程。那么,如果我们把这个问题的条件改成同一个进程中的线程A和线程B,它们访问的是同一个进程的地址空间,也就是同一个 lib.so 的副本,所以它对 G的修改,对方都是看
得到的。
那么我们可不可以做到跟前面答案相反的情况呢?比如要求两个进程共享一个共享对象的副本或者要求两个线程访问全局变量不同的副本,这两种需求都是存在的,比如多个进程可以共享一个全局变量可以用来实现
进程间的通信;而多个线程访问全局变量的不同副本可以防止不同线程对全局变量的干扰。比如 C 语言运行库的 errno 全局变量。实际上这2个需求都有相应的解决方法的,多进程共享全局变量又被叫做"共享数据段".
而多个线程访问不同的全局变量副本又叫做 '线程私有存储'。
5.数据段地址无关性
通过上面的方法,我们能够保证共享对象中的代码部分地址无关,但是数据部分是不是也有绝对地址引用的问题呢?
对于数据段来说,它在每个进程都有独立的副本,所以并不担心被进程修改。从这点看,我们可以选择在装载时重定位的方法来解决数据段中绝对地址引用的问题。对于共享对象来说,如果数据段中有绝对地址的引用,
那么编译器和链接器就会产生一个重定位表,这个重定位表里包含了 'R_386_RELATIVE' 类型的重定位入口,用于解决上述问题。当动态链接器装载共享对象时,如果发现该共享对象有这样的重定位入口,那么动态
链接器就会对该共享对象进行重定位。
如果我们在编译共享对象时不适用 '-fPIC' 参数 :
gcc -shared pic.c -o pic.so
上面的命令就会产生一个不使用地址无关代码而使用装载时重定位的共享对象。如果代码不是地址无关的,它就不能被多个进程共享,于是也就失去了省内存的优点。但是装载时重定位的共享对象的运行速度比使用
地址无关的代码的共享对象快,因为它省去了地址无关代码中每次访问全局数据和函数需要做一次计算当前地址以及间接地址寻址的过程。
对于可执行文件来说,默认情况下,如果可执行文件是动态链接的,那么 gcc 会使用 PIC 的方法来产生可执行文件的代码部分,以便于不同的进程能够共享代码段,节省内存。所以我们可以看到,动态链接的可
执行文件中存在 '.got' 这样的段。
4.延迟绑定(PLT)
动态链接的确有很多优势,比静态链接灵活的多,但是它是以牺牲一部分性能为代价的,大约在 1%~5%。我们知道动态链接比静态链接慢的主要
原因是动态链接下对于全局和静态的数据访问都要进行复杂的 GOT 定位,然后间接寻址;对模块间的调用也要进行 GOT 定位,然后再进行间接跳转。
如此一来,程序的运行速度必定会减慢。另外一个减慢的原因是动态链接的链接工作在运行时完成,即程序开始执行时,动态链接器都要进行一次链接
工作,正如我们上面提到的,动态链接器会寻找并装载所需要的共享对象,然后进行符号查找地址重定位等工作,这些工作必然减慢程序的启动速度。
延迟绑定实现:
在动态链接下,程序模块之间包含大量的函数引用(全局变量往往很少,因为大量的全局变量会导致模块之间的耦合度变大),所以程序开始执行之前,
动态链接会消耗不少用于解决模块之间的函数引用的符号查找和重定位。在一个程序的运行过程中,可能很多函数在程序执行完时都不会被用到,比如一些
错误处理函数或者用户很少用到的模块,如果一开始就把所有的函数都链接实际上是一种浪费。所以 ELF 采用了一种叫做延迟绑定的做法,基本思想就是当
函数第一次被用到时才进行绑定(符号查找,重定位),如果没有则不进行绑定。所以程序开始执行时,模块间的函数调用都没有进行绑定,而是需要用到时才
由动态链接器负责绑定。这样大大加快了程序的启动速度。
_dl_runtime_resolve(); // 绑定函数
ELF 使用 PLT()
当我们调用某个外部模块的函数时,如果按照通常的做法应该是通过 GOT 中相应的项进行间接跳转。PLT 为了实现延迟绑定,这个过程中又增加一层间接跳转。
调用函数并不直接通过 GOT 跳转,而是通过一个叫做 PLT 项的结构进行跳转。每个外部函数在 PLT 中都有一个相应的项,比如 bar() 函数在 PLT 中的项的
地址我们称之为 bar@plt。
5.动态链接相关结构
可执行文件的装载与静态链接的情况基本一样。操作系统会读取可执行文件的头部,检查文件的合法性,然后从头部的 "Program Header"中读取每个 "Segment"
的虚拟地址,文件地址和属性,并将它们映射到进程的虚拟空间的相应位置,这些步骤跟前面的静态链接的情况的装载基本无异。在静态链接情况下,操作系统接着就可以
把控制器交给可执行文件的入口地址了,然后程序开始执行。
但是在动态链接情况下,操作系统还不能在装载完可执行文件之后就把控制器交给可执行文件,因为我们知道可执行文件依赖于很多共享文件。这个时候,可执行文件里对于
很多外部符号的引用还处于无效地址的状态,即还没有跟相应的共享对象中的实际位置链接起来。所以在映射完可执行文件之后,操作系统会先启动一个动态链接器。
在 Linux 下,动态链接器 ld.so 实际上是一个共享对象,操作系统同样通过映射的方式将它加载到进程的地址空间中。操作系统在加载完动态链接器之后,就将控制器交给
动态链接器的入口地址(与可执行文件一样,共享对象也有入口地址)。当动态链接器得到控制权之后,它开始执行一系列自身的初始化操作,然后根据当前环境,开始对可执行文件
进行动态链接工作。当所有动态链接工作完成之后,动态链接器会将控制权交给可执行文件的入科地址,程序开始正式执行。
".interp" 段 :
那么系统中哪个才是动态链接器呢,它的位置由谁决定呢? 实际上,动态链接器的位置既不是由系统配置指定,也不是由环境参数决定,而是由 ELF 可执行文件决定。在动态
连接的ELF可执行文件中,有一个专门的段叫做 ".interp" 段(interpreter,解释器的缩写).
objdump -s a.out
".interp" 的内容很简单,里面保存的就是一个字符串,这个字符串就是可执行文件所需要的动态链接器的路径。在 Linux 下,可执行文件所需要的动态链接器的路径几乎都是
"/lib/ld-linux.so.2", 其他 *nix 可能有所不同。在 Linux 的系统中, /lib/ld-linux.so.2 通常是一个软连接,它指向的 /lib/ld-2.6.1.so 才是真的动态链接器。
在 Linux 中,操作系统在对可执行文件进行加载的时候,它回去寻找该可执行文件所需要的相应的动态链接器,即 '.interp' 段指定的路径的共享对象。
动态链接器在 Linux 下是 glibc 的一部分,也就是属于系统库级别的,它的版本号往往跟 glibc 库的版本号是一样的。当系统中的 glibc 库更新或者安装其他版本的时候,
/lib/ld-linux.so.2 这软连接就会指向新的动态链接器,而可执行文件本身不需要修改 '.interp' 中的动态链接器的路径来适应系统的升级。
".dynamic" 段:
类似于 '.interp' 段,ELF 中海油其他几个段也是专门用于动态链接的,比如 '.dynamic' 段和 '.dynsym' 段等。
动态链接 ELF 中最重要的结构应该是 '.dynamic' 段,这个段里保存了动态链接器所需要的基本信息,比如依赖哪些共享对象,动态链接符号表的位置,动态链接重定位表的位置,
共享对象初始化代码的地址等。
ldd a.out // 查看程序依赖哪些共享库
动态符号表:
为了完成动态链接,最关键的还是所以来的符号和相关文件的信息。我们知道静态链接中,有一个专门的段叫做符号表 '.symtab',里面保存了所有关于该目标文件的符号的定义和引用。
动态链接的符号表实际上它跟静态链接十分相似,比如前面的例子中的 program1 程序依赖 Lib.so,引用到了 foobar() 函数。那么对于 program1 来说,我们往往称 program1 导入
了 foobar() 函数,foobar()是 program1 的导入函数。而站在 Lib.so 的角度看,它实际上是定义了 foobar() 函数,并且提供了给其他模块使用,我们往往称 Lib.so 导出了
foobar() 函数,foobar() 是 Lib.so 的导出函数.把这种导入和导出关系放到静态链接的情形下,我们可以把它们看做普通的函数定义和引用。
为了表示动态链接这些模块之间的符号导入导出关系,ELF 专门有一个叫做动态符号表的段来保存这些信息,这个段的段名通常叫做 '.dynsym'。与 '.symtab' 不同的是,'.dynsym'
只保存了与动态链接相关的符号,对于那些模块内部的符号,比如模块私有变量则不保存。很多时候动态链接的模块同时拥有 '.dynsym' 和 '.symtab' 两个表,而 '.symtab' 中往往
保存了所有的符号,包括 '.dynsym' 中的符号。
与 '.symab' 类似,动态符号表也需要一些辅助的表,比如用于保存符号名的字符串表。静态链接时叫做符号字符串表 '.strtab' ,这里就是动态符号字符串表 '.dynstr';由于动态
链接下,我们需要在程序运行时查找符号,为了加快符号查找过程,往往还有辅助的 符号哈希表。
readelf -sD Lib.so
动态链接重定位表:
共享对象需要重定位的主要原因是导入符号的存在。在动态链接下,无论是可执行文件或共享对象,一旦它依赖于其他共享对象,也就说有导入符号时,那么它的代码或数据中就会有对于导入
符号的引用。在编译时,这些导入符号的地址未知,在静态链接中,这些未知地址引用在最终链接时被修正。但是在动态链接时,导入符号的地址在运行时才确定,所以需要在运行时将这些导入
符号的引用修正,即需要重定位。
我们在前面的地址无关章节也提到,动态链接的可执行文件使用的是 PIC 方法,但是这不能改变它需要重定位的本质。对于动态链接俩说,如果一个共享对象不是以 PIC 模式编译的,那么
毫无疑问,它是需要在装载时被重定位的。如果一个共享对象是 PIC 模式编译的,那么也需要重定位。
对于使用 PIC 技术的可执行文件或者共享对象来说,虽然它们的代码段不需要重定位(因为地址无关),但是数据段还包含了绝对地址的引用,因为代码段中绝对地址相关的部分被分离了出来,
变成了 GOT,而 GOT 实际上是数据段的一部分。除了 GOT 之外,数据段还可能包含绝对地址的引用。
动态连接重定位相关结构:
共享对象的重定位与我们在前面 '静态链接' 中分析过的目标文件的重定位十分类似,唯一的区别是目标文件的重定位是静态链接时完成的,而共享对象的重定位是装载时完成的。在静态链接中,
目标文件里面包含有专门用于表示重定位信息的重定位表,比如'.rel.text' 表示的是代码段的重定位表,'.rel.data' 是数据段的重定位表。
动态链接的文件中,也有类似的重定位表分别叫做 '.rel.dyn' 和 '.rel.plt' ,它们分别相当于 '.rel.text' 和 '.rel.data'。 '.rel.dyn' 实际上是对数据引用的修正,它所修正的
位置位于 '.got' 以及数据段;而 '.rel.plt' 是对函数引用的修正,它所修正的位置位于 '.got.plt'。
共享对象的数据段是没有办法做到地址无关的,它可能会包含绝对地址的引用,对于这种绝对地址的引用,我们必须在装载时将其重定位。
动态链接时进程堆栈初始化信息:
站在动态链接器的角度看,当操作系统把控制器交给它时,它将开始做链接工作,那么至少它需要知道关于可执行文件和本进程的一些信息,比如可执行文件有几个段('segment'),每个段的属性,
程序的入口(因为动态链接器到时候需要把控制器交给可执行文件)等。这些信息往往由操作系统传递给动态链接器,保存在进程的堆栈里面。我们前面提过,进程初始化时,堆栈里面保存了关于进程
执行环节和命令行参数等信息。事实上,堆栈里面还保存了动态链接器所需要的一些辅助信息数组。辅助信息的格式也是一个结构数组,定义在 'elf.h'.它位于环节变量指针后面。
6.动态链接的步骤和实现
动态链接的步骤分为3步:
1.启动动态链接器本身
2.装载所有需要的共享对象
3.重定位和初始化
1.动态链接器的自举:
我们知道动态链接器本身也是一个共享对象,但事实上它有一些特殊性。对于普通共享对象文件来说,它的重定位工作由动态链接器完成;
它也可以依赖其他共享对象,其中的被依赖的共享对象由动态链接器负责链接和装载。可是对于动态链接器本身来说,它的重定位工作由谁
来完成?它是否可以依赖于其他的共享对象?
这是一个"鸡生蛋,蛋生鸡"的问题。首先是,动态链接器本身不可以依赖于其他共享对象;其次是动态链接器本身所需要的全局和静态变量
的重定位工作由它本身完成。对于第一个条件我们可以人为的控制,在编写动态链接器时保证不适用任何系统库,运行库;对于第二个条件,
动态链接器必须在启动时有一段非常精巧的代码可以完成这项艰巨的工作而同时又不能用到全局和静态变量。这种具有一定限制条件的启动代码
往往被称为自举。
动态链接器入口地址即使自举的入口,当操作系统将进程控制器交给动态链接器时,动态链接器的自举代码即将开始执行。自举代码首先会找到
自己的 GOT, 而 GOT 的第一个入口保存的即使 '.dynamic' 段的偏移地址,由此找到了动态链接器本身的 '.dynamic' 段。通过 '.dynamic'
中的信息,自举代码便可以获得动态链接器本身的重定位表和符号表等,从而得到动态链接器本身的重定位入口,将它们全部重定位。从这一步开始,
动态链接器代码中才可以开始使用自己的全局变量和静态变量。
实际上在动态链接器的自举代码中,除了不可以使用全局变量和静态变量之外,甚至不能调用函数,即动态链接器本身的函数也不能调用。这是为什么呢?
其实我们在前面分析地址无关代码时已经提到过,实际上使用 PIC 模式编译的共享对象,对于模块内部的函数调用也是采用跟模块外部函数调用一样的方式,
即使 GOT/PLT 的方式,所以在 GOT/PLT 没有被重定位之前,自举代码不可以使用任何全局变量,也不可以调用函数。
2.装载共享对象
完成基本自举以后,动态链接器将可执行文件和链接器本身的符号表都合并到一个符号表中,我们可以称他为全局符号表。然后链接器开始寻找可执行文件所
依赖的共享对象,我们前面提到过的 '.dynamic' 段中,有一种类型的入口是 DT_NEEDED,它指出的是该可执行文件(或共享对象)所依赖的共享对象。由此,
链接器可以列出可执行文件所需要的所有共享对象,并将这些共享对象的名字放入一个装载集合中。然后链接器开始从集合里取一个所需要的共享对象的名字,找到
相应的文件后打开该文件,读取相应的 ELF 文件头和 '.dynamic' 段,然后将它相应的代码段和数据段映射到进程空间中。如果这个 ELF 共享对象还依赖其他的
共享对象,那么将所依赖的共享对象的名字放到装载集中。如果循环直到所有依赖的共享对象都被装载进来为止,当然链接器可以有不同的装载顺序,如果我们把依赖关系
看做一个图的话,那么这个装载过程就是一个图的遍历过程,链接器可能会使用深度优先或广度优先或者其他的顺序来遍历整个图,这取决于连接器,比较常见的算法一般是
广度优先的。
当一个新的共享对象被装载进来的时候,它的符号表会被合并到全局符号表中,所以当所有的共享对象都被装载进来的时候,全局符号表里将包含进程中所有的动态链接
所需要的符号。
符号的优先级:
在动态链接器按照各个模块之间的依赖关系,对它们进行装载并且将它们的符号并入到全局符号表时,会不会有这么一种情况发生,那就是有可能两个不同的模块定义了同一个符号?
这种在一个共享对象里面的全局符号被另外一个共享对象的同名全局符号覆盖的现象又被称为共享对象符号介入。
对于这个问题,实际上 Linux 下的动态链接器是这样处理的:它定义了一个规则,那就是当一个符号需要被加入全局符号表时,如果相同的符号名已经存在,则后加入的符号被忽略。
从动态链接器的装载顺序可以看到,它是按照广度优先的顺序进行加载的,首先是 main, 然后是 b1.so, b2.so, a1.so 最后是 a2.so。当 a2.so 中的函数 a 要被加入全局符号
表时,先前装载的 a1.so 时,a1.so 中的函数 a 已经存在于全局符号表,那么 a2.so 中的函数 a 只能被忽略。
全局符号介入与地址无关代码:
为了提高模块内部函数调用的效率,有一个办法是把 bar() 函数变成编译单元私有函数,即使用 'static' 关键字定义 bar() 函数。这种情况下,编译器要确定 bar() 函数不能被
其他模块覆盖,就可以使用第一类方法,即模块内部调用指令,可以加快函数的调用速度。
3.重定位和初始化
当上面的步骤完成之外,链接器开始重新遍历可执行文件和每个共享对象的重定位表,将它们的 GOT/PLT 中的每个需要重定位的位置进行修正。因为此时动态链接器已经拥有了进程的
全局符号表,所以这个修正过程也显得比较容易。
重定位完成之后,如果某个共享对象有个 '.init' 段,那么动态链接器会执行 '.init' 段中的代码,用以实现共享对象特有的初始化过程,比如常见的,共享对象中的 c++的
全局/静态对象的构成函数就需要通过 '.init' 来初始化。相应的,共享对象中还可能有 '.finit' 段,当进程退出时会执行 '.finit' 段中的代码,可以用来实现类似 c++
全局对象析构之类的操作。
如果进程的可执行文件也有 '.init' 段,那么动态链接器不会执行它,因为可执行文件中的 '.init' 段和 '.finit' 段由程序初始化部分代码负责执行。
当完成了重定位和初始化之后,所有的准备工作就宣告完成了,所需要的共享对象也都已经装载并链接完成了,将进程的控制器交给程序的入口并开始执行。
Linux 动态链接器的实现:
在前面分析 Linux 下的程序的装载时,已经介绍了一个通过 execve() 系统调用被装载到进程的地址空间的程序,以及内核如何处理可执行文件。内核在装载完 ELF 可执行文件以后就返回
到用户空间,将控制器交给程序的入口。对于不同链接形式的 ELF 可执行文件,这个程序的入口是有区别的。对于静态链接的可执行文件来说,程序的入口就是 ELF 文件头里面的 e_entry 指定
的入口;对于动态链接的可执行文件来说,如果这时候把控制器交给 e_entry 指定的入口地址,是不行的,因为可执行文件所依赖的共享库还没有被装载,也没有进行动态链接。所以对于动态链接
的可执行文件,内核会分析它的动态链接地址(在 '.interp' 段),将动态链接器映射至进程地址空间,然后把控制器交给动态链接器。
Linux 动态链接器是个很有意思的东西,它本身是一个共享对象,它的路径是 /lib/ld-linux.so.2 ,它实际上是个软连接,它指向 /lib/ld-x.y.z.so,这个才是真正的动态链接器文件。
共享对象其实也是 ELF 文件,它也有可能跟可执行文件一样的 ELF 文件头(包含 e_entry,段表等)。动态链接器是个非常特殊的共享对象,它不仅是个共享对象,还是个可执行的程序。可以
直接运行:
/lib64/ld-linux-x86-64.so.2
其实 Linux 的内核在执行 execve()时,不关心目标 ELF 文件是否可执行(文件头 e_type 是 ET_EXEC 还是 ET_DYN),它只是简单的按照程序头表里面的描述对文件进行装载然后把控制权
交给 ELF 入口地址(没有 '.ineterp' 就是 ELF 文件的 e_entry; 如果有 '.interp' 的话就是动态链接器的 e_entry)。这样我们就很好的解释为什么动态链接器本身可以作为可执行程序运行,
这也从侧面证明了共享库和可执行文件实际上没有什么区别,除了文件头的标志位和扩展名所有不同之外,其他都是一样的。Windows 系统中的 EXE 和 DLL 也是类似的。
Linux 的 ELF 动态链接器是 glibc 的一部分,它的源代码位于 glibc 的源代码的 elf 目录下,它的实际入口位于 sysdeps/i386/dl-manchine.h 中的 _start(普通程序的入口地址 _start()
在 sysdeps/i386/elf/start.S)。
_start 调用位于 elf/rtld.c 的 _dl_start() 函数。_dl_start() 函数首先对 ld.so 进行重定位,因为 ld.so 自己就是动态链接器没有人帮它做重定位工作,所以只好自己来,这叫 "自举".
自举的过程要十分谨慎,因为有很多限制。完成自举之后就可以调用其他函数并访问全局变量了。调用 _dl_start_final,收集了一些基本的运行数值,进入 _dl_sysdep_start,这个函数进行一些平台
相关的处理之后,就进入 _dl_main, 这就是真正意义上的动态链接器的主函数了。
很明显,如果指定的用户入口地址是动态链接器本身,那么说明动态链接器是被当作可执行文件在执行的。在这种情况下,动态链接器就会解析运行时的参数,并且进行相应的处理。_dl_main 本身非常的长,
主要的工作就是前面提到的对程序所依赖的共享对象进行装载,符号解析和重定位。
1.动态链接器本身是动态连接还是静态链接的?
动态链接器本身应该是静态连接的,它不能依赖其他共享对象,动态链接器本身是用来帮助其他 ELF 文件解决共享对象依赖问题的,如果它也依赖其他共享对象,那么谁来帮助解决依赖问题?所以它本身必须
不依赖其他共享对象。这一点可以用 ldd 来证明:
ldd /lib64/ld-linux-x86-64.so.2
statically linked
2.动态链接器本身必须是 PIC 的吗?
是不是 PIC 对于动态链接来说并不关键,动态链接器可以是 PIC 的也可以不是,但往往是由 PIC 会更简单一点。一方面,如果不是 PIC的话,会使得代码段无法共享,浪费内存;另一方面也会使得 ls.so
本身初始化更加复杂,因为自举时还需要对代码段进行重定位,实际上的 ld-linux.so.2 是 PIC 的。
3.动态链接器可以被当作可执行文件运行,那么装载的地址应该是多少?
ld.so 的装载地址跟一般的共享对象没有区别,即为 0x000000。这个装载地址是一个无效的装载地址,作为一个共享库,内核在装载它时,会选择一个合适的装载地址。
7.显式运行时链接
支持动态链接的系统往往都支持一种更加灵活的模块加载方式,叫做显式运行时链接,有时候也叫做运行时加载。也就是让程序
自己在运行时控制加载指定的模块,并且可以在不需要该模块时将其卸载。这种共享对象往往被叫做动态装载库。本质上与一般的
共享对象没什么区别。可以在程序运行的时候重新加载某个模块,这样使得程序本身不必重新启动而实现模块的增加,删除,更新等。
这对于很多需要长期运行的程序来说是很大的优势。最常见的例子是 web 服务器程序,对于 web 服务器程序来说,它需要工具配置
来选择不同的脚本解释器,数据库连接驱动等,对于不同的脚本解释器分别做成一个独立的模块,当web服务器需要某种脚本解释器的时候
可以将其加载进来;对于数据库连接的驱动也是一样的。另外对于一个可靠的 web 服务器来说,长期的运行是必要的保证,如果我们需要
增加某种脚本解释器,或者某个脚本解释器模块需要升级,则可以通知web服务器重新装载该模块以实现相应的目的。
在 Linux 中,从文件本身的格式上看,动态库实际上跟一般的共享对象没有区别。主要的区别是共享对象是由动态链接器在程序启动之前
负责装载和连接的,这一系列步骤都是由动态链接器自动完成的,对于程序本身是透明的;而动态链接库则是通过一系列由动态链接器提供的
API,具体的将有4个函数:打开动态库(dlopen),查找符号(dlsym),错误处理(dlerror)以及关闭动态库(dlclose)。
void* dlopen(const char *filename, int flag);
第一个参数是被加载动态库的路径,如果这个路径是绝对路径,则该函数直接打开该动态库;如果是相对路径,那么 dlopen() 会尝试以
一定的顺序查找该动态链接库,顺序如下:
1.查找有环境变量 LD_LIBRARY_PATH 指定的一系列目录
2.查找由 /etc/ld.so.cache 指定的共享库路径
3./lib, /usr/lib
有意思的是,如果我们讲 filename 这个参数设置为0,那么 dlopen 返回的将是全局符号表的句柄,也就是说我们可以在运行时找到这个全局
符号表里面的任何一个符号,并且可以执行它们,这有些类似高级语言的反射的特性。
第二个参数 flag 表示函数符号的解析方式,常量 RTLD_LAZY 表示使用延迟绑定,当函数第一次被用到时才进行绑定,即 PLT 机制;而 RTLD_NOW
表示当模块被加载时即完成所有的函数绑定操作,如果有任何未定义的符号引用的绑定工作没有完成,那么 dlopen 就返回错误。还有一个常量 RTLD_GLOBAL
可以跟上面的两者中任意一个一起使用,它表示将被加载的模块的全局符号合并到进程的全局符号表中,使得以后加载的模块可以使用这些符号。在调试程序的时候,
我们可以使用 dlerror() 立即捕获相应的错误信息;而如果使用 RTLD_LAZY 的话,这种符号绑定的错误会发生在加载后,则难以捕获。当然,使用 RTLD_NOW
会导致加载动态库的速度变慢。
事实上 dlopen 还会加载模块时执行模块中的初始化部分的代码。我们前面提到过,动态链接器在加载模块时,会执行 '.init' 段的代码,用以完成模块的初始化
工作,dlopen 的加载过程基本跟动态链接器一致,在完成装载,映射和重定位以后,就会执行 '.init' 段的代码然后返回。
void* dlsym(void *handle, char *symbol);
符号优先级:
前面再介绍动态链接器时,我们已经碰到过很多共享模块中符号名字冲突的问题,结论是当多个同名符号冲突的时候,先装入的符号优先,我们把这种优先级方式
称为装载序列。当我们使用 dlopen 装入的共享对象时,如果有冲突,也是采用 装载序列。
当我们使用 dlsym() 进行符号地址查找时,优先级分2种。第一种情况是,如果我们是在全局符号表中进行查找,即 dlopen()时,参数 filename 为 NULL,
那么,由于全局符号表使用的装载序列,所以 dlsym() 使用的也是装载序列。第二种情况是,如果我们是对通过 dlopen() 打开的共享对象进行符号查找的时候,
那么采用的是 依赖序列 的优先级。什么叫依赖序列呢?它是以被 dlopen() 打开的那个共享对象为根节点,对它所有依赖的共享对象进行广度优先遍历,直到找到
符号为止。
dlerror()
每次我们调用 dlopen,dlsym,dlclose 以后,我们可以调用 dlerror() 函数来判断上次调用是否成功。
dlclose() :
dlclose 的作用跟 dlopen() 刚好相反,它的作用是将一个已经加载的模块卸载。系统会维持一个加载的引用计数器,每次使用 dlopen() 加载某模块时,相应的计数器
加一;卸载时,减一。只有当计数器为0时,模块才会被真正的卸载掉。卸载的过程跟加载刚好相反,先执行 '.finit' 段的代码,然后将相应的符号从符号表中去除,取消进程
空间跟模块的映射关系,然后关闭模块文件。
1.为什么要动态链接
2.简单的动态链接例子
3.地址无关代码
4.延迟绑定(PLT)