1. 动态链接技术的诉求来源
静态链接:从目标文件到可执行文件,将所需的所有的模板链接,最终生成单一的可执行文件模块;
动态链接:单一的可执行文件模块被拆分成若干个模块,在程序运行过程中动态进行链接的方案。
静态链接可以使得开发者可以专注地开发自己的程序模块,但随着程序规模的增大,静态链接的一次性链接装配存在浪费内存和磁盘空间、模块更新困难等问题。甚至说基本的字符串公用库函数,每个程序内部处理都要保留一份
printf()
,
scanf()
,
strlen()
等函数,还有数量可观的其他库函数及它们所需要的辅助数据结构。一个普通C程序光用到的C语言静态库就至少有1MB以上,那么如果我们的机器运行着100个这样的程序,浪费空间可以达到100MB。Linux操作系统不像Windows那样吃机器,甚至很低配置的机器运行Linux也很流畅,这是得益于Linux很讲究内存空间效率的极致使用,锱铢必较。
程序开发和发布时,如果采用静态链接,则一旦程序中任何模块有更新,由于静态链接的文件就如同早期的整版木刻印刷版,哪怕改一个字,也得重刻一版,同样静态链接需要将改后后的程序重新链接、发布给用户,即哪怕再小的更新,用户都需要重新下载一次。在早期的网速较慢的年底,这种动辄整体回退的做法,显然不能忍。故而把文件根据功能分成若干模块,链接过程推迟到程序运行时再进行,这便是动态链接的基本思想。显然模块分得越细粒度,模块间的耦合度越小,则更改起来的要变动的部分便越少。
此外分模块的做法,还让程序间共享模块得以实现,否则每个程序都需要一套C语言
stdio.h
的独立拷贝。共享模块的好处不仅可以节省内存,还可以减少物理页面的换入换出(减少IO),也可以增加CPU缓存命中率,因为不同进程可能都集中采用了一个共享模块。故而对于动态链接,升级只需要将旧的目标文件覆盖掉即可。动态链接可以使得开发过程中各个模块更加独立,耦合度更小。ELF程序在静态链接下要比动态链接快5%左右,但是采用动态链接节省了磁盘空间和内存空间,并且程序模块升级更便捷。
简而言之,动态链接便是模块化编程的真正贯彻,虽然管理起来略微繁琐,但是
可实现内存空间效率和操作时间效率的双赢。
2. Linux动态链接的实现
动态链接涉及运行时链接多个文件,涉及到整个进程的虚拟空间的地址分配和布置,比静态链接的确定性定位排布而言,动态链接中有诸多的地址引用需要到运行时才能确定,此外还涉及到存储管理、内存共享、多线程等机制。故而动态链接需要操作系统支持。目前Linux支持的共享动态文件.so,Windows支持.dll插件型动态链接库。
静态链接是在将程序装载进虚拟空间之前便完成了所有程序文件的链接,而动态链接是在程序装载进虚拟空间串联式实时装载的,即链接和装载同步进行。比如当前主程序文件
maincontent.c
引用某个外部库的函数
foobar()
,则动态链接器会将
maincontent.c
内对
foobar()
的引用标记为一个动态链接的符号,不在链接阶段对它进行地址重定位,而把这个过程推迟到主程序文件装载进虚拟进程空间时才进行。至于
maincontent.c
中如何声明
foobar()
为外部动态链接符号,则便涉及到了符号导入表等机制。不过既然说到了链接阶段不进行地址重定位,而是将符号引用地址定位推迟到装载时,那么便接着介绍下装载时重定位。
3. 装载时重定位
任何文件要被装载进虚拟进程空间,是需要有个装载地址的,显然主程序文件被装载进虚拟进程空间时是没有人和它争的,而其他动态链接库装载时,则可能出现装载地址冲突的问题。
显然为每个贡献库模块指定固定载入地址,是会严重限制程序升级,甚至导致模块间出现地址冲突。这种做法被称作静态共享库(不是静态库),它的做法是将多种模块统一交给OS,让OS在特定的地址范围内划分出足够的空间预留给这些已知模块。因为静态共享库存在足够先天设计的弊病,导致这种设计很少见,已经被动态链接彻底取代。
换个说法:.so对象在编译时不能假设自己在进程虚拟空间中的位置,但是可执行文件可以唯一确定自己的起始位置,因为可执行文件往往是第一个被加载的文件,它可以选择一个固定空闲的地址,如Linux下一般是
0x08040000
,Windows下一般是
0x00400000
。
所以对于共享对象文件而言是存在一个所谓“装载时所有地址引用重定位”的需求。那么该如何实现这种重定位目的呢?
装载时重定位便是针对这个目的的一个直接方法:根据共享对象在装载时的真正位置,遍历文件中所有的绝对地址引用,不过由于整个共享对象是作为一个整体被加载的,故而程序中指令和数据的相对位置是不会变的,如果一个共享对象文件被编译时先预设自己的装载位置为0x1000,但在装载时被分配到0x4000,则程序文件中所有绝对地址引用只需要加上0x3000的整体偏移量即可。
装载时重定位可以解决动态模块中有绝对地址引用的情况,但是它最大的缺点是在内存中的指令部门将因为绝对地址修改导致在多个进程间无法共享,并不节省内存,只节省了磁盘空间,并不节省内存空间。
4. 地址无关代码
我们的目的是尽可能让共享模块中的指令集尽可能被复用,显然这些复用的指令集合是不需受装载地址影响的。所以实现思路是将把指令中需要被修改的部分分离出来,跟需要被修改的RW数据段放在一起,这些RW权限的段是每个进程都需要有一个副本的,而RX权限的纯无需修改.code段则可以被复用,这种技术成为地址无关代码(PIC, Position-independent Code)。也是Linux区别于Windows的一个重要特征。
显然地址无关技术增加对程序的遍历编译次数,故而在前期编译阶段Linux要花费更多的时间用于区分代码中的装载地址无关性代码和相关性代码。而Windows则采用的是一种所谓的基地址重定位技术,其实和装载时重定位并无本质区别,但是会导致共享对象文件的指令集合一旦出现绝对地址引用并无法共享的结果,这也是为什么说Linux比Windows在某些方面要小家子气。
5. 共享对象模块的代码段地址无关性处理
现将共享对象模块的代码段中地址引用的情况分为以下几种情况:
1. 模块内部的函数调用、跳转
此情况函数调用者和被调用者的相对位置是固定的,故而模块内部的跳转可以通过相对地址调用,或者是基于寄存器的相对调用,即采用所谓的近址偏移跳转。
2.模块内部的数据访问,如模块中定义的全局变量、局部静态变量
任何一条指令与它要访问的模块内部数据之间的相对位置是固定的,那么只需要相对于当前指令加上固定的偏移量就可以访问模块内部数据。现代的体系结构中,数据的相对寻址并没有相对于当前指令地址PC的寻址方式,ELF用了一个巧妙的办法先得到了当前PC值
对于函数段调用,AT&T汇编集提供了一个call指令使用近址偏移跳转来实现程序流的跳转,而对于数据的相对引用,虽然任何一个文件被编译之后.code和.data段的相对位置已经可以确定了,但是如何利用call指令才实现类似的近址偏移获取.data段中的模块内部数据的地址。上面的ELF汇编指令很巧妙,其制造出一个辅助函数
<_i686.get_pc_thunk.cx>
,当系统执行call指令以后,下一条指令的地址(这里是454)会被压倒栈顶,而esp寄存器就是始终指向栈顶的,那么当进入
<_i686.get_pc_thunk.cx>
内部后,ecx寄存器便存放在下一条指令的首地址(即454),这时则返回便可通过加上距.data段的偏移量,再加上a在.data段 中偏移量0x28,便可以获取了模块内数据a的控制权限。
3.模块外部的数据访问,比如其中模块中定义的全局变量
模块间的数据访问要比模块内部数据访问复杂点,因为模块外数据的具体位置得等到装载时才能确定,其他模块的全局变量存放在宿主模块的.data段中,这时需要有个中转的数据结构来复杂模块间的数据引用切换,类似于电话接线员一样(
假设电话网内的电话号码每天重置,根据用户接入顺序依次分配,假如A用户先接入电话网时,A想和B通信,但是B还未接入电话网,所以A不能通过直接拨号码来直接和B通信,所以A向接线员申请了要和B通信,接线员记录下“A向B发起请求”,这样等到B接入网络时,接线员便将该标签上内容修改成今天B的号码,那么下次A重新发起请求时,接线员便可以将B的号码直接告诉A)。
类似的原理,Linux将接线员的角 {MOD}换成了全局偏移表GOT(global offset table),模块将自己对外部模块数据的请求先声明在GOT表中,得到目标模块加载后,再将GOT表相对应的请求项换成对应的数据地址。
当指令中需要访问变量b时,动态链接器会到GOT表中查找变量b的地址(引用处可能置为b在GOT表中下标),如果当前为空,则等待动态链接器完成GOT表填充。链接器会在装载宿主模块时,遍历每个外部可见性符号的地址,然后填充GOT各项。一般GOT表被放在.data段中,以使在它可以在装载时权限为
RWXP
。
4.模块外部的函数调用、跳转等
函数和变量一样是符号,故而模块间的函数调用也是通过GOT表来实现中转的,只不过此时GOT中相应项保存的是函数的地址。
5.共享模块的全局变量问题
面四种情况并没有考虑定义在模块内部的全局变量,粗略地看确实模块内部的全局变量和模块内部的静态变量一样处理不就可以了吗?但是存在特殊情况。
当一其他模块B引用模块A内部定义的全局变量
extern int global;
int foo()
{
global = 1;
}
当编译器编译到该代码时,它是无法根据上下文判断出该段代码中的
global
是引用于同一模块的其他文件还是其他模块的全局变量。如果此时该段代码时存在主程序中,因为程序主模块是不需要考虑共享的,故而不会采用地址无关PIC代码分拣工作,故而在主程序中关于该全局变量
global
的引用和普通数据访问方式一样,编译器会产生类似这样的代码
movl $0x1, xxxxxxx
其中xxxxxxx便是
global
的地址,由于可执行文件在运行时是不进行代码重定位的,所以变量的地址必须在链接时确定下来,为了能够使链接过程正常进行,则链接器在加载主程序文件时,会先在它的.bss段中声明一个暂未初始化的
global
变量副本,但如果
global
定义在原先的共享对象中,这便会出现一个变量在虚拟进程空间中有两处副本,肯定不行!但是可执行文件的
global
必须在链接主程序时就要确定,不然没法确定下汇编代码,所以只能让所有的
global
指向主程序文件创建在.bss段中的那个
global
副本。
ELF共享库在编译时,因为不知道自己的全局变量在主程序文件是否已经被“先斩后奏”地先生造出一个.bss副本,故而默认把模块内部的所有全局变量当做引用其他模块的全部变量,通过GOT中转访问。当共享模块被装载时,如果某个全局变量已经在主程序文件中被创建了副本,那么动态链接器就要把GOT中相应符号的地址都指向该.bss副本,如果变量在共享模块已被初始化,还得被初始值复制到主程序的.bss副本中;如果主程序文件并未创建该变量副本,也意味着主程序未声明对该全局变量的使用,那么就是GOT中相应符号的地址指向宿主模块中的全局变量地址。
通过以上的5种分类,对代码段进行分类,将代码段可以做到地址无关性的保留,有绝对地址引用问题的,通过引入额外的数据结构,实现替代牺牲,将GOT迁移到数据段(如利用GOT表,来作间接访问),而代码本身转化成地址无关性。
6. 共享对象模块的数据段的地址无关性
解决了代码段的共享对象动态链接的重定位问题,那么如果数据段也存在绝对地址引用问题?
static int a;
static int* p = &a;
因为数据段是每个进程都有一份副本的,所以可以采用装载时重定位的整体绝对地址引用偏移的手法。对于共享对象.so,如果数据段中有绝对地址引用,那么编译器和链接器便会在产生该.so对象时,同时产出一个重定位表.data.rel,该表里包含了“R_386_Relative”类型的重定位入口,当动态链接器装载.so时,发现.so中含有.data.rel,则动态链接器也会对该.so对象进行重定位。
如果不使用-fPIC参数编译生成.so:
$gcc -shared pic.c -o pic.so
那么上面这个命令就会产生一个不使用地址无关代码而使用装载时重定位的共享对象.so,但是如果代码段不是地址无关的,它就不能被多个进程之间共享,于是失去了节省内存的优点,但装载时重定位的共享对象的运行速度要比地址无关代码类型的.so快,因为省去了地址无关代码中每次访问全局数据和函数时需要做一次计算当前PC指令地址以及间接地址寻址的转换过程计算。