程序的链接

程序的链接是一个非常实际的问题,他建立在很实际的问题之上,不从程序员的角度去思考问题,则是从软件的角度去思考如何复用错综复杂的代码。因为,这个问题的本质是我们没有给底层的硬件一个完整的可按顺序执行的程序,我们在前几章虽然讨论了指令流的问题,但是都是基于一个给定的按顺序执行的指令流,我们没有考虑这个按序到来的指令流是从何而来的。事实上,我们基本不会按照一个顺序的方法去构建我们的程序,也就是我们更少的去使用面向过程的方法去编写我们的代码,转而去使用面向对象的思想,更多的考虑代码复用以及内存如何寻址的问题。

简而言之,如果我们只是面向过程的进行编程,只使用物理内存去进行程序的运行,那样的话我们可能根本就不需要链接这个东西,但是我们并不直接使用硬件,因为这样会更多的考虑一些细节的实现,我们也不进行过程化的编程,因为即使使用过程化编程的思想,我们在构建项目的时候也会对其进行模块化的分割,只要我们进行了分割或是代码的不断复用,那么我们就会得不到一个按照顺序到来的指令流,因此链接对于我们现代的计算机和操作系统是非常必要的。

而对于程序员来说,我们不必了解链接的底层细节,但是对于我们来说了解链接是怎么回事,以及我们参数以及函数的作用域是非常比较的,他不但能帮助我们避免一些隐式的错误,还会让我们更容易的进行编程。

程序的转换处理过程

我们一个程序要经历预处理、编译、汇编、链接再到最后的运行几个阶段。

链接

与书上不同,我们先去探究链接究竟是什么,做了什么,然后再去讨论,这个过程中所需以及生成的东西。(以下这段都是个人见解,没有任何的理论依据)

链接是为了把一堆按照我们规定的顺序写出的代码组织起来的东西,所以他的工作就更像是为分离在项目里面的各个文件进行穿针引线,而他穿针引线的依据就是我们在文件里面写出来的组织方法,所以他要找到各个文件之间的关系,最重要的就是找到互相引用的地方,这也是链接最重要的工作,然后要把他们组织再一次,这是另一项工作,把整个项目按照我们最初讨论的那种样子,组织成为一个可以按照顺序执行的指令流再加载到内存。

因此,我们从上述的描述就可以得出,链接程序主要做了两件事,一件事是找到互相引用的地方,我们称之为符号解析,然后产生一张符号表,用来记录需要链接或者说可能会需要被链接的符号。第二项工作是组织这个项目,我们称之为重定位,我们靠重定位去从另一个文件中引用另一个文件的变量或函数,这就是链接的两个主要工作。

符号解析:

  • 程序定义和引用符号(函数、全局变量、静态变量)
  • 符号的定义由编译器保存在.o 文件关联的符号表中
  • 连接器把每个符号引用和定义关联起来

重定位:

  • 将独立的代码和数据节合并到单个节中(可以分配绝对地址)
  • 将符号从.o文件中的相对位置重新定位到可执行文件中的最终绝对地址:符号定义有了绝对地址(逻辑地址或虚拟地址)
  • 将这些符号的所有符号更新到其新位置(符号引用有了绝对地址)

目标文件

我们知道了链接究竟是怎么回事,再来回头看他生成的这些东西就有迹可循了。

目标文件有三种形式:

  • 可重定位目标文件。用来在编译时与其他可重定位目标文件合并起来,去创建一个可执行目标文件;(.o)
  • 可执行目标文件。其形式可以直接复制到内存并执行;(a.out, .bin, 无后缀)
  • 共享目标文件。可以在加载或运行时被动态地加载进内存并链接;(.a, .so)

可重定位目标文件

然后再来了解三种不同的目标文件。

一个典型的ELF文件结构如图所示:

ELF头:

  • 以一个16字节的序列开始,这个序列描述了生成该文件的系统的字宽和字节顺序(大小端存储模式);
  • ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。包括ELF头的大小、目标文件的类型、及其类型、节头部表的文件偏移地址,以及节头部表中条目的大小和数量;

节头部表:

  • 描述各节的位置和大小
  • 其中目标文件中每个节都有一个固定大小的条目

.text:已编译程序的机器代码

.rodata:只读数据(printf中的语句串或跳转表)

.data:已初始化的全局和静态C变量;局部C变量保存在栈中(因为不需要链接,所以不出现在这里)

.bss:未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量,目标文件中这个节不占实际的空间。

.symtab:一个符号表,存放在程序中定义和引用的函数和全局变量的信息。(不包括局部变量)

.rel.text:.text节中需要重定位的信息,在合并生成可执行文件时需要修改的指令的指针;

.rel.data:.data节的重定位信息,在合并生成可执行文件时需要修改的数据的指针;

.debug:调试符号表

.line:原始C源程序中的行号和.text节中机器指令之间的映射

.strtab:字符串表

ELF可执行目标文件

符号和符号表

按照书上的顺序结构,在这里讨论符号和符号表。

每个可重定位目标模块都有一个符号表,包含三种不同的符号:

  • 由模块定义并能被其他模块引用的全局符号,全局符号对应于非静态的C函数和全局变量;
  • 由其他模块定义并被该模块引用的全局符号,这些符号称为外部符号,对应于其他模块中定义的非静态C函数和全局变量;
  • 只被模块定义和引用的局部符号,他们对应于带static属性的C函数和全局变量,这些符号在模块内部可见,但是不能被其他模块引用;

认识到本地链接器符号和本地程序变量不同是很重要的,,symtab中的符号表不包含对应于本地非静态程序变量的任何符号,这些符号在运行时在栈中被管理。

而带static属性的本地过程变量不在栈中管理,编译器在.data或.bss中分配空间,并在符号表中创建一个有唯一名字的本地链接器符号。(这节读多了会觉得很乱,因为他翻译的时候用词不是很准确,会导致理解起来很费劲)

符号解析

链接器解析符号引用的方法是将每个引用与它输入的可重定位目标文件的符号表中的一个确定的符号定义关联起来。(想知道更详细的建议自己看,因为没什么好总结的)

对于多重定义的全局符号,我们使用强弱符号将其区别:函数和已初始化的全局变量是强符号,未初始化的全局变量是弱符号。

按照如下规则处理多重定义的符号:

  • 不允许有多个同名的强符号
  • 如果有一个强符号和多个若符号同名,那么选择强符号
  • 如果有多个弱符号同名,那么从这么些弱符号中任意选择一个

重定位

在符号解析之后,合并输入模块,并为每个符号分配运行时的地址,(关联代码中的符号引用和符号定义)

  • 重定位节及其节内定义的符号
  • 重定位代码节和数据节中的符号引用

汇编器遇到最终位置未知的目标引用,就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。

重定位条目格式:

两种重定位类型:

  • R_X86_64_PC32:重定位一个使用32位PC相对地址的使用;
  • R_X86_64_32:重定位一个使用32位绝对地址的引用;

重定位算法:

 

可执行文件的存储器映像

加载可执行目标文件

静态链接

首先讲讲什么是静态库,所有的编译系统都提供一种机制,将所有相关的目标打包成为一个单独的文件,称为静态库,我们可以在程序中引用一个静态库中的内容而非自己实现。

静态库相关的函数可以被编译为独立的目标模块,然后封装成一个单独的静态库文件。

链接库在解析引用的时候从左到右按照它们在编译器驱动程序命令行上出现的顺序来扫描可重定位目标文件和存档文件。在扫描中,链接器维护一个可重定位目标文件的集合E,一个未解析的符号集合U,以及一个在前面输入文件中已经定义的符号集合D,如果f是一个目标文件就把它添加到E修改U和D来反映f中的符号定义和引用。如果f是一个存档文件,那么链接器就尝试匹配U中未解析的符号和有存档文件定义的符号。如果某个存档文件成员m,定义了一个符号来解析U中的引用,那么就将m添加到E中,并且修改U和D来反映m中的符号定义和引用。对存档文件中所有的成员目标文件都依次进行这个过程,直到U和D不再发生变化。此时任何不包含在E中的成员目标文件都简单地被丢弃。

最后如果U非空,就会输出一个错误并终止,否则回合并和重定位目标文件,构建输出的可执行文件。

在命令行中,如果定义一个符号的库出现在引用这个符号的目标文件之前,那么引用就不能被解析,链接会失败。因此如果库不是相互独立的,就必须对他们排序。

动态链接

静态库仍然有缺点:

  • 如果更新版本,必须重新链接;
  • 很多标准函数的复用造成资源浪费;

共享库是一个目标模块,可以加载到任意的内存地址,并和一个在内存中的程序链接起来。

两种使用模式:

  • 程序加载过程中加载和链接共享库(加载后、执行前)
  • 程序执行过程中加载和链接共享库(无需再编译时将库链接到应用中)

位置无关代码

共享库也有缺点,但是相较于静态库缺点没有那么明显,我们的烦恼来源于更高层面的困扰,我们对于每个进程来说给予了他独自占有虚拟内存空间的假象,但是我们作为管理者,我们不能自己也相信这种假象,否则就真变成了全是假象。我们要思考更为深层次的问题,对于多个进程使用一个相同的共享库,我们在这个时候显然是要让其共享一个副本,但是他会使地址空间的使用效率严重降低,因为即使一个进程不使用这个库,空间依然会为他分配,同时更难的是对其管理,我们很大程度上要为其加入很多的标志位才能保证其正常使用。而当修改或者创建一个新库的时候,我们会更加头痛,要在内存中为其重新寻找位置,进而无数个共享库堆积在内存中,产生内碎片也严重影响了地址空间的使用,这为我们操作系统的管理带来了很大的负担。

所以我们使用位置无关代码来解决这个问题,他可以加载到内存的任意位置而无需链接器修改。

我们对于位置无关代码的讨论分成两个类型:数据引用和函数调用;

PIC数据引用

对于数据节,我们利用数据段和代码段的距离固定这一事实为数据引用构建了全局偏移表,然后在表中再去寻找相应的地址。

PIC函数调用

我们在这里提出两个数据结构的概念去实现这个问题,一个是过程链接表(PLT)、另一个是全局偏移表(GOT),我们对其调用时第一次会去PLT中寻找,查找其PLT[2]项,如果没有链接过就会去GOT表中跳转查找,然后返回压栈跳转到PLT[0],最后实现其动态链接。而之后的链接就会直接通过PLT[2]的条目跳转到相应位置。

库打桩

胖胖的人过年之前告诉我这里是重点,我看了半天也不觉得这里会是重点,库打桩允许我们截获对共享库函数的调用,取而代之执行自己的代码,总而言之是一种功能很强大的东西。

它能够在编译、链接、运行时进行打桩。(不想看里面的睿智代码)

  • 2
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值