摘 要:本文介绍了动态连接库的优点,详细阐述了x86体系结构上Linux系统的编译器、连接器、加载器如何使用多种重定位方式来实现该功能
关键词:动态连接库;Linux;重定位
The Implementation Mechanism of DLL under Linux
【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
Linux与Windows的动态连接库概念相似,但是实现机制不同。它引入了GOT表和PLT表的概念,综合使用了多种重定位项,实现了"浮动代码",达到了更好的共享性能。本文对这些技术逐一进行了详细讨论。
本文着重讨论x86体系结构,这是因为(1)运行Linux的各种体系结构中,以x86最为普及;(2)该体系结构上的Windows操作系统广为人知,由此可以较容易的理解Linux的类似概念;
下表列出了Windows与Linux的近义词,文中将不加以区分:
Windows Linux
动态连接库(DLL) Shared Object
目标文件(.obj) 文件名结尾常是 .o
可执行文件(.exe) Executable(文件名无特定标志)
连接器(link.exe) Linker Editor (ld)
加载器(exec/loader) Dynamic Linker (ld-linux.so)
段(segment) 节(section)
一些关键字在本文中有特定含义,需要澄清:
编译单元:一个C语言源文件,经过编译后将生成一个目标文件运行模块:一个动态连接库或者一个可执行文件。简称为模块
自动变量、函数:C语言auto关键字修饰的对象
静态变量、函数:C语言static关键字修饰的对象
全局变量、函数:C语言extern关键字修饰的对象
1 动态连接库的优点
程序编制一般需经编辑、编译、连接、加载和运行几个步骤。由于一些公用代码需要反复使用,就把它们预先编译成目标文件并保存在"库"中。当它与用户程序的目标文件连接时,连接器得从库中选取用户程序需要的代码,然后复制到生成的可执行文件中。这种库称为静态库,其特点是可执行文件中包含了库代码的一份完整拷贝。显然,当静态库被多个程序使用时,磁盘上、内存中都是多份冗余拷贝。
而使用动态连接库就克服了这个缺陷。当它与用户程序的目标文件连接时,连接器只是作上标记,说明程序需要该动态连接库,而不真的把库代码复制到可执行文件中;仅当可执行文件运行时,加载器根据这个标记,检查该库是否已经被其它可执行文件加载进内存。如果已存在于内存中,不用再从磁盘上加载,只要共享内存中已有的代码即可。这样磁盘、内存中始终只有一份代码,较静态库为优。
2 Linux动态连接库的重要特点:浮动代码
在Windows中,连接生成动态连接库时要指定一个首地址。应用程序运行时,加载器将尽可能把动态连接库装入到该地址;如果地址已被占用,该动态连接库只能被加载到其它地址空间内,这时就要对库中的代码和数据进行修补,或叫做重定位。如此一来,库的多个实例在内存中经过重定位后,彼此将不尽相同,自然不再能共享了。为了避免这个缺陷,Windows自带的库都指定了互不重叠的地址,尽管如此,其它软件厂商的产品仍然
不可避免的使用重叠地址,由此部分丧失了使用动态连接库的好处。
在Linux中,为了达到更好的共享性能,使用了与Windows不一样的策略:浮动代码(Position Independent Code,简称PIC)。具体说,使用的转移指令都是相对于当前程序计数器(IP)的偏移量;代码中引用变量、函数的地址都是相对于某个基地址的偏移量。总之,从不引用一个绝对地址。这样,动态连接库无论被加载到什么地址空间,不用修补代码就可以正常工作。既然只有一份代码,就容易实现共享了。
值得指出,此处所指的共享,是指为了节省存储器,多个进程使用动态连接库代码段、只读数据段在内存中的唯一映像;另一种常用的共享定义,是指多个进程对同一段(可能是动态分配的)存储区进行读写,实现进程间通信(IPC)。后一种共享定义与本文无可执行文件运行时,加载器根据这个标记,检查该库是否已经被其它可执行文件加载进内存。如果已存在于内存中,不用再从磁盘上加载,只要共享内存中已有的代码即可。这样磁盘、内存中始终只有一份代码,较静态库为优。
2 Linux动态连接库的重要特点:浮动代码
在Windows中,连接生成动态连接库时要指定一个首地址。应用程序运行时,加载器将尽可能把动态连接库装入到该地址;如果地址已被占用,该动态连接库只能被加载到其它地址空间内,这时就要对库中的代码和数据进行修补,或叫做重定位。如此一来,库的多个实例在内存中经过重定位后,彼此将不尽相同,自然不再能共享了。为了避免这个缺陷,Windows自带的库都指定了互不重叠的地址,尽管如此,其它软件厂商的产品仍然不可避免的使用重叠地址,由此部分丧失了使用动态连接库的好处。
在Linux中,为了达到更好的共享性能,使用了与Windows不一样的策略:浮动代码(Position Independent Code,简称PIC)。具体说,使用的转移指令都是相对于当前程序计数器(IP)的偏移量;代码中引用变量、函数的地址都是相对于某个基地址的偏移量。总之,从不引用一个绝对地址。这样,动态连接库无论被加载到什么地址空间,不用修补代码就可以正常工作。既然只有一份代码,就容易实现共享了。
值得指出,此处所指的共享,是指为了节省存储器,多个进程使用动态连接库代码段、只读数据段在内存中的唯一映像;另一种常用的共享定义,是指多个进程对同一段(可能是动态分配的)存储区进行读写,实现进程间通信(IPC)。后一种共享定义与本文无关。
3 Linux动态连接库的实现机制:重定位
3.1 重定位概述
浮动代码通过重定位操作得以实现。而重定位可以按多种标准进行分类:
-- 按发生的地点,可分成对代码段(.text)重定位和对数据段(.data)重定位。
-- 按发生的时间,可分成连接时重定位和加载时重定位(加载时重定位也称为动态重定位)。但这两步并不总是必不可少的。例如,要实现浮动代码就不能对代码段进行动态重定位,这时采取的办法是,把需要动态重定位的项搬到数据段中去,然后在代码段中引用这些项。
-- 按重定位项引用的对象,可分成数据引用和函数引用。如果引用的是静态数据或静态函数,连接器会优化生成的代码,去掉动态重定位项。
-- 从字面上讲, x86体系结构上的Linux使用了多种重定位方式,名字前缀以"R_386_",后面分别接:32、GOT32、PLT32、COPY、GLOB_DAT、JMP_SLOT、RELATIVE、GOTOFF、GOTPC。每种方式都有特定的含义。
以上几种分类中最重要的是按地点分类。而下文也将以它为主线,逐一介绍各种重定位
项。首先,引入两个关键概念:GOT表和PLT表。
3.2 GOT表
GOT(Global Offset Table)表中每一项都是本运行模块要引用的一个全局变量或函数的地址。可以用GOT表来间接引用全局变量、函数,也可以把GOT表的首地址作为一个基准,用相对于该基准的偏移量来引用静态变量、静态函数。由于加载器不会把运行模块加载到固定地址,在不同进程的地址空间中,各运行模块的绝对地址、相对位置都不同。这种不同反映到GOT表上,就是每个进程的每个运行模块都有独立的GOT表,所以进程间不能共享GOT表。
在x86体系结构上,本运行模块的GOT表首地址始终保存在%ebx寄存器中。编译器在每个函数入口处都生成一小段代码,用来初始化%ebx寄存器。这一步是必要的,否则,如果对该函数的调用来自另一运行模块,%ebx中就是调用者模块的GOT表地址;不重新初始化%ebx就用来引用全局变量和函数,当然出错。
3.3 PLT表
PLT(Procedure Linkage Table)表每一项都是一小段代码,对应于本运行模块要引用的一个全局函数。以对函数fun的调用为例,PLT中代码片断如下:
.PLTfun: jmp *fun@GOT(%ebx)
pushl $offset
jmp .PLT0@PC
其中引用的GOT表项被加载器初始化为下一条指令(pushl)的地址,那么该jmp指令相当于nop空指令。
用户程序中对fun的直接调用经编译连接后生成一条call [email]fun@PLT 指令,这是一条相对跳转指令(满足浮动代码的要求!),跳到.PLTfun 。如果这是本运行模块中第一次调用该函数,此处的jmp等于一个空指令,继续往下执行,接着就跳到PLT[email]0。该PLT项保留给编译器生成的额外代码,会把程序流程引入到加载器中去。加载器计算fun的实际入口地址,填入fun@GOT表项。图示如下:
user program
--------------
call fun@PLT
|
v
DLL PLT table loader
-------------- -------------- -----------------------
fun: <-- jmp*fun@GOT --> change GOT entry from
| $loader to $fun,
v then jump to there
GOT table
--------------
第一次调用以后,GOT表项已指向函数的正确入口。以后再有对该函数的调用,跳到PLT表后,不再进入加载器,直接跳进函数正确入口了。从性能上分析,只有第一次调用才要加载器作一些额外处理,这是完全可以容忍的。还可以看出,加载时不用对相对跳转的代码进行修补,所以整个代码段都能在进程间共享。
熟悉Windows的程序员很容易注意到,GOT表、PLT表与Windows中的引入表(Import)有类似之处。其它对应关系还有: Linux的version script与Windows的.DEF文件;Linux的dynamic symbols section与Windows的输出表(Export)。不再举更多例子了。
3.4 代码段重定位
需要说明,由浮动代码的要求,代码段内不应该存在重定位项。此处只是借用了"在代码段中"这个短语,实际的重定位项还是位于数据段的GOT表内。尽管如此,它与3.5节"数据段中的重定位"的区别是很明显的。
a) 装载GOT表首地址
使用GOT表当然事先要知道它的首地址,然而该首地址会随运行模块被加载的首地址不同而不同。Linux使用了一个技巧在运行时求出正确的GOT表首地址。代码片断如下,紧接其后列出的是对应的目标文件(.o)与动态连接库(.so)中的重定位项类型:
call L1
L1: popl %ebx
addl $GOT+[.-.L1], %ebx
.o: R_386_GOTPC
.so: NULL
如前所述,该代码片断存在于每个函数的入口处。程序第一句把当前程序计数器(IP)值推进堆栈,第二句又把它从堆栈中弹出来,结果相当于movl %eip, %ebx,只不过合法的x86指令集中不允许%eip作为操作数而已。然后第三句把%ebx加上一个GOT表与IP值的差,这个差值是个与动态连接库加载首地址无关的常数,在连接时即可求出。整个过程用类C语言描述如下:
%ebx = %eip;
%ebx += ($GOT - %eip)
至此%ebx等于GOT表首地址。
上述过程是编译、连接相合作的结果。编译器生成目标文件时,因为此时还不存在GOT表(每个运行模块有一个GOT表,一个PLT表,由连接器生成),所以暂时不能计算GOT表与当前IP间的差值,仅在第三句处设上一个R_386_GOTPC重定位标记而已。然后进行连接。连接器注意到GOTPC重定位项,于是计算GOT与此处IP的差值,作为addl指令的立即寻址方式操作数。以后再也不需要重定位了。
b) 引用变量、函数地址
当引用的是静态变量、静态函数或字符串常量时,使用R_386_GOTOFF重定位方式。它与GOTPC重定位方式很相似,同样首先由编译器在目标文件中设上重定位标记,然后连接器计算GOT表与被引用元素首地址的差值,作为leal指令的变址寻址方式操作数。代码片断如下:
leal .LC1@GOTOFF(%ebx), %eax
.o: R_386_GOTOFF
.so: NULL
当引用的是全局变量、全局函数时,编译器会在目标文件中设上一个R_386_GOT32重定位标记。连接器会在GOT表中保留一项,注上R_386_GLOB_DAT重定位标记,用于加载器填写被引用元素的实际地址。连接器还要计算该保留项在GOT表中的偏移,作为movl指令的变址寻址方式操作数。代码片断如下:
movl x@GOT(%ebx), %eax
.o: R_386_GOT32
.so: R_386_GLOB_DAT
需要指出,引用全局函数时,由GOT表读出不是全局函数的实际入口地址,而是该函数在PLT表中的入口.PLTfun(参见3.3节)。这样,无论直接调用,还是先取得函数地址再间接调用,程序流程都会转入PLT表,进而把控制权转移给加载器。加载器就是利用这个机会进行动态连接的。
c) 直接调用函数
如前所述,浮动代码中的函数调用语句会编译成相对跳转指令。首先编译器会在目标文件中设上一个R_386_PLT32重定位标记,然后视静态函数、全局函数不同而连接过程也有所不同。
如果是静态函数,调用一定来自同一运行模块,调用点相对于函数入口点的偏移量在连接时就可计算出来,作为call指令的相对当前IP偏移跳转操作数,由此直接进入函数入口,不用加载器操心。相关代码片断如下:
call f@PLT
.o: R_386_PLT32
.so: NULL
如果是全局函数,连接器将生成到.PLTfun的相对跳转指令,之后就如3.3节所述,对全局函数的第一次调用会把程序流程转到加载器中去,然后计算函数的入口地址,填充fun@GOT表项。这称为R_386_JMP_SLOT重定位方式。相关代码片断如下:
call f@PLT
.o: R_386_PLT32
.so: R_386_JMP_SLOT
如此一来,一个全局函数可能有多至两个重定位项。一个是必需的JMP_SLOT重定位项,加载器把它指向真正的函数入口;另一个是GLOB_DAT重定位项,加载器把它指向PLT表中的代码片断。取函数地址时,取得的总是GLOB_DAT重定位项的值,也就是指向.PLTfun,而不是真正的函数入口。
进一步考虑这样一个问题:两个动态连接库,取同一个全局函数的地址,两个结果进行比较。由前面的讨论可知,两个结果都没有指向函数的真正入口,而是分别指向两个不同的PLT表。简单进行比较,会得出"不相等"的结论,显然不正确,所以要特殊处理。
3.5 数据段重定位
在数据段中的重定位是指对指针类型的静态变量、全局变量进行初始化。它与代码段中的重定位比较起来至少有以下明显不同:一、在用户程序获得控制权(main函数开始执行)之前就要全部完成;二、不经过GOT表间接寻址,这是因为此时%ebx中还没有正确的GOT表首地址;三、直接修改数据段,而代码段重定位时不能修改代码段。
如果引用的是静态变量、函数、串常量,编译器会在目标文件中设上R_386_32重定位标记,并计算被引用变量、函数相对于所在段首地址的偏移量。连接器把它改成R_386_RELATIVE重定位标记,计算它相对于动态连接库首地址(通常为零)的偏移量。加载器会把运行模块真正的首地址(不为零)与该偏移量相加,结果用来初始化指针变量。代码片断如下:
.section .rodata
.LC0: .string "Ok\n"
.data
p: .long .LC0
.o: R_386_32 w/ section
.so: R_386_RELATIVE
如果引用的是全局变量、函数,编译器同样设上R_386_32重定位标记,并且记录引用的符号名字。连接器不必动作。最后加载器查找被引用符号,结果用来初始化指针变量。对于全局函数,查找的结果仍然是函数在PLT表中的代码片断,而不是实际入口。这与前面引用全局函数的讨论相同。代码片断如下:
.data
p: .long printf
.o: R_386_32 w/ symbol
.so: R_386_32 w/ symbol
3.6 总结
下表给出了前面讨论得到的全部结果:
.o .so
------------------------------------------------------------
|装载GOT表首地址 R_386_GOTPC NULL
代码段|-----------------------------------------------------
重定位|引用变量函数地址 静态 R_386_GOTOFF NULL
| 全局 R_386_GOT32 R_386_GLOB_DAT
|-----------------------------------------------------
|直接调用函数 静态 R_386_PLT32 NULL
| 全局 R_386_PLT32 R_386_JMP_SLOT
------|-----------------------------------------------------
数据段|引用变量函数地址 静态 R_386_32 w/sec R_386_RELATIVE
重定位| 全局 R_386_32 w/sym R_386_32 w/sym
------------------------------------------------------------
4 结束语
Windows使用PE文件格式,Linux使用ELF文件格式,这是两种动态连接库不同的根源。本文从ELF规范出发,深入讨论了Linux动态连接库的具体实现,目的在于进一步推广Linux的研究与应用。
5 附录:Linux汇编程序语法
x86体系结构上的Linux汇编器兼容于AT&T System V/386汇编器的语法,与常见的Intel语法颇有不同,如下表:
AT&T Intel
常数 前缀$:pushl $4 push 4
寄存器 前缀%:%ebx ebx
跳转指令(绝对地址) 前缀*:jmp *fun
跳转指令(相对偏移) 无标记:jmp fun
目的、源操作数的顺序 源在前:movl $4,%eax 目的在前:mov eax,4
操作数尺寸 后缀b、w、l:movl 修饰符byte ptr等等
变址寻址 [base+disp] disp(base)
参考文献
[1] Executable and Linking Format Spec v1.2, TIS Committee, 1995
[2] GNU Project (gcc, libc, binutils), Free Software Foundation, Inc., 1999
[3] Solaris 2.5 Linker and Libraries Guide, Sun Microsystems Inc., 1999
[4] SVR4 ABI x86 Supplement, The Santa Cruz Operation, Inc., 1999
http://www.sco.com/developer/devspecs/abx86-4.pdf[5] ELF: From The Programmer's Perspective, H J Lu, 1995
[6] Using ld: The GNU linker, S Chamberlain, Cygnus Support, 1994