《深入理解计算机系统》第七章学习笔记
第七章 链接
【学习时间:1小时30分 撰写博客时间:2小时20分钟】
链接是将各种代码和数据部分收集起来并组合成为一个单一文件的过程,这个文件可被加载(或拷贝)到存储器并执行。链接可以执行于编译时,也就是在源代码被翻译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到存储器并执行时;甚至执行于运行时,由应用程序来执行。在早期的计算机系统中,链接是手动执行的。在现代系统中,链接是由叫链接器的自动执行的。
- 理解链接器将帮助构造大型程序
- 理解链接器将帮助避免一些危险的编程错误
- 理解链接器将帮助语言的作用域规则是如何实现的
- 理解链接器将帮助其他重要的系统概念
- 理解链接器便于利用共享库
一、编译器驱动程序
大多数编译系统提供编译驱动程序,它代表用户在需要时调用语言预处理器、编译器、汇编器和链接器。
gcc -O2 -g -o p main.c swap.c
以上代码将示例程序从ASCII码源文件翻译成可执行目标文件,具体行为如下图:
二、静态链接
像Unix ld程序这样的静态链接器以一组可重定位目标文件和命令行参数作为输入,生成一个完全链接的可以加载和运行的可执行目标文件作为输出。输入的可重定位目标文件由各种不同的代码和数据节组成。指令在一个节中,初始化的全局变量在另一个节中,而未初始化的变量又在另外一个节中。
1. 为了构造可执行文件,链接器必须完成两个主要任务:
- 符号解析:目标文件定义和引用符号。符号解析的目的是将每个符号引用刚好和一个符号定义联系起来
- 重定位:编译器和汇编器生成从地址0开始的饿代码和数据节。链接器通过把每个符号定义与一个存储器位置联系起来,然后修改所有对这些符号的引用,使得它们指向这个存储器位置,从而重定位这些节
2. 链接器的一些基本事实:目标文件纯粹是字节块的集合。这些块中,有些包含程序代码,有些则包含程序数据,而其他的则包含指导链接器和加载器的数据结构。链接器将这些块连接起来,确定被连接块的运行时位置,并且修改代码和数据块中的各种位置。链接器和汇编器已经完成了大部分工作。
三、目标文件
1. 目标文件的三种形式:
- 可重定位目标文件。包含二进制代码和数据,其形式可以在编译时与其他可重定位目标文件合并起来,创建一个可执行目标文件
- 可执行目标文件:包含二进制代码和数据,其形式可以被直接拷贝到存储器并执行
- 共享目标文件:一种特殊类型的可重定位目标文件,可以在加载或运行时被动态地加载到存储器并链接
2. 编译器和汇编器生成可重定位目标文件(包括共享目标文件)。链接器生成可执行目标文件。从技术上来说,一个目标模块就是一个字节序列,而一个目标文件就是一个存放在磁盘文件中的目标模块。
四、可重定位目标文件
1. 一个典型的ELF可重定位目标文件的格式P451。ELF头(ELF header)以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型(如可重定位、可执行或是共享的)、机器类型(如IA32)、节头部表的文件偏移,以及节头部表中的条目大小和数量。不同的节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定大小的条目。
2. 夹在ELF头和节头部表之间的都是节。一个典型的ELF可重定位目标文件包含下面几个节:
-
.text 已编译程序的机器代码
-
.rodata 只读数据
-
.data 已初始化的全局C变量。局部C变量在运行时保存在栈中,既不出现在.data节中 ,也不出现在.bss节中
-
.bass 未初始化的全局C变量。在目标文件中这个节不占据实际的空间,它仅仅是一个占位符。目标文件格式区分初始化和未初始化变量是为了空间效率:在目标文件中,未初始化变量不需要占据任何实际的磁盘空间
-
.symtab 一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。每个可重定位目标文件在.symtab中都有一张符号表
-
.rel.text 一个.text节中位置的列表,当链接器吧这个目标文件和其他文件结合时,需要修改这些位置。一般而言,任何调用外部函数或引用全局变量的指令都需要修改。另一方面,调用本地函数的指令则不需要修改。注意,可执行目标文件中并不需要重定位信息,因此通常省略,除非用户显示第指示链接器包含这些信息
-
.rel.data 被模块引用或定义的任何全局变量的重定位信息。一般而言,任何已初始化的全局变量,如果它的初始值是一个全局变量地址或者外部定义函数的地址,都需要被修改
-
.debug 一个调试符号表,其条目是程序总定义的局部变量和类型定义,程序中定义和引用的 全局变量,以及原始的C源文件
-
.line 原始C源文件中的行号和.text节中机器指令之间的映射
-
.strtab 一个字符串表,其内容包括.symtab和.debug节中的符号表,以及节头部中的节名字。
五、符号和符号表
1. 每个可重定位目标模块m都有一个符号表,它包含m所定义和引用的符号的信息。
2. 链接器上下文中三种不同的符号:
- 由m定义并能被其他模块引用的全局符号。全局链接器符号对应于非静态的C函数以及被定义为不带C static属性的全局变量
- 由其他模块定义并被模块m引用的全局符号。这些符号称为外部符号,对应于定义在其它函数中的C函数和变量
- 只被模块m定义和引用的本地符号。有的本地链接器符号对应于带static属性的C函数和全局变量。这些符号在模块m中随处可见,但是不能被其他模块引用。目标文件中对应于模块m的节和相应的源文件的名字也能获得本地符号
3. 定义为带有C static属性的本地过程变量不在栈中管理。编译器在.data和.bss中为每个定义分配空间,并在符号表中创建一个唯一有名字的本地链接器符号。
4. 利用static属性隐藏变量和函数名字。任何声明带有static属性的全局变量或函数都是模块私有的。
- name是字符串表中的字节偏移,指向符号的以null结尾的字符串名字
- value是符号的地址。对于可重定位的模块而言,value是距定义目标的节的起始位置的偏移。对于可执行目标文件而言,该值是一个绝对运行时地址
- size是目标的大小,以字节为单位
- type是数据或函数
- binding字段表示符号是本地的还是全局的
- section字段表示一个到节头部表的索引
六、符号解析
6.1 链接器如何解析多重定义的全局符号
1. 在编译时,编译器向汇编器输出每个全局符号,或者是强或者是弱,而汇编器把这个信息隐含地编码在可重定位目标文件的符号表里。函数和已初始化的全局变量时强符号,未初始化的全局变量是弱符号。
2. 根据强弱符号的定义,Unix链接器使用下面的规则来处理多重定义的符号:
- 规则1:不允许有多个强符号
- 规则2:如果有一个强符号和多个弱符号,那么选择强符号
- 规则3:如果有多个弱符号,那么从这些弱符号中任意选择一个
6.2 与静态库链接
1. 使用标准C库和数学库中函数的程序可以用形式如下的命令行来编译和链接:
gcc main.c /usr/lib/libm.a /usr/lib/libc.a
2. 在Unix系统中,静态库以一种称为存档的特殊文件格式村凡在磁盘中。存档文件是一组连接起来的可重定位目标文件的集合,有一个头部用来描述每个成员目标文件的大小和位置。存档文件名由后缀.a标识。
- 与静态库链接的过程
6.3 链接器如何使用静态库来解析引用
1. 在符号解析的阶段,链接器从左到右按照它们在编译器驱动程序命令行上出现的相同顺序来扫描可重定位目标文件和存档文件。在这次扫描中,链接器维持一个可重定位目标文件的集合E(这个集合中的文件会被合并起来形成可执行文件),一个未解析的符号(即引用了但是尚未定义的符号)集合U,以及一个在前面输入文件中已定义的符号集合D。初始时,E、U和D都是空的。
-
对于命令行上的每个输入文件f,链接器会判断f是一个目标文件还是一个存档文件。如果f是一个目标文件,那么链接器把f添加到E, 修改U和D来反映f中的符号定义和引用,并继续下一个输入文件
-
如果f是一个存档文件,那么链接器就尝试匹配U中未解析的符号和由存档文件成员定义的符号。如果某个存档文件成员m,定义了一个符号来解析U中的一个引用,那么就将m加到E中,并且链接器修改U和D来反映m中的符号定义和引用。对存档文件中所有的成员目标文件都反复进行这个过程,直到U和D都不再发生变化。在此时,任何不包含在E中的目标文件都简单地被丢弃,而链接器将继续处理下一个输入文件
-
如果当链接器完成对命令行上输入文件的扫描后,U是非空的,那么链接器就好输出一个错误并终止。否则,它会合并和重定位E中的目标文件,从而构建输出的可执行文件
2. 这种算法会导致一些令人困扰的链接时错误,因为命令行上的库和目标文件的顺序非常重要。在命令行中,如果定义一个符号的库出现在引用这个符号的目标文件之前,那么引用就不能被解析,链接会失败。关于库的一般准则是将它们放在命令行的结尾。
3. 另一方面,如果库不是相互独立的,那么它们必须排序,使得对于每个被存档文件的成员外部引用的符号s,在命令行中至少有一个s的定义实在对s的引用之后的。
4. 如果需要满足依赖需求,可以在命令行上重复库。
七、重定位
1. 一旦链接器完成了符号解析这一步,它就是把代码中的每个符号引用和确定的一个符号定义(即它的一个输入目标模块中的一个符号表条目)联系起来。在此时,链接器就知道它的输入目标模块中的代码节和数据节的确切大小。现在就可以开始重定位了,在这个步骤中,将合并输入模块,并为每个符号分配运行时地址。重定位由两步组成:
-
重定位节和符号定义。在这一步中,链接器将所有相同类型的节合并为同一类型的新的聚合节。然后,链接器将运行时存储器地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。当这一步完成时,程序中的每个指令和全局变量都有唯一的运行时存储器地址。
-
重定位节中的符号引用。在这一步中,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。为了执行这一步,链接器依赖于称为重定位条目的可重定位目标模块中的数据结构
7.1 重定位条目
1. 当汇编器生成一个目标模块时,它并不知道数据和代码最终存放在存储器中的什么位置。它也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。所以,无论何时汇编器遇到对最终位置位置的目标引用,它就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。代码的重定位条目放在.rel.text中。 已初始化的数据的重定位条目放在.rel.data中。
2. ELF定义了11种不同的重定位类型。其中两种最基本的重定位类型:
- R_ 386_PC32 重定位一个使用32位PC相对地址的引用。
- R_ 386_32 重定位一个使用32位绝对地址的引用。
7.2 重定位符号引用
链接器修改代码节和数据节中对每个符号的引用,使得他们指向正确的运行时地址。
- 重定位PC相对引用
- 重定位绝对引用
八、可执行目标文件
- 可执行目标文件的格式类似于可重定位目标文件的格式。ELF头部描述文件的总体格式。它还包括程序的入口点,也就是当程序运行时要执行的第一条指令的地址。.text 、.rodata和.data 节和可重定位目标文件中的节是相似的,除了这些节已经被重定位到它们最终的运行时存储器地址以外。.init节定义了一个小函数,叫做_init,程序的初始化代码会调用它。因为可执行文件是完全链接的(已被重定位了),所以它不再需要.rel节。
- ELF可执行文件被设计得很容易加载到存储器,可执行文件的连续的片被映射到连续的存储器段。段头部表描述了这种映射关系。
九、加载可执行目标文件
1. 要运行可执行目标文件p,可以在Unix外壳的命令行中输入它的名字:
unix> ./p
因为p不是一个内置的外壳命令,所以外壳会认为p是一个可执行目标文件,通过调用某个驻留在存储器中的称为加载器(loader)的操作系统代码来运行它。任何Unix程序都可以通过调用execve函数来调用加载器。加载器将可执行目标文件中的代码和数据从磁盘拷贝到存储器中,然后通过跳转到程序的第一条指令或入口点来运行该程序。这个将程序拷贝到存储器并运行的过程叫做加载。
2. 每个Unix程序都有一个运行时存储器映像。例如:在32位Linux系统中,代码段总是从地址(0x8048000)处开始。数据段是在接下来的下一个4KB对齐的地址处。运行时堆在读/写段之后接下来的第一个4KB对齐的地址处,并童工调用malloc库往上增长。还有一个段是为共享库保留的。用户栈总是从最大的合法用户地址开始,向下增长的(向低存储器地方向增长)。从栈的上部开始的段是为操作系统驻留存储器的部分(也就是内核)的代码和数据保留的。
3. 在可执行文件中段头部表的指导下,加载器将可执行文件的相关内容拷贝到代码和数据段。接下来,加载器跳转到程序的入口点,也就是符号_ start的地址。在_ start地址处的启动代码是在目标文件ctrl.o中定义的,对所有的C程序都是一样的。在从.text和.init节中调用了初始化例程后,启动代码调用atexti例程,这个程序附加了一系列在应用程序正常中止时应该调用的程序。exit函数运行atexit注册的函数,然后通过调用_ exit将控制返回给操作系统。接着,启动代码调用应用程序的main程序,它会开始执行我们的C代码。在应用程序返回之后,启动代码调用_ exit程序,它将控制返回给操作系统。
十、动态链接共享库
1. 概念:共享库是致力与解决静态库缺陷的一个现代创新产物。共享库是一个目标模块,在运行时,可以加载到任意的存储器地址,并加一个在存储器中的程序链接起来。这个过程称为动态链接,是由一个叫做动态链接器的程序来执行的。共享库也称为共享目标,在Unix系统中通常用.so后缀来表示。
调用编译器构造向量运算示例程序的共享库libvector.so: gcc -shared -fPIC -o libvector.so addvec.c multvec.c 将库链接到程序中,创建一个可执行目标文件p2: gcc -o p2 main.c /libvector.so
2. 动态链接器通过执行下面的重定位完成链接任务:
- 重定位libc.so的文本和数据到某个存储器段
- 重定位libvector.so的文本和数据到另一个存储器段
- 重定位p2中所有对libc.so和libvector.so定义的符号的引用
- 最后动态链接器将控制传递给应用程序,此时共享库的位置已固定,并且在程序执行的过程中不会改变
十一、从应用程序中加载和链接共享库
思路:将生成动态内容的每个函数打包在共享库中。
十二、与位置无关的代码
概念:编译库代码,使不需要链接器修改库代码就可以在任何地址加载和执行这些代码。这样的代码叫做与位置无关的代码(PIC)。
- PIC数据引用
- PIC函数调用
十三、处理目标文件的工具
在Unix系统中有大量可用的工具可以帮助你理解和处理目标文件。
- AR:创建静态库,插入、删除、列出和提取成员
- STRINGS:列出一个目标文件中所有可打印的字符串
- STRIP:从目标文件中删除符号表信息
- NM:列出一个目标文件的符号表中定义的符号
- SIZE:列出目标文件中节的名字和大小
- READELF:显示一个目标文件的完整大小,包括ELF头中编码的所有信息。包含SIZE和NM的功能
- OBJDUMP:所有二进制工具之母。能够显示一个目标文件中的所有信息。它最大的作用是反汇编.text节中的二进制指令
Unix系统为操作共享库还提供了LDD程序:
- LDD:列出一个可执行文件在运行时所需要的共享库
总结
本章主要讲述了链接的意义、静态链接和动态链接、目标文件、符号和符号表等内容。链接可以在编译时由静态编译器来完成,也可以在加载或运行时由动态链接器完成。链接器处理被称为目标文件的二进制文件,它有三种不同形式:可重定位的、可执行的、共享的。链接器有两个主要任务:符号解析和重定位,符号解析将目标文件中的每个全局符号都绑定到一个唯一的定义,重定位确定每个符号的最终存储器地址并修改对那些目标的引用。静态链接器将多个可重定位目标文件合并成一个单独的可执行文件。而动态链接器通过加载共享库和重定位程序中的引用来完成链接。