链接
链接( linking)是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。
链接器在软件开发中扮演着一个关键的角色,因为它们使得分离编译( separate com-pilation) 成为可能 。
学习链接知识的理由:
理解链接器将帮助你构造大型程序。
理解链接器将帮助你避免一些危险的编程错误。
理解链接将帮助你理解语言的作用域规则是如何实现的。
理解链接将帮助你理解其他重要的系统概念。
理解链接将使你能够利用共享库 。
编译器驱动程序
大多数编译系统提供编译器驱动程序 ( compiler driver), 它代表用户在需要时调用语言预处理器、编译器、汇编器和链接器。
静态链接
像 Linux LD 程序这样的静态链接器( static linker ) 以一组可重定位目标文件和命令行参数作为输入,生成一个完全链接的、可以加载和运行的可执行目标文件作为输出。
为了构造可执行文件,链接器必须完成两个主要任务: 符号解析 ( symbol resolution) 。 目标文件定义和引用符号, 每个符号对应于一个函数、一个全局变量或一个静态变量(即 C 语言中任何以 static 属性声明的变量)。符号解析的目的是将每个符号引用正好和一个符号定义关联起来。 重定位 ( relocation) 。 编译器和汇编器生成从地址 0 开始的代码和数据节。链接器通过把每个符号定义与一个内存位置关联起来,从而重定位这些节,然后修改所有对这些符号的引用,使得它们指向这个内存位置。链接器使用汇编器产生的重定位条目 ( relocation entry) 的详细指令,不加甄别地执行这样的重定位。
目标文件
目标文件有三种形式: 可重定位目标文件。 包含二进制代码和数据,其形式可以在编译时与其他可重定位目标文件合并起来,创建一个可执行目标文件。 可执行目标文件。 包含二进制代码和数据,其形式可以被直接复制到内存并执行。 共享目标文件。 一种特殊类型的可重定位目标文件,可以在加载或者运行时被动态地加载进内存并链接。
目标文件是按照特定的目标文件格式来组织的,各个系统的目标文件格式都不相同。
可重定位目标文件
符号和符号表
每个可重定位目标模块 m 都有一个符号表,它包含 m 定义和引用的符号的信息。
在链接器的上下文中,有三种不同的符号:
-
由模块 m 定义并能被其他模块引用的全局符号。全局链接器符号对应于非静态的C函数和全局变量。 -
由其他模块定义并被模块m引用的全局符号。这些符号称为外部符号 ,对应于在其他模块中定义的非静态 C 函数和全局变量。 -
只被模块 m 定义和引用的局部符号。它们对应于带 static 属性的 C 函数和全局变量。这些符号在模块 m 中任何位置都可见,但是不能被其他模块引用。
符号表是由汇编器构造的,使用编译器输出到汇编语言 .s 文件中的符号。
符号解析
链接器解析符号引用的方法是将每个引用与它输入的可重定位目标文件的符号表中的一个确定的符号定义关联起来。 对那些和引用定义在相同模块中的局部符号的引用,符号解析是非常简单明了的。 编译器只允许每个模块中每个局部符号有一个定义。 静态局部变量也会有本地链接器符号,编译器还要确保它们拥有唯一的名字。
链接器如何解析多重定义的全局符号
根据强弱符号的定义, Linux 链接器使用下面的规则来处理多重定义的符号名:
规则1: 不允许有多个同名的强符号。 规则2 : 如果有一个强符号和多个弱符号同名,那么选择强符号。 规则3 : 如果有多个弱符号同名,那么从这些弱符号中任意选择一个。
与静态库链接
所有的编译系统都提供一种机制,将所有相关的目标模块打包成为一 个单独的文件,称 静态库 ( static library),它可以用做链接器的输入。当链接器构造一个输出的可执行文件时,它只复制静态库里被应用程序引用的目标模块。
重定位
重定位步骤,在这个步骤 中,将合并输入模块,并为每个符号分配运行时地址。
重定位由两步组成:
重定位节和符号定义。
重定位节中的符号引用。
重定位条目
无论何时汇编器遇到对最终位置未知的目标引用,它就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。 代码的重定位条目放在.rel.text 中。巳初始化数据的重定位条目放在.rel.data 中。
两种最基本的重定位类型:
R_X86_ 64_ PC32 。 重定位一个使用 32 位 PC 相对地址的引用。 R_X86_ 64_ 32 。 重定位一个使用 32 位绝对地址的引用。
重定位符号引用
链接器是如何重定位这些引用的:
- 重定位PC相对引用
- 重定位绝对引用
可执行目标文件
可执行目标文件的格式类似于可重定位目标文件的格式。
ELF 头描述文件的总体格式。 它还包括程序的入口点 ( entry point ) , 也就是当程序运行时要执行的第一条指令的地址。 . text 、.rodata 和.data 节与可重定位目标文件中的节是相似的 ,除了这些节巳经被重定位到 它们最终的运行时内存地址以外 。 . init 节定义了一个小函数,叫做_ init , 程序的初始化代码会调用它。 因为可执行文件是完全链接的(已被重定位), 所以它不再需要.rel 节。
加载可执行目标文件
通过调用execve系统调用函数来调用加载器。
加载器(loader)根据可执行文件的程序(段)头表中的信息,将可执行文件的代码和数据从磁盘“拷贝”到存储器中。
加载后,将PC(EIP)设定指向 Entry point(即符号 _start 处),最终执行main函数,以启动程序执行。
动态链接共享库
静态库的一些缺点:
- 库函数(如printf)被包含在每个运行进程的代码段中,对于并发运行上百个进程的系统,造成极大的主存资源浪费。
- 库函数(如printf)被合并在可执行目标中,磁盘上存放着数千个可执行文件,造成磁盘空间的极大浪费。
- 程序员需关注是否有函数库的新版本出现,并须定期下载、重新编译和链接,更新困难、使用不便。
解决方案:
共享库(Shared Libraries)
- 是一个目标文件,包含有代码和数据;
- 从程序分离出来,磁盘和内存中都只有一个备份;
- 可以动态地在装入时或运行时被加载并链接;
- Window称其为动态链接库(Dynamic Link Libraries, .dll文件);
- Linux称其为动态共享库(Dynamic Shared Objects, .so文件)
动态链接可以按一下两种方式进行:
-
在第一次加载并运行时进行(load-time linking)。 - Linux通常由动态链接器(ld-linux.so)自动处理 - 标准C库(libc.so)通常按这种方式动态被链接 -
在已经开始运行后进行(run-time linking)。 - 在Linux中,通过调用dlopen()等接口来实现
优点:
在内存只有一个备份,被所有进程共享,节省内存空间; 一个共享库目标文件被所有程序共享链接,节省磁盘空间; 共享库升级时,被自动加载到内存和程序动态链接,使用方便; 共享库可分模块、独立、用不同编程语言进行开发,效率高; 第三方开发的共享库可作为程序插件,使程序功能易于扩展。
位置无关代码
可以加载而无需重定位的代码称为位置无关代码(Position-Independent Code, PIC) 。
用户对 GCC 使用- fpic 选项指示 GNU 编译系统生成 PIC 代码。共享库的编译必须总是使用该选项 。
-
PIC 数据引用 无论我们在内存中的何处加载一个目标模块(包括共享目标模块), 数据段与代码段的距离总是保持不变。因此,代码段中任何指令和数据段中任何变量之间的距离都是一个运行时常量,与代码段和数据段的绝对内存位置是无关的。 要生成对全局变量 PIC 引用,在数据段开始的地方创建了一个表,叫做全局偏移量表 (Global Offset Table, GOT) 。 -
PIC 函数调用 延迟绑定(lazy binding), 将过程地址的绑定推迟到第一次调用该过程时。 延迟绑定是通过两个数据结构之间简洁但又有些复杂的交互来实现的,这两个数据结构是: GOT 和过程链接 表( Procedure Linkage Table, PLT)。 GOT 是数据段的一部分,而PLT 是代码段的一部分 。
库打桩机制
Linux 链接器支持一个 很强大的技术,称为库打桩(library interpositioning), 它允许你截获对共享库函数的调用,取而代之执行自己的代码。
使用打桩机制,你可以追踪对某个特殊库函数的调用次数,验证和追踪它的输入和输出值,或者甚至把它替换成一个完全不同的实现。
基本思想: 给定一个需要打桩的目标函数,创建一个包装函数 ,它的原型与目标函数完全一样。 使用某种特殊的打桩机制,你就可以欺骗系统调用包装函数而不是目标函数了。包装函数通常会执行它自己的逻辑,然后调用目标函数,再将目标函数的返回值传递给调用者。
打桩可以发生在编译时、链接时或当程序被加载和执行的运行时。
编译时打桩
-I. 参数,进行打桩。
linux> gee -DCOMPILETIME -e mymalloe.e
linux> gee -I. -o inte int.e mymalloe.o
C 预处理器在搜索通常的系统目录之前, 先在当前目录中查找 malloc.h 。
链接时打桩
Linux 静态链接器支持用 - - wrapf 标志进行链接时打桩。 这个标志告诉链接器 ,把对符号 f 的引用解析成 _ _wrap_f ( 前缀是两个下划线), 还要把对符号 _ _real_f( 前缀是两个下划线)的引用解析为 f 。
运行时打桩
编译时打桩需要能够访问程序的源代码,链接时打桩需要能够访间程序的可重定位对象文件。
有一种机制能够在运行时打桩,它只需要能够访问可执行目标文件。这个机制基于动态链接器的 LD_PRELOAD 环境变量。
如果 LD_PRELOAD 环境变量被设置为一个共享库路径名的列表(以空格或分号分隔),那么当你加载和执行一个程序,需要解析未定义的引用时,动态链接器( LD-LINUX.so) 会先搜索 LD_ PRELOAD 库, 然后才搜索任何其他的库。 有了这个机制,当你加载和执行任意可执行文件时 ,可以对任何共享库中的任何函数打桩,包括 libc.so。
处理目标文件的工具
AR : 创建静态库 , 插入、删除 、列出和提取成员。
STRINGS: 列出一个目标文件中所有可打印的字符串。
STRIP : 从目标文件中删除符号表信息。
NM: 列出一个目标文件的符号表中定义的符号。
SIZE : 列出目标文件中节的名字和大小 。
READELF: 显示一个目标文件的完整结构,包括 ELF头中编码的所有信息。包含SIZE 和 NM 的功能。
OBJDUMP: 所有二进制工具之母。能够显示一个目标文件中所有的信息。它最大的作用是反汇编 .text 节中的二进制指令。
Linux 系统为操作共享库还提供了 LDD 程序 : LDD: 列出一个可执行文件在运行时所需要的共享库。
学习参考资料:
《深入理解计算机系统》 第3版
|