浅析程序的链接与加载


前言


实际上光是链接这一块就有很多大牛洋洋洒洒的写成一本书了,所以本文只是简要的介绍链接、加载的基本原理、要完成的工作等,并不涉及流程与实现细节。另本文主要针对C语言程序与Unix系统。


C语言程序的生命周期


  • 编写源码。编写hello.c源码文件,它遵循某种字符编码格式,最终以字节序列的形式存储在文件中。
  • 预处理。根据源码中的预处理命令,修改原始C程序,得到修改过的程序hello.i。
  • 编译。编译器将源码中的内容翻译成汇编语言程序hello.s。
  • 汇编。汇编器将hello.s翻译成机器语言指令,并将这些指令打包成一种叫做可重定位目标程序(relocatable object program)的格式,得到hello.o,它是一个二进制文件,即它的字节编码是机器指令,而不是某种格式的字符。
  • 链接。链接器将hello.o与它所使用到的库函数以某种格式合并成一个可执行目标文件hello,它可以被加载到内存中,由系统执行。

链接的任务


从上文中的链接过程可以看到,链接器的任务是合并各个独立的可重定位目标文件,最终得到一个可执行目标文件。在构造可执行文件的过程中,链接器必须完成两个主要任务:

  • 符号解析:将每个符号引用(变量与函数引用)刚好和一个符号定义联系起来。
  • 重定位:编辑器和汇编器生成从地址0开始的代码和数据节。在汇编器生成的可重定位目标程序中,数据和代码的位置都是局部的逻辑地址,而想要在系统中执行这些指令,就需要以某种方式将这些逻辑地址重定位为实际的存储器地址。链接器通过将每个符号定义与一个存储器位置联系起来,然后修改所有对这些符号的引用,使得它们指向这个存储器位置,从而重定位这些节。

可重定位目标文件


先来看看汇编得到的可重定位目标文件,以下主要讨论Unix系统中的可执行和可链接格式(Executable and Linkable Format,ELF)。

一个典型的ELF文件包含以下几个部分:

  • ELF头:包括文件的基本信息、类型,节的位置和大小,节头部表的位置偏移等。
  • .text:已编译程序的机器代码。
  • .rodata:只读数据。
  • .data:已初始化的全局C变量。
  • .bss:未初始化的全局C变量。区分已初始化与未初始化全局变量主要是为了空间效率,未初始化的全局变量仅仅是一个占位符,不占据实际空间。
  • .symtab:符号表,存放程序中定义和引用的函数与全局变量的信息。
  • .rel.text:代码重定位条目。包含.text节中需要重定位项目的位置列表。
  • .rel.data:已初始化数据的重定位条目。包含被引用或定义的任何全局变量的重定位信息。
  • .strtab:字符串表。
  • 节头部表:描述每个节的位置和大小。

可见其中的.rel就存放着需要重定位的引用条目。


符号解析


符号解析是为了将每个符号引用和一个符号定义联系起来。

对于本地符号引用,符号解析是非常简单的。编译器会确保每个模块中每个本地符号只有一个定义。
而对全局符号的引用解析就棘手得多。当编译器遇到一个不是在当前模块中定义的符号(变量或函数名)时,它会假设该符号是在其他某个模块中定义的,然后生成一个链接器符号表的条目,把它叫交给链接器处理。若链接器在它的任何输入模块中都找不到这个被引用的符号,它会就输出错误信息并停止链接。

重定位


在完成符号解析后,连接器就知道它的输入目标模块中的代码节和数据节的确切大小,可以开始重定位了。重定位时链接器将合并输入模块,并为每个符号分配运行时地址,它由两步组成:

  • 重定位节和符号定义。这一步中,链接器将所有相同类型的节合并为同一类型的新的聚合节,然后链接器将运行时存储器地址赋给新的聚合节中的每个节和符号。这一步完成时,程序中的每个指令和全局变量都有唯一的运行时存储器地址了。
  • 重定位节中的符号引用。这一步中,链接器将依据重定位条目,修改代码节和数据节中的每个符号引用,使得它们指向正确的运行时地址。

重定位条目:当汇编器生成目标模块时,它并不知道数据和代码最终将存放在存储器的什么位置,也不知道这个模块引用的任何外部定义的函数或全局变量的位置。无论何时汇编器遇到对最终位置未知的目标引用,它就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。重定位条目实际上就是可重定位目标文件中的两个.rel节。


可执行目标文件


可执行目标文件的结构与可重定位目标文件类似,典型的可执行文件包含以下几个部分:

只读存储器段(代码段):

  • ELF头部:描述文件的基本信息,以及程序的入口点。
  • 段头部表:描述如何将可执行文件映射到存储器段中。
  • .init:此节中定义了_init函数,程序的初始化代码会调用。
  • .text:程序的机器代码。
  • .rodata:只读数据。

读/写存储器段(数据段):

  • .data:已初始化的全局变量。
  • .bss:未初始化的全局变量。

不加载到存储器的信息:

  • .symtab:符号表,包括程序中定义和引用的函数和全局变量的信息。
  • .strtab:字符串表。
  • 节头表:描述每个节的位置和大小。

在链接器完成重定位之后,就不再需要.rel节了。


加载与运行


可以通过execve系统函数来调用加载器,然后由加载器将可执行目标文件中的代码和数据从磁盘拷贝到存储器中,然后跳转到程序的入口点来运行该程序。

实际上由于虚拟存储器机制的存在,除了一些头部信息,加载器在加载过程中并没有执行从磁盘到存储器的实际数据拷贝,只是简单的将可执行文件映射到虚拟地址空间。直到程序真正运行,即CPU引用一个被映射的虚拟地址时才会触发缺页中断进行拷贝,而这是由页面调度机制自动完成的。

程序的运行时存储器映像:

  • 内核虚拟存储器:为内核的代码和数据保留。
  • 用户栈。
  • 共享库的存储器映射区域。
  • 运行时堆。由malloc创建。
  • 读/写段(数据段)。即.data与.bss节。
  • 只读段(代码段)。即.init,.text与.rodata节。

静态链接库


到现在为止,我们都是假设链接器读取一组包含程序中所有使用到的变量与符号的可重定位目标文件,并它们其链接成为一个完整的可执行文件。

例如hello程序中可能用到了printf函数,这个函数是定义在C语言标准库中的,那么怎么让链接器能理解这个函数呢?
一种方法是在编译时直接将每个语言提供的标准函数翻译成相应代码。但这样将给编译器增加显著的复杂性,而且每次修改库函数时,就需要一个新的编译器版本。
另一种方法是将所有的库函数都放在一个单独的可重定位目标文件中,程序员将这个文件链接到自己的目标文件中。这种方法的缺点就是系统中每个可执行文件都会包含一份标准函数集合的完全拷贝,而且对任何标准函数的任何改变,都要求库的开发人员重新编译整个库的源文件。也可以通过为每个标准函数创建一个独立的可重定位文件,把它们放在一个众所周知的目录中来解决上面的问题。然而,这就要求程序员显示地链接所有用到的库函数到可执行文件中。

在此情况下,静态链接库的概念产生了。一些相关的函数可以被编译为独立的目标模块,然后封装成为一个单独的静态库文件。程序员制定相应的文件名来使用这些静态库中定义的函数,而在链接时,链接器将只拷贝被程序引用的目标模块。


共享库


上文中讨论到通过静态链接库来解决链接库函数的某些问题,但静态库仍有一些明显缺点。首先是库函数发生改变后,静态库的用户仍然需要显示地将他们的程序与更新了的库重新链接。另一个问题是即使链接器只拷贝被程序引用的目标模块,每个程序中还是会存在很多重复部分。

共享库就是为了解决静态库的缺陷的产物。共享库也是一个目标模块,它可以在运行时加载到任意的存储器地址,并和一个在存储器中的程序链接起来,这个过程成为动态链接,由动态链接器来完成。

共享库包含了两种共享。首先是每个库只有一个共享库文件,所有引用该库的可执行目标文件共享这个文件中的代码和数据,而不是像静态库那样将相应代码拷贝和嵌入到可执行文件中。其次,在存储器中,一个共享库的.text节的副本可以被不同的正在运行的进程所共享。


动态链接


加载时链接


加载时链接的基本思路是在创建可执行文件时,即传统的链接阶段,静态执行一些链接,得到一个部分链接的可执行文件,然后在程序加载时动态完成链接过程。实际上链接时并未拷贝共享库的任何代码和数据节,而是拷贝了一些重定位和符号表信息,它们使得在运行时可以解析对共享库中的代码和数据的引用。

而在程序加载时,加载器拷贝可执行文件之后,不再像通常那样将控制传递给应用,而是加载和运行动态链接器。动态链接器先重定位共享库的代码和数据到某个存储器段,然后重定位程序中所有对共享库的符号的引用,最后动态链接器将控制传递给应用程序。


运行时链接


除了在程序加载时链接,还可以在运行时调用动态链接器加载和链接任意共享库。


位置无关代码(PIC)


共享库的一个主要目的就是允许多个正在运行的进程共享存储器中相同的库代码,从而节约存储器资源。那么不同的进程如何共享程序的一个拷贝呢?一种方法就是为共享库生成位置无关的代码,使得不需要链接器修改库代码,即不需要重定位,就可以在任何地址加载和执行这些代码。

从上文中我们知道,编译器生成的普通可重定位目标文件需要经过符号解析和重定位,将目标文件中的引用地址改为运行时的存储器地址,才能在运行时正常执行这些代码。那么如何使共享库中的代码始终保持不变,而在运行时又能获得这些引用真正的运行时地址呢?

编译器通过运用一个有趣的事实来生成对全局变量的PIC引用:无论在存储器中的何处加载一个目标模块(包括共享目标模块),数据段总是被分配在紧跟在代码段后面。因此,代码段中任何指令和数据段中任何变量之间的距离都是一个确定的常量,这与代码段和数据段在存储器中的位置是无关的。

编译器在数据段开始的地方创建了一个全局偏移量表(Global Offset Table,GOT),每个全局数据对象都有一个GOT条目,每个条目会有一个重定位记录,动态链接器会重定位GOT中的每个条目,使得它包含正确的绝对地址。在运行时,通过额外的机器代码来通过GOT间接引用每个全局变量,从而获得真正的存储器地址。同样的方法也可以用于解析函数引用。


参考资料


《深入理解计算机系统》

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值