程序员的自我修养 第7章 动态链接

程序员的自我修养 第7章 动态链接
静态链接原理上很容易理解,但是实践上很难实现。
静态链接存在着空间浪费、静态链接对程序的更新部署和发布也会带来很多麻烦。
当program1和program2同时使用lib.o时,lib.o在磁盘中和内存中有两份副本。当程序越来越大,引用越来越复杂,空间的浪费就会很严重。
一旦程序中有任何模块更新,整个程序就要重新链接、发布给用户。
要解决空间浪费和更新困难这两个问题,最简单的办法就是把程序的模块相互分割开来,形成独立的文件。就是不对组成程序的目标文件进行链接,等到程序要运行时才进行链接。把链接的这个过程推迟到了运行时再进行,这就是动态链接的概念。

动态链接对于上面的实例,当我们运行program1时,系统首先加载lib.o,如果还依赖于其他目标文件,系统都将他们加载进来内存中。所有需要的目标文件加载完毕之后,如果依赖关系满足,系统开始进行链接工作。这个链接过程和静态链接非常类似,包括符号解析、地址重定位等等。完成这些步骤之后,系统开始把控制权交给program1.o的程序入口处开始执行。这时候如果需要运行program2,那么系统只需要加载program2.o,而不需要加载lib.o,因此内存中已经存在了一份lib.o副本,系统要做的只是将program2.o和lib.o链接起来。
程序员的自我修养 第7章 动态链接
内存中共享一个目标文件模块的好处不仅仅是节省了内存,也可以增加CPU缓存的命中率,因为不同进程间的数据和指令访问都集中在同一个共享模块上。

这样的链接方案也可以使程序升级变得更加容易,当要升级程序库或者程序共享的某个模块时,理论上只要简单的将旧目标文件覆盖掉,而无需将所有的程序再重新链接一遍。当程序下一次运行的时候,新版本的目标文件会自动装载到内存并且链接起来,程序就完成了升级的目标。

程序扩展性和兼容性
动态链接还有一个特点就是程序在运行时可以动态地选择加载各个程序模块,这个优点就是后来人们用来制作程序的插件。
动态链接还可以加强程序的兼容性。一个程序在不同平台上运行时可以动态地链接到由操作系统提供的动态链接库,这些动态链接库相当于在程序和操作系统之间增加了一个中间层,从而消除了程序对不同平台之间的依赖的差异性。

但是动态链接库并不能包治百病,也有一些问题。当陈旭所以来的某个模块更新后,由于新模块和旧模块之间的接口不兼容,导致了原有程序无法运行。

动态链接的基本实现
动态链接的基本思想就是把程序按照模块拆分成各个相对独立的部分,在程序运行时才将他们链接在一起形成一个完整的程序。
动态链接涉及到运行时链接及多个文件的装载,必须要有操作系统的支持,因为动态链接的情况下,进程的虚拟地址空间的分布会比静态链接情况下更为复杂,还有一些存储管理、内存共享、进程线程等机制在动态链接下也会有些微妙的变化。目前主流的操作系统几乎都支持动态链接,在linux上ELF动态链接文件称为动态共享对象DSO(dynamic shared objects)简称共享对象,一般以.so扩展名。在windows系统中,动态链接文件称为动态链接库DLL(dynamical linking library),一般以.dll扩展名命名。

在linux中,常用的C语言库的运行库glibc,动态链接库保存在/lib/libc.so,整个系统只保留一份C语言库的动态链接库libc.so。所有的C语言编写的、动态链接的程序都可以在运行时使用它。当程序被装载时候,系统的动态链接库会将程序所需要的所有的动态链接库装载到进程地址空间,并且将程序中所有未决议的符号绑定到相应的动态链接库中,并进行重定位工作。
程序与libc.so之间真正的链接工作是由动态链接器完成的,动态链接是把链接这个过程从本来的程序装载前推迟到装载的时候。

实例
程序员的自我修养 第7章 动态链接
gcc -fPIC -shared -o lib.so lib.c
gcc -o program1 program1.c ./lib.so
gcc -o program2 program2.c ./lib.so
程序员的自我修养 第7章 动态链接
程序员的自我修养 第7章 动态链接
当程序模块program1.c被编译program1.o时,编译器还不知道foobar函数的地址。当链接器将program1.o链接成可执行文件时候,这时候链接器就必须确定program1.o中的引用的foobar函数的性质。如果foobar是一个定义与其他静态目标模块中的函数,那么链接器将会按照静态连接的规则,将program1.o中的foobar地址引用重定位。如果foobar是一个定义在某个动态共享对象中的函数,那么链接器就会将这个符号的引用标记为一个动态链接的符号,不对它进行地址重定位,把这个过程留到装载时再进行。
但是问题是,链接器怎么知道foobar是动态符号还是静态符号呢。在lib.so中保存了完整的符号信息,把lib.so也作为链接的输入文件,链接器在解析符号时候就可以知道,foobar是一个定义在lib.so的动态符号。这样链接器就可以对foobar的引用做特殊处理,使他成为一个对动态符号的引用。

动态链接程序运行时地址空间分布
程序员的自我修养 第7章 动态链接
可以看到program1除了lib.so,还用到了C语言运行库libc-2.6.1.so, 还有共享对象是ld-2.6.1.so,这就是linux下的动态链接器。动态链接器与普通共享对象一样被映射到进程的地址空间,在系统开始运行program1之前,首先会把控制权交给动态链接器,由它完成所有的动态链接器工作以后 再把控制权交给program1,然后开始执行。

程序员的自我修养 第7章 动态链接
地址装载地址是从0x00000000开始的。我们知道这个地址是无效地址,但是从进程虚拟空间分布来看,lib.so最终的装载地址并不是0x00000000,而是0xB7EFC000. 从这儿可以推断,共享对象的最终装载地址在编译时是不确定的,而是在装载时,装载器更具当前地址空间的空闲情况,动态分配一块足够大小的虚拟地址空间给相应的共享对象。

地址无关代码
固定装载地址的困扰
为了实现动态链接,首先会遇到问题是共享对象地址的冲突问题。
动态链接库和静态链接库不同,静态共享库的做法就是将程序的各个模块统一交给操作系统来管理,操作系统在某个特定的地址划分出来一些地址块,为那些已知的模块预留足够的空间。
静态共享库的目标地址导致了很多问题,地址冲突问题、静态共享库的升级问题。因为升级之后必须保持共享库中的全局函数和变量地址的不变,如果应用程序在链接是已经绑定了这些地址,一旦更改,就必须重新链接应用程序,否则就会引起应用程序的崩溃。即使升级静态共享库后保持原来的函数和变量地址不变,只是增加了一些全局函数或白娘,也会受到限制,因为静态共享库被分配到虚拟地址空间有限,不能增长太多,否则可能会超出被分配的空间。这些限制导致了静态共享库的方式再现在系统中已经很少见了。

装载时重定位
在没有虚拟存储概念的时候,程序是直接被加载进物理内存的。当同时多个程序运行的时候,操作系统根据当时内存的空闲情况,动态分配一块大小合适的物理内存给程序,所以程序被装载的地址是不确定的。系统在装载程序的时候需要对程序的指令和数据中对绝对地址的引用进行重定位。
前面我们介绍静态链接时候的重定位叫做链接时重定位,现在这种情况叫做装载时重定位。在windows中叫做基址重置。
但是装载时重定位并不合适用来解决共享对象中所存在的问题。因为动态链接库被装载映射到虚拟空间后,指令部分是在多个进程之间共享,指令被重定位后对于每一个进程来说是不同的。
linux和gcc支持这种装载时重定位。我们在前面生成共享对象时候,使用了-shared -fPIC参数。如果只使用-shared参数,那么输出的共享对象就是使用装载时重定位的方法。

地址无关代码
那为什么要加-fPIC参数呢。有什效果?
装载时重定位是解决动态模块中有绝对地址引用的方法之一,但是指令部分无法再多个进程之间共享,就失去了节省内存的优势。
我们希望程序模块中共享的指令部分在装载时不需要因为装载地址的改变而改变,所以实现的基本想法就是把指令中那些需要被修改的部分分离出来,跟数据分放在一起,这样指令部分就可以保持不变,而数据部分可以在每一个进程中拥有一个副本。这种方案称之为地址无关代码PIC(position independent code)技术。

对于共享模块的不同引用(模块内引用和模块外引用)、不同引用方式(指令引用和数据访问)我们可以把寻址方式分为四类。

  • 第一种是模块内部的函数调用、跳转
  • 第二种是模块内部的数据访问
  • 第三种是模块外部的函数调用、跳转
  • 第四种是模块外部的数据访问

程序员的自我修养 第7章 动态链接
类型1、模块内部调用或者跳转
对于模块内部调用,因为被调用的函数和调用者在同一个模块,他们之间的相对位置是固定的。模块内部的跳转和函数调用都可以是相对地址调用,或者基于寄存器的相对调用,这些指令是不需要重定位的。
foo对bar的调用实际上是一条相对地址调用指令。

程序员的自我修养 第7章 动态链接
类型2、模块内部数据访问
模块内部数据访问在ELF是通过获取当前的PC值,然后再加上一个偏移量就可以访问到相应变量的地址了。
如果模块的被装载到0x10000000地址,变量a实际地址是0x10000000+0x454+0x118C+0x28 = 0x10001608
程序员的自我修养 第7章 动态链接
程序员的自我修养 第7章 动态链接
类型3、模块间数据访问
我们前面提到要使得代码地址无关基本思想就是把与地址相关的部分放到数据段。模块间的数据这些全局变量地址是跟模型相关的。ELF的做法就是在数据段里面建立一个纸箱这些变量的指针数组,也就是全局偏移表GOT(global offset table),当代码需要引用该全局变量时,可以通过GOT中相对应的项 间接引用。
程序员的自我修养 第7章 动态链接
GOT是如何做到地址无关的
ELF中,通过得到PC值然后加上一个偏移量得到GOT的位置,然后根据变量地址在GOT中的偏移就可以得到变量的地址。GOT中每一个地址对应于哪个变量是由编译器决定的。
为了计算b的地址,首先计算b在GOT中的位置,0x10000000+0x454+0x118C+(-8)=0x100015D8,然后使用寄存器间接寻址给变量b赋值2.
程序员的自我修养 第7章 动态链接
程序员的自我修养 第7章 动态链接
程序员的自我修养 第7章 动态链接
类型4、模块间调用或者跳转
调动ext函数的方法和上面访问变量b的方法基本类似。先得到当前指令地址PC,然后加上一个偏移得到函数地址在GOT中的偏移,然后是一个间接引用。
程序员的自我修养 第7章 动态链接
程序员的自我修养 第7章 动态链接
小结
程序员的自我修养 第7章 动态链接
使用GCC产生地址无关的代码,只需要使用-fPIC参数即可。还有一个参数-fpic。-fpic和-fPIC功能类似,只是-fpic在某些平台上会有些限制,如全局符号的数量或者代码长度等等。

如何区分一个DSO是否为PIC
readelf -d foo.so | grep TEXTREL
如果有输出就不是PIC,否则就是PIC的。TEXTREL是代码段的重定位表地址。

PIC除了可以应用在共享对象上,也可以应用到可执行内存上。一个地址无关的方式编译的可执行文件被称为地址无关可执行文件PIE(position independent executable)。

共享模块的全局变量问题
module.c
程序员的自我修养 第7章 动态链接
当编译器编译module.c时,他无法根据上下文判断global是定义在同一个模块的其他目标文件,还是定义在另外一个共享对象之中的,即无法判断是否是跨模块间的调用。
为了能够使得连接过程正常进行,链接器会在创建可执行文件时,在它的.bss段创建一个global的副本。解决的办法只有一个,就是所有的额使用这个变量的指令都指向位于可执行文件中的那个。ELF共享库在编译的时候,默认都把定义在模块内部的全局变量当做定义在其他模块的全局变量,也就是说当前面的类型四,通过GOT来实现变量的访问。当共享模块被装载时,如果某个全局变量在可执行文件中的拥有副本,那么动态链接库会把GOT中的响应地址指向该副本,这样该变量在运行时实际上最终就只有一个实例。如果变量在共享模块中被初始化,那么动态链接器还需要将该初始化值复制到程序主模块中的变量副本。如果该全局变量在程序主模块中没有副本,那么GOT中的相应地址就指向模块内部的该变量副本。

数据段地址无关
举例:
static int a;
static int* p = &a;
p的地址是一个绝对地址,指向a,而变量a的地址随着共享对象的装载地址改变而改变。
对于数据段来说,他在每个进程中的都有一个独立的副本,所以并不担心被进程改变。我们可以选择装载时重定位的方法解决数据段中绝对地址引用问题,对于共享对象来说,如果数据段中有绝对地址引用,那么编译器和链接器会产生一个重定位表,这个重定位表里面包含一个R_386_RELATIVE类型的重定位入口,用于解决上述问题。当动态链接器装载共享对象时,如果发现该共享对象有这样的重定位入口,那么动态链接器就会对该共享对象进行重定位。

延迟绑定
动态链接库比静态链接库要灵活,但是也浪费一些时间。因为动态链接库要进行GOT计算、在程序运行时进行链接重定位等动作。下面主要介绍一些优化动态链接库的方法。
在动态链接下,程序模块之间包含了大量的函数引用,所以在程序开始执行之前,动态链接会耗费不少的时间用于解决模块之间的函数引用的符号查找以及重定位,这是拖慢性能的一个重要原因。不过,在一个程序运行过程中,可能很多函数在程执行完成时都不会被用到,比如一些错误处理函数等,如果一开始就把所有的函数都链接好实际上是一种浪费。所以ELF采用了一种延迟绑定的技术,就是当函数第一次被用到时才进行绑定,如果没有用到就不进行绑定。
ELF使用PLT(procedure linkage table)的方法来实现。当第一次调用函数时,需要调用动态链接器的_dl_runtime_resolve()函数,这个函数需要知道绑定的模块和哪个函数等信息来进行绑定操作。当调用某个外部函数时,PLT为了实现延迟绑定,调用函数不直接通过GOT跳转,而是通过一个PLT项的结构来进行跳转。每个外部函数在PLT中都有一个相应的项。
ELF将GOT拆分成了两个表 .got和.got.plt。.got用来保存全局变量引用的地址,.got.plt用来保存函数引用的地址。所有对于外部函数的引用全部被分离出来存放到了.got.plt中。另外.got.plt还有前三个特殊的项

  • 第一项保存的是.dynamic段的地址,描述了本模块动态链接相关的信息
  • 第二项是本模块的ID
  • 第三项是保存的_dl_runtime_resolve的地址

第二项和第三项由动态链接器在装载共享模块的时候负责将他们初始化。.got.plt的其余项分别对应每个外部函数的引用。
程序员的自我修养 第7章 动态链接

动态链接相关的结构
动态链接情况下,可执行文件的装载过程如下,首先操作系统会读取可执行文件的头部,检查文件的合法性,然后从头部中的program header中读取每个segment的虚拟地址、文件地址、属性, 并将它们映射到进程虚拟地址空间的相应位置,然后操作系统会先启动一个动态链接器,在Linux下动态链接器ld.so其实是一个共享对象,操作系统同样通过映射的方式将它加载到进程的地址空间中,操作系统在加载完动态链接库之后,就将控制权交给动态链接器的入口地址。当动态链接器得到控制权之后,就开始一系列自身的初始化操作,然后根据当时的环境参数,开始对可执行文件进行动态链接工作。当所有动态链接工作完成以后,动态链接器就会将控制权交给可执行文件的入口地址,程序开始正式执行。

.interp段
动态链接器的位置不是由系统配置指定的,也不是由环境变量决定的,而是由ELF可执行文件的.interp段指定的。
程序员的自我修养 第7章 动态链接
不同的系统,链接器的位置不同。
程序员的自我修养 第7章 动态链接
.dynamic段
.dynamic段保存了动态链接所需要的基本信息。
程序员的自我修养 第7章 动态链接
程序员的自我修养 第7章 动态链接
ldd可以查看一个程序依赖于哪些共享库
程序员的自我修养 第7章 动态链接
动态符号表
为了表示动态链接这些模块之间的符号导入导出关系,ELF专门有一个动态符号表的段来保存这些信息,段名是.dynsym。.dynsym只保存了与动态链接相关的符号,对于模块内部的符号则不保存。
动态符号表也需要一些辅助的表,比如用于保存符号名的字符串表,.dynstr。在动态链接下,需要在程序运行时查找符号,为了加快符号的查找过程,需要一个辅助的符号哈希表.hash。
程序员的自我修养 第7章 动态链接
动态链接重定位表
动态链接的文件中,也有类似于重定位表,叫.rel.dyn和.rel.plt,分别相当于.rel.text和.rel.data。.rel.dyn实际上是对数据引用的修正,它所修正的位置是.got以及数据段,而.rel.plt是对函数引用的修正,所修正的位置位于.got.plt。
程序员的自我修养 第7章 动态链接
在静态链接中已经遇到两个重定位入口R_386_32和R_386_PC32。上图中我们可到有几个新的重定位入口类型:R_386_RELATIVE / R_386_GLOB_DAT / R_386_JUMP_SLOT。

动态链接的步骤和实现

显示运行时链接