分类: LINUX
2009-06-28 12:41:56
【Abstract】In this paper, we discuss the advantage of using dynamic linking. We also demonstrate(示范证明论证) in detail how compiler, linker and loader implement(贯彻实现执行) this feature by using several kinds of relocations under nowadays(现今现在) Linux system, especially on x86 architectures.
【Keywords】dynamic link library; DLL; Linux; relocation
Copyright (C) 1999, 2000 jargon
THIS DOCUMENT IS PRESENT TO < ACADEMIC PURPOSE ONLY. NO OTHER USAGE AND/OR DISTRIBUTION OUTSIDE OF
Linux下的动态连接库及其实现机制
摘 要:本文介绍了动态连接库的优点,详细阐述了x86体系结构上Linux系统的编译器、连接器、加载器如何使用多种重定位方式来实现该功能。
关键词:动态连接库;Linux;重定位
Linux与Windows的动态连接库概念相似,但是实现机制不同。它引入了GOT表和PLT表的概念,综合使用了多种重定位项,实现了"浮动代码",达到了更好的共享性能。本文对这些技术逐一进行了详细讨论。
本文着重讨论x86体系结构,这是因为
(1) 运行Linux的各种体系结构中,以x86最为普及;
(2) 该体系结构上的Windows操作系统广为人知,由此可以比较容易地理解Linux的类似概念;
下表列出了Windows与Linux的近义词,文中将不加以区分:
Windows Linux
动态连接库(DLL) shared object file (文件名结尾常是 .so)
目标文件(.obj) relocatable file (文件名结尾常是 .o)
可执行文件(.exe) executable file(文件名无特定标志后缀)
连接器(link.exe) Linker Editor (ld)
加载器(exec/loader) Dynamic Linker (/lib/ld-linux.so)
段(segment) 节(section)
一些关键字在本文中有特定含义,需要澄清:
编译单元:一个C语言源文件,经过编译后将生成一个目标文件(例如:.obj(windows) .o(linux))
运行模块:一个动态连接库(*.so)或者一个可执行文件(.exe)。简称为模块
自动变量、函数:C语言auto关键字修饰的对象 (自动变量就是指在函数内部定义使用的变量,又叫局部变量。)
静态变量、函数:C语言static关键字修饰的对象
全局变量、函数:C语言extern关键字修饰的对象
(注意:本文主要是讲动态连接库的,主要讲的是.so文件的重定位,而不是讲.exe的重定位。重定位包括:连接时进行重定位,加载内存时进行重定位。连接时重定位 2者是差不多的,但主要是加载时有区别:因为.so加载进内存的地址不固定,所以需要动态加载器重定位解析符号在内存中的实际地址。而exe程序,加载进内存的位置是固定的,所以很多符号在连接时就已经确定了。所以有些重定位条目(比如:R_386_RELATIVE)在exe文件里是没有的。)
1 动态连接库的优点
程序编制一般需经编辑、编译、连接、加载和运行几个步骤。由于一些公用代码需要反复使用,就把它们预先编译成目标文件(*.o)并保存在"库"中。当它与用户程序的目标文件(*.o)连接时,连接器得从"库"中选取用户程序需要的(公用)代码,然后复制到生成的可执行文件中。这种库称为静态库(*.a),其特点是可执行文件中包含了库代码的一份完整拷贝。显然,当静态库被多个程序使用时,磁盘上、内存中都有多份冗余拷贝。
而使用动态连接库(*.so)就克服了这个缺陷。当它与用户程序的目标文件(*.o)连接时,连接器只是作上标记,说明程序需要(在某处用到)该动态连接库(*.so),而不真的把库代码复制到生成的可执行文件中;仅当可执行文件运行时,加载器根据这个标记,检查该动态连接库(*.so)是否已经被其它可执行文件加载进内存。如果已存在于内存中,就不用再从磁盘上加载,只要共享内存中已有所需的(公用)代码即可。这样磁盘、内存中始终只有一份代码,较静态库为优。
2 Linux动态连接库的重要特点:浮动代码
在Windows中,连接生成动态连接库时要指定一个首地址(即规定了固定的加载地址,将来令加载器装入不同库的时候上下为难)。应用程序运行时,加载器将尽可能把动态连接库装入到该地址;如果地址已被占用,该动态连接库只能被加载到其它地址空间内,这时就要对动态连接库中的代码和数据(中的地址部分)进行修改,或叫做重定位。如此一来,同一个动态连接库的多个实例在内存中经过重定位后,(每个内存实例中代码指令中和数据中的地址)彼此将不尽相同,自然不再能共享了(linux动态连接库只使用同1份内存拷贝,windows动态库只能动态连接但不能共享使用)。为了避免这个缺陷,Windows自带的库都指定了互不重叠的地址,尽管如此,其它软件厂商的产品仍然不可避免的使用重叠地址,由此部分丧失了使用动态连接库的好处。
在Linux中,为了达到更好的共享性能,使用了与Windows不一样的策略:浮动代码(Position Independent Code,简称PIC)。具体说就是,1.使用的转移指令都是相对于当前程序计数器(IP)的偏移量;2.代码中引用变量、函数的地址都是相对于某个基地址(比如统一以GOT起始地址为基准)的偏移量。总之,绝不引用一个绝对地址。这样,动态连接库无论被加载到什么地址空间,不用修改就可以正常工作。既然只有一份代码,就容易实现共享了。
值得指出,此处所指的共享,是指为了节省存储器,多个进程(共同)使用动态连接库代码段、以及只读数据段在内存中的唯一映像;另一种常用的共享定义,是指多个进程对同一段(可能是动态分配的)存储区进行读写,实现进程间通信(IPC)。后一种共享定义与本文无关。
3 Linux动态连接库的实现机制:重定位
3.1 重定位概述
浮动代码通过重定位操作得以实现。而重定位可以按多种标准进行分类:
-- 按发生的地点,可分成对代码段(.text)重定位和对数据段(.data)重定位。
-- 按发生的时间,可分成连接时重定位和加载时重定位(加载时重定位也称为动态重定位)。但这两步并不总是必不可少的。例如,要实现浮动代码就不能对代码段进行动态重定位(# 浮动代码要求是可以在内存的任何位置运行,放进内存后就不能再做任何地址修改了),这时采取的办法是,把需要动态重定位的项搬到数据段中去,然后在代码段中引用这些项。
(# elf规范说:代码段使用的都是位置无关的地址,需要动态重定位的项是那些运行时还没确定出绝对地址的符号,这些东西以及相关信息放在.plt section .got section和.dynamic section等里面。.plt 中都是位置无关代码,位于代码段中,.got和.dynamic包含绝对地址和数值,位于数据段中。.dynamic辅助动态连接器解析出符号的真实地址,.got保存此真实地址。绝对地址不能放在代码段中,否则会影响代码段的位置无关性。)
-- 按重定位项引用的对象,可分成数据引用和函数引用。如果引用的是静态数据或静态函数(静态符号位于同一运行模块,符号偏移量在连接时就可计算出来),连接器会优化生成的代码,去掉动态重定位项。
(静态全局变量则限制了其作用域, 即只在定义该变量的源文件内有效, 在同一源程序的其它源文件中不能使用它。由于静态全局变量的作用域局限于一个源文件内,只能为该源文件内的函数公用, 因此可以避免在其它源文件中引起错误。)
-- 从字面上讲, x86体系结构上的Linux使用了多种重定位方式,重定位名字前缀为"R_386_",后面分别接:32、GOT32、PLT32、COPY、GLOB_DAT、JMP_SLOT、RELATIVE、GOTOFF、GOTPC。每种重定位方式都有特定的含义。
以上几种分类中最重要的是按地点分类。而下文也将以它为主线,逐一介绍各种重定位项。首先,引入两个关键概念:GOT表和PLT表。
3.2 GOT表
(# 每个运行模块有一个GOT表,一个PLT表,由连接器生成;*.so的.got .plt .dynsym .dynstr .dynamic section是由连接器/bin/ld产生的,*.o目标文件还没有这些section。可以用readelf –a 查看比较。)
GOT(Global Offset Table)表中每一项都是本运行模块要引用的一个全局变量或函数的地址(刚开始时,got表中只是一些变量、函数的记号地址,并没有绝对地址,在运行时,动态加载器重定位出绝对地址再填写进got表)。可以用GOT表来间接引用全局变量、函数,也可以把GOT表的首地址作为一个基准,用相对于该基准的偏移量来引用静态变量、静态函数(静态符号位于同一运行模块,符号偏移量在连接时就可计算出来)。
由于加载器不会把运行模块加载到固定地址,在不同进程的地址空间中,各运行模块的绝对地址、相对位置都不同。这种不同反映到GOT表上,就是每个进程映像里包含的每个运行模块(*.so)都有独立的GOT表,所以进程间不能共享GOT表。
在x86体系结构上,本运行模块的GOT表首地址始终保存在%ebx寄存器中(因为是和具体的寄存器相关,所以称为处理器相关,因为每种cpu体系结构的寄存器都不一样)。编译器在每个函数入口处都生成一小段代码(参见下面 3.4 a 装载GOT表首地址),用来(重新)初始化%ebx寄存器。这一步是必要的,否则,如果调用者A 调用的函数B.f是来自于另一个运行模块B,%ebx中却仍然是调用者模块A 的GOT表首地址;不重新初始化%ebx,就用来引用全局变量和函数B.f,当然出错。(# 正确的应该是 调用者模块A调用B.f时,B.f函数入口地址处的一小段代码重新初始化%ebx寄存器,令%ebx等于运行模块B的GOT表首地址)
(#elf规范中说:GOT表是用来确定全局符号(全局变量或函数)的绝对地址的;PLT表是用来确定函数的绝对地址的。2者作用相近。但是GOT表只是一个简单数组,只存放着各种绝对地址或地址信息。估计是一个变量的绝对地址很好确定,用GOT表就够了;但是单靠GOT还不足以确定一个函数的绝对地址,函数包含在不同的object文件里面,需要再用一个PLT表来辅助GOT表,由PLT表调用动态连接器(/lib/ld-linux.so)来确定被调函数的绝对地址,动态连接器根据动态连接数组中记载的*.so依赖关系,逐个搜索每个.so的符号表,找到被调函数包含在哪个.so文件里,如果该.so文件不在内存,就到硬盘上的库路径(比如:/lib:/usr/lib)中找到这个.so文件,动态连接器把这个.so文件加载到内存中,并计算出被调函数的内存绝对地址,最后把被调函数绝对地址填写到GOT表里面。)
3.3 PLT表
(# 每个运行模块(*.so)有一个GOT表,一个PLT表,由连接器生成;*.so的.got .plt .dynsym .dynstr .dynamic section是由连接器/bin/ld产生的,*.o目标文件还没有这些section。可以用readelf –a 查看比较。)
PLT(Procedure Linkage Table)表每一项都是一小段代码,对应于本运行模块要引用的一个全局函数。以对函数fun的调用为例,fun在PLT中对应的代码片段如下:
.PLTfun: jmp *fun@GOT(%ebx)
pushl $offset
jmp .PLT0@PC
其中指令jmp *fun@GOT(%ebx)引用的GOT表项被加载器(/lib/ld-linux.so 而不是 连接器 /bin/ld)初始化为下一条指令(pushl $offset)的地址,那么该jmp指令相当于nop空指令。
(GOT表 在内存中会被加载器不断修改的。查看GOT在文件中的初始内容:先用 readelf –S ./test 查出 .got section的 offset值,比如 0x518,然后 hexdump -s 0x518 -n 80 ./test)
用户程序中对fun的直接调用经编译、连接后生成一条call fun@PLT指令,这是一条相对跳转指令(不使用绝对地址,满足浮动代码的要求! ),跳到PLT中对应的代码片段 .PLTfun: 。如果这是本运行模块中第一次调用该函数,此处的jmp *fun@GOT(%ebx)相当于一个nop空指令,继续往下执行pushl $offset,接着执行jmp .PLT0@PC 跳到.PLT0,.PLT0项专门保留给编译器生成的额外代码(每个运行模块有一个GOT表,一个PLT表,由连接器生成,.PLT0项应该是连接器生成的),会把程序流程引入到加载器中去。加载器计算fun的实际入口地址,填入fun@GOT表项。图示如下:
user program
--------------
call fun@PLT
|
V
DLL PLT table loader #加载器计算fun的实际地址,修改
-------------- -------------- -----------------------
fun: <-- jmp *fun@GOT --> change GOT entry from
#第2次直接跳到fun | $loader to $fun,
的绝对地址执行 V then jump to there
GOT table
--------------
fun@GOT:$loader
#第1次调用fun 跳到.PLT0项代码片段,.PLT0项包含
的程序片段,会把程序流程引入到加载器中去
第一次调用以后,函数符号对应的GOT表项已指向函数的正确入口地址。以后再有对该函数的调用,跳到PLT表后,不会再进入加载器,直接跳进函数正确入口了。从性能上分析,只有第1次调用函数才需要加载器作一些额外处理,这是完全可以容忍的。还可以看出,加载时不用对相对跳转的代码(call fun@PLT)进行修补,所以整个代码段都能在进程(之)间共享。
(#数据段是地址有关的,进程间不能共享。.got也位于数据段。)
(#elf规范说:代码段使用的都是位置无关的地址,需要动态重定位的项是那些运行时还没确定出绝对地址的符号,这些东西以及相关信息放在.plt section .got section和.dynamic section等里面。.plt 中都是位置无关代码,位于代码段中,.got和.dynamic包含绝对地址和数值,位于数据段中。.dynamic辅助动态连接器解析出符号的真实地址,.got保存此真实地址。绝对地址不能放在代码段中,否则会影响代码段的位置无关性。)
熟悉Windows的程序员很容易注意到,GOT表、PLT表与Windows中的引入表(Import)有类似之处。其它对应关系还有: Linux的version script 对应 Windows的.DEF文件;Linux的dynamic symbols section 对应 Windows的输出表(Export)。不再举更多例子了。
3.4 代码段重定位
需要说明,由于浮动代码的要求,代码段内不应该存在重定位项(即代码段内不能有 运行时还需要修改的地址)。此处只是借用了"在代码段中"这个短语,实际的重定位项还是位于数据段的GOT表内。尽管如此,它与3.5节"数据段中的重定位"的区别是很明显的。
(位置无关的代码段的重定位发生在对.o文件的连接过程中,加载以后不能再修改了)
a) 装载GOT表首地址
(# 在x86体系结构上,本运行模块的GOT表首地址始终保存在%ebx寄存器中(因为是和具体的寄存器相关,所以称为处理器相关,因为每种cpu体系结构的寄存器都不一样)。编译器在(*.o文件中的)每个函数入口处都生成一小段代码,用来(重新)初始化%ebx寄存器。)
(# 也可以把GOT表的首地址作为一个基准,用相对于该基准的偏移量来引用静态变量、静态函数。)
使用GOT表当然事先要知道它的首地址,然而在内存中,GOT首地址会随运行模块(*.so)被加载的首地址不同而不同(GOT在.so文件内的偏移是固定的,但是.so加载进内存的起始地址不固定,则GOT在内存里的地址也不固定)。Linux使用了一个技巧在运行时求出正确的GOT表首地址。连接器在*.o里的每个函数的入口处产生代码片断如下,紧接其后列出的是对应的目标文件(*.o)与动态连接库(*.so)中的重定位项类型:
call L1
L1: popl %ebx
addl $GOT+[.-.L1], %ebx
.o: R_386_GOTPC
.so: NULL
如前所述,该代码片断存在于每个函数的入口处。程序第一句把当前程序计数器(IP)值(L1)推进堆栈(执行call L1指令时,EIP指向下1指令地址 即L1处),第二句又把它(L1)从堆栈中弹出来,结果相当于movl %eip, %ebx (合法的x86指令集中不允许%eip作为操作数)。然后第三句把%ebx加上一个GOT表与IP值的差,这个差值是与动态连接库(*.so)加载首地址无关的常量,在连接时即可计算出来。至此%ebx等于GOT表首地址。
整个过程用类C语言描述如下:
%ebx = %eip;
%ebx += ($GOT - %eip)
目的是令:%ebx等于GOT表首地址。实现在执行任一个模块里的任意一个函数时,把函数所属模块的GOT表地址加载进%ebx寄存器。
上述过程是编译、连接 合作的结果。编译器生成目标文件(*.o)时,因为此时还不存在GOT表,所以暂时不能计算GOT表与当前IP间的差值,仅在上述第三句(addl $GOT+[.-.L1], %ebx)处设置一个R_386_GOTPC重定位标记而已。然后进行连接,连接器注意到(*.o文件里面的)R_386_GOTPC类型的重定位项,于是计算GOT表与此处IP的差值,作为addl指令( addl $GOT+[.-.L1], %ebx )的立即寻址方式操作数。以后再也不需要重定位了。
(# 按发生的地点,属于对代码段(.text)重定位;按发生的时间,属于ld连接时重定位。)
(# 当程序执行到某一个函数入口处时,此时的%eip值就是这个函数的入口地址。*.so文件里每个函数的入口地址相对于 GOT表首地址 的偏移量是固定的,这些偏移量无论在文件中还是加载内存里都是不会变的。连接时候可以事先分别计算出来。)
(# 编译器产生下面的代码片断放在*.o文件每个函数的入口处:
call L1
L1: popl %ebx
addl $GOT+[.-.L1], %ebx
*.o中的每个函数入口代码片段 在重定位表.rel.text中都对应有R_386_GOTPC重定位项,包含 计算偏移量的方法和应该对代码段中哪个地址进行修改。GOT是连接器ld建立的,所以ld知道GOT的地址,ld根据每个R_386_GOTPC重定位项,逐个计算出$GOT+[.-.L1]的差值,把每个函数入口处的add 指令修改完成。
例子:
[elf@redhat elf]# vi bbb.c
int a111 = 15;
char *g111;
char *g222 = "456";
static char *p111;
static char *p222 = "aaaaaaaaaa";
static char *p333;
static char *p444 = "bbbb";
int foo2()
{
p333 = "cc";
char *p555 = "dd";
static char *p666= "123";
p111 = (char *)malloc(10);
strcpy(p111, "13579");
}
int foo3()
{
char *p777 = "ee";
}
[elf@redhat elf]# gcc -fPIC -c bbb.c
[elf@redhat elf]# gcc -shared -o bbb.so ./bbb.o
[elf@redhat elf]# objdump –dx bbb.o
编译器处理以后bbb.o文件中foo3函数入口的这段代码变成这样:
00000058
58: 55 push %ebp
59: 89 e5 mov %esp,%ebp
5b: 53 push %ebx
64: 5b pop %ebx
65:
[elf@redhat elf]# objdump –dx bbb.so
Ld处理后bbb.so文件中foo3函数入口的这段代码变成这样:
00000784
784: 55 push %ebp
785: 89 e5 mov %esp,%ebp
787: 53 push %ebx
788: 83 ec 04 sub $0x4,%esp
78b: e8 00 00 00 00 call 790
790: 5b pop %ebx
791:
)
(# 思考:*.so的加载地址是不确定的。在文件中,GOT在文件内的偏移值是固定的,连接器可以事先计算出GOT相对于文件头的偏移量。加载器把 .so在内存的起始地址+GOT在文件内偏移量 计算出GOT在内存中的地址。然后加载器怎么办呢?修改每个函数的入口代码?把GOT的计算代码放在每个函数的入口处?这样就不是位置无关代码了。加载器自己修改%ebx也不合适。此方法不可行。)
b) 引用变量、函数地址
(# 引用函数的地址就是取他们的地址,和直接调用函数是不同的,表现为:编译器在*.o中对于引用函数和调用函数产生的汇编指令是不同的引用使用 leal/movl指令,调用使用 jmp/call)
b.1) 当引用的是静态变量、静态函数或字符串常量时,使用R_386_GOTOFF重定位方式。它与GOTPC重定位方式很相似,同样首先由编译器在目标文件(*.o)中设置R_386_GOTOFF重定位标记,然后进行连接,连接器注意到R_386_GOTOFF重定位项,于是计算GOT表与被引用元素首地址的差值,作为leal指令( leal .LC1@GOTOFF(%ebx), %eax )的变址寻址方式操作数。
代码片断如下:
leal .LC1@GOTOFF(%ebx), %eax
.o: R_386_GOTOFF # R_386_GOTOFF是编译器在*.o中为引用静态变量\函数产生的重定位表项(rel.text)
.so: NULL
(静态变量、静态函数或字符串常量一定是位于本模块内,其偏移量在文件内是固定的。该符号在重定位表.rel.text中都对应有R_386_GOTOFF重定位项,包含 计算偏移量的方法和应该对代码段中哪个地址进行修改。)
b.2) 当引用的是全局变量、全局函数时,编译器会在目标文件(*.o)中设上一个R_386_GOT32重定位标记。连接器也会为该符号在GOT表中新建立一项,注上R_386_GLOB_DAT重定位标记,用于加载器填写被引用元素的实际地址。连接器还要计算新建立的GOT表项在GOT表中的偏移,作为movl指令( movl x@GOT(%ebx), %eax )的变址寻址方式操作数x。
代码片断如下:
movl x@GOT(%ebx), %eax # 编译器在*.o中对于引用全局变量产生的汇编指令
.o: R_386_GOT32 # R_386_GOT32是编译器在*.o中为引用全局变量、全局函数产生的重定位表项(rel.text)
.so: R_386_GLOB_DAT # R_386_GLOB_DAT是连接器在*.so中为引用全局变量、全局函数产生的重定位表项(.rel.dyn)
需要指出,第1次引用全局函数时,由GOT表读出不是全局函数的实际入口地址,而是该函数在PLT表中的入口.PLTfun的地址。这样,无论直接调用一个函数(后面会对直接调用进一步描述。参见3.3节 用户程序中对fun的直接调用经编译、连接后生成一条call fun@PLT指令。),还是先取得函数地址再间接调用(估计就是指“引用” 即由GOT表读出全局函数地址;如前所述:GOT表中每一项都是本运行模块要引用的一个全局变量或函数的地址。可以用GOT表来间接引用全局变量、函数),程序流程都会先转入PLT表,进而把控制权转移给加载器。加载器就是利用这个机会进行动态连接的。
c) 直接调用函数
如前所述,浮动代码中的函数调用语句会编译成相对跳转指令。首先编译器会在目标文件(*.o)中设上一个R_386_PLT32重定位标记,然后视静态函数、全局函数不同而连接过程也有所不同。
c.1) 如果是静态函数,调用一定来自同一运行模块内,则在同一运行模块内 主调函数内的调用点call指令的位置(call f@PLT)相对于被调静态函数入口点的偏移量在连接时就被计算出来,作为call指令( call f@PLT )的相对当前IP的跳转操作数,而后直接转入静态函数入口地址,不用加载器操心(即不需要动态重定位)。
相关代码片断如下:
call f@PLT # 编译器在*.o中对于直接调用静态函数产生的汇编指令
.o: R_386_PLT32 # R_386_PLT32是编译器在*.o中对于直接调用静态函数产生的重定位表项(.rel.text)
.so: NULL
c.2) 如果是全局函数,(被调用的全局函数可能来自不同的运行模块)连接器将生成到.PLTfun的相对跳转指令(call fun@PLT),之后就如3.3节所述,对全局函数的第1次调用会把程序流程转到加载器中去,然后加载器计算函数的入口地址,加载器填充fun@GOT表项。这称为R_386_JMP_SLOT重定位方式。相关代码片断如下:
call f@PLT # 编译器在*.o中对于直接调用全局函数产生的汇编指令
.o: R_386_PLT32 # R_386_PLT32是编译器在*.o中为直接调用全局函数产生的重定位表项(.rel.text)
.so: R_386_JMP_SLOT # R_386_JMP_SLOT是连接器在*.so中为直接调用全局函数产生的重定位表项(.rel.plt)
如此一来,一个全局函数可能会有两个重定位项。一个是必需的R_386_JMP_SLOT重定位项,加载器把它指向真正的函数入口;另一个是R_386_GLOB_DAT重定位项,加载器把它指向PLT表中的代码片断。取函数地址时,取得的总是R_386_GLOB_DAT重定位项的值,也就是指向.PLTfun,而不是真正的函数入口。 (此段的理解可以参考《elf动态解析符号过程》)