文章目录
一、为什么要动态链接
1.1 静态链接的缺点
- 内存和磁盘空间占用。每个程序都将包含的库函数直接打包使用,使得占用内存和磁盘大。
- 程序开发和发布。一个工程项目包含多个模块,在静态链接的做法下,如果有一个模块需要更新,那么就需要将所有的模块重新获取一次才能运行。因为,一旦程序任何一个位置的一个小的改动,都会导致整个程序需要重新下载。
1.2 动态链接
将程序的模块互相分割开,不让它们静态地链接在一起;即不对那些组成程序的目标文件进行链接,等到程序运行时再链接。有如Program1和Program2两个程序,并假定保留了Program1.o、Program2.o和Lib.o三个文件。当我们需要运行Program1这个程序时,系统首先加载 Program1.o,而系统发现Program1.o中用到了Lib.o,即Program1.o依赖于Lib.o,此时系统加载Lib.o,并将其加载至内存。所有需要的目标文件加载完毕后,若依赖关系满足,即所有的依赖文件都在磁盘中,此时系统开始进行链接工作(与静态链接类似,符号解析、地址重定位等)。
完成这些工作,系统将控制权交给Program1.o的程序入口,程序开始运行;若此时我们想运行Program2.o,则不需要再重新加载Lib.o——因为前面在内存中已经存有一份Lib.o的副本,如下图所示,系统只需要将Program2.o与Lib.o链接起来即可。
动态链接的基本实现
静态链接是将所有的程序模块都连接成一个单独的可执行文件,而动态链接则是将程序拆分成各个相对独立的部分,在程序运行时才将它们链接在一起形成一个完整的程序。动态链接涉及运行时的链接和多个文件的装载,动态链接下进程的虚拟地址空间分布复杂,另外,涉及到的存储管理、内存共享、进程线程等机制在动态链接下也有不同。在Linux中,ELF动态链接文件被称为动态共享对象,简称共享对象,是以.so
为扩展名的文件;而在Windows中,动态链接文件则被称为动态链接库,即.dll
扩展名的文件。
程序被装载时,系统的动态链接器会将程序所需的所有动态链接库(最基本的是libc.so)装载到进程的地址空间,并将程序中所有未决议的符号绑定到相应动态链接库中,并进行重定位工作。
程序与libc.so之间的链接由动态链接器完成,而非静态链接器ld。即动态链接将连接过程由本来的程序装载前推迟到装载时——程序每次装载时进行链接,这样会不会很慢?是的,所以后续对动态链接的连接过程进行优化,如后将介绍的延迟绑定(Lazy Binding)。
二、简单的动态链接举例
2.1 使用共享文件
下列代码中,Program1.c与Program2.c分别调用了Lib.c的foobar()
函数,传入一个数字,foobar()
函数的作用就是打印这个数字。
/* Program1.c */
#include "Lib.h"
int main()
{
foobar(1);
return 0;
}
/* Program2.c */
#include "Lib.h"
int main()
{
foobar(2);
return 0;
}
/* Lib.c */
#include <stdio.h>
void foobar(int i)
{
printf("Printing from LIb.so %d\n", i);
}
/* Lib.h */
#ifndef LIB_H
#define LIB_H
void foobar(int i);
#endif
根据GCC编译程序常用命令,将Lib.c编译成一个共享对象文件(Lib.so):gcc -fPIC -shared -o Lib.so Lib.c
。
得到Lib.so文件,是一个包含了Lib.c的foobar()
函数的共享对象文件,然后分别编译链接Program1.c和Program2.c:
gcc -o Program1 Program1.c ./Lib.so
gcc -o Program2 Program2.c ./Lib.so
这样得到两个程序Program1和Program2,它们都用到了Lib.so里的foobar()
函数。从Program1的角度来看,编译和链接过程如下图所示:
Lib.c被编译成Lib.so共享对象文件,Program1.c被编译成Program.o后,链接成为可执行程序Program1;其中Program1.o被链接成可执行文件的过程中,静态链接和动态链接是不同的。在静态链接中,会把Program1.o和Lib.o链接到一起,并产生输出可执行文件Program1;但是在动态链接中,Lib.o没有被链接进来,链接的输入目标文件只有Program1.o(还有C语言库),并且这里Lib.so也参与了链接过程——这是怎么回事?
在动态链接中,当程序模块(动态链接下,可执行文件和共享对象都可以看作是程序的一个模块)program1.c被编译program1.o时,编译器还不知道foobar
函数的地址。当链接器将program1.o链接成可执行文件时候,这时候链接器就必须确定program1.o中的引用的foobar
函数的性质。如果foobar
是一个定义于其他静态目标模块中的函数,那么链接器将会按照静态连接的规则,将program1.o中的foobar
地址引用重定位。如果foobar
是一个定义在某个动态共享对象中的函数,那么链接器就会将这个符号的引用标记为一个动态链接的符号,不对它进行地址重定位,把这个过程留到装载时再进行。
但是问题是,链接器怎么知道foobar
是动态符号还是静态符号呢?在lib.so中保存了完整的符号信息,把lib.so也作为链接的输入文件,链接器在解析符号时候就可以知道,foobar
是一个定义在lib.so的动态符号。这样链接器就可以对foobar
的引用做特殊处理,使他成为一个对动态符号的引用。
2.2 动态链接程序运行时地址空间分布
对于静态链接而言,整个进程只需要对可执行文件进行映射,但动态链接中除了可执行文件外,还有它所依赖的共享目标文件。进程的地址空间分布如下图所示:
可以看到program1除了lib.so,还用到了C语言运行库libc-2.6.1.so,在还有共享对象是ld-2.6.1.so,这就是linux下的动态链接器。动态链接器与普通共享对象一样被映射到进程的地址空间,在系统开始运行program1之前,首先会把控制权交给动态链接器,由它完成所有的动态链接器工作以后 再把控制权交给program1,然后开始执行。
使用命令readelf -l Lib.so
查看可知,地址装载地址是从0x00000000
开始的。我们知道这个地址是无效地址,但是从进程虚拟空间分布来看,lib.so最终的装载地址并不是0x00000000
,而是0xB7EFC000
。从这儿可以推断,共享对象的最终装载地址在编译时是不确定的,而是在装载时,装载器根据当前地址空间的空闲情况,动态分配一块足够大小的虚拟地址空间给相应的共享对象。
三、地址无关代码
《CSAPP:第七章——链接》的位置无关代码中也有对这一块内容做总结。
3.1 固定装载地址的困扰
共享对象在被装载时,如何确定它在进程虚拟地址空间中的位置?
动态链接首先会遇到共享对象地址的冲突问题。在动态链接的情况下,若不同模块的目标装载地址都一样是不行的。而对于单个程序来说,可以手工指定各个模块的地址,比如:把0x1000
到0x2000
分配给模块A,0x2000
到0x3000
分配给模块B。但是手工指定各个模块的地址是不行的,因为多个模块的程序下,容易导致地址冲突。比如一个人制作了一个程序,需要用到模块B,而不需要模块A,所以他以为地址0x1000
到0x2000
是空闲的,分配给了另一个模块C,导致模块A和C冲突。这种手工指定分配模块地址的方法叫做静态共享库,早期系统的确使用过。
静态共享库和静态库还是有很明显的区别,静态共享库的做法就是将程序的各个模块统一交给操作系统来管理,操作系统在某个特定的地址划分出来一些地址块,为那些已知的模块预留足够的空间。静态共享库的目标地址导致了很多问题,地址冲突问题、静态共享库的升级问题。因为升级之后必须保持共享库中的全局函数和变量地址的不变,如果应用程序在链接时已经绑定了这些地址,一旦更改,就必须重新链接应用程序,否则就会引起应用程序的崩溃。即使升级静态共享库后保持原来的函数和变量地址不变,只是增加了一些全局函数或变量,也会受到限制,因为静态共享库被分配到虚拟地址空间有限,不能增长太多,否则可能会超出被分配的空间。这些限制导致了静态共享库的方式在现在系统中已经很少见了。
为解决模块装载地址固定的问题,设想是否可以让共享对象在任意地址加载——即共享对象在编译时,不能假设自己在进程虚拟地址空间中的位置。
3.2 装载时重定位
为了使得共享对象能够在任意地址装载,首先想到的方法就是静态链接的重定位。基本思路就是,在链接时,对所有的绝对地址的引用不做重定位,而将重定位推迟到装载时完成。一旦模块装载地址确定,即目标地址确定,那么系统就对程序中所有的绝对地址引用进行重定位。假设foobar
函数相对于代码段的起始地址为0x100
,当模块被装载到地址0x10000000
时,我们假设代码段位于模块的最开始则代码段的地址为0x10000000
,那么可以确定foobar
函数地址为0x10000100
。这时候,系统遍历模块中的重定位表,把所有对foobar
函数地址的引用都重定位至0x10000100
。
类似的方法其实早就存在,在没有虚拟存储概念的时候,程序是直接被加载进物理内存的。当同时多个程序运行的时候,操作系统根据当时内存的空闲情况,动态分配一块大小合适的物理内存给程序,所以程序被装载的地址是不确定的。系统在装载程序的时候需要对程序的指令和数据中对绝对地址的引用进行重定位。比如一个程序在编译时假设被装载的目标地址为0x1000
,但在装载时操作系统发现0x1000
已经被别的程序使用了,从0x4000
开始有一块足够大的空间可以容纳该程序,那么该程序就可以被装载至0x4000
,程序指令或数据中的所有引用只要加上0x3000
的偏移量就ok。
前面我们介绍静态链接时候的重定位叫做链接时重定位,现在这种情况叫做装载时重定位,在windows中叫做基址重置。但是装载时重定位并不合适用来解决共享对象中所存在的问题。因为动态链接库被装载映射到虚拟空间后,指令部分是在多个进程之间共享,指令被重定位后对于每一个进程来说是不同的。
3.3 地址无关代码
指令地址无关,即无论模块被加载到哪个位置,这条指令都是有效的。想想编译动态库时为什么要加-fPIC
参数呢?有什效果?
装载时重定位是解决动态模块中有绝对地址引用的方法之一,但是他有一个很大的缺点,就是指令部分无法再多个进程之间共享(部分指令会引用绝对地址,绝对地址受装载时重定位的影响,不同进程对同一共享对象装载的目标地址可能不同,所以共享对象中引用绝对地址的指令因不同进程可能不同),就失去了节省内存的优势。所以需要有一种更好的方法解决共享对象指令中对绝对地址的重定位问题。
我们希望程序模块中共享的指令部分在装载时不需要因为装载地址的改变而改变,所以实现的基本想法就是把指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每一个进程中拥有一个副本。这种方案称之为地址无关代码PIC(position independent code)技术。
我们可以把共享对象中的地址引用分类,按照是否跨模块分为模块内引用和模块外引用;按照不同引用方式又可以分为指令引用和数据访问。这样就可以得到以下4种情况:
- 第一种是模块内部的函数调用、跳转
- 第二种是模块内部的数据访问,比如模块中定义的全局变量、静态变量
- 第三种是模块外部的函数调用、跳转
- 第四种是模块外部的数据访问,比如其他模块中定义的全局变量
类型一、模块内部的函数调用、跳转
对于模块内部调用,因为被调用的函数和调用者在同一个模块,他们之间的相对位置是固定的。模块内部的跳转和函数调用都可以是相对地址调用,或者基于寄存器的相对调用,这些指令是不需要重定位的。foo
对bar
的调用实际上是一条相对地址调用指令。
类型一看似容易解决,但是存在共享对象全局符号介入(Global System Interposition)问题,后续会详细介绍。
类型二、模块内部的数据访问
指令中不能直接包含数据的绝对地址(绝对地址根据模块被装载的位置而变化),所以只能使用相对寻址。一般来说,一个模块前面是若干个页的代码,后面紧跟着若干个页的数据,这些页之间相对位置固定,即任意一条指令与它需要访问的模块内部数据之间的相对位置是固定的,那么相对于当前指令加上偏移量就可以访问模块内的数据了。
现代的体系结构中,数据的相对寻址往往没有相对于当前指令地址(PC)的寻址方式,所以ELF使用了一个方法得到当前PC值,然后加上一个偏移量就可以达到访问相应变量的目的。
call
指令执行以后,下一条指令(add
)的地址将会被压入栈顶,esp
寄存器永远指向栈顶,那么当函数__i686.get_pc_thunk.cx
执行mov (%exp), %ecx
时,add
指令的地址就被放在%ecx
中。如果模块被装载到0x10000000
地址,根据上图汇编代码可知,变量a
实际地址是0x10000000 + 0x454 + 0x118C + 0x28 = 0x10001608
。
类型三、模块间的数据访问
要做到代码地址无关,基本思想就是把跟地址相关的部分放到数据段里面,而其他模块的全局变量的地址和模块装载地址有关。ELF做法为,在数据段建立一个指向这些变量的指针数组,也被称为全局偏移表(Global Offset Table, GOT),当代码需要引用该全局变量时,可通过GOT中相对应的项间接引用。如下图所示,当指令要访问数据b,程序先找到GOT,然后根据GOT中变量所对应的项找到对应变量目标地址;链接器在装载模块的时候会查找每个变量的地址,然后填充GOT,以确保每个指针所指向的地址正确。由于GOT本身放在数据段,所以它可以在模块装载时被修改,并且每个进程可以有独立的副本,互不影响。
GOT是如何做到地址无关的?ELF中,通过得到PC值然后加上一个偏移量得到GOT的位置,然后根据变量地址在GOT中的偏移就可以得到变量的地址。GOT中每一个地址对应于哪个变量是由编译器决定的。对照类型二中的图,464处寄存器%ecx
中的值为0x10000000 + 0x454 + 0x118C
,然后将%ecx-8
后放入寄存器%eax
中(GOT中每项的偏移为4字节,-8相当于是第三项),此时%eax
中存放的地址为0x10000000+0x454+0x118C+(-8)=0x100015D8
,然后使用寄存器%eax
间接寻址给变量b赋值2。
可以在通过工具验证一下。使用命令objdump -h pic.so
,查看GOT在模块中的偏移值为0x15d0
。
使用命令objdump -R pic.so
,可以看到变量b需要重定位,它位于模块中的偏移值为0x15d8
,也就是GOT中偏移8(0x15d8-0x15d0=8
)。类型二可知模块装载地址为0x10000000
,所以变量b的地址为0x10000000+0x15d8=0x100015d8
,同上文算出的想对应。
类型四、模块外部的函数调用、跳转
可采用类型三的方法进行,但有所不同的是,GOT中相应项保存的是目标函数的地址,当模块需要调用目标函数时,可以通过GOT中的项进行间接跳转,调用ext()
函数的方法与上面调用数据b的方法类似,先得到当前指令地址PC,然后加上一个偏移得到函数地址在GOT的偏移,然后进行间接调用:
3.4 共享模块的全局变量问题
模块内部的静态全局变量可以直接使用相对寻址进行访问,而模块内部的非静态全局变量却不能,因为编译器无法确定对全局变量的引用是不同模块间的还是模块内部的。所以无论是模块内部还是外部的全局变量,都只能以GOT的方式来访问。可执行文件在生成代码的过程中,在链接时就会确定地址,这时链接器将在.bss
段创建一个该全局变量的副本,在之后的动态链接过程中,其他模块的GOT都会指向该副本,不会导致地址上的冲突。
3.5 数据段地址无关性
下面代码中,p的地址是一个绝对地址,指向a,而变量a的地址随着共享对象的装载地址改变而改变。
static int a;
static int* p = &a;
对于数据段来说,他在每个进程中的都有一个独立的副本,所以并不担心被进程改变。我们可以选择装载时重定位的方法解决数据段中绝对地址引用问题,对于共享对象来说,如果数据段中有绝对地址引用,那么编译器和链接器会产生一个重定位表,这个重定位表里面包含一个R_386_RELATIVE
类型的重定位入口,用于解决上述问题。当动态链接器装载共享对象时,如果发现该共享对象有这样的重定位入口,那么动态链接器就会对该共享对象进行重定位。
实际上,也可以让代码段使用这种装载时重定位的方法,而不是使用地址无关代码,但这样就不能在进程间共享代码段。
- 装载时重定位:优点——运行速度快;缺点——对象不能被共享,浪费内存;
- 地址无关代码:优点——可进行对象共享,节省内存;缺点——多出计算当前地址以及间接寻址的工作,运行速度慢;
四、延迟绑定
动态链接库比静态链接库要灵活,但是也浪费一些时间(其他环境相同下,静态链接相比动态链接要快1%~5%)。影响动态链接性能的主要因素为:1、复杂的GOT定位;2、动态链接的链接工作在运行时完成(即开始开始执行时,动态链接器都会进行一次链接工作,寻找并装载所需要的共享对象,然后进行符号查找地址重定位等工作)。延迟绑定就是对这一块的优化。
在一个程序中,可能有很多的函数,例如错误处理、或者一些用户极少用到的功能模块,在程序运行完都并不会使用到,如果在程序运行时都要将这些函数进行链接,那么就是对性能的浪费,ELF延迟绑定(Lazy Binding)的想法就出现了,其做法是当函数第一次使用时才进行绑定(符号查找、重定位等操作),若没有用到则不进行绑定。
延迟绑定实现
ELF使用PLT(procedure linkage table)的方法来实现,这个方法使用了一些很精巧的指令序列来完成。当调用某个外部函数时,PLT为了实现延迟绑定,调用函数不直接通过GOT跳转,而是通过一个PLT项的结构来进行跳转。每个外部函数在PLT中都有一个相应的项,比如bar()
函数在PLT中的项的地址我们称之为bar@plt
,以下是bar@plt
的实现:
bar@plt:
jmp *(bar@GOT)
push n
push moduleID
jump _dl_runtime_resolve
- 第1行指令,
jmp *(bar@GOT)
:通过GOT间接跳转的指令。其中,bar@GOT
表示GOT中保存bar()
这个函数相应的项,如果链接器在初始化阶段已经初始化该项,并将bar()
的地址填入该项,那么这个跳转指令直接跳转至bar()
,实现函数的正确调用;但是为了实现延迟绑定,链接器在初始阶段没有将bar()
的地址填入到该项,而是将第2条指令push n
的地址填入bar@GOT
中;所以第1条指令的效果是跳转到第2条指令。 - 第2行指令,
push n
:将一个数字n
压入堆栈中,这个数字是bar
这个符号引用在重定位表.rel.plt
中的下标。 - 第3条指令,
push moduleID
:将模块的ID压入堆栈。 - 第4条指令,
jump _dl_runtime_resolve
:跳转到指令_dl_runtime_resolve
。其实这个指令就是相当于lookup(module, function)
函数的调用:先将所需要决议符号的下标压入堆栈,再将模块ID压入堆栈,然后调用动态链接器的_dl_runtime_resolve()
函数来完成符号解析和重定位工作;_dl_runtime_resolve
在进行一系列的工作以后将bar()
的真正地址填入bar@GOT
中。
一旦bar()
函数解析完成,当再次调用bar@plt
时,第一条jmp
指令就跳转到真正的bar()
函数,而bar()
函数返回的时候会根据堆栈里面保存的EIP直接返回到调用者,而不会继续执行bar@plt
中第二条指令开始的那段代码(那段代码只会在符号未被解析时执行一次)。
ELF将GOT拆分成了两个表 .got
和.got.plt
。其中.got
用来保存全局变量引用的地址,.got.plt
用来保存函数引用的地址。所有对于外部函数的引用全部被分离出来存放到了.got.plt
中。另外.got.plt
还有前三个特殊的项:
- 第1项,
.dynamic
段的地址:它描述了本模块动态链接相关信息; - 第2项,本模块的ID。
- 第3项,
_dl_runtime_resolve()
的地址。
动态链接在装载共享模块的时候会将第二项和第三项初始化,.got.plt
的其余项分别对应每个外部函数的引用。PLT的结构也与上述举例中bar
项的结构稍有不同,为了简化代码,ELF把上面例子中最后两条指令放到PLT的第一项,代码如下:
PLT0:
push *(GOT+4)
push *(GOT+8)
...
bar@plt:
jmp *(bar@GOT) ; 此处GOT是.got.plt中bar()函数的地址
push n
jump PLT0
PLT的基本结构如下图所示,另外PLT在ELF文件中以单独的段存放,段名通常叫.plt
。
五、动态链接相关结构
在动态链接情况下,可执行文件的装载与静态链接情况基本一样。首先,操作系统读取可执行文件头部,检查文件的合法性;然后,从头部中的“Program Header”中读取每个Segment的虚拟地址、文件地址和属性,并将它们映射到进程虚拟空间的相应位置——这些都与静态链接的装载相同。而在此之后,静态链接的情况下,就会直接将控制权转交给可执行文件的入口地址,然后程序开始执行。
但是在动态链接情况下,由于可执行文件依赖于很多共享对象,此时的可执行文件中对于很多外部符号的引用还处于无效地址的状态,即没有和相应的共享对象中的实际位置链接起来。所以在映射完可执行文件之后,操作系统会先启动一个动态链接器(Dynamic Linker)。
动态链接器ld.so
是一个共享对象,操作系统以映射的方式将它加载到进程的地址空间;在加载完动态链接器之后,操作系统就将控制权交给动态链接器的入口地址(与可执行文件一样,共享对象也有入口地址)。当动态链接工作完成之后,动态链接器会将控制权转交到可执行文件的入口地址,程序开始正式执行。
5.1 .interp段
动态链接器的位置不是由系统配置指定的,也不是由环境变量决定的,而是由ELF可执行文件的.interp
段(interpreter解释器)指定的,且不同的系统,链接器的位置不同。
5.2 .dynamic段
.dynamic
段保存了动态链接所需要的基本信息,如:依赖于哪些共享对象、动态链接符号表的位置、动态链接重定位表的位置、共享对象初始化代码的地址等。它是一个结构体数组,定义在elf.h中,具体如代码所示。
typedef struct {
Elf32_Sword d_tag;
union {
Elf32_Word_d_val;
Elf32_Addr_d_ptr;
} d_un;
} Elf32_Dyn;
.dynamic
段中保存的信息类似于ELF文件头,所以.dynamic
段可以看成是动态链接下ELF文件的文件头。使用readelf工具指令$ readelf -d Lib.so
可查看.dynamic
段的内容。ldd
命令也可以查看一个程序依赖于哪些共享库。
5.3 动态符号表
为了表示动态链接这些模块之间的符号导入导出关系,ELF专门有一个动态符号表的段来保存这些信息,段名是.dynsym
。.dynsym
只保存了与动态链接相关的符号,对于模块内部的符号则不保存,比如模块的私有变量等。
动态符号表也需要一些辅助的表,比如用于保存符号名的字符串表.dynstr
。在动态链接下,需要在程序运行时查找符号,为了加快符号的查找过程,需要一个辅助的符号哈希表.hash
。
我们可以使用readelf工具指令readelf -sD Lib.so
查看ELF文件的动态符号表及其哈希表。
Symbol table for image:
Num Buc: Value Size Type Bind Vis Ndx Name
490 0: 0000000000097f48 516 FUNC GLOBAL DEFAULT 12 __mbrtowc
978 0: 00000000000e1160 192 FUNC GLOBAL DEFAULT 12 __setmntent
1618 0: 0000000000043bc4 44 FUNC WEAK DEFAULT 12 isnanl
Symbol table of `.gnu.hash' for image:
Num Buc: Value Size Type Bind Vis Ndx Name
27 1: 0000000000078790 452 FUNC GLOBAL DEFAULT 12 putwchar
28 1: 00000000000f8488 20 FUNC GLOBAL DEFAULT 12 __gethostname_chk
29 3: 0000000000113474 220 FUNC GLOBAL DEFAULT 12 setrpcent
5.4 动态链接重定位表
由于导入符号(可执行文件或共享对象依赖于其他共享对象)的存在,它的代码或数据中就会有对于导入符号的引用,而在编译时这些导入符号的地址是未知的。在静态链接中,这些未知的地址引用在最终链接时被修正;但是在动态链接中,导入符号的地址在运行时才确定,所以在运行时需要对这些导入符号的引用进行修正,即需要对共享对象进行重定位。
如果一个共享对象不是以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
。可以使用readelf工具指令readelf -r Lib.so
查看动态链接文件的重定位表。
5.5 动态链接时进程堆栈初始化信息
在操作系统将控制权交给动态链接器后,它开始进行链接工作,那么它至少需要知道关于可执行文件和本进程的一些信息,有如可执行文件有几个段(Segment)、每个段的属性、程序的入口地址(因为动态链接器后续需要将控制权交给可执行文件)等,堆栈中保存了动态链接器所需要的一些辅助信息数组(Auxiliary Vector),它的格式是一个结构数组,被定义在elf.h
中。
六、动态链接的步骤和实现
动态链接3大步骤:启动动态链接器本身 → 装载所有需要的共享对象 → 重定位和初始化。
6.1 动态链接器自举
普通共享对象文件的重定位工作由动态链接器来完成,而动态链接本身是一个共享对象,那么动态链接器又是由谁来进行重定位呢?这是一个“鸡生蛋,蛋生鸡”的问题,为了解决这种循环似的依赖问题,动态链接器必须有一定的特殊性,其特殊性包含以下两点:
- 动态链接器本身不可以依赖于其他任何共享对象;
- 动态链接器本身所需要的全局和静态变量的重定位工作由它本身完成。
以上两点条件的解决方式如下所示,这种含限制条件的启动代码也被称为自举(Bootstrap)。
- 动态链接器的编写,不使能用任何系统库、运行库;
- 动态链接器的编写,不能使用全局变量和静态变量。
动态链接器的入口地址就是自举代码的入口,当OS将进程控制权交给动态链接器时,动态链接器的自举代码即开始执行。首先,自举代码会找到它自己的GOT,GOT的第一个入口保存的就是.dynamic
段的偏移地址,由此找到动态链接器本身的.dynamic
段。通过.dynamic
,自举代码可获得动态链接本身的重定位表和符号表等,从而得到动态链接器本身的重定位入口,先将它们全部重定位。从这一步开始,动态链接器才可以使用自己的全局变量和静态变量。
在动态链接器的自举代码中,除不可使用全局变量和静态变量外,还不能调用函数,即动态链接器本身的函数也不能调用。原因就在于,PIC模式编译的共享对象,对于模块内部的函数调用也是采用和模块外部函数调用一样的GOT/PLT方式。所以在GOT/PLT没有被重定位之前,自举代码不可使用全局变量,也不可调用函数。
6.2 装载共享对象
完成基本自举后,动态链接器将可执行文件和链接器本身的符号表都合并到一个符号表中,称之为全局符号表(Global Symbol Table),将共享对象的依赖关系看做是一个图数据结构,那么链接器一般使用深度优先或广度优先或其他顺序来遍历整个图,通常使用广度优先算法。当一个新的共享对象被装载进来,它的符号表会被合并到全局符号表中,所以当所有的共享对象都被装载进来时,全局符号表里面,将包含进程中所有的动态链接所需的符号。
另外,还会出现符号命名重复的问题,对于一个共享对象里面的全局符号被另一个共享对象的同名全局符号覆盖的现象又被称为共享对象全局符号介入(Global Symbol Interpose),在Linux下它遵从以下规则——当一个符号需要被加入全局符号表时,若相同的符号名已经存在,则后被加入的符号忽略。
6.3 重定位和初始化
在这个阶段下,链接器开始重新遍历可执行文件和每个共享对象的重定位表,将它们的GOT/PLT的每个需要重定位的位置进行修正。
重定位完成后,若共享对象有.init
段,那么动态链接器将会执行.init
段中的代码,以实现共享对象特有的初始化过程;而若进程的可执行文件也有.init
段,那么动态链接不会执行它。
6.4 Linux动态链接器实现
动态链接器是共享对象,同时也是一个可执行文件。以下几个问题值得注意:
- 动态链接器本身是静态链接的,它不能依赖于其他共享对象;
- 动态链接器往往使用PIC的模式,方便代码段的共享;
- 动态链接器ld.so的装载地址与一般的共享对象没有区别,一般是
0x00000000
,是一个无效地址,内核在装载时会为它选择一个合适的装载地址。
七、显示运行时链接
以上介绍的是动态链接情况下动态链接器的自动装载方法,还有一种更加灵活的手动装载方式——运行时加载,就是让程序自己在运行时控制加载指定的模块,并可在不需要该模块时将其卸载。具体就是在代码中显式调用相关API来加载共享对象——动态装载库(Dynamic Loading Library)。
程序可以通过以下4个由动态链接器提供的API对动态库进行操作:打开动态库(dlopen)、查找符号(dlsym)、错误处理(dlerror)和关闭动态库(dlclose),它们都位于dlfcn.h
头文件中。