程序员的自我修养--链接、装载与库笔记:动态链接

1. 为什么要动态链接

静态链接诸多缺点,比如浪费内存和磁盘空间、模块更新困难等

内存和磁盘空间:静态链接的方式对于计算机内存和磁盘的空间浪费非常严重,特别是在多进程操作系统情况下。

程序开发和发布:空间浪费是静态链接的一个问题,另一个问题是静态链接对程序的更新、部署和发布也会带来很多麻烦。一旦程序中有任何模块更新,整个程序就要重新链接、发布给用户。如果程序都使用静态链接,那么通过网络来更新程序将会非常不便,因为一旦程序任何位置的一个小改动,都会导致整个程序重新下载。

动态链接:要解决空间浪费和更新困难这两个问题最简单的办法就是把程序的模块相互分割开来,形成独立的问题,而不再将它们静态地链接在一起。简单地讲,就是不对那些组成程序的目标文件进行链接,等到程序要运行时才进行链接。也就是说,把链接这个过程推迟到了运行时再进行,这就是动态链接(Dynamic Linking)的基本思想。动态链接解决了共享的目标文件多个副本浪费磁盘和内存空间的问题。另外在内存中共享一个目标文件模块的好处不仅仅是节省内存,它还可以减少物理页面的换入换出,也可以增加CPU缓存的命中率,因为不同进程间的数据和指令访问都集中在了同一个共享模块上。动态链接方案也可以使程序的升级变得更加容易,当我们要升级程序库或程序共享的某个模块时,理论上只要简单地将旧的目标文件覆盖掉,而无须将所有的程序再重新链接一遍。当程序下一次运行的时候,新版本的目标文件会被自动装载到内存并且链接起来,程序就完成了升级的目标。动态链接的方式使得开发过程中各个模块更加独立,耦合度更小,便于不同的开发者和开发组织之间独立进行开发和测试。

程序可扩展性和兼容性:动态链接还有一个特点就是程序在运行时可以动态地选择加载各种程序模块,这个优点就是后来被人们用来制作程序的插件(Plug-in)。比如某个公司开发完成了某个产品,它按照一定的规则制定好程序的接口,其它公司或开发者可以按照这种接口来编写符合要求的动态链接文件。该产品程序可以动态地载入各种由第三方开发的模块,在程序运行时动态地链接,实现程序功能的扩展。

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

动态链接的基本实现:动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有的程序模块都链接成一个单独的可执行文件。动态链接涉及运行时的链接及多个文件的装载,必需要有操作系统的支持,因为动态链接的情况下,进程的虚拟地址空间的分布会比静态链接情况下更为复杂,还有一些存储管理、内存共享、进程线程等机制在动态链接下也会有一些微妙的变化。目前主流的操作系统几乎都支持动态链接这种方式,Linux系统中,ELF动态链接文件被称为动态共享对象(DSO, Dynamic Shared Objects),简称共享对象,它们一般都是以”.so”为扩展名的一些文件;而在Windows系统中,动态链接文件被称为动态链接库(Dynamical Linking Library),它们通常就是很常见的以”.dll”为扩展名的文件

在Linux中,常用的C语言库的运行库glibc,文件名叫做”libc.so”。整个系统只保留一份C语言库的动态链接文件”libc.so”,而所有的C语言编写的、动态链接的程序都可以在运行时使用它。当程序被装载的时候,系统的动态链接器会将程序所需要的所有动态链接库(最基本的就是libc.so)装载到进程的地址空间,并且将程序中所有未决议的符号绑定到相应的动态链接库中,并进行重定位工作。程序与libc.so之间真正的链接工作是由动态链接器完成的,而不是静态链接器ld完成的。也就是说,动态链接是把链接这个过程从本来的程序装载前被推迟到了装载的时候。

2. 简单的动态链接例子

测试代码如下: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.h:

#ifndef LIB_H
#define LIB_H

void foobar(int i);

#endif

Lib.c:

#include <stdio.h>

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

脚本build.sh内容如下:执行此脚本后可生成动态库Lib.so,可执行文件Program1和Program2。

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

在静态链接时,整个程序最终只有一个可执行文件,它是一个不可以分割的整体;但是在动态链接下,一个程序被分成了若干个文件,有程序的主要部分,即可执行文件(Program1)和程序所依赖的共享对象(Lib.so),很多时候我们也把这些部分称为模块,即动态链接下可执行文件和共享对象都可以看作是程序的一个模块

当程序模块Program1.c被编译成为Program1.o时,编译器还不知道foobar()函数的地址。但链接器将Program1.o链接成可执行文件时,这时候链接器必须确定Program1.o中所引用的foobar()函数的性质。如果foobar()是一个定义与其它静态目标模块中的函数,那么链接器将会按照静态链接的规则,将Program1.o中的foobar地址引用重定位;如果foobar()是一个定义在某个动态共享对象中的函数,那么链接器就会将这个符号的引用标记为一个动态链接的符号,不对它进行地址重定位,把这个过程留到装载时再进行。链接器如何知道foobar的引用是一个静态符号还是一个动态符号?这实际上就是我们要用到Lib.so的原因。Lib.so中保存了完整的符号信息(因为运行时进行动态链接还须使用符号信息),把Lib.so也作为链接的输入文件之一,链接器在解析符号时就可以知道:foobar是一个定义在Lib.so的动态符号。这样链接器就可以对foobar的引用做特殊的处理,使它成为一个对动态符号的引用。

动态链接程序运行时地址空间分布:对于静态链接的可执行文件来说,整个进程只有一个文件要被映射,那就是可执行文件本身。但是对于动态链接来说,除了可执行文件本身之外,还有它所依赖的共享目标文件。

查看进程的虚拟地址空间分布,结果如下图所示,可以看到,整个进程虚拟地址空间中,多出了几个文件的映射。Lib.so与Program1一样,它们都是被操作系统用同样的方法映射至进程的虚拟地址空间,只是它们占据的虚拟地址和长度不同。Program1除了使用Lib.so以外,它还用到了动态链接形式的C语言运行库libc-2.19.so。另外还有一个很值得关注的共享对象就是ld-2.19.so,它实际上是Linux下的动态链接器。动态链接器与普通共享对象一样被映射到了进程的地址空间,在系统开始运行Program1之前,首先会把控制权交给动态链接器,由它完成所有的动态链接工作以后再把控制权交给Program1,然后开始执行。

通过readelf工具来查看Lib.so的装载属性,结果如下图所示,除了文件的类型与普通程序不同以外,其它几乎与普通程序一样。还有有一点比较不同的是,动态链接模块的装载地址是从地址0x0000000000000000开始的。我们知道这个地址是无效地址,并且从上面的进程虚拟空间分布看到,Lib.so的最终装载地址并不是0x0000000000000000。从这一点我们可以推断,共享对象的最终装载地址在编译时是不确定的而是在装载时,装载器根据当前地址空间的空闲情况,动态分配一块足够大小的虚拟地址空间给相应的共享对象

3. 地址无关代码

装载时重定位:为了能够使共享对象在任意地址装载,我们首先想到的方法就是静态链接中的重定位。这个想法的基本思路就是,在链接时,对所有绝对地址的引用不作重定位,而把这一步推迟到装载时再完成。一旦模块装载地址确定,即目标地址确定,那么系统就对程序中所有的绝对地址引用进行重定位。我们前面在静态链接时提到过重定位,那时的重定位叫做链接时重定位(Link Time Relocation),而现在这种情况经常被称为装载时重定位(Load Time Relocation),在Windows中,这种装载时重定位又被叫做基址重置(Rebasing)。但是装载时重定位的方法并不适合用来解决上面的共享对象中所存在的问题。可以想象,动态链接模块被装载映射至虚拟空间后,指令部分是在多个进程之间共享的,由于装载时重定位的方法需要修改指令,所以没有办法做到同一份指令被多个进程共享,因为指令被重定位后对于每个进程来讲是不同的。当然,动态链接库中的可修改数据部分对于不同的进程来说有多个副本,所以它们可以采用装载时重定位的方法来解决。Linux和GCC支持这种装载时重定位的方法,我们前面在产生共享对象时,使用了两个GCC参数”-shared”和”-fPIC”,如果只使用”-shared”,那么输出的共享对象就是使用装载时重定位的方法

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

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

第一种是模块内部的函数调用、跳转等:被调用的函数与调用者都处于同一个模块,它们之间的相对位置是固定的,所以这种情况比较简单。对于现代的系统来讲,模块内部的跳转、函数调用都可以是相对地址调用,或者是基于寄存器的相对调用,所以对于这种指令是不需要重定位的。

第二种是模块内部的数据访问,比如模块中定义的全局变量、静态变量:指令中不能直接包含数据的绝对地址,那么唯一的办法就是相对寻址。我们知道,一个模块前面一般是若干个页的代码,后面紧跟着若干个页的数据,这些页之间的相对位置是固定的,也就是说,任何一条指令与它需要访问的模块内部数据之间的相对位置是固定的,那么只需要相对于当前指令加上固定的偏移量就可以访问模块内部数据了。现代的体系结构中,数据的相对寻址往往没有相对于当前指令地址(PC)的寻址方式,所以ELF用了一个很巧妙的办法来得到当前的PC值,然后再加上一个偏移量就可以达到访问相应变量的目的了。得到PC值的方法很多。

第三种是模块外部的数据访问,比如其它模块中定义的全局变量:模块间的数据访问目标地址要等到装载时才决定,比如上面例子中的变量b,它被定义在其它模块中,并且该地址在装载时才能确定。要使得代码地址无关,基本的思想就是把跟地址相关的部分放到数据段里面,很明显,这些其它模块的全局变量的地址是跟模块装载地址有关的。ELF的做法是在数据段里面建立一个指向这些变量的指针数组,也被称为全局偏移表(Global Offset Table, GOT),当代码需要引用该全局变量时,可以通过GOT中相对应的项间接引用。当指令中需要访问变量b时,程序会先找到GOT,然后根据GOT中变量所对应的项找到变量的目标地址。每个变量都对应一个4个字节的地址,链接器在装载模块的时候会查找每个变量所在的地址,然后填充GOT中的各个项,以确保每个指针所指向的地址正确。由于GOT本身是放在数据段的,所以它可以在模块装载时被修改,并且每个进程都可以有独立的副本,相互不受影响。GOT如何做到指令的地址无关性?从第二中类型的数据访问我们了解到,模块在编译时可以确定模块内部变量相对于当前指令的偏移,那么我们也可以在编译时确定GOT相对于当前指令的偏移。确定GOT的位置跟上面的访问变量a的方法基本一样,通过得到PC值然后加上一个偏移量,就可以得到GOT的位置。然后我们根据变量地址在GOT中的偏移就可以得到变量的地址,当然GOT中每个地址对应于哪个变量是由编译器决定的。

第四种是模块外部的函数调用、跳转等: 也可以采用上面类型三的方法来解决,与上面的类型有所不同的是,GOT中相应的项保存的是目标函数的地址,当模块需要调用目标函数时,可以通过GOT中的项进行间接跳转。

使用GCC产生地址无关代码很简单,我们只需要使用”-fPIC”参数接口。实际上GCC还提供了另外一个类似的参数叫做”-fpic”,即”PIC”3个字母小写,这两个参数从功能上来讲完全一样,都是指示GCC产生地址无关代码。唯一的区别是,”-fPIC”产生的代码要大,而”-fpic”产生的代码相对较小,而且较快。那么我们为什么不使用”-fpic”而要使用”-fPIC”呢?原因是,由于地址无关代码都是跟硬件平台相关的,不同的平台有着不同的实现”-fpic”在某些平台上会有一些限制,比如全局符号的数量或者代码的长度等,而”-fPIC”则没有这样的限制。所以为了方便起见,绝大部分情况下,我们都使用”-fPIC”参数来产生地址无关代码。

如何区分一个DSO是否为PIC:执行命令:$ readelf -d Lib.so | grep TEXTREL

如果上面的命令有任何输出,那么Lib.so就不是PIC的,否则就是PIC的。PIC的DSO是不会包含任何代码段重定位表的,TEXTREL表示代码段重定位表地址。

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

共享模块的全局变量问题:ELF共享库在编译时,默认都把定义在模块内部的全局变量当作定义在其它模块的全局变量,也就是说当作前面的类型三,通过GOT来实现变量的访问。当共享模块被装载时,如果某个全局变量在可执行文件中拥有副本,那么动态链接器就会把GOT中的相应地址指向该副本,这样该变量在运行时实际上最终就只有一个实例。如果变量在共享模块中被初始化,那么动态链接器还需要将该初始化值复制到程序主模块中的变量副本;如果该全局变量在程序主模块中没有副本,那么GOT中的相应地址就指向模块内部的该变量副本。

 数据段地址无关性对于数据段来说,它在每个进程都有一份独立的副本,所以并不担心被进程改变。从这点来看,我们可以选择装载时重定位的方法来解决数据段中绝对地址引用问题。对于共享对象来说,如果数据段中有绝对地址引用,那么编译器和链接器就会产生一个重定位表,这个重定位表里面包含了”R_386_RELATIVE”类型的重定位入口,用于解决上述问题。当动态链接器装载共享对象时,如果发现该共享对象有这样的重定位入口,那么动态链接器就会对该共享对象进行重定位。实际上,我们甚至可以让代码段也使用这种装载时重定位的方法,而不使用地址无关代码。但是,如果代码不是地址无关的,它就不能被多个进程之间共享,于是也就失去了节省内存的优点。但是装载时重定位的共享对象的运行速度要比使用地址无关代码的共享对象快,因为它省去了地址无关代码中每次访问全局数据和函数时需要做一次计算当前地址以及间接地址寻址的过程。

对于可执行文件来说,默认情况下,如果可执行文件是动态链接的,那么GCC会使用PIC的方法来产生可执行文件的代码段部分,以便于不同的进程能够共享代码段,节省内存。所以我们可以看到,动态链接的可执行文件中存在”.got”这样的段。

4. 延迟绑定(PLT)

动态链接的确有很多优势,比静态链接要灵活得多,但它是以牺牲一部分性能为代价的。据统计ELF程序在静态链接下要比动态库稍微快点,当然这取决于程序本身的特性及运行环境等。我们知道动态链接比静态链接慢的主要原因是动态链接下对于全局和静态的数据访问都要进行复杂的GOT定位,然后间接寻址;对于模块间的调用也要先定位GOT,然后再进行间接跳转,如此一来,程序的运行速度必定会减慢。另外一个减慢运行速度的原因是动态链接的链接工作在运行时完成,即程序开始执行时,动态链接器都需要进行一次链接工作,动态链接器会寻找并装载所需要的共享对象,然后进行符号查找地址重定位等工作,这些工作势必减慢程序的启动速度。这是影响动态链接性能的两个主要问题。

延迟绑定实现:在动态链接下,程序模块之间包含了大量的函数引用(全局变量往往比较少,因为大量的全局变量会导致模块之间耦合度变大),所以在程序开始执行前,动态链接会耗费不少时间用于解决模块之间的函数引用的符号查找以及重定位。不过可以想象,在一个程序运行过程中,可能很多函数在程序执行完时都不会被用到,比如一些错误处理函数或者是一些用户很少用到的功能模块等,如果一开始就把所有函数都链接好实际上是一种浪费。所以ELF采用了一种延迟绑定(Lazy Bingding)的做法,基本的思想就是当函数第一次被用到时才进行绑定(符号查找、重定位等),如果没有用到则不进行绑定。所以程序开始执行时,模块间的函数调用都没有进行绑定,而是需要用到时才由动态链接器来负责绑定。这样的做法可以大大加快程序的启动速度,特别有利于一些有大量函数引用和大量模块的程序。ELF使用PLT(Procedure Linkage Table)的方法来实现延迟绑定,这种方法使用了一些很精巧的指令序列来完成。当我们调用某个外部模块的函数时,如果按照通常的做法应该是通过GOT中相应的项进行间接跳转。PLT为了实现延迟绑定,在这个过程中间又增加了一层间接跳转。调用函数并不直接通过GOT跳转,而是通过一个叫做PLT项的结构来进行跳转。每个外部函数在PLT中都有一个相应的项。

ELF将GOT拆分成了两个表叫做”.got”和”.got.plt”。其中”.got”用来保存全局变量引用的地址,”.got.plt”用来保存函数引用的地址,也就是说,所有对于外部函数的引用全部被分离出来放到了”.got.plt”中。另外”.got.plt”还有一个特殊的地方是它的前三项是有特殊意义的,分别含义如下:第一项保存的是”.dynamic”段的地址,这个段描述了本模块动态链接相关的信息;第二项保存的是本模块的ID;第三项保存的是_dl_runtime_resolve()的地址。其中第二项和第三项由动态链接器在装载共享模块的时候负责将它们初始化。”.got.plt”的其余项分别对应每个外部函数的引用。PLT在ELF文件中以独立的段存放,段名通常叫做”.plt”,因为它本身是一些地址无关的代码,所以可以跟代码段等一起合并成同一个可读可执行的”Segment”被装载入内存。

5. 动态链接相关结构

动态链接在不同的系统上有不同的实现方式,ELF的动态链接实现方式比PE稍微简单一点。动态链接情况下,可执行文件的装载与静态链接情况基本一样。首先操作系统会读取可执行文件的头部,检查文件的合法性,然后从头部中的”Program Header”中读取每个”Segment”的虚拟地址、文件地址和属性,并将它们映射到进程虚拟空间的相应位置,这些步骤跟前面的静态链接情况下的装载基本无异。在静态链接情况下,操作系统接着就可以把控制权转交给可执行文件的入口地址,然后程序开始执行,一切看起来非常直观。但是在动态链接情况下,操作系统还不能在装载完可执行文件之后就把控制权交给可执行文件,因为我们知道可执行文件依赖于很多共享对象。这时候,可执行文件里对于很多外部符号的引用还处于无效地址的状态,即还没有跟相应的共享对象中的实际位置链接起来。所以在映射完可执行文件之后,操作系统会先启动一个动态链接器(Dynamic Linker)

在Linux下,动态链接器ld.so实际上是一个共享对象,操作系统同样通过映射的方式将它加载到进程的地址空间中。操作系统在加载完动态链接器之后,就将控制权交给动态链接器的入口地址(与可执行文件一样,共享对象也有入口地址)。当动态链接器得到控制权之后,它开始执行一系列自身的初始化操作,然后根据当前的环境参数,开始对可执行文件进行动态链接工作。当所有动态链接工作完成以后,动态链接器会将控制权转交给可执行文件的入口地址,程序开始正式执行。

“.interp”:动态链接器的位置既不是由系统配置指定,也不是由环境参数决定,而是由ELF可执行文件决定。在动态链接的ELF可执行文件中,有一个专门的段叫做”.interp”段(“interp”是”interpreter”(解释器)的缩写)。如果我们使用objdump工具来查看,可以看到”.interp”内容,如下图所示。”.interp”的内容很简单,里面保存的就是一个字符串,这个字符串就是可执行文件所需要的动态链接器的路径,Linux下,可执行文件所需要的动态链接器的路径几乎都是”/lib64/ld-linux-x86-64.so.2”,其它的*nix操作系统可能会有不同的路径。在Linux的系统中,/lib64/ld-linux-x86-64.so.2通常是一个软链接,比如在我的机器上,它指向/lib/x86_64-gnu/ld-2.19.so,这个才是真正的动态链接器。在Linux中,操作系统在对可执行文件进行加载的时候,它会去寻找装载该可执行文件所需要相应的动态链接器,即”.interp”段指定的路径的共享对象。

动态链接器在Linux下是Glibc的一部分,也就是属于系统库级别的,它的版本号往往跟系统中的Glibc库版本号是一样的。当系统中的Glibc库更新或者安装其它版本的时候,/lib64/ld-linux-x86-64.so.2这个软链接就会指向到新的动态链接器,而可执行文件本身不需要修改”.interp”中的动态链接器路径来适应系统的升级。

我们也可以使用”readelf -l Program1 | grep interpreter”命令来查看一个可执行文件所需要的动态链接器的路径。

“.dynamic”:动态链接ELF中最重要的结构应该是”.dynamic”段,这个段里面保存了动态链接器所需要的基本信息,比如依赖于哪些共享对象、动态链接符号表的位置、动态链接重定位表的位置、共享对象初始化代码的地址等。”.dynamic”段的结构Elf64_Dyn或Elf32_Dyn定义在/usr/include/elf.h文件中,如下:

/* Dynamic section entry.  */
typedef struct {
  Elf32_Sword   d_tag;                  /* Dynamic entry type */
  union {
      Elf32_Word d_val;                 /* Integer value */
      Elf32_Addr d_ptr;                 /* Address value */
    } d_un;
} Elf32_Dyn;

typedef struct {
  Elf64_Sxword  d_tag;                  /* Dynamic entry type */
  union {
      Elf64_Xword d_val;                /* Integer value */
      Elf64_Addr d_ptr;                 /* Address value */
    } d_un;
} Elf64_Dyn;

Elf64_Dyn或Elf32_Dyn结构由一个类型值加上一个附加的数值或指针,对于不同的类型,后面附加的数值或者指针有着不同的含义。这里列举几个比较常见的类型值(这些值都是定义在”elf.h”里面的宏),如下图所示:

使用readelf工具可以查看”.dynamic”段的内容,另外,Linux还提供了一个ldd命令用来查看程序主模块或一个共享库依赖于哪些共享库,如下图所示:可以看到有个linux-vdso.so.1的共享对象很特殊,你在整个文件系统中都搜索不到这个文件,因为它根本不存在于文件系统中,它实际上是一个内核虚拟共享对象(Kernel Virtual DSO)。

动态符号表:在静态链接中,有一个专门的段叫做符号表”.symtab”(Symbol Table),里面保存了所有关于该目标文件的符号的定义和引用。动态链接的符号表示实际上它跟静态链接十分相似。比如前面例子中的Program1程序依赖于Lib.so,引用到了里面的foobar()函数。那么对于Program1来说,我们往往称Program1导入(Import)了foobar函数,foobar是Program1的导入函数(Import Function);而站在Lib.so的角度来看,它实际上定义了foobar()函数,并且提供给其它模块使用,我们往往称Lib.so导出(Export)了foobar()函数,foobar是Lib.so的导出函数(Export Function)。把这种导入导出关系放到静态链接的情形下,我们可以把它们看作普通的函数定义和引用。为了表示动态链接这些模块之间的符号导入导出关系,ELF专门有一个叫做动态符号表(Dynamic Symbol Table)的段来保存这些信息,这个段的段名通常叫做”.dynsym”(Dynamic Symbol)。与”.symtab”不同的是,”.dynsym”只保存了与动态链接相关的符号,对于那些模块内部的符号,比如模块私有变量则不保存。很多时候动态链接的模块同时拥有”.dynsym”和”.symtab”两个表,”.symtab”中往往保存了所有符号,包括”.dynsym”中的符号。

 与”.symtab”类似,动态符号表也需要一些辅助的表,比如用于保存符号名的字符串表。静态链接时叫做符号字符串表”.strtab”(String Table),在这里就是动态符号字符串表”.dynstr”(String Table),在这里就是动态符号字符串表”.dynstr”(Dynamic String Table);由于动态链接下,我们需要在程序运行时查找符号,为了加快符号的查找过程,往往还有辅助的符号哈希表(“.hash”)。我们可以用readelf工具来查看ELF文件的动态符号表及它的哈希表,如下图所示:

动态链接符号表的结构与静态链接的符号表几乎一样,我们可以简单地将导入函数看作是对其它目标文件中函数的引用;把导出函数看作是在本目标文件定义的函数就可以了

动态链接重定位表共享对象需要重定位的主要原因是导入符号的存在。动态链接下,无论是可执行文件或共享对象,一旦它依赖于其它共享对象,也就是说有导入的符号时,那么它的代码或数据中就会有对于导入符号的引用在编译时这些导入符号的地址未知,在静态链接中,这些未知的地址引用在最终链接时被修正。但是在动态链接中,导入符号的地址在运行时才确定,所以需要在运行时将这些导入符号的引用修正,即需要重定位。动态链接的可执行文件使用的是PIC方法,但这不能改变它需要重定位的本质。对于动态链接来说,如果一个共享对象不是以PIC模式编译的,那么毫无疑问,它是需要在装载时被重定位的;如果一个共享对象是PIC模式编译的,也需要重定位。对于使用PIC技术的可执行文件或共享对象来说,虽然它们的代码段不需要重定位(因为地址无关),但是数据段还包含了绝对地址的引用,因为代码段中绝对地址相关的部分被分离了出来,变成了GOT,而GOT实际上是数据段的一部分。除了GOT以外,数据段还可能包含绝对地址引用。

动态链接重定位相关结构:共享对象的重定位与前面”静态链接”中分析的目标文件的重定位是否类似,唯一有区别的是目标文件的重定位是在静态链接时完成的,而共享对象的重定位是在装载时完成的。在静态链接中,目标文件里面包含有专门用于表示重定位信息的重定位表,比如”.rel.text”表示是代码段的重定位表,”.rel.data”是数据段的重定位表。动态链接的文件中,也有类似的重定位表分别叫做”.rel.dyn”(“.rela.dyn”)和”.rel.plt”(“.rela.plt”),它们分别相当于”.rel.text”和”.rel.data”。”.rel.dyn”实际上是对数据引用的修正,它所修正的位置位于”.got”以及数据段;而”.rel.plt”是对函数引用的修正,它所修正的位置位于”.got.plt”。我们可以使用readelf来查看一个动态链接的文件的重定位表,如下图所示:

动态链接时进程堆栈初始化信息:站在动态链接器的角度看,当操作系统把控制权交给它的时候,它将开始做链接工作,那么至少它需要知道关于可执行文件和本进程的一些信息,比如可执行文件有几个段(“Segment”)、每个段的属性、程序的入口地址(因为动态链接器到时候需要把控制权交给可执行文件)等。这些信息往往由操作系统传递给动态链接器,保存在进程的堆栈里面。进程初始化的时候,堆栈里面保存了关于进程执行环境和命令行参数等信息。事实上,堆栈里面还保存了动态链接器所需要的一些辅助信息数组(Auxiliary Vector)。辅助信息的格式也是一个结构数组,它的结构定义在” /usr/include/elf.h”:

/* Auxiliary vector.  */

/* This vector is normally only used by the program interpreter.  The
   usual definition in an ABI supplement uses the name auxv_t.  The
   vector is not usually defined in a standard <elf.h> file, but it
   can't hurt.  We rename it to avoid conflicts.  The sizes of these
   types are an arrangement between the exec server and the program
   interpreter, so we don't fully specify them here.  */

typedef struct {
  uint32_t a_type;              /* Entry type */
  union {
      uint32_t a_val;           /* Integer value */
      /* We use to have pointer elements added here.  We cannot do that,
         though, since it does not work when using 32-bit definitions
         on 64-bit platforms and vice versa.  */
    } a_un;
} Elf32_auxv_t;

typedef struct {
  uint64_t a_type;              /* Entry type */
  union {
      uint64_t a_val;           /* Integer value */
      /* We use to have pointer elements added here.  We cannot do that,
         though, since it does not work when using 32-bit definitions
         on 64-bit platforms and vice versa.  */
    } a_un;
} Elf64_auxv_t;

跟前面的”.dynamic”段里面的结构如出一辙。先是一个32位/64位的类型值,后面是一个32位/64位的数值部分。几个比较重要的类型值,如下图所示:

6. 动态链接的步骤和实现

动态链接的步骤基本上分为3步:先是启动动态链接器本身,然后装载所有需要的共享对象,最后是重定位和初始化

动态链接器自举:动态链接器本身也是一个共享对象,但是事实上它有一些特殊性。对于普通共享对象文件来说,它的重定位工作由动态链接器来完成;它也可以依赖于其它共享对象,其中的被依赖的共享对象由动态链接器负责链接和装载。动态链接器的特殊性:首先是,动态链接器本身不可以依赖于其它任何共享对象;其次是动态链接器本身所需要的全局和静态变量的重定位工作由它本身完成。对于第一个条件我们可以人为地控制,在编写动态链接器时保证不使用任何系统库、运行库;对于第二个条件,动态链接器必须在启动时有一段非常精巧的代码可以完成这项工作而同时又不能用到全局和静态变量。这种具有一定限制条件的启动代码往往被称为自举(Bootstrap)。

动态链接器入口地址即是自举代码的入口,当操作系统将进程控制权交给动态链接器时,动态链接器的自举代码即开始执行。自举代码首先会找到它自己的GOT。而GOT的第一个入口保存的即是”.dynamic”段的偏移地址,由此找到了动态链接器本身的”.dynamic”段。通过”.dynamic”中的信息,自举代码便可以获得动态链接器本身的重定位表和符号表等,从而得到动态链接器本身的重定位入口,先将它们全部重定位。从这一步开始,动态链接器代码中才可以开始使用自己的全局变量和静态变量。实际上在动态链接器的自举代码中,除了不可以使用全局变量和静态变量之外,甚至不能调用函数,即动态链接器本身的函数也不能调用。

 装载共享对象完成基本自举以后,动态链接器将可执行文件和链接器本身的符号表都合并到一个符号表当中,我们可以称它为全局符号表(Global Symbol Table)。然后链接器开始寻找可执行文件所依赖的共享对象,”.dynamic”段中,有一种类型的入口是DT_NEEDED,它所指出的是该可执行文件(或共享对象)所依赖的共享对象。由此,链接器可以列出可执行文件所需要的所有共享对象,并将这些共享对象的名字放入到一个装载集合中。然后链接器开始从集合里取一个所需要的共享对象的名字,找到相应的文件后打开该文件,读取相应的ELF文件头和”.dynamic”段,然后将它相应的代码段和数据段映射到进程空间中。如果这个ELF共享对象还依赖于其它共享对象,那么将所依赖的共享对象的名字放到装载集合中。如此循环直到所有依赖的共享对象都被装载进来为止,当然链接器可以有不同的装载顺序,如果我们把依赖关系看做一个图的话,那么这个装载过程就是一个图的遍历过程,链接器可能会使用深度优先或者广度优先或者其它的顺序来遍历整个图,这取决于链接器,比较常见的算法一般都是广度优先的。当一个新的共享对象被装载进来的时候,它的符号表会被合并到全局符号表中,所以当所有的共享对象都被装载进来的时候,全局符号表里面将包含进程中所有的动态链接所需要的符号。

符号的优先级:一个共享对象里面的全局符号被另一个共享对象的同名全局符号覆盖的现象又被称为共享对象的全局符号介入(Global Symbol Interpose)。关于全局符号介入这个问题,实际上Linux下的动态链接器是这样处理的:它定义了一个规则,那就是当一个符号需要被加入全局符号表时,如果相同的符号名已经存在,则后加入的符号被忽略。由于存在这种重名符号被直接忽略的问题,当程序使用大量共享对象时应该非常小心符号的重名问题,如果两个符号重名又执行不同的功能,那么程序运行时可能会将所有该符号名的引用解析到第一个被加入全局符号表的使用该符号名的符号,从而导致程序莫名其妙的错误。

重定位和初始化:当所有依赖的共享对象被装载进来以后,链接器开始重新遍历可执行文件和每个共享对象的重定位表,将它们的GOT/PLT中的每个须要重定位的位置进行修正。因为此时动态链接器已经拥有了进程的全局符号表,所以这个修正过程也显得比较容易,跟前面提到的地址重定位的原理基本相同。重定位完成之后,如果某个共享对象有”.init”段,那么动态链接器会执行”.init”段中的代码,用以实现共享对象特有的初始化过程,比如最常见的,共享对象中的C++的全局/静态对象的构造就需要通过”.init”来初始化。相应地,共享对象中还可能有”.finit”段,当进程退出时会执行”.finit”段中的代码,可以用来实现类似C++全局对象析构之类的操作。如果进行的可执行文件也有”.init”段,那么动态链接器不会执行它,因为可执行文件中的”.init”段和”.finit”段由程序初始化部分代码负责执行。当完成了重定位和初始化之后,所有的准备工作就宣告完成了,所需要的共享对象也都已经装载并且链接完成了,这时候动态链接器就如释重负,将进程的控制权转交给程序的入口并且开始执行。

Linux动态链接器实现:内核在装载完ELF可执行文件以后就返回到用户空间,将控制权交给程序的入口。对于不同链接形式的ELF可执行文件,这个程序的入口是有区别的。对于静态链接的可执行文件来说,程序的入口就是ELF文件头里面的e_entry指定的入口;对于动态链接的可执行文件来说,如果这时候把控制权交给e_entry指定的入口地址,那么肯定是不行的,因为可执行文件所依赖的共享库还没有被装载,也没有进行动态链接。所以对于动态链接的可执行文件,内核会分析它的动态链接器地址(在”.interp”段),将动态链接器映射至进程地址空间,然后把控制权交给动态链接器。

在Linux下,可执行文件所需要的动态链接器的路径几乎都是”/lib64/ld-linux-x86-64.so.2”,其它的*nix操作系统可能会有不同的路径。在Linux的系统中,/lib64/ld-linux-x86-64.so.2通常是一个软链接。动态链接器是个非常特殊的共享对象,它不仅是个共享对象,还是个可执行的程序,可以直接在命令行下面运行共享库和可执行文件实际上没什么区别,除了文件头的标志位和扩展名有所不同之外,其它都是一样的。Windows系统中的EXE和DLL也是类似的区别,DLL也可以被当作程序来运行,Windows提供了一个叫做rundll32.exe的工具可以把一个DLL当作可执行文件运行。

动态链接器本身应该是静态链接的,它不能依赖于其它共享对象,动态链接器本身是用来帮助其它ELF文件解决共享对象依赖问题的,如果它也依赖于其它共享对象,那么谁来帮它解决依赖问题?所以它本身必须不依赖于其它共享对象。这一点可以使用ldd来判断,如下图所示:

是不是PIC对于动态链接器来说并不关键,动态链接器可以是PIC的也可以不是,但往往使用PIC会更加简单一些。一方面,如果不是PIC的话,会使得代码段无法共享,浪费内存;另一方面也会使/lib64/ld-linux-x86-64.so.2本身初始化更加复杂,因为自举时还需要对代码段进行重定位。实际上的/lib64/ld-linux-x86-64.so.2是PIC的。

/lib64/ld-linux-x86-64.so.2的装载地址跟一般的共享对象没区别,即为0x000000000。这个装载地址是一个无效的装载地址,作为一个共享库,内核在装载它是会为其选择一个合适的装载地址。

7. 显式运行时链接

支持动态链接的系统往往都支持一种更加灵活的模块加载方式,叫做显式运行时链接(Explicit Run-time Linking),有时候也叫做运行时加载。也就是让程序自己在运行时控制加载指定的模块,并且可以在不需要该模块时将其卸载。如果动态链接器可以在运行时将共享模块装载进内存并且可以进行重定位等操作,那么这种运行时加载在理论上也是很容易实现的。而且一般的共享对象不需要进行任何修改就可以进行运行时加载,这种共享对象往往被叫做动态装载库(Dynamic Loading Library),其实本质上它跟一般的共享对象没什么区别,只是程序开发者使用它的角度不同。这种运行时加载使得程序的模块组织变得很灵活,可以用来实现一些诸如插件、驱动等功能。当程序需要用到某个插件或者驱动的时候,才将相应的模块加载进来,而不需要从一开始就将它们全部加载进来,从而减少了程序启动时间和内存使用。并且程序可以在运行的时候重新加载某个模块,这样使得程序本身不必重新启动而实现模块的增加、删除、更新等,这对于很多需要长期运行的程序来说是很大的优势。

在Linux中,从文件本身的格式上来看,动态库实际上跟一般的共享对象没有区别。主要的区别是共享对象是由动态链接器在程序启动之前负责装载和链接的,这一系列步骤都由动态链接器自动完成,对于程序本身是透明的;而动态库的装载则是通过一系列由动态链接器提供的API,具体地讲共有4个函数:打开动态库(dlopen)、查找符号(dlsym)、错误处理(dlerror)以及关闭动态库(dlclose),程序可以通过这几个API对动态库进行操作。这几个API的实现实在/lib/x86_64-linux-gnu/libdl.so.2里面,它们的声明和相关常量被定义在系统标准头文件/usr/include/dlfcn.h,这几个函数的声明如下:

/* Open the shared object FILE and map it in; return a handle that can be
   passed to `dlsym' to get symbol values from it.  */
void *dlopen (const char *__file, int __mode);
/* Find the run-time address in the shared object HANDLE refers to
   of the symbol called NAME.  */
void *dlsym (void * __handle, const char * __name);
/* When any of the above functions fails, call this function
   to return a string describing the error.  Each call resets
   the error string so that a following call returns null.  */
char *dlerror (void);
/* Unmap and close a shared object opened by `dlopen'.
   The handle cannot be used again after calling `dlclose'.  */
int dlclose (void *__handle);

dlopen():此函数用来打开一个动态库,并将其加载到进程的地址空间,完成初始化过程。第一个参数是被加载动态库的路径,如果这个路径是绝对路径(以”/”开始的路径),则该函数将会尝试直接打开该动态库;如果是相对路径,那么该函数会尝试在以一定的顺序去查找该动态库文件:(1). 查找有环境变量LD_LIBRARY_PATH指定的一系列目录;(2). 查找由/etc/ld.so.cache里面所指定的共享库路径;(3). /lib、/usr/lib,注意:这个查找顺序与旧的a.out装载器的顺序刚好相反,旧的a.out的装载器在装载共享库的时候会先查找/usr/lib,然后是/lib。如果我们将__file这个参数设置为0,那么dlopen返回的将是全局符号表的句柄,也就是说我们可以在运行时找到全局符号表里面的任何一个符号,并且可以执行它们,这有些类似高级语言反射(Reflection)的特性。全局符号表包括了程序的可执行文件本身、被动态链接器加载到进程中的所有共享模块以及在运行时通过dlopen打开并且使用了RTLD_GLOBAL方式的模块中的符号。

第二个参数__mode表示函数符号的解析方式,常量RTLD_LAZY表示使用延迟绑定,当函数第一次被用到时才进行绑定,即PLT机制;而RTLD_NOW表示当模块被加载时即完成所有的函数绑定工作,如果有任何未定义的符号引用的绑定工作没法完成,那么dlopen就返回错误。上面的两种绑定方式必须选其一。另外还有一个常量RTLD_GLOBAL可以跟上面的两者中任意一个一起使用(通过常量的”或”操纵),它表示将被加载的模块的全局符号合并到进程的全局符号表中,使得以后加载的模块可以使用这些符号。在调试程序的时候我们可以使用RTLD_NOW作为加载参数,因为如果模块加载时有任何符号未被绑定的话,我们可以使用dlerror立即捕获到相应的错误信息;而如果使用RTLD_LAZY的话,这种符号未绑定的错误会在加载后发生,则难以捕获。当然,使用RTLD_NOW会导致加载动态库的速度变慢。

dlopen的返回值是被加载的模块的句柄,这个句柄在后面使用dlsym或者dlclose时需要用到。如果加载模块失败,则返回NULL。如果模块已经通过dlopen被加载过了,那么返回的是同一个句柄。另外如果被加载的模块之间有依赖关系,比如模块A依赖于模块B,那么程序员需要手工加载被依赖的模块,比如先加载B,再加载A。

事实上dlopen还会在加载模块时执行模块中初始化部分的代码。动态链接器在加载模块时,会执行”.init”段的代码,用以完成模块的初始化工作,dlopen的加载过程基本跟动态链接器一致,在完成装载、映射和重定位以后,就会执行”.init”段的代码,然后返回。

dlsym():此函数基本上是运行时加载的核心部分,我们可以通过这个函数找到所需要的符号。第一个参数是由dlopen返回的动态库的句柄;第二个参数即所要查找的符号的名字,一个以”\0”结尾的C字符串。如果dlsym找到了相应的符号,则返回该符号的值;没有找到相应的符号,则返回NULL。dlsym返回的值对于不同类型的符号,意义是不同的。如果查找的符号是个函数,那么它返回函数的地址;如果是个变量,它返回变量的值;如果这个符号是个常量,那么它返回的是该常量的值。如果符号找到了,那么dlerror返回NULL,如果没找到,dlerror就会返回相应的错误信息。

符号不仅仅是函数和变量,有时还是常量,比如表示编译单元文件名的符号等,这一般由编译器和链接器产生,而且对外不可见,但它们的确存在于模块的符号表中。dlsym是可以查找到这些符号的,我们也可以通过”objdump -t”来查看符号表,常量在符号表里面的类型是”*ABS*”。

符号优先级:动态链接实现时,共享模块中符号名冲突时,先装入的符号优先,我们把这种优先级方式称为装载序列(Load Ordering)。那么当我们的进程中有模块是通过dlopen装入的共享对象时,这些后装入的模块中的符号可能会跟先前已经装入的模块之间的符号重复时,也都是采用装载序列。当使用dlsym进行符号的地址查找工作时,这个函数也是按照装载序列的优先级进行符号的查找。

dlerror():每次我们调用dlopen、dlsym、dlclose以后,我们都可以调用dlerror函数来判断上一次调用是否成功。dlerror的返回值类型是char*,如果返回NULL,则表示上一次调用成功;如果不是,则返回相应的错误信息。

dlclose():此函数的作用跟dlopen刚好相反,它的作用是将一个已经加载的模块卸载。系统会维持一个加载引用计数器,每次使用dlopen加载某模块时,相应的计数器加一;每次使用dlclose卸载某模块时,相应计数器减一。只有当计数器减到0时,模块才被真正地卸载掉。卸载的过程跟加载刚好相反,先执行”.finit”段的代码,然后将相应的符号从符号表中去除,取消进程空间跟模块的映射关系,然后关闭模块文件。

GitHubhttps://github.com/fengbingchun/Messy_Test

发布了718 篇原创文章 · 获赞 1131 · 访问量 609万+

猜你喜欢

转载自blog.csdn.net/fengbingchun/article/details/101120761