第七章——链接
序言
链接:将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件被加载到内存并执行。
其可以执行于编译时,加载时,运行时。
编译器驱动程序
在GNU编译系统构造示例程序,通过命令行调用gcc驱动程序(程序见书本):
linux> gcc -Og -o prog main.c sum.c
静态链接处理该命令过程如下:
- 通过C预处理器cpp: main.c——>main.i(ASCII中间文件)
- 通过C编译器cc1: main.i——> main.s(ASCII汇编语言文件)
- 运行汇编器as: main.s——>main.o(可重定位目标文件)
- 通过链接程序ld将main.o sum.o和一些必要的系统目标文件组合起来,创建一个可执行目标文件。
linux> ./prog
调用加载器函数,将可执行文件prog中的代码、数据加载到内存,将控制转移到这个程序开头。
静态链接
输入可重定位目标文件和命令行,静态链接器将其生成为完全链接的可执行目标文件。
链接器主要完成两大主要任务:
- 符号解析 将每个符号引用和符号定义关联,这里的符号包括(函数名、全局变量、静态变量(C语言中static定义的变量))
- 重定位,将每个符号定义和一个内存位置关联起来,从而重定位这些节,修改所有对这些符号的引用,是它们指向这个内存位置。
- 汇编器生成从地址0开始的代码和数据(产生相对地址),链接器将这些定义和内存位置关联起来(产生相对于本程序绝对地址),
注意:目标文件纯粹是字节块的集合,有些包含程序代码,有些包含程序数据,而其他则包含引导链接器和加载器的数据结构。
目标文件
目标文件分为三种:
- 可重定位目标文件
- 可执行目标文件
- 共享目标文件(可执行目标文件的特例,在加载或运行时动态的加载到内存并链接)
不同OS的目标文件不同:
- Windows:PE格式
- Max OS-X:Mach-O格式
- Linux: ELF格式
可重定位目标文件
ELF可重定位目标文件格式如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
- ELF头,首先由16字节序列开始,该序列描述该文件系统字大小和字节顺序,剩下部分包括帮助链接器语法分析和解释目标文件的信息。
- 节头部表,描述不同节位置和大小
- .text 已编译程序的机器代码
- .data 已初始化的全局变量和静态C变量(局部C变量存放在栈中,不在.data和.bss节中)
- .bss 未初始化的全局和静态C变量,及其已初始化为0的全局变量和静态C变量。目标文件中这个节不占实际的空间大小,只是占位符,也不需要任何磁盘空间。
- .symtab 一个符号表,存放程序中定义和引用的函数和全局变量信息。和编译器的符号表不同,其没有局部变量的条目。
- .debug 调试表,包括局部变量和类型定义
- .rel .data 被模块引用和定义的所有全局变量的重定位信息(后面会又详细解释)
符号和符号表
每一个可重定位目标模块m都包含一个符号表,其包含定义和引用的符号信息:
- 由m模块定义而被其他模块引用的全局符号。全局链接器对应于非静态的C函数和全局变量。
- 由其他模块定义而被m模块引用的全局符号,也称之为外部符号。对应于其他模块定义的非静态的C函数和全局变量。
- 只能被模块m定义和引用的局部符号(非局部变量)。对应于m中带static属性的C函数和全局变量。
有趣的是,链接器对本地非静态程序变量不感兴趣,这些符号往往在运行时栈进行管理。
接触过C++和Java的朋友,应该知道。它们为了包含私有性,使用public 和 private 声明,其中C语言中的static就是对应于private声明,可以起到一样的效果。
使用编译器输出到汇编语言.s文件的符号,汇编器会使用符号构成符号表。.symtab中包含ELF符号。如图为ELF符号表条目。
这里需要注意的是,value中若在可重定位目标文件中,是距离定义目标起始位置的偏移,如在可执行目标文件中,则是一个固定值(绝对运行时地址)。每个符号被分配到目标文件的每个节中,由section表示。有三个伪节只在可重定位目标文件中才有,ABS, UNDEF, COMMON。 Binding代表符号是本地的还是全局的。type代表通常要么是数据,要么是函数。
- ABS代表不该被重定位的的符号。
- UNDEF代表不在本目标模块定义,而在其他地方定义的模块。
- COMMON代表还未被分配位置的未初始化的数据目标。这里注意和之前ELF文件中的.bss类似,不同点在于COMMON中不包含初始化为0的静态变量和全局变量(真tm细节)
上述说的的ELF符号表条目不太好理解,我们可以使用GNU READELF程序(查看一个目标文件内容很方便的工具)来帮助理解。
符号解析
链接器解析符号引用的方法是将每个引用和它输入的可重定位目标文件的符号表的一个确定符号定义关联起来。
细节:
- 编译器只允许每个模块的局部符号有一个定义
- 静态 局部变量 也会有本地链接器符号,编译器要确保他们拥有唯一的名字
- 对于全局变量来说,就比较困难。当编译器遇到一个不是在当前模块定义的符号,会假设该符号是在其他某个模块中定义的,生成一个链接符号表条目,交给链接器处理。
链接器如何解析多重定义全局变量
链接器输入是一组可重定位目标模块。局部符号如上述比较好处理,但是全局符号比较难处理。
Linux 处理方法如下:
-
过程:
- 在编译时,编译器汇编器输出每个全局符号,定义为强/弱符号。
- 汇编器把这个信息隐含在可重定位目标文件的符号表里。
-
强弱符号判断
- 函数和已初始化的全局变量是强符号
- 未初始化的全局变量是弱符号
-
根据强弱符号定义,Linux链接器使用以下规则处理多重符号定义的符号名:
- 不允许多强
- 强弱一起,选强
- 弱鸡一起,随便挑一个
这些规则的话,使用不当会造成一些意想不到的麻烦。特别是2、3的混合使用时,要特别小心。当怀疑有此类错误时,使用像GCC-fno-common标志这样的选项调用链接器,这个选项会告诉链接器,遇到多重定义的全局符号时,触发一个错误,或者使用-Werror选项,把所有的警告都变成错误。
之前提到的COMMON与.bss的区别在这里有所体现,如果定义了全局变量,未初始化的话,则为弱全局符号,分配到.COMMON伪节中,把决定权交给链接器。如果是初始化为0,直接分配给.bss节中。
与静态库链接
实践上,所有的编译系统都提供一种机制,将所有相关的目标模块打包生成一个单独的文件,称之为静态库,用做链接器的输入。
为什么需要使用库:
- 如果让编译器识别需要的标准函数,增加了复杂性。如果增删改一个标准函数,需要另一个版本的编译器。对应用程序员友好,但是对于其他程序员不友好。
- 如果让所有C函数都放入一个单独的可重定位目标文件中,可以实现编译器和标准函数实现分离。但是,占据浪费内存和磁盘。
- 对每一个C标准库制定专门的可重定位目标文件,程序员使用显式链接一一链接。但是耗时且容易出错
静态库:相关函数被编译为独立的目标模块,封装到单独的静态库文件。链接时,链接器将只复制被程序引用的目标模块,减少可执行文件占据的磁盘和内存大小。这里是对2、3两点的改写。
在Linux系统中,静态库以一种归档的特殊文件格式存放在磁盘中,是一组连接起来的可重定位目标文件的集合。
栗子:假定a.c与m.c两个文件分别代表两个向量的加和乘,创建这些函数的一个静态库,使用AR工具:
linux> gcc -c a.c m.c
linux> ar rcs libvector.a a.o m.o
为了使用这个库,编写main2.c来调用a.c中的函数,其头文件中包含了两个文件的函数原型。
linux> gcc -c main2.c
linux> gcc -static -o prog main2.o ./libvector.a
其中-static 告诉编译器驱动程序,链接器应该构建一个完全链接的可执行目标文件,它可以加载到内存并允许,加载时无需进一步链接。链接过程如图所示,链接器中应用需要目标模块的定义的符号,像m.o定义模块统统不引用:
链接器如何使用静态库来解析应用
符号解析时,链接器从左到右按照它们在编译器驱动程序命令行上出现的顺序来扫描可重定位目标文件和归档文件。链接器维护了一个可重定位目标文件的集合E(这个集合中的文件会被合并起来形成可执行文件),一个未解析的符号(即引用了但是尚未定义的符号)集合U,一个在前面输入文件中已经定义的符号集合D。初始化时,E、U、D都为空。
规则如下:
- 对于命令行上的每一个输入文件f,链接器判断其为目标文件还是归档文件。如果是目标文件,链接器添加到E中,修改U和D来反映f的符号定义和符号引用,并继续一个输入文件。
- 如果f是归档文件,链接器尝试匹配U中为未定义的符号和有归档文件成员定义的符号。如果归档文件成员m,定义了一个符号来解析U中的一个引用,那么就将m加到E中,并且链接器修改U和D来反映m中符号定义和引用。每一个归档文件都是这样操作,直到U、D不再发生变化。任何不包含在E中的成员目标文件都简单地被抛弃,而链接器将继续处理下一个输入文件。
- 链接器完成命令行上输入文件扫描后,U是非空的,链接器报错并终止。否则,合并E中的可重定位目标文件,构建输出的可执行文件。
输入文件命令行的顺序也十分重要,如果定义一个符号的库出现在引用这个符号的目标文件之前,那引用不能被解析(因为之前已经完全被抛弃,没有加入到E集合中)。
一般来说,
-
库文件与库文件之前都是相互独立,可以以任意顺序放到输入命令行的后面。
-
如果库文件之间有相互调用的顺序,要严格按照顺序进行链接。
-
如果库文件与库文件之间相互满足原来依赖需求,比如 foo.c ——> libx.o ——> liby.a ——> libx.a
那命令行必须标志为:
linux> gcc foo.c libx.a liby.a libx.a
重定位
一旦链接器完成了符号解析这一步,就把代码中的每一个符号引用和正好和一个符号(即输入目标模块的一个符号表条目)定义一一关联起来。链接器就知道了目标模块.data 与 .text 中的确切大小。之后进入重定位阶段,合并输入模块,为每个模块分配运行时地址。
重定位由以下两步构成:
-
重定位节和符号定义:
- 将所有相同类型的节合并成为同一类型的新的聚合节。
- 然后将运行时内存地址赋给新的聚合节,赋给输入模块定义的每一个节,以及赋给输入模块定义的每一个符号。
达成效果:程序中的每条指令和全局变量都有唯一的运行时内存地址。
-
重定位节中的符号引用:
- 链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。
- 这一步依赖于可重定位目标文件中的
重定位条目
重定位条目
当汇编器生成一个目标模块时,它并不知道数据和代码最终将放在内存中的什么位置。它也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。所以,无论何时汇编器遇到对最终位置未知的目标引用,它就会生成一个 重定位条目。 用来告诉链接器在目标文件合并成可执行文件时如何修改这个引用。
-
代码的重定位条目放在**.real.text**中。
-
已初始化数据的重定位条目放在**.real.data**中。
ELF定义了32种不同的重定位类型,我们这里只关心两种最基本的类型:
- R_X86_64_PC32:重定位一个使用32位 PC 相对地址的引用。
- R_X86_64_32:重定位一个使用32位绝对地址的引用。
这两种重定位类型支持x86-64小型代码模型,该模型假设可执行目标文件种的代码和数据的总体大小小于2GB,因此运行时可以使用32PC相对地址来访问。GCC默认使用小型代码模型。
重定位符号引用
强烈建议按照课本流程推导一遍,这样可以很大程度加深印象。可以知道call 指令调用过程及其ELF可重定位目标文件种的重定位条目。
可执行目标文件
链接器从c程序,一组ASCII文件到现在完全转换为二进制文件,并且这个文件包含加载程序到内存并运行它所需的所有信息。如图为可执行目标文件的各类信息:
与可重定位目标文件不同的是,.rel text
.rel data
因为完全链接,所以不再需要了。ELF头包含了程序的入口点,.text
.data
.rodata
节与可重定位目标文件的节相似,除了这些节已经被重定位到它们最终的运行时内存地址。.init
节定义了一个小函数,叫_init
,程序的初始化代码会调用它。
程序头部表
描述了可执行文件的连续片被映射到连续内存段的关系。
加载可执行目标文件
linux > ./prog
使用Linux运行过程序的同学一定对上述命令不陌生,因为prog不是shell内置的命令,使用把prog作为一个可执行目标文件对待,通过调用某个驻留在存储器中称为加载器
的操作系统代码来运行它。
任何Linux程序都可以通过execve
函数来调用加载器,将可执行目标文件的代码和数据复制从磁盘复制到内存中,然后通过跳转到程序的第一条指令或入口点
来运行该程序。把程序复制到内存并运行的过程叫做加载
。
每一个Linux程序程序都有一个运行时内存映像,如图所示:
首先是代码段,在Linux x86-64中,总是从0x400000开始的,后面是数据段,运行时堆在数据段之后,通过调用malloc库向上增长。堆后面是为共享模块保留的。用户栈都是从最大的合法地址开始,向较小的内存地址增长。栈以上的区域,是为内存的代码段和数据段保留的,所谓内核就是操作系统留在内存的部分。
当然,目前这些都是简化版的,比如数据段还需要对齐,分配用户栈、共享库、堆的运行时地址的时候,链接器还会使用地址空间随机化保护程序安全。这些程序运行时这些区域都会变化,它们的相对位置是不变的。
当加载器运行时,创建所7-15的内存映像。在程序头部表的引导下,加载器将可执行文件的片复制到代码段和数据段。然后加载器跳转到程序的入口点(_start地址),这个程序由ctrl.o定义,每一个C程序都是这样。_start 地址调用系统启动函数__libc_start_main
,定义在libc.so
中。初始化执行环境,调用用户层main函数,处理main函数返回值,并且在需要的时候把控制返回给内核。
动态链接共享库
静态库之前介绍过,但是其也有缺点。比如所有C程序都使用标准的I/O函数,运行时,这些函数的代码会复制到每个运行进程的文本段,这样的话,如果同时运行上百个进程,这样的话对内存来说是个极其的浪费。同时来说,如果静态库需要更新的话,我们也要显式进行更新。
共享库也称之为共享目标,在Linux系统中通常使用.so文件表示。微软的OS通常代表为DLL.共享库时一个目标模块,在运行和加载时,可以加载到任意的内存地址,并且和一个在内存的程序链接起来。这个过程叫做动态链接
,是由一个叫做动态链接器
的程序来执行。
如图是一个动态链接的过程:
linux> gcc -shared -fpic -o libvector.so a.c m.c
通过以上代码可以给编译器和链接器构建动态库,-fpic代表编译器生成与位置无关的代码,-shared代表链接器创建一个共享的目标文件。
创建可执行目标文件prog2:
linux> gcc -o prog2 main2.c ./libvector.so
这样就可以在运行时和libvector.so链接起来。基本思路:创建可执行文件,静态执行一些链接,然后程序加载时,动态完成链接过程。注意在此时:libvector.so没有任何代码和数据节复制到prog2中,反而,链接器复制了一些重定位和符号表信息,它们使得运行时可以解析对libvector.so代码和数据的引用。
当加载器加载和运行可执行文件prog2时,加载链接可执行文件prog2。prog2包含一个.interp节,这一节包含动态链接器的路径名,动态链接器本身就是一个共享目标。加载器不会把控制传递给应用,而是加载和运行这个动态链接器。执行以下重定位完成链接任务:
- 重定位lic.so 文件和数据到某个内存段
- 重定位libvector.so 文件和数据到另一个内存段
- 重定位prog2对lib.so libvector.so定义的符号的引用
静态链接类似旅游时带大量行李,动态链接类似旅游时只带钱即可
位置无关代码
可以加载而无需重定位的代码称之为位置无关代码,用户对gcc的使用-fpic指示编译系统生成pic代码。
- PIC数据引用
- 在内存何处加载一个目标模块(包括共享模块),数据段和代码段之间的距离是保持不变。因此,代码中任何指令和数据段中任何变量之间的距离都是一个运行时常量,与代码段和数据段的绝对内存位置是无关的。
- 在数据段开始的地方创建一个表,叫做全局偏移量表(GOT)。
- PIC函数调用
延迟绑定
:将过程地址的绑定推迟到第一次调用该过程中- 延迟绑定使用两种数据结构:GOT和过程链接表(PLT)。GOT是数据的一部分,而PLT是代码的一部分。
- PLT是一个数组,其中每个条目是16字节代码,
- PLT[0] 是一个特殊条目,跳转到动态链接器中。
- PLT[1]代表调用系统启动函数(
__libc_start_main
),初始化执行环境 - PLT[2]之后是用户代码调用的函数
库打桩机制
库打桩允许你截获对共享函数的调用,取而代之执行自己代码,用于包装目标函数,执行自己函数逻辑,在调用目标函数。
打桩可发生在编译时、链接时、当程序被加载运行时或执行时:
- 编译时打桩——>需访问源码
- 链接时打桩——>需访问重定位
- 运行时打桩——>只需要访问可执行文件。这个机制基于动态链接器的LD_PRELOAD环境变量
- 如果加载或者运行一个程序,需要解析未定义的引用,动态链接器会先搜索LD_PRELOAD库,然后才搜索任何其他库。有这个机制,当加载或者执行任意可执行文件时,可以对任何共享库中的任何函数打桩。