动态连接和装载

动态连接和装载

$Revision: 2.3 $

$Date: 1999/06/15 03:30:36 $

 

动态连接使得大部分的连接过程延迟,直到程序开始运行。这种做法做提供了许多其它方法难以实现的优点:

 

  动态连接库比静态连接库更容易创建。

 

  动态连接库比静态连接库更容易更新。

 

  动态连接库的和非共享库的语义非常的接近。(这里语义指什么)

 

  动态连接库容许程序在运行时装载和卸载例程,这是其它方式很难提供的一个功能。

 

当然,它也有一些缺点。相对那些静态连接,动态连接运行时的性能耗费是很多的,因为大部分的连接过程在每次程序运行时都必须重做。程序中所有的动态连接符号都得通过一个符号表(symbol talbles)来查找和解析。(Windows DLLs 稍微减轻了这些消耗,我们将在下面解释)由于动态库必须包含了这些符号表,因此它们比静态库要大些。

 

除开调用兼容性问题(???)a chronic source of problems is changes in library semantics .相对于静态连接库和非共享库而言,动态连接库是很容易更新的,所以更改程序的库就比较容易,这就意味着有可能这些程序的状态在不知情的状况下发生了改变。在微软的Windows平台下这是一个常见的问题,Windows平台使用了大量共享库,它们有很多版本,且缺乏较完善的版本控制。大多数程序附带了他们所需要的库,而安装程序经常在不经意间将老版本的共享库覆盖掉新版本,使得那些需要新版本共享库特性的程序无法运行。虽然在旧版本的库覆盖新版本库之前,一些友好的安装程序会弹出一个警告,但是,如果新版本库取代旧版本的库,依赖旧版本库的程序将无法运行。

 

ELF(Executable and Linkable Format) 动态连接

 

SUN公司的SunOS20世纪80年代末将动态连接库引入到了UNIX。随后,Sun协助开发的UNIX System V Release 4中引入了ELF,并使它与Sun的系统相适应。相比过去的目标文件格式,ELF无疑是一个进步,到了20世纪90年代末,ELF成为了UNIX和类UNIX操作系统(如LinuxBSD派生系统)的标准。

 

ELF文件内容

 

如第三章所提到的,一个ELF文件从连接器linker的角度看,是一些节sections的集合;从程序装载器loader的角度看,它是一些段segments的集合。ELF格式的程序和共享库具有相同的结构,只是段的集合和节的集合上有些不同。

 

ELF格式的共享库可以装载到任何地址,所以共享库使用PIC(位置无关代码),使得文件的text page(如何翻译?)不需要重定位,并且可以被多个进程共享。如第八章所说,ELF格式的连接器通过GOTGlobal Offset Table)来支持PIC代码。GOT包含了动态库所有被引用的静态数据地指针,动态连接器通过GOT解析和重定位所有的指针。这些可能带来一些性能问题,但实际上,除开一些大的库,GOT通常很小;一般一个标准的C库只有350k的代码和180GOT入口。

Figure 10-1: PLT and GOT

picture of program with PLT

picture of library with PLT and GOT

 

 

 

GOT和调用它的代码是在同一个ELF文件中,因此不管程序装载到地方,GOT的相对位置不会改变,所有代码可以通相对地址来定位GOT,把GOT的地址装到寄存器,然后装载GOT的内容,它指向一个静态数据。如果一个库没有引用静态数据,可以不包含GOT,但是事实上,所有的库都包含了一个GOT

 

为了支持动态连接,每个ELF共享库和每个使用共享库的可执行文件都有一个PLT(Procedure Linkage Table)。同GOT访问数据的方法类似,PLT提供了一个间接访问函数的方法。PLT支持懒惰模式lazy evaluation, 即直到函数首次被调用才解析。考虑到PLT项比GOT项要多得多(在一个通用的C库里面有近600个),而大部分的例程根本不会被调用,所以使用惰性模式可以节省启动时间和加速整个程序。

 

下面将讨论PLT的细节:

一个动态连接的ELF文件包含了所有的连接信息(原文说是连接器信息,按照原文的说法,上下读起来意思就无法连贯,不知道你的理解是什么样的),这些信息用来在运行时帮助连接器重定位文件和解析所有的符号。其中,.dynsym(dynamic symbol table),包含了文件所有的导入和导出符号;.dynstr 节和 .hash节包含了符号的名字,哈希表可以用来加速符号查找。

动态连接的ELF文件尾有一个特别的地方,DYNAMIC(或者叫.dynamic 节),动态连接器用它来查找一些必要的信息。它做为数据段装载,不过ELF文件头中包含了一个指向它的指针,保证运行时的动态连接器能够找到它。.dynamic 节是指针和标签的列表。有些项出只现在程序中,有些项只出现在库中,还有一些在两者中都有。

 

   NEEDED:必要的库文件名(一般在程序中,当一个库依赖另外一个库的时候,也可以出现在库中,出现次数可以多于一次)

 

   SONAMEshared object name):连接器要用到的文件名。(在库)

   SYMTAB, STRTAB, HASH, SYMENT, STRSZ:分别指向符号表,关联字符串表,哈希表,符号表大小,字符串表大小。(程序和库)

   PLTGOT:指向GOT,在某些平台上指向PLT。(程序和库)

   REL, RELSZ, RELENT (也可能叫RELA, RELASZ, RELAENT:指向重定位入口,重定位入口的数量和大小。RELRELA的区别是RELA有附加物。(程序和库)

   JMPREL, PLTRELSZ, and PLTREL:pointer to, size, and format

(REL or RELA) of relocation table for data referred to by the PLT.

(Both.)(中间的format,您是怎么理解的?)

   INITFINI:指向程序启动和结束的时候调用的初始化例程和结束清理例程(可选,但是通常程序和库中都有)

   除开上面的,还有一些不常用的项就不提了

 

一个完整的ELF共享库如下图所示。首先是只读部分,包含符号表,PLT,代码,只读数据,然后是可读写部分,包含一般数据,GOT.dynamic节,最后一个只读节的后面一般跟随bss段,但是通常它不出现在文件中

 

Figure 10-2: An ELF shared library

(Lots of pointer arrows here)

read-only pages:

.hash

.dynsym

.dynstr

.plt

.text

.rodata

read-write pages:

.data

.got

.dynamic

.bss

 

装载一个动态连接程序

 

装载一个动态连接的ELF程序是一个漫长但是直接的过程。

 

启动动态连接器

 

当操作系统运行程序,it maps in the files pages as normal(此处应该指的是将硬盘的文件镜像映射到内存,但是此处的files page 不好怎么翻译), 注意可执行文件中的INTERPRETER 节, 它所指的解释程序就是动态连接器:ld.sold.so本身就是ELF格式的共享库。操作系统启动程序的时候,首先将动态连接器映射到一个合适的地址,然后传递一个连接器所需要的辅助向量(辅助)信息到堆栈,最后启动ld.so

 

辅助向量(辅助信息)包括:

 

  AT_PHDR, AT_PHENT AT_PHNUM:程序文件头的地址, 程序头中入口项的大小和数目。入口项描叙了文件的段信息。如果系统还没有把程序映射到内存,那么就会有一个AT_EXECFD入口项,它包含了程序文件打开后的文件描叙符。

 

  AT_ENTRY:程序开始地址,动态连接器在初始化完毕后就会跳到这个地方。

 

  AT_BASE:动态连接器被装载的地址。

 

此时,ld.so 开始部分的自引导代码首先查找自己的GOTGOT的第一个入口项指向ld.so文件中的dynamic段。通过dynamic段,连接器可以找到自己的重定向入口,在自己的数据段中重定向指针,解析装载其它符号的基本例程的代码位置。linux ld.so _dt_ 给这些基本例程取名,使用特定的代码会去寻找以 _dt_ 开始的符号,然后将它们解析)

 

然后,连接器用程序符号表指针和连接器符号表指针来初始化符号表链。从概念上说,程序和装载到进程中的所有库共享一个符号表,与其在运行时将所有的符号表合并,不如由连接器维护一个每个文件符号表的链表。每个文件包含一个哈希表,通过一些哈希头和每个头对应的哈希链表,连接器只要计算符号的哈希值一次,就可以迅速查到所需的符号,加速符号了查询then running through apprpriate(这个单词好像拼写有误)hash chain in each of the symbol tables in the list.

 

库的查找

 

一旦连接器完成了自己的初始化工作,就查找程序所需要的库的名字。程序头中有一个指针指向dynamic段,它包含了动态连接的信息,dynamic段中的DT_STRTAB指向一个字符表,而其中的DT_NEEDED包含了一个相对于字符表的偏移,指向所需要的库名。(注:假设字符表的用数组 strtab[N][] 来描叙,那么DT_NEEDED字段就是一个大于0,小于N的数字,设为x,那么DT_NEEDED所指定的库名就是strtab[x]

 

对于每一个库,连接器首先查找库文件位置,从本质上说是一个相当复杂的过程。DT_NEEDED所描叙的库文件名一般类似libXt.so.6 (Xt开发包, 版本6),库文件可能在任意的库文件目录中,还也可能有重名的文件。在我的系统中,这个库的实际文件名是/usr/X11R6/lib/libXt.so.6.0,最后的“.0”表示次版本号。

 

连接器查找下列几个地方:

 

  首先查看 .dynamic 段是否包含了一个叫DT_RPATH的项(它是一个以冒号分隔的库文件搜索目录列表)。这个项是在程序被连接器连接时(这里所说的不是动态连接器 ld.so 而是所谓的静态连接器 ld ,由命令行开关或者环境变量添加上去的。它常应用于子系统中,比如像数据库应用,我们要装载一些程序集合以及支持库到一个目录中去的时候。

 

  查看是否存在环境变量 LD_LIBRARY_PATH(它是一个以冒号分隔的库文件搜索目录列表)。这个项可以帮助开发者建立一个新版本的库,把他的路径添加到LD_LIBRARY_PATH中,把它和现存的可连接程序一同使用,用来测试新的库,or equally well to instrument the behavior of the program. (It skips this step if the program

is set-uid, for security reasons.)

 

  连接器查看库高速缓存文件 /etc/ld.so.conf ,它包含了库名和路径的一个对应列表,如果库名存在,连接器就使用它对应的路径,用这个查找方法能够找到大部分的库(文件名不需要和要求完全符合,这点可以参考接下来的“库的版本”)。

 

  如果上叙的查找都失败,连接器就查找默认路径 /usr/lib ,如果库文件依旧没有找到,则显示一个错误然后退出。

 

连接器找到了库文件后,先打开它,然后读取ELF头,找到指向各个段的指针to find the program header which in turn points to the files segments including the dynamic segment,应该还有更好的翻译方式)。连接器为库的代码段和数据段分配空间并映射到内存,随后是bss(不分配空间)。.通过库的 .dynamic 段,连接器添加这个库的符号表到符号表链,如果库所依赖的其它库没有装载的话,则添加那个库到装载队列中。

 

完成这个过程后,所有的库都已经被映射,loader在逻辑上拥有了一个全局的符号表,它是全部程序和被映射库的符号表的联合。

 

共享库的初始化

 

现在loader再次访问每个库,处理库的重定向入口,填充库的GOT,将库的数据段进行重定位。x86下的装载时重定位类型包括:

 

        R_386_GLOB_DAT,用来初始化GOT项,符号地址定义在另外一个库中。

 

        R_386_32,非GOT项,符号地址定义在另外一个库中。(和上面的区别是什么?)

 

        R_386_RELATIVE,重定向数据,比较典型的是一个字符串的指针或者一个局部定义的静态数据。

 

  R_386_JMP_SLOT,用来初始化PLTGOT项,后面有详解。

 

如果一个库有 .init 节,则它作为库的特定初始化例程被 loader 调用,如C++的静态构造器;同样,程序在退出的时候要运行 .fini 节的例程。(上叙的工作不是由主程序main来完成的,而是程序自启动代码来处理,在linux中这些代码在 ld.so 中的_glibc_start_main 中)。当上面的工作完成以后,所有的库都被完全装载并准备运行,loader最终调用程序的开始地址AT_ENTRY,程序开始执行。

 

使用PLT的惰性连接过程

 

使用动态库的程序一般包含了很多的函数调用,但是它们中的许多在某次运行中根本不会被调用,这些函数可能是错误处理例程或者此程序不使用的函数。此外,每个共享库也包含了其它库函数的调用,但是在某次程序的运行中要用到的就更加少了,因为程序没有直接或者间接的调用它们。

 

为那加速程序的启动,动态连接的ELF程序使用惰性模式来绑定地址。就是说,一个函数(或者变量)在它第一次被调用的时候才确定它的地址。

 

ELF通过PLTProcedure Linkage Table)来支持惰性绑定,每个动态连接的程序和共享库都有一个PLT,程序或共享库中每一个非本地例程(函数??) ,在PLT包含了一个入口项,Figure 3。要注意的是,PLT里面使用的是PIC代码,其本身也是PIC代码,所以它可以成为只读代码段的一部分。

 

Figure 10-3: PLT structure in x86 code

 

Special first entry

PLT0:     pushl GOT+4

jmp *GOT+8

 

Regular entries, non-PIC code:

PLTn:      jmp *GOT+m

push #reloc_offset

jmp PLT0

 

Regular entries, PIC code:

PLTn:      jmp *GOT+m(%ebx)

push #reloc_offset

jmp PLT0

 

在一个程序或共享库中对某个例程的访问都被调整成为对PLT入口的访问。程序或共享库第一次访问一个例程,PLT入口调用动态连接器解析这个例程的实际地址。此后,PLT

入口直接跳转到实际地址,所以在第一次调用完成以后,使用PLT的消耗只是一个额外的跳转指令,返回的时候并没有多余的消耗。

 

PLT中的第一个入口,叫做PLT0 它是一段访问动态连接器的特殊代码,在装载时,动态连接器自动在GOT里面设置两个值。在GOT+4(是GOT[1]),它放了一些特定的库标识。在GOT+8(即GOT[2]),它放置了动态连接器的符号解析例程的地址。(在整个第十章中大量出项了routinefunction两个单词,严格的翻译应该是“例程”和“程序”,如此读起来不怎么方便,在一般的情况下,我认为没有必要严格区分,笼统的翻译成“程序”,不知可否?)

 

PLT剩下的入口项称之为PLTn,每个项以一个直接跳转开始(参见Figure103Regular entries。每个PLT入口项对应一个GOT入口项,GOT项初始化为一个指针,指向PLT直接跳转指令紧接下来的PUSH指令的地址。(PIC文件中,装载时还需要的重定位,不过减少了符号查询花费),跳转指令后面是一个PUSH指令,将一个偏移值(#reloc_offset)压入堆栈,偏移值指定文件的重定位表中,一个类型为R_386_JMP_SLOT的重定位入口项,重定位入口项的符号参考r_offset,参考下面地注释)指向符号表的一个符号,其符号地址r_info,参考下面的注释)指向一个GOT的入口项。

 

linux中一般有个重定位信息的表,通常叫做REL.PLT,它包含了某个符号的相对地址和其它的信息,它的数据结构是

typedef struct {

      Elf32_Addr    r_offset;

      Elf32_Word    r_info;

  } Elf32_Rel;

r_offset指向对应的GOT的入口项,r_info指向了符号表的一个符号。这样,我们通过#reloc_offset,也就是这个重定位入口项的地址,把它压入堆栈以后,后面的程序就可以通过它找到相应的符号信息,解析符号,然后存放到指定的GOT中去了)

 

这个紧凑但是比较变态的安排意味着:第一次程序或者共享库访问PLT入口项的时候,由于GOT项里面的指针指回了PLT项,在PLT的入口项的第一个跳转实际没有任何的效用。PUSH指令将一个偏移值压入堆栈,这个偏移值间接的指明了要解析的符号,和符号解析到哪个GOT项中去,然后跳转到PLT0PLT0里面的代码首先将程序或者库的标识符压入堆栈,然后跳转到动态连接器的符号解析代码部分,这样在栈顶就压入了了两个值。(注意,使用的是跳转而不是调用,压入的两个值的上面是调用PLT代码的返回地址)

 

现在stub code保存所有的寄存器,调用动态连接器的一个内部例程来解析符号。堆栈中的两个标识符足够找到库的符号表和在符号表的入口。动态连接器使用符号表链查找符号值,找到后,将例程的地址保存在指定的GOT项中。然后stub code恢复寄存器,弹出PLT压入的两个值,退出此段代码。GOT项到此时已经被更新,在接下里的调用里面,PLT直接跳转到例程,而不需要动态连接。

 

动态连接的其它特殊点

 

为了保持运行时的语义尽可能的合那些非共享库相似,ELF连接器和动态连接器有许多隐藏的代码处理各种特殊情况,

 

静态初始化

 

如果一个程序引用了一个定义在共享库的全局变量,由于程序的数据地址必须在连接时确定,连接器必须为之创建一个变量的拷贝,如Figure4。由于动态连接器可以通过一个GOT指针来修正这些地址,一般不会出现什么问题。但是,如果共享库初始化了这个变量,那么程序中将无法知道。为了解决这个问题,连接器在程序的重定位表中设置一个类型为 R_386_COPY(否则只包含 R_386_JMP_SLOT,R_386_GLOB_DAT, R_386_32, R_386_RELATIVE )的入口项,指向程序中变量拷贝的地址,然后告诉动态连接器从共享库重将变量的初始化值拷贝过来。

 

Figure 10-4: Global data initialization

 

Main program:

extern int token;

 

Routine in shared library:

int token = 42;

 

虽然这个特性在某些类型的代码中是很基本的,但是在实际中出现得确非常少。由于只作用于单个数据,所以是个权宜之计。但是被初始化的通常是函数或数据的指针,所以这种折衷就足够了。

 

库版本

 

动态库的名字有主版本号和次版本号组成,如 libc.so.1.1 但是程序只识别到主版本号,像 libc.so.1。次版本号只是为了支持兼容。

 

为了让程序迅速的加载,系统维护了一个库缓存文件,包含了每个库的最新版本的全路径名,当一个新的库安装的时候,由配置程序负责来更新。

 

为了支持这种设计,每个动态连接库有一个“真名”叫SONAME,在库创建时分配。举个例子:一个库叫libc.so.1.1它的“真名”叫做libc.so.1SONAME通常默认为库名)。当连接器创建一个使用共享库程序的时候,它列出使用的库的SONAME,而非全称。库缓存创建程序扫描包含共享库的所有路径,找出这个共享库,解出SONAME,看是否有多个版本,选择最高版本,然后将SONAME和最高版本的全路径写入缓存文件。这样就能够保证在运行时,动态连接器能够迅速的找到每个库的当前版本。

 

运行时的动态连接

 

尽管ELF动态连接器在程序运行时经常隐式的调用PLT入口,程序也可以显式的使用dlopen()来装载一个共享库,用dlsym()来查找一个符号(通常是一个函数)的地址。这两个例程在动态连接器被简单的包装成回调函数。当动态连接器通过dlopen()来装载一个库的时候,像它在其它库上的操作一样,做同样的重定位和符号解析。所以,所有动态装载程序,可以在不借助任何特殊的回调函数地情况下,在程序运行时装载和引用全局数据。so the dynamically loaded program can without any special arrangements call back to routines already loaded and refer to global data in the running program.最后这一句看能不能翻译得更好一些,我的翻译有些问题的:)

 

上面这些特性容许用户不要修改程序的原代码,就可以为程序添加额外的功能,甚至可以不要停止和重启程序(这种程序在数据库和web服务器中有用)。早在20世纪60年代初,大型机的操作系统提供了一个“exit routines"实现类似的功能,虽然没有提供像现在这么方便的接口,但是很长一段时间给打包的应用程序带来了相当大的弹性。它提供给程序扩充自己的途径,用CC++来写一段新的程序,运行编译器和连接器创建一个共享库,然后动态的装载并运行新的代码。Mainframe sort programs have linked and loaded custom inner loop code for each sort job for decades.

 

Microsoft动态连接库

 

Microsoft windows 提供了叫做动态连接库或者DLLs的共享库。它们和ELF共享库非常的相似,只不过要简单一些罢了。windows3.116位动态连接库和windows NT/95下的32位动态连接库有本质上的变化。在这里我们仅仅讨论新的win32库。DLLs的导入过程类似PLT。尽管从设计上说,可以使用类似GOT的方法来导入数据,实际上采用了一个更加简单的方案,使用显式的代码to dereference imported pointers,dereference 如果用本义,有点说不通)来共享数据。

 

Windows中,所有的程序和DLLs都是PE格式(portable executable)文件,这些文件作为虚拟内存被映射到进程空间。和windows3.1中所有应用程序共享一个地址空间不同,win32为每一个应用程序分配一个地址空间,然后将可执行程序和库映射到各自使用的地址空间上。对于只读代码段,这两种方法并没有什么区别,但对于DLLs中的数据,系统将为程序分配一个该数据的拷贝。(这种方法有些过于简单,虽然PE文件也可以指定某个节为共享数据段,将单个数据共享给所有使用该库的程序,但是,大多数情况下并不这样)

 

windows中,动态连接器虽然是内核的一部分Linux中,动态连接器是一个应用程序),但是其装载可执行程序和DLLs的过程,同装载一个动态连接的ELF程序是相似的。首先,内核通过PE头中的节信息映射可执行文件,然后同样使用DLL文件的PE头信息来映射动态连接库。

 

PE文件也可以包含重定位信息。不过一个可执行文件一般不包含重定位信息,它的映射地址在连接时就已经确定。而所有的DLLs都包含重定位入口,如果DLLs在连接时确定的映射地址不可用的话,将使用重定位项来重定位自己。(Microsoft 称运行时的重定位为 rebasing)

 

可执行文件和DLLs两种PE文件都有一个入口点。当DLLs被装载或卸载时,以及进程每次绑定和卸载DLLs时,装载器都调用DLLs的入口点(装载器会传递一个参数来说明调用原因)。这种方式和ELF中的.init.fini节功能类似,使得程序员能够在程序初始化之前以及退出之前做一些事情。This provides a hook for static initializers and destructors analogous to the ELF .init and .fini sections.上面的句子完全采取意译

 

PE文件中的输入和输出符号

 

PE通过文件中两个特殊的段来支持共享库,.edata(用来输出符号)列出一个文件的输出符号;.idata(用来输入符号)列出一个文件的输入符号。程序文件通常只有.idata段,而DLLs总是具有.edata段,如果DLLs使用了其它的DLLs,也有可能有.idata。一个符号可以通过符号名或者序数(指出符号在符号地址表中位置的小整数)导出。通过序数查找符号可以提高连接效率,但是,开发者在用序数创建DLLs时,很难保证各个版本的序数一致,犯错的可能性较大。所以在实际中,只有在更改很少的系统服务时才使用序数,其它情况都使用符号名。

 

.edata段包含了一个导出目录表,描叙其它的节,紧接着就是符号导出表。

 

10-5:  .edata 节结构

 

export directory pointing to:

export address table        导出地址表

ordinal table                  序数表

name pointer table        名字指针表

name strings                 名字字符串

 

 

导出地址表包含符号的RVA(relative virtual address, 相对虚拟地址,相对于PE 文件加载地址的偏移)。如果RVA指回到.edata节,它就是一个"forwarder"引用,and the value pointed to is a string naming the symbol to use to satisfy the reference, probably de-fined in a different DLL.(这一句如何理解?)序数表和名字指针表是并行的,名字指针表的每一个项是符号名的RVA,序数表的项值是符号在地址导出表的编号(序数不需要以0开始,将序数减去序数的基准值(这个值通常为1)就是导出符号表的编号)。导出符号不一定需要名字。不过它们通常都有名字。名字指针表中的符号以字母顺序排列,以便装载器使用二分法查找。

 

.idata节的用途和.edata节相反,它们将符号或者序数映射回为一个虚拟地址。.idata节若干0结尾的数组构成,包括导入目录表,每个DLL的导入查找表,最后是名字表。

 

10-6: .idata节的结构

 

import directory tables, with lotsa arrows

each has import lookup table RVA, time/date stamp, forwarder chain (unused?), DLL name, import address RVA table

NULL

import table, entries with high bit flag (table per DLL)

hint/name table

 

 

 

在程序的代码段中有一个数组维护导入DLL的地址,程序装载器在装载时要解析这个地址。导入查找表标记了要导入的符号,它的入口项和导入地址表的项是相关联的。每个查找表项由32比特构成。如果最高位为1,则低31位是一个符号的序数,否则低31位是hint/name table中的RVA地址。每个hint/name项有4个字节的hint,猜测符号在DLL导出名字指针表的编号,紧接着是以NULL结尾的符号名。程序装载器通过hint来查找导出符号表,如果符号名匹配,就使用这个符号,否对整个导出符号表做二分查找。(如果DLL没有改变过,或者至少导出符号表没有发生过改变,只要DLL被连接了,利用hint来猜测就可以找到符号)

 

ELF导入符号不同,通过.idata导入的符号地址只放置在导入符号表中,且导入文件的任意地方都不会被修正。对于代码地址,稍微有些不同,当连接器创建一个执行体或者DLLs的时候,要在代码段建立一个无名字的"thunks"表,通过它间接跳转到导入符号表,将”thunks”作为导入例程的地址,这些对程序员来说是透明的。(The thunks as well as most of the data in the .idata section actually come from a stub library created at the same time as the DLL.)在微软最新版本的c/c++编译器中,如果程序员知道要调用DLL中一个例程,可以将这个例程申明为"dllimport",编译器将产生一个对地址表相应项的间接跳转指令,以避免额外的间接跳转。对于数据地址,这样会有些问题,since its harder to hide the extra level of indirection required to address a symbol in another executable.Traditionally, programmers just bit the bullet and explicitly declared imported variables to be pointers to the real values and explicitly dereferencd the pointers.(我对这一段文字理解还不够)在最新版本的微软c c++编译器中,容许程序员声明全局变量为"dllimport",编译器将使用额外的指针引用,和ELF代码中通过GOT中指针来引用数据非常的相似。

 

惰性绑定

 

新版本的windows编译器添加了延时装载的功能,容许进程惰性绑定符号,有点像ELFPLT,延时装载的DLL.idata导出目录表有类似的结构,但是由于不在.idata节,所以装载器不会自动处理它。其导出目录表的每个项都被初始化为同一个例程的指针,这个例程用来查找和装载DLLs,替换每个项的内容为实际地址。延时装载的目录表有一处存放导入表的原始内容,使得DLL即使随后卸载,可以将其还原。Microsoft 提供了标准的例程,但是其接口是公开的,如果有需要的话程序员也可以自己实现一个版本。

 

Windows容许程序使用LoadLibraryFreeLibrary显式的装载和卸载DLLs,使用GetProcAddress来查找符号地址。

 

DLLs 和线程

 

如果DLL使用TLS(thread local storage-线程本地存储) 在这种状况下运转将不会很好。一个Windows程序在一个进程里可以启用多个线程,它们共享进程的地址空间。每个线程有一块TLS来存放线程私有数据,如线程使用的资源和数据结构的指针等。TLS需要为可执行程序和每个使用TLSDLL的数据准备slot(见小字注释)Windows的连接器能够在PE可执行文件中创建一个.tls节,定义了可执行程序和任意直接被引用的DLLs所需要的TLS中数据存放次序。每当进程创建线程的时候,新的线程以.tls节为摸板创建自己的TLS

 

每个TLS就是一块存储区,此存储区被分割为若干个基本的存储单元,每个存储单元就是一个slotSlot的数目一般有上千,每个slot可以存放一个数据。

 

问题是大多数的DLLs既可以隐式的被执行体连接,也可以通过LoadLibrary显式的装载。有于无法预测一个DLLs将被显式还是隐式的调用,所以DLLs不能去依赖.tls,显式连接的DLLs不会自动获取TLS.

 

幸运的是,WindowsTLS定义了运行时分配slotsAPI。写DLL最好使用这些API,而不是.tls节,除非你知道DLLs是隐式的调用。

 

OSF/1准共享库

 

OSF/1是来自与OSFOpen Software Foundation)的一个UNIX变种,它使用了一种介于动态连接和静态连接之间的共享库方案。它的开发者说,由于静态连接只需要很少的重定位,所以它比动态连接的速度要快;同时,由于库的更新并不频繁,因此系统管理员即算在更新库的时候要重连接系统中所有的程序,他们也愿意忍受。

 

因此OSF/1使用一个对所有进程可见的全局符号表的方法,将在系统启动的时候将所有的共享库装载到一个共享的地址空间。这就表示所有的库分配了地址后,在系统的运行时也不会发生改变。每当一个程序启动的时候,如果它使用了共享库,它就通过全局符号表映射共享库和符号以及解析未定义的引用。由于程序都是联接存在的某地址空间,重定位都是在启动时完成,所以运行时不要消耗装载时间。

 

当一个共享库改变,系统就得重启,然后装载新的库,并创建一个新的全局符号表。

 

这个方法很巧妙,但是确不是令人很满意。其一,程序查找符号的时间比重定位时间还要长,所以节省重定位时间不一定能够提高性能。其二,OSF/1不能够提供运行时装载和运行库的功能。

 

让共享库加速

 

共享库,特别市ELF共享库,可能会非常的慢。原因来自多个方面,有几个在第八章提到过:

 

  装载时库重定位

  装载时符号解析

  PICprolog代码开销

  PIC的间接数据引用开销

  PIC的保留地址寄存器

 

前两个问题可以通过缓存来改善,后两个可以通过将纯净的PIC代码退化来改善。

 

近代的计算机上有大量的地址空间,为某个共享库选择一个地址范围,对绝大部分使用该共享库的进程来说是可行的。Windows采取的一个比较有效率的方法是,无论库被连接还是第一次被装载,将它暂时绑定到一个地址上。然后,每次程序连接库的时候,尽可能的使用同样的地址,这就意味着不需要重定位。如果在某个进程中此地址不可用,则库要如前那样重定位。

 

SGI系统使用术语(QUICKSTART)来描叙进程在连接时的预重定位,(or in a separate pass over the shared library)BeOS在库第一次装载到进程的时候将它缓存。即使多个库之间互相依赖,理论上是可能在多个库之中预重定位和预解析符号的,虽然我并没发现有这样的连接器。

 

如果一个系统使用预重定位的库,PIC就显得不那么重要了。所有的进程装载库到预重定位地址,不管库是不是PIC,其代码都可以被共享。所以在一个合适地址的非PIC库实际上也可以被共享,且不会像PIC那样损失性能。这些基于第九章所说的静态连接库,即算地址冲突,动态连接器将库移动到其它的地址,损失一些性能,也好过连接失败,Windows就采用这种方式。

 

BeOS将需重定位的库彻底缓存起来,同时在库变化时还能保持正确的语义。当BeOS发现一个新版本的库安装了的话,它就在程序员引用这个库的时候为之创建一个新版本的缓存。库的改变具有连锁反应,如果库AB的符号,库B更新的时候,库A也会创建一个新的缓存。这些使得程序员的工作变得简单。但是我不明白的是,libraries are in practice updated often enough to merit the considerable amount of system code needed to track library updates.

 

几种动态连接方法的比较

 

Unix/ELF Windows/PE 的动态连接在几个方面都不同。

 

ELF对每个程序使用一个名字空间,而 PE对每个库都有一个名字空间。一个ELF程序知道它所需要的符号和库,但是并没有记录符号和库的对应关系。另一个方面,PE知道某个符号来自与某个库,这使得PE缺乏灵活,但是对不经意的欺骗有更大的抵抗力。想象一个可执行文件调用一个例程AFUNCBFUNCAFUNC在库A,BFUNC在库B中,如果一个新版本的A库恰好定义了BFUNC,则ELF程序会优先选择新的BFUNC,而PE程序不会这样。当有大量的库的时候,对于ELF来说是个麻烦;一个局部解决问题的方法是,使用DT_FILTERDT_AUXILIARY告诉动态连接器这个符号来自哪个库,让连接器先搜索这些库,然后才搜索可执行体和其它的库。DT_SYMBOLIC域告诉动态连接器首先寻找库本身的符号表,因此其它的库就不会覆盖库内部符号引用。(这些并不是总是需要的,考虑一下前面章节所描叙的malloc hack这些特别的方法可以尽量避免相关库的符号之间的覆盖,不过还是缺乏像第11章所看到的java那样的层次结构的名字空间的替代品。

 

维护静态连接程序的语义,EFLPE要困难得多。在一个LEF程序中,对外部库中数据的引用是自动解析的,而PE将它作为导入数据而特别处理。PE在比较一个指针和函数有些困难,因为一个导入函数的地址是"thunk",而不是在库中的实际地址。而ELF文件将所有的指针同样对待。

 

在运行时,几乎所有的Windows动态连接器都是在内核中,而EFL的的动态连接器是作为应用程序的一部分,内核只是将它映射到初始化文件中。Windows的方法显然要快,因为它不用每次在进程开始连接之前,将动态连接器映射和重定位。ELF的定义则更加灵活。因为每个可执行体使用叫做"interpreter"的程序来做连接器(现在一般的连接器叫做 ld.so)不同的可执行程序可以使用不同的翻译程序而不需要操作系统做改变。这样实际上就使得支持各种各样版本的Unix变得非常的容易,比如Linux and BSD,可以通过制造一个兼容库的动态连接器来支持非本地的可执行文件。

 

练习

 

ELF共享库中,库中的函数调用一般是通过PLT来实现的,而函数地址是在运行时才绑定的,这样做有用吗?为什么?

 

想象一个程序调用了一个共享库中的例程plugh(),然后程序员创建了一个使用该库的动态连接程序,后来系统管理员注意到plugh()是一个笨名字,装了一个新版本的库,这个例程在此库中叫做xsazq,当下次程序员运行这个程序时,将发生什么。

 

如果一个运行时的环境变量LD_BIND_NOW被设置,EFL动态连接器在装载时将程序所有的PLT入口绑定。在前一个问题中,如果LD_BIND_NOW被设置,结果将会如何?

 

Microsoft 靠在连接器中一些额外的技巧,和操作系统现存的一些机制,在没有内核的支持下实现了惰性绑定。那么要提供对共享数据透明的访问,避免在现在方案中使用多余的指针,有多困难?

 

工程

 

创建一个完整的动态连接器是不切实际的,因为大部分的工作动态连接在运行时,而不是连接时。在8-3我们创建一个PIC可执行体实际上已经为创建一个共享库做了大量的工作。一个动态连接的共享库就是一个PIC可执行体和定义好的导入和导出符号清单,以及它所依赖的库。将这样一个文件创建成为一个共享库或者使用共享库的可执行文件:

 

LINKLIB lib1 lib2 ...

 

LINK lib1 lib2 ...

 

其中lib1 lib2是这个动态连接的共享库所依赖的库

 

Project 10-1:扩展project 8-3版本的连接器的功能,让它处理共享库和需要共享库的可执行体。连接器将一系列的文件以及共享库作为输入,将它们合并成为一个可执行体或者库。输出文件包含一个包含已定义符号(导出)和未定义符号(导入)的符号表。Relocation types are the ones for PIC files along with AS4 and RS4 for references to imported symbols.

 

Project 10-2: 写一个运行时的binder,以一个使用动态连接库的可执行体为输入,解析其所有的引用。它首先应该读取可执行体,然后读取必要的库,将它们重定位到合适的非重叠地址,随后创建一个逻辑的符号表(也可以真正的创建这个表,使用链表或者如ELF的方式来做),最后解析所有的内部和外部引用。完成这些后,所有的代码和数据都应该被安置到内存空间,而且它们的地址都应该被解析和重定位到指定的地址。

 

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值