CSI-VIII:链接-程序构建

本文详细介绍了程序构建过程中的链接原理,包括链接的概念、链接器的作用、链接的时机、链接器驱动程序的组成部分、链接错误处理、静态链接与动态链接的区别、目标文件的类型与结构、符号解析与符号表的管理、重定位过程及其实现,以及可执行文件的生成与加载过程。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

前言

         或许,我们都曾为符号解析的问题困扰过,又或许,我们还并不清楚静态库和动态库到底是如何表现,如何和程序关联从而构建一个大型的项目。我们都希望自己开发的共享库能够为他人使用而表现良好。希望在构建大型程序的过程中,避免一些如缺少模块,缺少库或者不兼容的库版本引起的链接器错误。因此,在本篇中我们就将介绍和程序构建相关的重要内容-链接。

         链接对我们并不陌生,它是将不同的代码模块组合成一个单一文件的过程,这个文件可被加载到存储器并执行。链接可以执行于编译时,也就是源代码翻译成机器码时,也可以执行于加载时,也就是程序被加载到存储器并执行时,甚至执行与运行时,由应用程序来执行,链接是由叫做链接器的程序自动执行的。链接器的出现,使得分离编译成为可能,我们不用讲一个大型的应用程序组织为一个巨大的源文件,而是把它分解成更小、更好管理的模块,可以独立的修改和编译这些模块。当我们改变这些模块中的一个时,我们只要单独的编译它,并将它重新链接到应用上,而不用编译其他文件。

编译器驱动程序

         让我们先回忆一下程序构建的整个过程,分别经历预处理,编译,汇编和链接几个阶段,而这些都分别由预处理器,编译器,汇编器和链接器处理的,这些构成了编译系统的编译驱动程序。下面我们通过两个源文件main.c和swap.c来帮助我们说明关于链接是如何工作的一些重要知识点。

                                                  

         当然,没人会写出这么奇怪的方法来交换一对数字,不过,这里我们的目的是用它来说明和分析问题。我们接下来用编译驱动程序来构建程序,如下图示,我们给出了编译main.c和swap.c的完整过程(这次我用新装的Ubuntu 12.04进行演示^_^)。

           

-_-链接报错,未能找到入口符号_start,这是什么原因呢?大概意思是没能找到程序的入口点,没关系,我们使用-v 选项来看下生成过程,重点看后面的链接部分。

                   

                  

我们看到链接命令里有crt1.o ,ctri.o ,crtn.o等等目标文件,因此就明白了程序未能找到入口点是因为程序在加载之前需要运行时环境为执行做准备,也就是所需的外壳程序,通常由编译器或者操作系统提供,因此我们需要链接上相关库重新构建。

                 

程序没有输出,但链接已经是成功生成可执行文件test :D。

静态链接

         Ld程序属于静态链接器,可以以一组可重定位目标文件和命令行参数作为输入,生成一个完全链接的可以加载和运行的可执行目标文件作为输出,输入的可重定位目标文件由各种不同的代码和数据节组成。指令在一个节中,初始化的全局变量在另一个节中,而未初始化的变量又在另外一个节中。

为了构造可执行文件,链接器必须完成两个主要任务:

(1)    符号解析。目标文件定义和引用符号。符号解析的目的是将每个符号引用刚好和一个符号定义联系起来

(2)    重定位。编译器和汇编器生成从地址0开始的代码和数据节。链接器通过把每个符号定义与一个存储器位置联系起来,然后修改所有对这些符号的引用,使得他们指向这个存储器从而重定位这些节。

目标文件

         目标文件共有三种形式:

(1)    可重定位目标文件。包含二进制代码和数据,其可以在编译时于其他可重定位目标文件合并起来,创建一个可执行目标文件,如main.o

(2)    可执行目标文件。包含二进制代码和数据,其形式可以被直接拷贝到存储器并执行

(3)    共享目标文件。一种特殊类型的可重定位目标文件,可以在加载或者运行时被动态地加载到存储器并连接,如.dll 文件

各系统之间,目标文件各不相同,早期的UNIX使用的是一般目标文件COFF。Windows NT使用的是COFF的一个变种,叫做可移植性可执行PE格式。现代Unix系统(包括linux,solaris)使用的是Unix可执行和可链接格式ELF,这些格式尽管各不相同,但基本的概念是类似的。

这里我们可以查看下main.o的属性,来看看我所在liunx平台的可重定位目标文件的格式。


可以看到是ELF文件格式,为intel80386平台上编译。

可重定位目标文件

         在这里我们介绍一个典型的ELF可重定位目标文件的格式。ELF头以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型(可重定位、可执行或者共享的)、机器类型(如IA32)、节头部表的文件偏移,以及节头部表中的条目大小和数量。

         夹在ELF头和节头部表之间的都是节。一个典型的ELF可重定位目标文件包含下面几个节:

       

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

         .rodata:只读数据

         .data:已初始化的全局C变量,局部C变量在运行时保存在栈中

         .bss未初始化的全局变量。在目标文件中这个节不占据实际的空间,它仅仅是一个占位符。目标文件格式区分初始化和未初始化变量是为了空间效率。

         .symtab:符号表,它存放在程序中定义和引用的函数和全局变量的信息。

         .rel.text:一个在.text节中位置的列表,当链接器把这个目标文件和其他文件结合时,需要修改这些位置。一般而言,任何调用外部函数或者引用全局变量的指令都需要修改。

         .rel.data:被模块引用或者定义的任何全局变量的重定位信息。一般而言,任何已初始化的全局变量,如果它的初始值是一个全局变量地址或者外部定义函数的地址,都需要被修改。

         .debug:一个调试符号表,其条目是程序中定义的局部变量和类型定义,程序中定义和引用的全局变量,以及原始的C源程序。

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

         .strtab:一个字符串表,其内容包括.symtab和.debug节中的符号表,以及节头部中的节名字。

 符号和符号表

         每个可重定位目标模块m都有一个符号表,它包含m所定义和引用的符号信息。在链接器的上下文中,有三种不同的符号:

(1)    由m定义并能被其他模块引用的全局符号。全局链接器符号对应于非静态的C函数以及被定义为不带C static属性的全局变量。

(2)    由其他模块定义并被模块m引用的全局符号。这些符号称为外部符号,对应于定义在其他模块中的C函数和变量。

(3)    只被模块m定义和引用的本地符号。有的本地链接器符号对应于带static属性的C函数和全局变量


在.symtab中的符号表不包含对应于本地非静态程序变量的任何符号。这些符号在运行时再栈中管理。

然而如果本地变量是静态的,比如在函数中定义的static变量,编译器会在.data和.bss中为每个定义分配空间,并在符号表中创建一个唯一名字的本地链接器符号。

符号表是一张包含多个条目的数组,每个条目的格式为

                                    

Name 是字符串表中的字节偏移,指向符号的以null结尾的字符串名字。Value是符号的地址。对于可重定位的模块来说,value是距定义目标的节起始位置的偏移。对于可执行目标文件来说,该值是一个绝对运行时地址。Size是目标的大小。Type通常要么是数据,要么是函数。Binding字段表示符号是本地的还是全局的。

每个符号都和目标文件的某个节相关联,由section字段表示,该字段也是一个到节头部表的索引。有三个特殊的伪节,他们在节头部中是没有条目的:ABS代表不改被重定位的符号;UNDEF代表未定义的符号,也就是本目标模块中引用,但是在其他地方定义的符号;COMMON表示还未被分配位置的未初始化的数据目标。对于COMMON符号,value字段给出对齐要求,而size给出最小的大小。

 

我们使用GNU的readelf命令查看main.o的符号表,如下


我们看得到全局符号main的定义,它是位于.text字节偏移为0处的15字节函数,其后跟着的是一个来自对外符号swap的引用,最后一个是关于全局符号buf定义的条目,它是一个位于.data节偏移为零的8字节目标。

下面在看看swap.o的符号表条目:


首先我们全局符号swap,它是一个位于.text中偏移为0处的32字节的函数,下一个符号是全局变量bufp0定义的条目,它是从.data中偏移为0处开始的4字节的已初始化目标。下一个符号来自bufp0的初始化代码中对外部符号的buf的引用。最后的全局符号bufp1,它是一个未初始化的4字节数据目标,最终作为.bss目标分配。

符号解析

链接器解析符号引用的方法是将每个引用与它输入的可重定位目标文件的符号表目中的一个确定符号定义联系起来。对于引用的本地符号解析非常简单,编译器确保了每个模块中每个本地符号只有一个定义。

但如果编译器遇到一个不是在当前模块中定义的符号(变量或者函数)时,它就会假

设该符号是在其他某个模块中定义的,生成一个链接器符号表条目,把它交给链接器处理。如果链接器在它任何输入模块中都找不到这个被引用的符号,它就输出一条符号解析的错误信息并终止。

链接器如何解析多重定义的全局符号

         在编译时,编译器向汇编器输出每个全局符号,或者是强,或者是弱,而汇编器把这个信息隐含地编码在可重定位目标文件的符号表里。函数和已初始化的全局变量是强符号,未初始化的全局标量是弱符号。例如上例的,buf,bup0,main和swap是强符号,bufp1是弱符号。

 

         根据强弱符号的定义,Unix链接器使用下面的规则来处理多重定义的符号:

         规则1:不允许有多个强符号

         规则2:如果有个强符号和多个弱符号,那么选择强符号

         规则3:如果有多个弱符号,那么从这些弱符号中任意选择一个

下面我们验证下这些规则:

规则1,我们试图编译和链接下面的两个C模块 foo1.c 和bar1.c,它们都有一个强符号main,

在链接是,链接器生成一个错误信息,强调main被定义多次

同样,对于下面的强符号X,链接器也会给出错误提示


然而,如果在一个模块里x未被初始化,那么链接器将安静地选择定义在另一个模块中的强符号(规则2):


可以看到,编译器并没给出提示,如果两个模块是由不同的人所写,那么mian函数的作者可能会觉得意外。所以这也提示我们在编写不同模块时,特别的对于全局变量的定义,我们能够明确其值就尽量不要依赖编译器对其进行初始化。

如果x有两个弱定义,也会发生类似的事情(规则3):

 

规则2和规则3都可能导致一些不易察觉的运行时错误,对于不警惕的程序人员来说,这很难理解,尤其当如果重复的符号定义还有不同的类型时。

 

我们在bar5中使用了double类型的x,却不想覆盖掉了x和y的值,这里幸好gcc会给出了警告信息,我们才有所觉悟,但对于其他编译器并不能保证会给出警告,这样就可能导致莫名的执行错误,甚至带来灾难。而且类似的执行错误在一个大型系统中很难被发现和修改,不过当我们怀疑有此类错误是,用gcc –fno-common进行编译,链接器会给出一条错误信息。

与静态库的链接

         所有的编译系统都提供一种机制,在链接中,将需要的目标模块打包成一个文件,我们称作静态库。它作为链接器的输入,在构造一个输出的可执行文件时,它只拷贝静态库里被应用程序引用的目标模块。

         系统支持静态库的目的之一是为了方便开发人员更加有效率的进行开发。以ANSI C为例,它定义了一组广泛的标准I/O、字符串操作和整数数学函数,例如atoi、printf、scanf,它们被定义在libc.a库中,对每个C程序来说都是可用的。另一方面,相比可重定位目标模块,静态库能够节省存储空间,试想如果标准C函数都放在一个单独的可重定位目标模块中,那么在链接生成的每个可执行文件中都会包含一份标准函数集合的完全拷贝。而对于静态库,链接器只拷贝被引用的目标模块,这就减少了可执行文件在磁盘和存储器中的大小。

链接器如何使用静态库来解析引用

         在符号解析的阶段,链接器从左到右按照它们在编译器驱动程序命令上出现的相同顺序来扫描可重定位目标文件和存档文件。在扫描中,链接器维持一个可重定位目标文件的结合E(这个集合中的文件会被合并起来形成可执行文件),一个未解析的符号(即引用了但是尚未定义的符号)集合U,以及一个在前面输入文件中已定义的符号集合D。初始时,E、U和D都是空的。

         <1>对于命令行上的每个输入文件f,链接器会判断f是一个目标文件还是一个存档文件(静态库.a),如果f是一个目标文件,那么链接器把f添加到E,修改U和D来反应f中的符号定义和引用,并继续下一个输入文件。

         <2>如果f是一个存档文件,那么链接器会尝试匹配U中未解析的符号和由存档文件成员定义的符号。如果某个存档文件成员m,定义了一个符号来解析U中的一个引用,那么就将m加到E中,并且链接器修改U和D来反映m中的符号定义和引用。对存档文件中所有成员目标文件都反复进行这个过程,直到U和D都不再发生变化。在此时,任何不包含在E中的成员目标文件都简单地被丢弃,而链接器将继续处理下一个输入文件。

         <3>如果当链接器完成对命令行上输入文件的扫描后,U是非空的,那么链接器就会输出一个错误并终止。否则,它会合并和重定位E中的目标文件,从而构建输出的可执行文件。

不过,这个算法会导致一些令人困扰的链接时错误,因为命令行上的库和目标文件的顺序非常重要。在命令行中,如果定义一个符号的库出现在引用这个符号的目标文件之前,那么引用就不能被解析,链接会失败。关于库的一般准则是将它们放在命令行的结尾。如果各个库的成员是相互独立的,那么这些库就可以按照任何顺序放置在命令行的结尾处。但如果库不是相互独立的,那么它们必须排序,使得对于每个存档文件的成员外部引用的符号s,在命令行中至少有一个s的定义是在对s的引用之后的。比如,假设foo.c调用libx.a和libz.a中的函数,而这两个库又调用liby.a中的函数。那么,在命令行中libx.a和libz.a必须处在liby.a之前:gcc foo.c libx.a libz.a liby.a,还有,如果需要满足依赖需求,可以在命令行上重复库。比如,假设foo.c调用libx.a中的函数,该库又调用liby.a中的函数,而liby.a又调用libx.a中的函数。那么libx.a必须在命令行上重复出现:gcc foo.c libax.a liby.a libx.a

重定位

         一旦链接器完成了符号解析这一步,它就把代码中的每个符号引用和确定的一个符号定义联系起来。这样,链接器就知道它输入目标模块中的代码节和数据节的确切大小,并根据这些对目标模块进行重定位,分配运行时地址。重定位由两步组成:

         重定位节和符号定义。在这一步中,链接器将所有相同类型的节合并为同一个类型的新的聚合节。然后,链接器将运行时存储器地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。

         重定位节中的符号引用。在这一步中,链接器修改代码节和数据节中对每个符号的引用,使得他们指向正确的运行时地址。这些依赖于称为重定位条目的可重定位目标按模块中的数据结构。

a).重定位条目

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

ELF重定位条目的格式如下:offset是需要被修改的引用的节偏移。Symbol标识被修改的引用应该指向的符号。Type告知链接器如何修改新的引用。

Typedef struct {

         Int offset;

         Int symbol:24,

                   Type:8;    

}Elf32_Rel;

ELF定义了11中不同的重定位类型,我们在这里只关心其中最基本的两种:

R_386_PC32:重定位一个使用32位PC相对地址的引用。即当相对于当前距程序计数器PC的当前运行时值的偏移量。例如CALL指令的目标。

R_386_32:重定位一个使用32位绝对地址的引用。通过绝对寻址,CPU直接使用在指令中编码的32位值作为有效地址,不需要进一步修改。

b).重定位符号引用

         下面我们给出重定位算法的伪代码:


第1行和第2行在每个节s以及与每个节相关联的重定位条目r上迭代执行。这里假设每个s是一个字节数据,每个重定位条目r是一个类型为Elf32_rel的结构。另外,还假设算法在运行时为每个节(用ADDR(s)表示)和每个符号(用ADDR(r.symbol)表示)选定了运行时地址。第3行计算需要被重定位的节s的地址,然后再根据重定位的类型来对其进行重定位。

也许,从算法的角度理解稍微有点抽象,我们结合之前的程序来描述如何进行重定位。

(1)    重定位PC相对引用,我们首先使用objdump –r –d main.o生成 mian程序调用swap程序的反汇编列表。如下所示:


         我们看到call指令开始于      节偏移0x6处,由一个字节的操作码和随后的32位引用0xfffffffc(-4)组成,它是以小端法字节顺序存储的。下一行显示的是这个引用的重定位条目。重定位条目r的3个字段分别为:r.offset=0x7,r.symbo=swap ;r.type=R_386_PC32

这些字段告诉链接器修改开始于偏移量0x7处的32位PC相对引用,使得在运行时它指向swap程序,假设链接器已经确定:

ADDR(S)=ADDR(.text)=0x804883b4

和 ADDR(r.symbo)=ADDR(swap)=080483c8,即节.text和符号swap的运行时地址。

使用上述算法,链接器首先计算出引用的运行时地址(第7行)

Refaddr =ADDR(S)+r.offset=0x80483b4+0x7=0x80483bb

然后将引用从当前值(-4)修改为0x9,使得它在运行时指向swap程序(第8行):

*refptr=(unsigned)(ADDR(r.symbol)+*refptr-refaddr)

           =(unsigned)(0x80483c8+(-4)-0x80483bb)

           =(unsigned)0x9

在得到的可执行目标文件中,call指令有如下的重定位的形式:

80483ba:e8 09 00 00 00    call 80483c8<swap>

在运行时,call指令将存放在地址0x80483ba处,当CPU执行call指令时,PC的值为0x80483bf即紧随在call指令之后的指令地址。为了执行这条地址,CPU执行以下的步骤:

PC+0x9=0x80483bf+0x9=0x80483c8->PC

可以看到,这正好是swap程序的运行地址。

 

上面程序中,汇编器将call指令中的引用的初始值设置为-4,是因为PC总是指向当前指令的下一条指令。我们通过这个值可以调整下一条指令刚好是执行符号swap的运行地址。当然,这跟机器的指令大小和编码方式有关。

(2)    重定位绝对引用

回想swap模块中将全局指针bufp0初始化为指向全局数组buf的第一个元素的地址:

Int * bufp0=&buf[0];

因为bufp0是一个已经初始化的数据目标,那么它将被存放在.data节中。下面是swap.data节的反汇编列表:

 

我们看到在.data节的0偏移处包含一个32位引用,bufp0指针的值为0x0.R_386_32告诉链接器这是一个绝对引用,开始于偏移0处,必须重定位使它指向符号buf,现在我们假设链接器已经确定:ADDR(r.symbol)=ADDR(buf)=0x8049454

        

*refptr=(unsigned)(ADDR(r.symbol)+*refptr)

            =(unsigned)(0x8049454+0)

            =(unsigned)(0x8049454)

在得到的可执行目标文件中,引用有下面的重定位形式:

0804945c<bufp0>:

0804945c:54 94 04 08 

链接器决定在运行时变量bufp0将放置在存储器地址0x804945c处,并且被初始化为0x8049454,这个值就是buf数组的运行时地址。

可执行文件

     我们已经看到链接器是如何将多个目标模块合并成一个可执行目标文件的。我们的C程序开始时是一组ASCII文本文件,已经被转化为一个二进制文件,且这个二进制文件包含加载程序到存储器并运行它所需的所有信息。下图是一个典型的ELF可执行文件的信息。


可执行目标文件的格式类似于可重定位目标文件的格式。EFL头部描述文件的总体格式。它还包括程序的入口点。也就是程序执行的第一条指令地址。.text、.data节和可重定位目标文件中的节是相似的,除了这些节已经被重定位到他们最终的运行时存储器地址以外。.init节定义了一个小函数,叫做_init,程序的初始化代码会调用它。因为可执行文件时完全链接的,所以它不再需要.rel节。

加载可执行目标文件

要运行可执行目标文件test,可以在Unix的外壳命令中输入它的名字:

./test

因为test不是一个内置的外壳命令,所以外壳会认为test是一个可执行目标文件,通过调用某个驻留在存储器中称为加载器的操作系统代码来运行它。我们可以使用execve来调用加载器,它将可执行目标文件中的代码和数据从磁盘拷贝到存储器中,然后通过跳转到程序的第一个指令或者入口点来运行该程序。这个程序拷贝到存储器并运行的过程叫做加载。

在CSI-I中我们介绍过程序的存储器映像结构,如图:


在32位Linux系统中,代码段总是从地址0x08048000处开始。数据段是在接下来的下一个4KB对齐的地址处。运行时堆在读/写段之后接下来的第一个4KB对其的地址处,并通过调用malloc库往上增长。还有一个段为共享库保留的。用户栈总是从最大的合法用户地址开始,向下增长的。从栈的上部开始的段是为操作系统驻留存储器的部分的代码和数据保留的。

还记得我们之前在链接main.c和swap.c中遇到的错误?链接器提示找不到入口符号_start,

_start是程序的入口点(而并不是我们所了解的main),加载器就是在_start处开始执行启动代码,这段代码是在ctr1.o中定义的,对所有C程序都是一样的。启动代码具有的一定的调用序列,在从.text和.init节中调用了初始化例程后,启动代码调用atexit例程,这个例程附加了一些在应用程序正常中止时应该调用的程序。Exit函数运行atexit注册的函数,然后通过调用_exit将控制返回给操作系统。接着,启动代码调用应用程序的main程序,它会开始执行我们的C代码。在应用程序返回之后,启动代码调用_exit程序,它将控制返回给操作系统。可以看到在执行我们的应用程序前后编译器已经做了很多工作。

 

对于加载的详细细节需要我们了解进程、虚拟存储器和存储器映射的相关概念,关于这些我们会在下一篇中再进行介绍。

动态链接共享库

     前面我们了解了静态库(如libc.a和windows下的 lib文件),我们知道了静态库存在的理由,比如方便使用,节省磁盘空间,但相比动态共享库来说,静态库这些都不再是优势,静态库和其他软件一样需要定期维护和更新,可是一旦更新,如果开发人员想用较新版本的库,就必须重新链接编译程序。另外,静态库造成了一定程度的内存储器的空间浪费,如大多数的C程序使用printf,scanf函数,这样在每个进程的文本段中,都会有复制一份这些目标模块的拷贝,从而造成内存空间的浪费。

     共享库的出现正是为了静态库带来的问题。共享库是一个目标模块,在运行时,可以加载到任意的存储器地址,并存储器中的程序链接起来。这个过程称为动态链接,是由一个叫做动态链接器的程序来执行的。在Unix系统中通常用.so后缀来表示。Windows系统用DLL来表示。

         共享库的最大优势在于,在任何给定的文件系统中,对于一个库只有一个.so文件。所有引用该库的可执行目标文件共享这个.so文件中的代码和数据,而不是像静态库的内容那样被拷贝和嵌入到引用他们的可执行的文件中。其次,在存储器中,一个共享库的.text节的一个副本可以被不同的正在运行的进程共享。另一方面,从动态链接的角度来说,共享库使得更新和维护应用程序更加方便,我们只需要对共享库进行定期的更新,而不需要对整个工程进行再编译构建。


  本篇的介绍到此结束。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值