CSAPP 第七章 链接
动态链接和静态链接
链接(linking):将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。就是我们之前有学过的编译文件的最后一步,分静态和动态链接两种可能。
在现代系统中,链接是由叫做链接器(linker)的程序自动执行的。
编译器驱动程序
我们这一章都是在用下面的小函数示例讲解学习的:
这是两个源文件分别是 main.c 和 sum.c
1 |
|
大多编译系统提供编译器驱动程序(如 gcc ),它实际上是广义的”编译器“,是语言预处理器、编译器、汇编器和连接器的集合。
我们可以使用下面的命令把两个文件链接在一起编译:
1 |
|
当然也可以将这个命令拆来来编译:
1 |
|
静态链接
我们的命令 ld 就是调用了 Linux LD 程序这样的静态链接器,它以一组可重定位目标文件和命令行参数作为输入,生成一个完全连接的、可以加载和运行的可执行目标文件作为输入。
连接器要完成两个主要任务才能实现链接目的:
- 符号解析:将每个符号引用和一个符号定义关联起来
- 重定位:编译器和汇编器生成从地址 0 开始的代码和数据节,将每一个符号引用和一个内存位置关联起来。
目标文件
目标文件有三种形式:
- 可重定位目标文件(.o):包含二进制代码和数据,其形式可以在编译时与其他可重定位目标文件合并起来,创建一个可执行目标文件。
- 可执行目标文件(.elf):包含二进制代码和数据,其形式可以被直接复制到内存并执行。
- 共享目标文件(.libc):一种特殊类型的可重定位目标文件,可以在加载或者运行时被动态地加载进内存并链接。
可重定目标文件
可重定目标文件分成几个部分:
- .text:已编译程序的机器代码段
- .rodata:只读数据段
- .data:已初始化的全局变量和静态变量
- .bss:未初始化全局变量和静态变量
- .symtab:符号表,保存全局变量的信息。默认生成,可以使用STRIP命令去除。
- .rel.text:一个.text 借中的位置表,被链接的时候会被修改位置。
- .rel.data:被模块引用或定义的所有全局变量的重定位信息,被链接的时候会被修改位置。
- .debug:调试符号表,只有以 -g 选项时会得到这张表,其中包含了局部变量的定义和类型,以及原始的 C 文件,只有 -g 命令会生成这张表。
- .line:原始 C源程序中的行号和text 节中机器指令之间的映射。只有以-g 选项调用编译器驱动程序时,才会得到这张表。
- .strtab:一个字符串表,其内容包括 .symtab 和 .debug 节中的符号表,以及节头部中的节名字。
同是程序中的变量,区分 .bss 段 .data 段的原因是初始化的变量需要使用一段空间去保存初始化得到的值,而未初始化则不需要, .bss 段存储为初始化数据可以更好的节省空间
符号和符号表
每个可重定位目标模块 m 都有一个符号表,它包含 m 定义和引用的符号的信息。在链接器的上下文中,有三种不同的符号:
模块 m 定义并能被其他模块引用的全局符号。全局链接器符号对应于非静态的C函数和全局变量。
由其他模块定义并被模块 m 引用的全局符号。
只被模块 m 定义和引用的局部符号。它们对应了带 static 属性的 C 函数和全局变量。
.symtab 中的符号表并不包含对应于本地非静态程序变量的任何符号。这些符号在运行时在栈中被管理,连接器对此类符号不感兴趣。
静态的局部变量不会在栈中管理,会放到 bss 或者是 data 段上。
.symtab 节中会包含一个 ELF 符号表,这张符号表包含这样一个结构体条目的数组:
1 |
|
每个符号都被分配到目标文件的某个节,由 section 字段表示,该字段也是一个到节头部表的索引。
对于 section 字段有三个特殊的伪节,它们在节头部表中是没有条目的:
- ABS 代表不该被重定位的符号。
- UNDEF 代表未定义的符号,也就是在本目标模块中用,但是却在其他地方定义的符号。
- COMMON 表示还未被分配位置的未初始化的数据目标。
现代的GCC 版本根据以下规则来将可重定位目标文件中的符号分配到 COMMON 和 bss 中:
COMMON:未初始化的全局变量
.bss :未初始化的静态变量,以及初始化为 0 的全局或静态变量
符号解析
链接器解析符号引用的方法是将每个引用与它输入的可重定位目标文件的符号表中的一个确定的符号定义关联起来。
静态局部变量也会有本地链接器符号,编译器还要确保它们拥有唯一的名字。不过,对全局符号的引用解析就棘手得多。当编译器遇到一个不是在当前模块中定义的符号(变量或函数名)时,会假设该符号是在其他某个模块中定义的,生成一个链接器符号表条目,并把它交给链接器处理。
如果链接器在它的任何输人模块中都找不到这个被引用符号的定义,就输出一条(通常很难阅读的)错误信息并终止。
例如下面的文件:
1 |
|
我们编译和链接这个源文件,编译器会没有障碍地运行,但是当链接器无法解析对 foo 的引用时,就会终止。
连接器如何解析多重定义的全局符号
连接器会将模块与对应的符号一一对应,如果碰到两个模块同名的情况,就不面具发生混淆。
首先我们要理解一个强弱符号的概念:
在编译时,编译器向汇编器输出每个全局符号,或者是强或者是弱,而汇编器把这个信息隐含地编码在可重定位目标文件的符号表里。函数和已初始化的全局变量是强符号,未初始化的全局变量是弱符号。
而根据强弱符号的定义,Linux 链接器使用下面的规则来处理多重定义的符号名。规则:
- 不允许有多个同名的强符号
- 如果有一个强符号和多个弱符号同名,那么选择强符号
- 如果有多个弱符号同名,那么从这些弱符号中任意选择一个
有一个问题是:连接器只会对全局符号双重定义问题发出警告而不是报错。
例如在两个模块链接在一起的模块中分别定义了 double x;
和 int x;
,它只会触发一个警告而不是报错。这就会导致我们定义了相同的两个符号,编译到一起之后,两边的函数操作的是同一个变量,而我们很难发现问题所在。
为了避免这类错误,我们尽量加上 -fno-common
标志来高速链接器遇到多重符号定义的时候触发一个错误。
与静态库链接
我们可以把所有标准函数都编译在一个可重定目标文件里,再通过下面的命令链接到自己的可执行文件中:
1 |
|
但是这个方法对内存和磁盘空间来说都是极大的浪费,并且如果某一个模块发生了改变就要重新编译整个文件,维护和开发也很复杂。
所以静态库概念被提出来,以解决这些缺点。
在 Linux 系统中,静态库以一种称为存档(archive)的特殊文件格式存放在磁盘中。
静态库允许将所有相关的目标模块打包成一个单独的文件,用作连接器的输入,并以一种称为存档(archive)的特殊文件格式存放在磁盘中。
存档文件是一组连接起来的可重定位目标文件的集合,有一个头部用来描述每个成员目标文件的大小和位置。存档文件名由后缀
.a
标识。
当链接器构造一个输出的可执行文件时,它只复制静态库里被应用程序引用的目标模块。例如我们调用C标准库和数学库中的函数:
1 |
|
这就减少了可执行文件在磁盘和内存中的大小,同时应用程序员只需要包含较少的库文件的名字(C 编译器驱动程序总是默认传送 libc.a 给链接器,所以我们在命令行中可以省略对 lbc.a 的引用)。
用命令 ar rcs out.a xx1.o xx2.o ....
命令把可重定位文件整合成 out.a
静态库。链接的时候,我们需要将我们编译出的可重定向文件和静态库文件一起作为链接器的数。
连接器如何使用静态库来解析引用
在符号解析阶段,链接器从左到右按照它们在编译器驱动程序命令行上出现的顺序来扫描可重定位目标文件和存档文件。
链接过程维护了三个集合:
- E:可重定位目标文件,这个集合中的文件会被合并起来形成可执行文件
- U:未解析的符号,即引用了但是尚未定义的符号
- D:在前面输入文件中已定义的符号
初始时,E、U 和 D 均为空。
对于命令行上的每一个输入文件,会判断是目标文件还是静态库文件。如果是目标文件,就会修改 U 和 D 来反映文件中的符号引用,并继续下一个文件;如果是存档文件(.a),会对应匹配 U 中的一个引用,如果匹配成功会把对应的符号从 U 删除并加入到 D 中,并且会把 .a 中的对应的模块(.o)加入到 E 集合中,而不包含在 E 中的可重定位文件会被丢弃。
如果扫描完所有的输入文件之后, U 非空,那么报错推出,否则合并 E 集合的可重定位文件输出可执行文件。
需要注意的是,如果我们在静态链接的时候,把静态库放在了前面,那么会因为匹配不到任何一个 U 中的符号(因为一开始 U 为空)而直接被链接器丢弃。所以在上面的情况,我们如果使用命令 gcc --static -o a.out libcmath.a main.c
则会报错。
重定位
链接器完成符号解析后,就可以开始重定位步骤了。重定位有两个步骤:
- 重定位节和符号定义:链接器将所有相同类型的节合并为同一类型的新的聚合节。当这一步完成时,程序中的每条指令和全局变量都有唯一的运行时内存地址了。
- 重定位节中的符号引用:链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。
重定位条目
无论何时,汇编器遇到对最终位置未知的目标引用,它就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行文件时该如何修改这个引用。其中,代码的重定位条目放在 .rel.text
中,已初始化数据的重定位条目放在 .rel.data
中。
下面就是重定义位条目的格式:
1 |
|
ELF 定义了32种不同类型的引用,这里我们只关心两种最基本的类型:
- PC 相对寻址
R_X86_64_PC32
:一个PC相对地址就是距程序计数器(PC)的当前运行时值的偏移量 - 绝对地址引用
R_X86_64_32
:通过绝对寻址,CPU 直接使用在指令中编码的 32 位值作为有效地址,不需要进一步修改。
重定位符号引用
重定位符号引用也分为 PC 相对引用和绝对引用两种。
判断 PC 相对引用和绝对引用的标准主要取决于指令或数据的寻址方式和地址计算方法。我们需要实现分支跳转和存储数据的指令就会采取 PC 相对引用,例如 call 指令;我们在程序运行时需要确定确切的内存地址来表示目标地址的指令,就需要采用绝对引用,例如 mov 指令。
可执行目标文件
可执行目标文件的格式类似于可重定位目标文件的格式,分为以下几个部分:
ELF 头描述文件的总体格式,它还包括程序的入口点(entry point),也就是当程序运行时要执行的第一条指令的地址。
.text
、.rodata 和 .data
节与可重定位目标文件中的节是相似的,不同的是这些节已经被重定位到它们最终的运行时内存地址。
.init
节定义了一个小函数,叫做 init,是程序的初始化代码。
因为可执行文件是完全链接的(已被重定位),所以它不再需要 .rel
节。
程序头部表中的其中 LOAD 段会告诉我们哪些段在哪个位置,权限是什么,如下:
加载可执行文件
在Linux中运行可执行目标文件prog,可以在Linux shell的命令行运行下面的命令:
1 |
|
prog 是文件的名字,shell 通过调用某个驻留在存储器中叫做加载器的操作系统代码来运行它。
加载器将可执行目标文件中的代码与数据从磁盘复制到内存,然后通过跳转到程序的第一条指令或入口点来运行该程序,这个将程序复制到内存并运行的过程叫做加载。
加载器运行时,它创建如下图所示的内存映像。在程序头部表的引导下,加载器将可执行文件的片复制到代码段和数据段。接下来跳转到程序的入口点 _start
函数,这个函数会调用 libc.so
中的系统启动函数 __libc_start_main
函数去初始化执行环境,最后载调用用户层的 main
函数。
动态链接共享库
之前一直在讨论静态链接的内容,动态链接弥补了静态链接的缺点,致力于解决静态库缺陷的一个现代创新产物。
共享库是一个目标模块,在运行或加载时,可以加载到任意的内存地址,并和一个在内存中的程序链接起来。这个过程称为动态链接(dynamic linking),是由一个叫做动态链接器(dynamic linker)的程序来执行的。
共享库也称为共享目标(shared object),在 Linux 系统中通常用 .so 后缀来表示。windows操作系统的共享库称为 DLL(动态链接库)。
静态链接唯一的优势就是不依赖环境,只要架构支持,就一定能运行,而动态链接需要比较严格的环境要求。共享库是在运行时加载,可以加载到任意的内存,因此编译的时候,必须编译位置无关代码。
我们可以使用下面的命令编译动态链接库:
1 |
|
malloc.c:
1 |
|
main.c
1 |
|
我们想要 mymalloc.c
中的包装函数调用目标函数,打印追踪记录,并返回。只需要像下面这两条命令一样编译程序:
1 |
|
由于有 -I
参数,所以会进行打桩,它告诉 C 预处理器在搜索通常的系统目录之前,先在当前目录中查找 malloc.h
。
链接时打桩
我们调用 -wrap f
标志进行链接时打桩。
链接时库打桩不能自定义调用名,在我们自己写的模块中,我们定义的函数必须是 __wrap_xxx
,比如 __wrap_malloc
,我们在 __wrap_malloc
中调用真实的 malloc
要写成 __real_malloc
去调用。
我们在 malloc 和 free 两个函数打桩,可以使用下面的命令:
1 |
|
每个 -Wl,--wrap,xxx
就表示使用 __wrap_xxx
函数去替换 xxx
函数。
运行时打桩
编译时打桩需要能够访问程序的源代码,链接时打桩需要能够访问程序的可重定位对象文件。不过,基于动态链接器的 LD_PRELOAD 环境变量机制能够在运行时打桩,它只需要能够访问可执行目标文件。
如果 LD_PRELOAD 环境变量被设置为一个共享库路径名的列表(以空格或分号分隔),那么当你加载和执行一个程序,需要解析未定义的引用时,动态链接器(LD-LINUX.SO)会先搜索 LD_PRELOAD 库,然后才搜索任何其他的库。
1 |
|
使用命令 gcc malloc.c -o malloc.so -ldl -shared -fPIC
编译得到 .so
库。
然后使用命令 LD_PRELOAD="./malloc.so" ./xxxx
来运行时加载 malloc.so
进行打桩。
处理目标文件的工具
- ar:创建静态库,插入、删除、列出和提取成员。
- strings:列出一个目标文件中所有可打印的字符串。
- strip:从目标文件中删除符号表信息。
- nm:列出一个目标文件的符号表中定义的符号。
- size:列出目标文件中节的名字和大小。
- readelf:显示一个目标文件的完整结构,包括ELF头中编码的所有信息。包含 SIZE 和 NM 的功能。
- objdump:所有二进制工具之母。能够显示一个目标文件中所有的信息。它最大勺作用是反汇编.text节中的二进制指令。
- ldd:列出一个动态链接 ELF 文件的链接库
那些没有符号表的题目是不是就是用了 strip 命令!
这里的知识都是学过的,所以进行起来比较快
打桩是新知识!感觉很神奇!🤗