GNU开发工具简介(三)

第四节 链接

 

       链接就是将不同部分的代码和数据收集和组合成一个单一文件的过程,这个文件可以被加载到存储器并执行。链接可以执行于编译时,也就是在源代码被翻译成机器代码时;也可以执行于加载时,也就是程序被加载器加载到存储器并执行时;甚至执行于运行时,由应用程序来执行。在早期的计算机系统中,链接是手动执行的,在现代系统中,链接是由叫做链接器的程序自动执行的。

       链接器在软件开发中扮演一个重要角色,因为它使得分离编译成为可能。我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,我们只要简单地重新编译它,并将它重新链接到应用上,而不必重新编译其他文件。

       为什么要学习链接和知识呢?

理解链接器将帮助你构造大型程序。构造大型程序的程序员常会遇到由于缺少模块、缺少库或者不兼容的库版本引起的链接器错误。除非你理解链接器是如何解析引用、什么是库以及链接器是如何使用库来解析引用的,否则这类错误将令你感到迷惑和挫败。

理解链接器将帮助你避免一些危险的编程错误。Unix链接器解析符号引用时所做的决定可以不动声色地影响你的程序的正确性。在默认情况下,错误地定义多个全局变量的程序将通过链接器,而不产生任何警告信息,由此得到的程序会产生令人迷惑的运行时行为,而且非常难调试。我们将向你展示这是如何发生的,以及该如何避免它。

理解链接将帮助你理解语言的作用域规则是如何实现的。例如,全局和局部变量之间的区别是什么?当你定义一个具有静态属性的变量或者函数时,到底实际意味着什么?

理解链接将帮助你理解其他重要的系统概念。链接器产生的可执行目标文件在重要的系统功能中扮演着关键角色,比如加载和运行程序、虚拟存储器、分页和存储器映射。

理解链接将使你能够开发共享库。多年来,链接都被认为是相当简单和无趣的。然而,随着共享库和动态链接在现代操作系统中日益加强的重要性,链接成为了一个复杂的过程,它为知识丰富的程序员提供了强大的能力。比如,许多软件产品使用共享库在运行时来升级压缩封装的二进制程序。还有,大多数Web服务器都依赖于共享库的动态链接来提供动态内容。

 

1、编译器驱动程序

 

考虑下面的C程序,它包括两个源文件:main.cswap.c。函数main()调用swap(),它交换外部全局数组buf中的两个元素。一般认为,这是一种奇怪的交换数字的方式,但它将作为一个示例,帮助我们说明关于链接是如何工作的一些重要知识点。

/* main.c */

void swap();

 

int buf[2] = {1, 2};

 

int main()

{

       swap();

       return 0;

}

 

/*swap.c*/

extern int buf[];

 

int bufp0 = &buf[0];

int *bufp1;

 

void swap()

{

       int temp;

       bufp1 = &buf[1];

       temp = *bufp0;

       *bufp0 = *bufp1;

       *bufp1 = temp;

}

 

       大多数编译系统提供编译驱动程序(compiler driver),它为用户,根据需求调用语言预处理器、编译器、汇编器和链接器。比如,要用GNU编译系统构造示例程序,我们就需要通过在shell中输入下面的命令行来调用GCC驱动程序:

# gcc -O2 -g -o p main.c swap.c

驱动程序首先运行C预处理器(cpp),它将C源程序main.c翻译成一个ASCII码的中间文件main.i

cpp [other arguments] main.c /tmp/main.i

接下来,驱动程序运行C编译器(cc1),它将main.i翻译成一个ASCII汇编语言文件为main.s

cc1 /tmp/main.i main.c -O2 [other arguments] -o /tmp/main.s

然后,驱动程序运行汇编器(as),它将main.s翻译成一个可重定位目标文件(relocatable object filemain.o

as [other arguments] -o /tmp/main.o  /tmp/main.s

驱动程序经过相同的过程生成swap.o。最后,它运行链接器程序ld,将main.oswap.o以及一些必要的系统目标文件组合起来,创建一个可执行的目标文件p

ld -o p [system object files and args] /tmp/main.o /tmp/swap.o

要运行可执行文件p,我们在命令行上输入它的名字

# ./p

shell调用一个在操作系统中叫做加载器(loader)的函数,它拷贝可执行文件p中的代码和数据到寄存器,然后将控制转移到这个程序的开头。

 

2、静态链接

 

       Unix ld程序这样的静态链接器以一组可重定位目标文件和命令行参数作为输入,生成一个完全链接的可以加载和运行的可执行目标文件做为输出。输入的可重定位目标文件由各种不同的代码和数据节(section)组成,指令在一个节中,初始化的全局变量在另一个节中,而未初始化的变量又在另一个节中。

       为了创建可执行文件,链接器必须完成两个主要任务:

符号解析(symbol resolution)。目标文件定义和引用符号。符号解析的目的是将每个符号引用和一个符号定义联系起来。

重定位(relocation)。编译器和汇编器生成从地址零开始的代码和数据节。链接器通过把每个符号定义与一个存储器位置联系起来,然后修改所有对这些符号的引用,使得它们指向这个存储器位置,从而重定位这些节。

 

接下来的内容将更详细地描述这些任务。要记住关于链接器的一些基本事实:目标文件纯粹是字节块的集合。在这些块中,有些包含程序代码,有些则包含程序数据,而其他的则包含指导链接器和加载器的数据结构。链接器将这些块链接起来,确定被链接块的运行时位置,并修改代码和数据块中的各种位置。链接器对目标机器了解甚少,产生目标文件的编译器和汇编器已经完成了大部分工作。

 

3、目标文件

 

       目标文件有三种形式:

可重定位目标文件。包含二进制代码和数据,其形式可以在编译时与其他可重定位目标文件合并起来,创建一个可执行目标文件。

可执行目标文件。包含二进制代码和数据,其形式可以被直接拷贝到存储器并执行。

共享目标文件。一种特殊类型的可重定位目标文件,可以在加载或运行时,被动态地加载到存储器并链接。

 

       编译器和汇编器生成可重定位目标文件(包括共享目标文件),链接器生成可执行目标文件。

       各个系统之间,目标文件格式都不相同。第一个从贝尔实验室诞生的Unix系统使用的是a.out格式。System V Unix的早期版本使用的是COFFCommon Object File format,一般目标文件格式)。Windows使用的是COFF的一个变种,叫做PEPortable Executable)格式。现代Unix系统,比如Linux,还有System V Unix后来的版本,各种BSD Unix,以及SUN Solaris,使用的是Unix ELF(Executable and Linkable Format)。尽管我们讨论集中在ELF上,但是不管是哪种格式,基本的概念是相似的。

 

4、可重位目标文件

 

       下图展示了一个典型的ELF可重定位目标文件。

 

ELF

.text

.rodata

.data

.bss

.symtab

.rel.text

.rel.data

.debug

.line

.strtab

节头部表

 

ELF头以一个16字节的序列开始,这个序列描述了字的大小和生成该文件的系统的字节顺序。ELF头剩下的部分包含帮助链接器解析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型(比如,可重定位、可执行或可共享的)、机器类型(比如,IA32)、节头部表(section header table)的文件偏移,以及节头部表中的表目大小和数量。不同节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定大小的表目(entry)。

       夹在ELF头和节头部表之间的都是节。一个典型的ELF可重定位目标文件包括下面几个节:

.text:已编译程序的机器代码。

.rodata:只读数据,比如printf语句中的格式串和开关(switch)语句的跳转表。

.data:已经初始化的全局C变量。局部C变量在运行时被保存在栈中,既不出现在.data节中,也不出现在.bss节中。

.bss:未初始化的全局C变量。在目标文件中这个节不占据实际的空间,它仅仅是一个占位符。目标文件区分初始化和未初始化变量是为了空间效率:在目标文件中,未初始化变量不需要占据任何实际的磁盘空间。

.symtab:一个符号表(symbol table),它存放在程序中被定义和引用的函数和全局变量的信息。一些程序员错误地认为必须通过-g选项来编译一个程序,得到符号表信息。实际上,每个可重定位目标文件在.symtab中都有一张符号表。然而,和编译器中的符号表不同,.symtab符号表不包含局部变量的表目。

.rel.text:当链接器把这个目标文件和其他文件结合时,.text节中的许多位置都需要修改。一般而言,任何调用外部函数或者引用全局变量的指令都需要修改。另一方面,调用本地函数的指令则不需要修改。注意,可执行目标文件中并不需要重定位信息,因此通常省略,除非使用者显式地指示链接器包含这些信息。

.rel.data:被模块定义或引用的任何全局变量的信息。一般而言,任何已初始化全局变量的初始值是全局变量或外部定义函数的地址都需要被修改。

.debug:一个调试符号表,其有些表目是程序中定义的局部变量和类型定义,有些表目是程序中定义和引用的全局变量,有些是原始的C源文件。只有以-g选项调用编译驱动程序时,才会得到这张表。

.line:原始C源程序中的行号和.text节中机器指令之间的映射。只有以-g选项调用编译驱动程序时,才会得到这张表。

.strtab:一个字符串表,其内容包括.symtab.debug节中的符号表,以及节头部中的节名字。字符串表就是以null结尾的字符串序列。

 

为什么未初始化数据节称为.bss

它起始于IBM 704汇编语言中“块存储开始(Block Storage Start)”指令的首字母缩写,并沿用至今。一个记住区分.data.bss节的简单方法是把“bss”看成“更好地节省空间(Better Save Space)”的缩写。

 

5、符号和符号表

 

       每个可重定位目标模块m都有一个符号表,它包含m所定义和引用的符号的信息。在链接器的上下文中,有三种不同的符号:

m定义并能被其它模块引用的全局符号。全局链接器符号对应于非静态的C函数以及被定义为不带Cstatic属性的全局变量。

由其它模块定义并被模块m引用的全局符号。这些符号称为外部符号(external),对应于定义在其他模块中的C函数和变量。

只被模块m定义和引用的本地符号。有的本地链接器符号对应于带static属性的C函数和全局变量。这些符号在模块m中的任何地方都是可见的,但是不能被其他模块引用。目标文件中对应于模块m的节和相应的源文件的名字也能获得本地符号。

 

       认识到本地链接器符号和本地程序变量的不同是很重要的。.symtab中的符号表不包括对应于本地非静态程序变量的任何符号。这些符号在运行时在栈中被管理,链接器对此类符号不感兴趣。

       有趣的是,定义为带有C static属性的本地过程变量是不在栈中管理的。取而代之,编译器在.data.bss中为每个定义分配空间,并在符号表中创建一个有惟一名字的本地链接器符号。比如,假设在同一模块中的两个函数分别定义了一个静态本地变量x

int f()

{

       static int x = 0;

       return x;

}

 

int g()

{

       static int x = 1;

       return x;

}

在这种情况下,编译器在.bss中为两个整数分配空间,并引出(export)两个惟一的本地链接器符号给汇编器。比如,它可以用x.1表示函数f中定义的x,而用x.2表示函数g中定义的x

       符号表是由汇编器构造的,使用编译器输出到汇编语言.s文件中的符号。.symtab节中包含ELF符号表。这张符号表包含一个关于表目的数组。下面是每个表目(entry)的格式:

 

typedef struct{

       int name;               /*string table offset*/

       int value;               /*section offset, or VM address*/

       int size;                 /*object size in bytes*/

       char type:4,           /*data, func, section, or src file name(4 bits)*/

              binding:4;       /*local or global(4 bits)*/

       char reserved;        /*unused*/

       char section;          /*section header index, ABS, UNDEF, or COMMON*/

}Elf_Symbol;

 

name是字符串表中的字节偏移量,指向符号的名字,该名字是以null结尾的字符串名字。

value是符号的地址。对于可重定位的模块来说,value是距定义目标的节的起始位置的偏移。对于可执行目标文件来说,该值是一个绝对运行时地址。

size是目标的大小(以字节计算)。

type通常要么是数据,要么是函数。符号表还可以包含各个节的表目,以及对应原始源文件的路径名的表目。所以这些目标的类型也有些不同。

binding域表示符号是本地的还是全局的。

每个符号都与目标文件的某个节相关联,由section域表示,该域也是一个到节头表的索引。有三个特殊的伪节,它们在节头表中是没有表目的:ABS 代表不该被重定位的符号,UNDEF代表未定义的符号(比如,在本目标模块中引用,但是却在其他地方定义的符号),而COMMON表示还未被分配位置的未初始化的数据目标。对于COMMON符号,value域给出对齐请求,而size给出最小的大小。

 

6、符号解析

 

       链接器解析符号引用的方法是将每个引用与它输入的可重定位目标文件的符号表中一个确定的符号定义联系起来。对于那些引用定义在相同模块中的本地符号的引用,符号解析是非常简单明了的。编译器只允许每个模块中的每个本地符号只有一个定义。编译器确保静态本地变量,它们也会有本地链接器符号,拥有惟一的名字。

       不过,对全局符号的引用解析就棘手得多。当编译器遇到一个不是在当前模块中定义的符号(变量或函数名)时,它会假设该符号是在其他某个模块中定义的,生成一个链接器符号表表目,并把它交给链接器处理。如果链接器在它的任何输入模块中都找不到这个被引用的符号,它就输出一条(通常很难阅读)错误信息并终止。

       对全局符号的解析很棘手,还因为相同的符号会被多个目标文件定义,在这种情况中,链接器必须要么标志一个错误,要么以某种方式选出一个定义并抛弃其他定义。Unix系统采纳的方法包括编译器、汇编器和链接器之间的协作,这样也可能给不知情的程序员带来一些令人烦恼的问题。

 

       链接器如何解析多处定义的全局符号呢?

       在编译时,编译器输出每个全局符号给汇编器,或者是强(strong),或者是弱(weak),而汇编器把这个信息隐含地编码在可重定位目标文件的符号表里。函数和已初始化的全局变量是强符号,未初始化的全局变量是弱符号。

       根据强弱符号的定义,Unix链接器使用下面的规则来处理多处定义的符号:

规则1:不允许有多个强符号

规则2:如果有一个强符号和多个弱符号,那么选择强符号。

规则3:如果有多个弱符号,那么从这些弱符号中任意选择一个。

 

与静态库链接

 

       迄今为止,我们都假设链接器读取一组可重定位目标文件,并把它们链接起来,成为一个输出的可执行文件。实际上,所有的编译系统都提供一种机制,将所有相关的目标模块打包为一个单独的文件,称为静态库,它也可以做链接器的输入。当链接器构造一个输出的可执行文件时,它只拷贝静态库里被应用程序引用的目标模块。

       Unix系统中,静态库以一种称为存档(archive)的特殊文件格式存放在磁盘中,存档文件是一组连接起来的可重定位目标文件的集合,有一个头部描述每个成员目标文件的大小和位置。存档文件名由后缀.a标识。

 

       虽然静态库是很有用而且很重要的工具,但是它们同时也是程序员迷惑的源头,因为Unix链接器使用它们解析外部引用的方式令人困惑。在符号解析阶段,链接器从左到右按照它们在编译器驱动程序命令行上出现的相同顺序来扫描可重定位目标文件和存档文件。在这次扫描中,链接器维持一个可重定位目标文件的集合E,这个集合中的文件会被合并起来形成可执行文件,和一个未解析的符号(也就是引用了但没有定义的符号)集合U,以及一个在前面输入文件中已定义的符号集合D。初始地,EUD都是空的。

对于命令行上的每个输入文件f,链接器会判断f是一个目标文件还是一个存档文件。如果f是一个目标文件,那么链接器把f添加到E,修改UD来反映f中符号定义和引用,并继续下一个输入文件。

如果f是一个存档文件,那么链接器就尝试匹配U中未解析的符号和由存档文件成员定义的符号。如果某个存档文件成员m,定义了一个符号来解析U中的一个引用,那么就将m加到E中,并且链接器修改UD来反映m中的符号定义和引用。对存档文件中所有成员目标文件都反复进行这个过程,直到UD都不再发生变化。在此时,任何不包含在E中的成员目标文件都被丢弃,而链接器将继续到下一个输入文件。

如果当链接器完成对命令行上输入文件的扫描后,U是非空的,那么链接器就会输出一个错误并终止。否则,它会合并和重定位E中的目标文件,从而构建输出的可执行文件。

不幸的是,这种算法会导致一些令人困惑的链接时错误,因为命令行上的库和目标文件的顺序非常重要。如果在命令行中,定义一个符号的库出现在引用这个符号的目标文件之前,那么引用就不能被解析,链接会失败。

 

7、重定位

 

       一旦链接器完成了符号解析这一步,它就把代码中的每个引用和确定的一个符号定义(也就是,它的一个输入目标模块中的一个符号表表目)联系起来了。在此时,链接器就知道它的输入目标模块中的代码节和数据节的确切大小。现在就可以开始重定位步骤了。在这个步骤里,将合并输入模块,并为每个符号分配运行时地址。重定位由两步组成:

重定位节和符号定义。在这一步中,链接器将所有相同类型的节合并为同一类型的新的聚合节。例如,来自输入模块的.data节被全部合并成一个节,这个节成为输出的可执行目标文件的.data节。然后,链接器将运行时存储器地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。当这一步完成时,程序中的每个指令和全局变量都有惟一的运行时存储器地址了。

重定位节中的符号引用。在这一步中,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。为了执行这一步,链接器依赖于称为重定位表目(relocation entry)的可重定位目标模块中的数据结构,我们接下来将会描述这种数据结构。

 

重定位表目

 

       当汇编器生成一个目标模块时,它并不知道数据和代码最终将放在存储器的什么位置。它也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。所以,无论何时汇编器遇到对最终位置未知的目标引用,它就会生成一个重定位表目(relocation entry),告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。代码的重定位表目放在.rel.text中。已初始化数据的重定位表目放在.rel.data中。下面是ELF重定位表目的格式。

typedef struct{

       int offset;                     /*offset of the reference to relocate*/

       int symbol:24,        /*symbol the reference should point to*/

              type:8;            /*relocation type*/

}Elf32_Rel;

offset是需要被修改的引用的节偏移。symbol标识被修改引用应该指向的符号。type告诉链接器如何修改新的引用。

       ELF定义了11种不同的重定位类型,有些相当隐秘。我们只关心其中两种最基本的重定位类型:

R_386_PC32:重定位一个使用32PC相关的地址引用。一个PC相关地址就是距程序计数器(PC)的当前运行时值的偏移量。当CPU执行使用PC相关寻址的指令时,它就将在指令中编码的32位值加上PC的当前运行时值,得到有效地址(例如,call指令的目标),PC值通常是存储器中下一条指令的地址。

R_386_32:重定位一个使用32位绝对地址的引用。通过绝对地址,CPU直接使用在指令中编码的32位值作为有效地址,不需要进一步修改。

 

8、可执行目标文件

 

       我们已经看到链接器是如何将多个目标模块合并成一个可执行目标文件的。我们的C程序,开始是二组ASCII文本文件,已经被转化为一个二进制文件,且这个二进制文件包含加载程序到存储器并运行它所需要的所有信息。下图是一个典型的ELF可执行文件中的种类信息。

 

ELF头部

段头表

.init

.text

.rodata

.data

.bss

.symtab

.debug

.line

.strtab

节头表

 

       可执行目标文件的格式类似于可重定位目标文件格式。ELF头部描述文件的总体格式,它还包括程序的入口点(entry point),也就是当程序运行时要执行的第一条指令的地址。.text.rodata.data节和可重定位目标文件中的节是相似的,除了这些节已经被重定位到它们最终的运行时存储器地址以外。.init节定义了一个小函数,叫做init,程序的初始化代码会调用它。因为可执行文件是完全链接的(已经被重定位了),所以它不再需要.rel节。

       ELF可执行文件被设计为很容易加载到存储器,连续的可执行文件的组块(chunks)被映射到连续的存储器段。段表头(segment header table)描述了这种映射关系。

 

9、加载可执行目标文件

 

       要运行可执行目标文件p,我们可以在命令行中输入它的名字:#./p,因为p不是一个内置的shell命令,所以shell会认为p是一个可执行目标文件,通过调用某个驻留在存储器中称为加载器的操作系统代码来为我们运行之。任何Unix程序都可以通过调用execve函数来调用加载器。加载器将可执行目标文件中的代码和数据从磁盘拷贝到存储器中,然后通过跳转到程序的第一条指令,即入口点,来运行该程序。这个将程序拷贝到存储器中并运行的过程叫做加载(loading)。

       每个Unix程序都有一个运行时存储器映像,如下图所示:

内核虚拟存储器

用户栈(运行时创建)

共享库的存储器映射区域

运行时堆(由malloc创建)

/写段(.data.bss

只读段(.init.text.rodata

          未使用

0xc0000000

0x40000000

0x08048000

%esp(栈指针)

brk

               0

       Linux系统中,代码段总是从地址0x08048000处开始。数据段是在接下来的下一个4KB对齐的地址处。运行时堆在接下来的读写段之后的第一个4KB对齐的地址处,并通过malloc库往上增长。开始于地址0x40000000处的段是为共享库保留的。用户栈总是从地址0xbfffffff处开始,并向下增长(向低存储器地址方向增长)。从栈的上部开始于地址0xc0000000处的段是为操作系统驻留存储器的部分(也就是内核)的代码和数据保留的。

       当加载器运行时,它创建如上图所示的存储器映像。在可执行文件中段头表的指导下,加载器将可执行文件的相关内容拷贝到代码和数据段。接下来,加载器跳转到程序的入口点,就是符号_start的地址。在_start地址处的启动代码(startup code)是在目标文件ctrl.o中定义的,对所有的C程序都是一样的。下面的代码展示了在每个C程序中ctrl.o启动例程的伪代码,注意:没有显示将每个函数的参数压入栈中的代码。:

0x080480c0 <_start>       

       call _libc_init_first                /* entry point in .text*/

       call _init                              /* startup code in .text */

       call atexit                             /* startup code in .init */

       call main                              /* startup code in .text */

       call _exit                              /* returns control to OS */

/* control never reaches here */

 

       在从.text.init节中调用了初始化例程后,启动代码调用atexit例程,这个程序附加了一系列在应用调用exit函数时应该调用的程序。exit函数运行atexit注册的函数,然后通过调用_exit将控制返回给操作系统。接着,启动代码调用应用程序的main程序,这就开始执行我们的C代码了。在应用程序返回之后,启动代码调用_exit程序,它将控制返回给操作系统。

       加载器是如何工作的呢?

       Unix系统中的每个程序都运行在一个进程上下文中,这个进程上下文有自己的虚拟地址空间。当shell运行一个程序时,父shell进程生成一个子进程,它是父进程的一个复制品。子进程通过execve系统调用启动加载器。加载器删除子进程已有的虚拟存储器段,并创建一组新的代码、数据、堆和栈段。新的栈和堆段被初始化为0。通过将虚拟地址空间中的页映射到可执行文件的页大小的组块,新的代码和数据段被初始化为可执行文件的内容。最后,加载器跳转到_start地址,它最终会调用应用程序的main函数。除了一些头部信息,在加载过程中没有任何从磁盘到存储器的数据拷贝。直到CPU引用一个被映射的虚拟页,才会进行拷贝,此时,操作系统利用它的页面调度机制自动将页面从磁盘传送到存储器。

 

10、动态链接共享库

 

       静态库有一此明显的缺点。静态库和所有的软件一样,需要定期维护更新。如果一个应用程序员想使用一个库的最新版本,他们必须以某种方式了解到该库的更新情况,然后显式地将他们的程序与新的库重新链接。

       另一个问题是几乎每个C程序都使用标准I/O函数,比如printfscanf。在运行时,这些函数代码会被复制到每个运行进程的文本段中。在一个运行50-100个进程的典型系统上,这会是对存储资源的极大浪费。

       共享库是致力于解决静态库缺陷的一个现代创新产物。共享库是一个目标模块,在运行时,可以加载到任意的存储器地址,并在存储器中和一个程序链接起来。这个过程称为动态链接,是由一个叫动态链接器的程序来执行的。

       共享库也称为共享目标,在Unix系统中通常用.so后缀来表示。微软的操作系统大量地利用了共享库,它们称为DLL(动态链接库)。

       共享库的“共享”在两个方面有所不同。首先, 在任何给定的文件系统中,对于一个库只有一个.so文件。所有引用该库的可执行目标文件共享这个.so文件中的代码和数据,而不是像静态库的内容那样被拷贝和嵌入到引用它们的可执行的文件中。其次,在存储器中,一个共享库的.text节只有一个副本可以被不同的正在运行的进程共享。下图给出了一个动态链接过程的例子。为了构造共享库libvector.so,我们会调用编译器,给链接器如下特殊指令:

unix > gcc -shared -fPIC -o libvector.so addvec.c multvec.c

main2.c

vector.h

翻译器(cpp,cc1,as

可重定位目标文件main2.o

链接器(ld

libc.so

libvector.so

重定位和

符号表信息

部分链接的可执行文件p2

加载器(execve

动态链接器(ld-linux.so

存储器中完全链接的可执行文件

代码和数据

libc.so

libvector.so

       -fPIC选项指示编译器生成与位置无关的代码(下面将讨论这个问题)。-shared选项指示链接器创建一个共享的目标文件。

       一旦我们创建了这个库,我们随后就要将它链接到程序中。

       unix > gcc -o p2 main2.c ./libvector.so

       这样就创建了一人可执行目标文件p2,而此文件的形式使得它在运行时可以和libvector.so链接。基本的思路是当创建可执行文件时,静态执行一些链接,然后在程序加载时,动态完成链接过程。

       认识到这一点是很重要的:在此时刻,没有任何libvector.so的代码和数据节被真的拷贝到可执行文件p2中。取而代之的是,链接器拷贝了一些重定位和符号表信息,它们使得运行时可以解析对libvector.so中代码和数据的引用。

       当加载器加载和运行可执行文件p2时,它利用了前面一节讨论过的技术,加载部分链接的可执行文件p2,接着,它注意到p2包含一个.interp节,这个节包含动态链接器的路径名,动态链接器本身就是一个共享目标(比如,在Linux系统上的LD-LINUX.SO)。加载器不再像它通常那样将控制传递给应用,取而代这的是加载和运行这个动态链接器。

       然后,动态链接器通过执行下面的重定位完成链接任务:

重定位libc.so的文本和数据到某个存储器段,在IA32/Linux系统中,共享库被加载到从地址0x40000000开始的区域中。

重定位libvector.so的文本和数据到另一个存储器段。

重定位p2中所有对由libc.solibvector.so定义的符号的引用。

       最后,动态链接器将控制传递给应用程序,从这个时刻开始,共享库的位置就固定了,并且在程序执行的过程中都不会改变。

 

11、从应用程序中加载和链接共享库

 

       到此刻为止,我们已经讨论了在应用程序执行之前,即应用程序被加载时,动态链接器加载和链接共享库的情景。然而,应用程序还可能在它运行时要求动态链接器加载和链接任意的共享库,而无需在编译时链接那些库到应用中。

       动态链接是一项强大有用的技术。下面是一些现实世界中的例子:

分发软件。微软Windows应用的开发者常常利用共享库来分发软件更新。他们生成一个共享库的新版本,然后用户可以下载,并用它替代当前的版本。下一次他们运行应用程序时,应用将自动链接和加载新的共享库。

构建高性能Web服务器。许多Web服务器生成动态内容,比如个性化的Web页面、账户余额和广告标语。早期的Web服务器通过使用forkexecve创建一个子进程,并在该子进程的上下文中运行CGI程序,来生成动态内容。然而,现代高性能的Web服务器可以使用基于动态链接的更有效和完善的方法来生成动态内容。其思路是将生成动态内容的每个函数打包在共享库中。当一个来自Web浏览器的请求到达时,服务器动态地加载和链接适当的函数,然后直接调用它,而不是使用forkexecve在子进程的上下文中运行函数。函数会一直缓存在服务器的地址空间中,所以只要一个简单的函数调用就可以处理随后的请求了。这对一个繁忙的网站来说是有很大影响的。更进一步,可以在运行时,无需停止服务器,更新已存在的函数,以及添加新的函数。

 

       LinuxSolaris这样的Unix系统,为动态链接器提供了一个简单的接口,允许应用程序在运行时加载和链接共享库。

#include <dlfcn.h>

void *dlopen(const char *filename, int flag);

若成功则为指向句柄的指针,若出错则为NULL

dlopen函数加载和链接共享库filename。用以前带RTLD_GLOBAL选项打开的库解析filename中的外部符号。如果当前可执行文件是带-rdynamic选项编译的,那么对符号解析而言,它的全局符号也是可用的。flag参数必须要么包括RTLD_NOW,该标志告诉链接器立即解析对外部符号的引用,要么包括RTLD_LAZY标志,该标志指示链接器推迟符号解析直到执行来自库中的代码时。这两个值中的任意一个都可以和RTLD_GLOBAL标志取或。

 

#include <dlfcn.h>

void *dlsym(void *handle, char *symbol);

若成功返回指向符号的指针,若出错则为NULL

dlsym函数的输入是一个指向前面已经打开共享库的句柄和一个符号名字,如果该符号存在,就返回符号的地址,否则返回NULL

 

#include <dlfcn.h>

int dlclose(void *handle);

若成功返回0,若出错返回1

如果没有其他共享库还在使用这人共享库,dlclose函数就卸载该共享库。

 

#include <dlfcn.h>

const char *dlerror(void);

如果前面对dlopendlsymdlclose的调用失败,则为错误消息,如果前面的调用成功,则为NULL

dlerror函数返回一个字符串,它描述的是调用dlopendlsym或者dlclose函数时发生的最近的错误,如果没有错误发生,就返回NULL

 

下面的程序展示了我们如何利用这个接口动态链接我们的libvector.so共享库,然后调用它的addvec程序。要编译这个程序,我们将以下面的方式调用GCC

unix > gcc -rdynamic -O2 -o p3 main3.c -ldl

 

/* main3.c */

#include <stdio.h>

#include <dlfcn.h>

 

int x[2] = {1, 2};

int y[2] = {3, 4};

int z[2];

 

int main()

{

       void *handle;

       void (*addvec)(int *, int *, int *, int);

       void *error;

 

       /*dynamically load the shared library that contains addvec() */

       handle = dlopen(“./libvector.so”, RTLD_LAZY);

       if(!handle)

       {

              fprintf(stderr, “%s/n”, dlerror());

              exit(1);

       }

 

       /*get a pointer to the addvec() function we just loaded */

       addvec = dlsym(handle, “addvec”);

       if((error = dlerror()) != NULL)

       {

              fprintf(stderr, “%s/n”, error);

              exit(1);

       }

 

       /*Now we can call addvec() just like any other function */

       addvec(x, y, z, 2);

       printf(“z = [%d %d]/n”, z[0], z[1]);

 

       /*unload the shared library */

       if(dlclose(handle) < 0)

       {

              fprintf(stderr, “%s/n”, dlerror());

              exit(1);

       }

       return 0;

}

 

12、与位置无关的代码(PIC

 

       共享库的一个主要目的就是允许多个正在运行的进程共享存储器中相同的库代码,因而节约宝贵的存储器资源。那么,多个进程是如何共享一个程序的一个拷贝的呢?一种方法是给每个共享库分配一个事先预备的专用的地址空间组块(chunk),然后要求加载器总是在这个地址加载共享库。虽然这种方法很简单,但是它也造成了一些严重的问题。首先,它对地址空间的使用效率不高,因为即使一个进程不使用这个库,那部分空间还是会被分配出来。第二,它也难以管理。我们将不得不保证没有组块会重叠。每次当一个库修改了之后,我们必须确认它的已分配的组块还适合它的大小。如果不适合了,我们必须找一个新的组块。并且,如果我们创建了一个新的库,我们还必须为它寻找空间。随着时间的进展,假设在一个系统中有了成百个库和各种版本的库,就很难避免地址空间分裂成大量小的、未使用而又不再能使用的小洞。甚至更糟的是,对每个系统而言,从库到存储器内分配都是不同的,这就引起了更多令人头痛的管理问题。

       一种更好的方法是编译库代码,使得不需要链接器修改库代码,就可以在任何地址加载和执行这些代码,这样的代码叫与位置无关的代码(position-independent code, PIC)。用户对GCC使用-fPIC选项指示GNU编译系统生成PIC代码。

       在一个IA32系统中,对同一个目标模块中过程的调用是不需要特殊处理的,因为引用是PC相关的,已知偏移量,就已经是PIC了。然而,对外部定义的过程调用和对全局变量的引用通常不是PIC,因为它们都要求在链接时重定位。

      

       PIC数据引用

       编译器通过运用以下有趣的事实来生成对全局变量的PIC引用:无论我们在存储器中的何处加载一个目标模块(包括共享目标模块),数据段总是分配为紧随在代码段后面。因此,代码段中任何指令和数据段中任何变量之间的距离都是一个运行时常量,与代码段和数据段的绝对存储器位置是无关的。

       为了运用这个事实,编译器在数据段开始的地方创建了一个表,叫做全局偏移量表(global offset table, GOT)。GOT包含每个被这个目标模块引用的全局数据目标的表目。编译器还为GOT中每个表目生成一个重定位记录。在加载时,动态链接器会重定位GOT中的每个表目,使得它包含正确的绝对地址,每个引用全局数据的目标模块都有一张自己的GOT

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值