深入理解计算机系统:链接

链接是将各种代码和数据部分收集起来并组合成为一个单一文件的过程,这个文件可被加载(拷贝)到存储器中并执行。链接可以执行于编译时,也就是源代码翻译成机器码时,也可以执行于加载时,也就是程序被加载到存储器并执行时,甚至执行于运行时,由应用程序来执行。链接是由叫做链接器的程序自动执行的。
链接器的出现,使得分离编译成为可能,我们不用将一个大型的应用程序组织为一个巨大的源文件,而是把它分解成更小、更好管理的模块,可以独立的修改和编译这些模块。当我们改变这些模块中的一个时,我们只要单独地编译它,并将它重新链接到应用上,而不用编译其他文件。

编译器驱动程序

让我们先回忆一下程序构建的整个过程:分别经历预处理,编译,汇编和链接几个阶段,而这些都分别由预处理器,编译器,汇编器和链接器处理的,这些构成了编译系统的编译驱动程序。下面我们通过两个源文件main.c和swap.c来说明关于链接是如何工作的一些重要知识点。
这里写图片描述

大多数编译系统提供编译驱动程序,它首先运行C预处理器cpp,将C源程序main.c翻译成一个ASCII码的中间文件main.i。
接下来,C编译器cc1将main.i翻译成一个ASCII汇编语言文件main.s。
最后,汇编器as将main.s翻译成一个可重定位目标文件main.o。

类似地,生成swap.o。最后,运行链接器程序ld,将main.o和swap.o以及一些必要的系统目标文件组合起来,创建一个可执行目标文件

在Shell中,使用加载器函数,拷贝可执行文件中的代码和数据到存储器,然后将控制转移到这个程序的开头。

静态链接

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

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

  1. 符号解析。目标文件定义和引用符号。符号解析的目的是将每个符号引用刚好和一个符号定义联系起来。
  2. 重定位。编译器和汇编器生成从地址0开始的代码和数据节。链接器通过把每个符号定义与一个存储器位置联系起来,然后修改所有对这些符号的引用,使得他们指向这个存储器,从而重定位这些节。

目标文件纯粹是字节块的结合,有些包含代码,有些包含数据。

目标文件

目标文件共有三种形式:

  1. 可重定位目标文件。包含二进制代码和数据,其可以在编译时与其他可重定位目标文件合并起来,创建一个可执行目标文件。
  2. 可执行目标文件。包含二进制代码和数据,其形式可以被直接拷贝到存储器并执行。
  3. 共享目标文件。一种特殊类型的可重定位目标文件,可以在加载或者运行时被动态地加载到存储器并链接。

编译器和汇编器生成可重定位目标文件(包括共享目标文件),链接器生成可执行目标文件。
各系统之间,目标文件的格式各不相同,早期的UNIX使用的是一般目标文件COFF。Windows NT使用的是COFF的一个变种,叫做可移植性可执行PE格式。现代Unix系统(包括linux,solaris)使用的是Unix可执行和可链接格式ELF。这些格式尽管各不相同,但基本的概念是类似的。

可重定位目标文件

在这里我们介绍一个典型的ELF可重定位目标文件的格式。ELF头以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型(可重定位、可执行或者共享的)、机器类型(如IA32)、节头部表的文件偏移,以及节头部表中的条目大小和数量。

夹在ELF头和节头部表之间的都是节。一个典型的ELF可重定位目标文件包含下面几个节:
这里写图片描述

.text:已编译程序的机器代码
.rodata:只读数据
.data:已初始化的全局C变量,局部C变量在运行时保存在栈中
.bss:未初始化的全局变量。在目标文件中这个节不占据实际的空间,它仅仅是一个占位符。目标文件格式区分初始化和未初始化变量是为了空间效率。
.symtab:符号表,它存放在程序中定义和引用的函数和全局变量的信息。
.rel.text:一个在.text节中位置的列表,当链接器把这个目标文件和其他文件结合时,需要修改这些位置。一般而言,任何调用外部函数或者引用全局变量的指令都需要修改。
.rel.data:被模块引用或者定义的任何全局变量的重定位信息。一般而言,任何已初始化的全局变量,如果它的初始值是一个全局变量地址或者外部定义函数的地址,都需要被修改。
.debug:一个调试符号表,其条目是程序中定义的局部变量和类型定义,程序中定义和引用的全局变量,以及原始的C源程序。
.line:原始C源程序中的行号和.text节中机器指令之间的映射。
.strtab:一个字符串表,其内容包括.symtab和.debug节中的符号表,以及节头部中的节名字。

符号和符号表

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

  1. 由m定义并能被其他模块引用的全局符号。全局链接器符号对应于非静态的C函数以及被定义为不带C static属性的全局变量。
  2. 由其他模块定义并被模块m引用的全局符号。这些符号称为外部符号,对应于定义在其他模块中的C函数和变量。
  3. 只被模块m定义和引用的本地符号。有的本地链接器符号对应于带static属性的C函数和全局变量。

在.symtab中的符号表不包含对应于本地非静态程序变量的任何符号。这些符号在运行时再栈中管理。
然而如果本地变量是静态的,比如在函数中定义的static变量,编译器会在.data和.bss中为每个定义分配空间,并在符号表中创建一个唯一名字的本地链接器符号。
符号表是一张包含多个条目的数组,每个条目的格式为
这里写图片描述

name 是字符串表中的字节偏移,指向符号的以null结尾的字符串名字。Value是符号的地址。对于可重定位的模块来说,value是距定义目标的节起始位置的偏移。对于可执行目标文件来说,该值是一个绝对运行时地址。Size是目标的大小。Type通常要么是数据,要么是函数。Binding字段表示符号是本地的还是全局的。

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

符号解析

链接器解析符号引用的方法是将每个引用与它输入的可重定位目标文件的符号表中的一个确定符号定义联系起来。对于和引用定义在同一模块中的本地符号的解析非常简单,编译器确保了每个模块中每个本地符号只有一个定义。

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

链接器如何解析多重定义的全局符号

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

根据强弱符号的定义,Unix链接器使用下面的规则来处理多重定义的符号:
规则1:不允许有多个强符号
规则2:如果有个强符号和多个弱符号,那么选择强符号
规则3:如果有多个弱符号,那么从这些弱符号中任意选择一个

与静态库的链接

所有的编译系统都提供一种机制,在链接中,将需要的目标模块打包成一个文件,我们称作静态库。它作为链接器的输入,在构造一个输出的可执行文件时,它只拷贝静态库里被应用程序引用的目标模块。

系统支持静态库的目的之一是为了方便开发人员更加有效率的进行开发。以ANSI C为例,它定义了一组广泛的标准I/O、字符串操作和整数数学函数,例如atoi、printf、scanf,它们被定义在libc.a库中,对每个C程序来说都是可用的。另一方面,相比可重定位目标模块,静态库能够节省存储空间,试想如果标准C函数都放在一个单独的可重定位目标模块中,那么在链接生成的每个可执行文件中都会包含一份标准函数集合的完全拷贝。而对于静态库,链接器只拷贝被引用的目标模块,这就减少了可执行文件在磁盘和存储器中的大小。

链接器如何使用静态库来解析引用

在符号解析的阶段,链接器从左到右按照它们在编译器驱动程序命令上出现的相同顺序来扫描可重定位目标文件和存档文件。在扫描中,链接器维持一个可重定位目标文件的集合E(这个集合中的文件会被合并起来形成可执行文件),一个未解析的符号(即引用了但是尚未定义的符号)集合U,以及一个在前面输入文件中已定义的符号集合D。初始时,E、U和D都是空的。

对于命令行上的每个输入文件f,链接器会判断f是一个目标文件还是一个存档文件(静态库.a),如果f是一个目标文件,那么链接器把f添加到E,修改U和D来反映f中的符号定义和引用,并继续下一个输入文件。
如果f是一个存档文件,那么链接器会尝试匹配U中未解析的符号和由存档文件成员定义的符号。如果某个存档文件成员m,定义了一个符号来解析U中的一个引用,那么就将m加到E中,并且链接器修改U和D来反映m中的符号定义和引用。对存档文件中所有成员目标文件都反复进行这个过程,直到U和D都不再发生变化。在此时,任何不包含在E中的成员目标文件都简单地被丢弃,而链接器将继续处理下一个输入文件。
如果当链接器完成对命令行上输入文件的扫描后,U是非空的,那么链接器就会输出一个错误并终止。否则,它会合并和重定位E中的目标文件,从而构建输出的可执行文件。

不过,这个算法会导致一些令人困扰的链接时错误,因为命令行上的库和目标文件的顺序非常重要。在命令行中,如果定义一个符号的库出现在引用这个符号的目标文件之前,那么引用就不能被解析,链接会失败。关于库的一般准则是将它们放在命令行的结尾。如果各个库的成员是相互独立的,那么这些库就可以按照任何顺序放置在命令行的结尾处。但如果库不是相互独立的,那么它们必须排序,使得对于每个存档文件的成员外部引用的符号s,在命令行中至少有一个s的定义是在对s的引用之后的。比如,假设foo.c调用libx.a和libz.a中的函数,而这两个库又调用liby.a中的函数。那么,在命令行中libx.a和libz.a必须处在liby.a之前:gcc foo.c libx.a libz.a liby.a,还有,如果需要满足依赖需求,可以在命令行上重复库。比如,假设foo.c调用libx.a中的函数,该库又调用liby.a中的函数,而liby.a又调用libx.a中的函数。那么libx.a必须在命令行上重复出现:gcc foo.c libax.a liby.a libx.a。

重定位

一旦链接器完成了符号解析这一步,它就把代码中的每个符号引用和确定的一个符号定义联系起来。这样,链接器就知道它输入目标模块中的代码节和数据节的确切大小,并根据这些对目标模块进行重定位,分配运行时地址。重定位由两步组成:

  1. 重定位节和符号定义。在这一步中,链接器将所有相同类型的节合并为同一个类型的新的聚合节。然后,链接器将运行时存储器地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。
  2. 重定位节中的符号引用。在这一步中,链接器修改代码节和数据节中对每个符号的引用,使得他们指向正确的运行时地址。这些依赖于称为重定位条目的可重定位目标按模块中的数据结构。

重定位条目

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

ELF重定位条目的格式如下:offset是需要被修改的引用的节偏移。Symbol标识被修改的引用应该指向的符号。Type告知链接器如何修改新的引用。

Typedef struct {
    Int offset;
    Int symbol:24,
    Type:8;    
}Elf32_Rel;

ELF定义了11中不同的重定位类型,我们在这里只关心其中最基本的两种:

R_386_PC32:重定位一个使用32位PC相对地址的引用。即当相对于当前距程序计数器PC的当前运行时值的偏移量。例如CALL指令的目标。

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

可执行文件

我们已经看到链接器是如何将多个目标模块合并成一个可执行目标文件的。我们的C程序开始时是一组ASCII文本文件,已经被转化为一个二进制文件,且这个二进制文件包含加载程序到存储器并运行它所需的所有信息。下图是一个典型的ELF可执行文件的信息。
这里写图片描述
可执行目标文件的格式类似于可重定位目标文件的格式。EFL头部描述文件的总体格式。它还包括程序的入口点。也就是程序执行的第一条指令地址。.text、.data节和可重定位目标文件中的节是相似的,除了这些节已经被重定位到他们最终的运行时存储器地址以外。.init节定义了一个小函数,叫做_init,程序的初始化代码会调用它。因为可执行文件时完全链接的,所以它不再需要.rel节。

加载可执行目标文件

要运行可执行目标文件test,可以在Unix的外壳命令中输入它的名字:

./test

因为test不是一个内置的外壳命令,所以外壳会认为test是一个可执行目标文件,通过调用某个驻留在存储器中称为加载器的操作系统代码来运行它。我们可以使用execve来调用加载器,它将可执行目标文件中的代码和数据从磁盘拷贝到存储器中,然后通过跳转到程序的第一个指令或者入口点来运行该程序。这个程序拷贝到存储器并运行的过程叫做加载

在32位Linux系统中,代码段总是从地址0x08048000处开始。数据段是在接下来的下一个4KB对齐的地址处。运行时堆在读/写段之后接下来的第一个4KB对其的地址处,并通过调用malloc库往上增长。还有一个段为共享库保留的。用户栈总是从最大的合法用户地址开始,向下增长的。从栈的上部开始的段是为操作系统驻留存储器的部分的代码和数据保留的。

动态链接共享库

前面我们了解了静态库(如libc.a和windows下的 lib文件),我们知道了静态库存在的理由,比如方便使用,节省磁盘空间,但相比动态共享库来说,静态库这些都不再是优势,静态库和其他软件一样需要定期维护和更新,可是一旦更新,如果开发人员想用较新版本的库,就必须重新链接编译程序。另外,静态库造成了一定程度的内存储器的空间浪费,如大多数的C程序使用printf,scanf函数,这样在每个进程的文本段中,都会有复制一份这些目标模块的拷贝,从而造成内存空间的浪费。

共享库的出现正是为了静态库带来的问题。共享库是一个目标模块,在运行时,可以加载到任意的存储器地址,并存储器中的程序链接起来。这个过程称为动态链接,是由一个叫做动态链接器的程序来执行的。在Unix系统中通常用.so后缀来表示。Windows系统用DLL来表示。

共享库的最大优势在于,在任何给定的文件系统中,对于一个库只有一个.so文件。所有引用该库的可执行目标文件共享这个.so文件中的代码和数据,而不是像静态库的内容那样被拷贝和嵌入到引用他们的可执行的文件中。其次,在存储器中,一个共享库的.text节的一个副本可以被不同的正在运行的进程共享。另一方面,从动态链接的角度来说,共享库使得更新和维护应用程序更加方便,我们只需要对共享库进行定期的更新,而不需要对整个工程进行再编译构建。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值