一、链接概述
链接(linking)是将各种代码和数据部分收集起来并组合成为一个单一文件的过程,这个文件可被加载(或被拷贝)到存储并执行。
-
链接的时机
- 编译时,也就是在源代码被翻译成机器代码时
- 加载时,也就是在程序被加载器加载到存储器并执行时。
- 运行时,由应用程序执行。
-
在现代系统中,链接是由链接器自动执行的。
-
链接器的关键角色:使分离编译称为可能。
1. 静态链接
(1)编译驱动程序
- 大多数编译系统提供,它代表用户在需要时调用语言预处理器、编译器、汇编器和链接器。
(2)静态链接器
- Unix的静态链接器(static linker)ld,以一组可重位目标文件和命令行参数作为输入,生成一个完全链接的可以加载和运行的可执行目标文件作为输出。输入的可重定位目标文件由各种不同的代码和数据节(section)组成。指令在一个节中,初始化的全局变量在另一个节中,而未初始化的变量又在另外一个节中。
(3)链接器的两个主要任务
-
符号解析:目标文件定义和引用符号。符号解析的目的是将每个符号引用刚好和一个符号定义联系起来。
-
重定位:编译器和汇编器生成从地址0开始的代码和数据节。链接器通过把每个符号定义与一个存储器位置联系起来,然后修改所有对这些符号的引用,使得它们指向这个存储器位置,从而重定位这些节。
2. 目标文件
-
三种形式
- 可重定位目标文件。包含二进制代码和数据,其形式可以在编译时与其他可重定位目标文件合并起来,创建一个可执行目标文件。 - 可执行目标文件。包含二进制代码和数据,其形式可以被直接拷贝到存储器并执行。 - 共享目标文件。一种特殊类型的可重定位目标文件,可以在加载或者运行地被动态地加载到存储器并链接。
-
编译器和汇编器生成可重定位目标文件(包括共享目标文件)。链接器生成可执行目标文件。从技术上来说,一个目标模块就是一个字节序列,而一个目标文件就是一个存放在磁盘文件中的目标模块。
3. 可重定位目标文件
-
一个典型的ELF可重定位目标文件的格式:
图片来源:《深入理解计算机系统》
-
.text:已编译程序的机器代码。
-
.rodata:只读数据,比如printf语句中的格式串和开关语句的跳转表。
-
.data:已初始化的全局C变量。
-
.bss:未初始化的全局C变量。在目标文件中这个节不占据实际的空间,它仅仅是一个占位符。
-
.symtab:一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。
-
.rel.text:一个.text节中位置的列表,当链接器把这个目标文件和其他文件结合时,需要修改这些位置。
-
.rel.data:被模块引用或定义的任何全局变量的重定位信息。
-
.debug:一个调试符号表,其条目是程序中定义的局部变量和类型定义,程序中定义和引用的全局变量,以及原始的C源文件。只有以-g选项调用编译驱动程序时才会得到这张表。
-
.line:原始C源程序中的行号和.text节中机器指令之间的映射。
-
.strtab:一个字符串表,其内容包括:.symtab和.debug节中的符号表,以及节头部中的节名字。字符串表就是以null结尾的字符串序列。
4. 符号和符号表
每个可重定位目标模块m都有一个符号表,它包含m所定义和引用的符号的信息。
(1)链接器上下文中的三种不同符号
- 由m定义并能被其他模块引用的全局符号。全局链接器符号对应于非静态的C函数以及被定义为不带C static属性的全局变量。
- 由其他模块定义并被模块m引用的全局符号。这些符号称为外部符号(external),对应于定义在其他模块中的C函数和变量。
- 只被模块m定义和引用的本地符号。有的本地链接器符号对应于带static属性的C函数和全局变量。
(2)符号表
typedef struct{
int name; //字符串表中的字节偏移,指向符号的以NULL结尾的名字
int value; //符号的地址,对于可重定位的模块是距定义目标的节起始位置的偏移。
int size; //目标大小(单位:字节)
char type:4, //数据或函数
binding:4; //本地LOCAL/全局GLOBAL
char reserved;
char section; //到节头部表的索引
}Elf_Symbol;
- 每个符号都和目标的某个节相关联,由section字段表示。
- section字段三个特殊的伪节
- ABS:不该被重定位的符号。
- UNDEF:未定义的符号,在本目标模块中引用,但在其他地方定义。
- COMMON:未被分配位置的未初始化数据目标。
- Ndx=1表示.test节,Ndx=3表示.data节。
5. 符号解析
(1)多重定义的全局符号
-
强符号:函数和已经初始化的全局变量
-
弱符号:未初始化的全局变量
-
规则:
规则1:不允许有多个强符号。 规则2:如果有一个强符号和多个弱符号,那么选择强符号。 规则3:如果有多个弱符号,那么从这些弱符号中任意选择一个。
(2)静态库链接
所有的编译系统都提供一种机制,将所有相关的目标模块打包成为一个单独的文件,称为静态库(Linux下是存档文件,Windows下是lib),可以用做链接器的输入。
- 当链接器构造一个输出的可执行文件时,它只拷贝静态库里被应用程序引用的目标模块。
- 存档文件:一组连接起来的可重定位目标文件的集合,有一个头部用来描述每个成员目标文件的大小和位置。存档文件名由后缀.a标识。
- 链接时加上-static参数:告诉编译器驱动程序,链接器应该构建一个完全链接的可执行目标文件,它可以加载到存储器并执行,在加载时无需更进一步的链接。
(3)链接器如何使用静态库来解析引用
-
在符号解析的阶段,链接器从左到右按照它们在编译器驱动程序命令行上出现的相同顺序来扫描可重定位目标文件和存档文件。(驱动程序自动将命令行中所有的.c文件翻译为.o文件。)
-
在这次扫描中,链接器将维持:
- 一个可重定位目标文件的集合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中的目标文件,从而构建输出的可执行文件。
-
命令行上的库和目标文件的顺序非常重要。在命令行中,如果定义一个符号的库出现在引用这个符号的目标文件之前,那么引用就不能解析,链接会失败。
-
一般准则是将库放在命令行的结尾。
-
如果各个库的成员是相互独立(也就是说没有成员引用另一个成员定义的符号),那么这些库就可以按照任何顺序放置在命令行的结尾处。
-
如果库不是相互独立的,那么它们必须排序,使得对于每个被存档文件的成员外部引用的符号s,在命令行中至少有一个s的定义是在对s的引用之后的。
6. 重定位
(1)重定位两步
- 重定位节和符号定义:
- 链接器将所有相同类型的节合并为同一类型的新的聚合节,将运行时存储器地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。
- 此时,程序中的每个指令和全局变量都有唯一的运行时存储器地址了。
- 重定位节中的符号引用:
- 链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。
- 链接器依赖于称为重定位条目的可重定位目标模块中的数据结构。
(2)重定位条目
-
无论何时汇编器遇到对最终位置位置的目标引用,它就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。
-
代码的重定位条目放在.rel.text中。
-
已初始化的数据的重定位条目放在.rel.data中。
-
ELF定义了11种不同的重定位类型。两种最基本的重定位类型:
*R_386_PC32 重定位一个使用32位PC相对地址的引用。 *R_386_32 重定位一个使用32位绝对地址的引用。
(3)重定位符号引用
- 相对引用
- 绝对引用
7. 可执行目标文件及加载
(1)可执行目标文件
-
C程序开始时是一组ASCII文本文件,已经被转化为一个二进制文件,且这个二进制文件包含加载程序到存储器并运行它所需的所有信息。
图片来源:《深入理解计算机系统》
-
段头部表:可执行文件的连续片被映射到连续的存储器段,段头部表描述了这种关系。
(2)加载可执行目标文件
加载器将可执行目标文件中的执行代码和数据从磁盘拷贝到存储器中,然后通过跳转到程序的第一条指令或入口点来运行该程序。这个将程序拷贝到存储器并运行的过程叫做加载。
-
在32位Linux系统中,代码段总是从地址0x08048000处开始。
-
数据段是在接下来的下一个4KB对齐的地址处。
-
运行时堆在读/写段之后接下来的第一个4KB对齐的地址处,并通过调用malloc库往上增长。
-
有一个段是为共享库保留的。
-
用户栈总是最大的合法用户地址开始,向下增长的(向低存储器地址方向增长)。从栈的上部开始的段是为操作系统驻留存储器的部分(也就是内核)的代码和数据保留的。
Linux运行时的存储器映像
-
当加载器运行时,它创建如上图所示的存储器映像。在可执行文件中段头部表的指导下,加载器将可执行文件的相关内容拷贝到代码和数据段。
-
接下来,加载器跳转到程序的入口点,也就是符号_start的地址。在_start地址处的启动代码(startup code)是在目标文件ctrl.o中定义的,对所有的C程序都是一样的。
(3)加载器实际工作概述
- Unix系统中的每个程序都运行在一个进程上下文中,有自己的虚拟地址空间。
- 当外壳运行一个程序时,父外壳进程生成一个子进程,它是父进程的一个复制品。子进程通过execve系统调用启动加载器。加载器删除子进程现在的虚拟存储器段,并创建一组新的代码、数据、堆 和栈段。新的栈和堆段被初始化为零。
- 通过将虚拟地址空间中在页映射到可执行文件的页大小的片(chunk),新的代码和数据段被初始化为可执行文件的内容。
- 最后,加载器跳转到_start地址,它最终会调用应用程序的main函数。
8. 动态连接共享库
(1)静态库的缺点
- 首先,静态库在更新时,使用该库的程序需要与更新的库进行重新链接。
- 其次,由于使用静态库的程序在链接时都会拷贝静态库里被应用程序引用的目标模块,像printf和scanf这样的函数的代码在运行时都会被复制到每个运行进程的文本段中,这造成了冗余,浪费了稀缺的存储器资源。
(2)共享库
- 共享库是一个目标模块,在运行时,可以加载到任意的存储器地址,并和一个在存储器中的程序链接起来。这个过程称为动态链接,是由一个叫做动态链接器的程序来执行的。
- 共享库也称为共享目标,在Unix系统中通常用.so后缀来表示。微软的操作系统大量地利用了共享库,它们称为DLL(动态链接库)。
- 共享库是以两种不同的方式来“共享”的(在Windows中分别称为“隐式链接”和“显示链接”)。
- 首先,在任何给定的文件系统中,对于一个库只有一个.so文件。所有引用该库的可执行目标文件共享这个.so文件中的代码和数据,而不是像静态库的内容那样被拷贝和嵌入引用它们的可执行的文件中。
- 其次,在存储器中,一个共享库的.text节 一个副本可以被不同的正在运行的进程共享。
(3)从应用程序中加载和链接共享库
#include<dlfcn.h>
void *dlopen(const char *filename,int flag);
//返回:若成功则为指向句柄的指针,若出错则为NULL
void *dlsym(void *handle,char *symbol);
//返回:若成功则为指向符号的指针,若出错则为NULL
int dlclose(void *handle);
//返回:若成功则为0,若出错则为-1
const char *dlerror(void);
//返回:如果前面对dlopen、dlsym或dlclose的调用失败,则为错误消息,如果前面的调用成功,则为NULL
(4)与位置无关的代码PIC
编译库代码,使得不需要链接器修改库代码就可以在任何地址加载和执行这些代码。
- 用户对GCC使用
-fPIC
选项指示GNU生成PIC代码
9. 处理目标文件的工具
- AR:创建静态库,插入、删除、列出和提取成员。
- STRINGS:列出一个目标文件中所有可打印的字符串。
- STRIP:从目标文件中删除符号的信息。
- NM:列出一个目标文件的符号表中定义的符号。
- SIZE:目标文件中节的名字和大小。
- READELF:显示一个目标文件的完整结构,包括ELF头中的编码的所有信息。包含SIZE和NM的功能。
- OBJDUMP:所有二进制工具之母,能够显示一个目标文件中所有的信息。它最大的作用是反汇编.text节中的二进制指令。
- LDD:列出一个可执行文件在运行时所需要的共享库。
二、总结
- 链接可以在编译时由静态编译器完成,或者在加载和运行时由动态链接器完成。
- 链接器处理称为目标文件的二进制文件,有三种形式:可重定位的、可执行的、共享的。
- 可重定位的目标文件由静态链接器合并成一个可执行的目标文件,可以加载到存储器中并执行。
- 共享目标文件是在运行时由动态链接器链接和加载的,或者隐含地在调用程序被加载和开始执行时,或者根据需要再程序调用dlopen库的函数时。
- 链接器的两个主要任务:符号解析和重定位。
- 静态链接器是由像GCC这样的编译驱动器调用的,可将多个可重定位目标文件合并成一个单独的可执行目标文件。多个目标文件可以定义相同的符号,可以被连接到一个单独的静态库中。链接器用库来解析其他目标模块的符号引用。
- 加载器将可执行文件的内容映射到存储器,并运行这个程序。链接器还可能生成部分连接的可执行目标文件。这样的文件中有对定义在共享库中的程序和数据的未解析的引用。
- 被编译为位置无关的共享库可以加载到任何地方,也可以在运行时被多个进程共享。