链接二三事

4 篇文章 0 订阅

引子

最近,室友在腾讯面试的时候,被问一个c++问题,模板的声明和定义放在哪?对于这个问题,我是很错愕的,平时用java比较多,c++的一些知识了解比较少,出于好奇,我进行了一些浅显的研究。

 

一个程序产生的过程

在这里,程序通常指的是单进程。对于程序的产生。不同语言是不一样的,主要分为编译型语言和解释性语言。

编译型语言:程序在执行之前需要一个专门的编译过程,把程序编译成 为机器语言的文件,运行时不需要重新翻译,直接使用编译的结果就行了。程序执行效率高,依赖编译器,跨平台性差些。如C、C++、Delphi等

解释型语言:程序不需要编译,程序在运行时才翻译成机器语言,每执 行一次都要翻译一次。因此效率比较低。比如python语言,专门有一个解释器能够直接执行python程 序,每个语句都是执行的时候才翻译。(在运行程序的时候才翻译,专门有一个解释器去进行翻译,每个语句都是执行的时候才翻译。效率比较低,依赖解释器,跨 平台性好.)

C语言是编译型的。

Java比较特殊,Java程序也需要编译,但是没有直接编译成机器语言,而是 编译成字节码,然后用解释方式执行字节码。

下面一张图揭示了编译型语言和解释型语言的过程。

 

图1 编译器和解释器

这里我不用大量篇幅去解释编译和解释。那么对于一个编译型语言,到这里生成目标代码就可以了吗?其实还有很重要的一部,链接

 

链接是什么,有什么用

链接(linking)是将各种代码和数据部分收集起来并组合成为一个单一文件的过程,这个文件可以被加载(或拷贝)到存储器并执行。链接可以执行于编译时(compile time),也就是在源代码被翻译成为机器代码时;也可以执行于加载时(load time),也就是在程序被加载器(loader)加载到存储器并执行时;甚至执行于运行时(rum time),由应用程序来执行。

链接器使得分离编译(separate compilation)成为可能。

这是什么意思呢?对于c语言,因为链接器的存在,我们不需要每次编译时,使用所有的文件,也就是说当某个源文件发生了改变,我们只需要编译这个文件即可,极大的节省了编译时间。此外使用静态库(.a)或者共享库(.so),我们可以将常用的函数打包放在磁盘中,在需要时链接即可,减轻了开发压力。

 

链接的奥秘

为了使描述具体可解。我们讨论是基于这样的环境:linux x86,标准ELF目标文件格式。

下面的代码贯穿全文,我将基于它来探讨链接的过程。

 

生成可重定位目标文件。

Gcc -O2 -g -c main.c swap.c

这样会生成两个可重定位目标文件main.o 和 swap.o。

最后运行链接器ld,将main.o,swap.o和一些必要的系统目标文件组合起来,创建一个克制性目标文件p。

Ld main.o swap.o -o p

要运行p,只需要输出./p。外壳调用操作系统的加载器函数,拷贝p的代码和数据到存储器,然后将控制权转移到程序的开头。

 

目标文件

目标文件可以分为三种形式:

可重定位目标文件:包含二进制代码和数据,其形式可以在编译时与其他可重定位目标文件合并起来,创建一个可执行目标文件。

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

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

 

可重定位目标文件

一个典型的可重定位目标文件(ELF)的格式如下: 


ELF头:包含生成该文件的系统的字的大小和字节顺序,以及帮助连接器分析和解释的目标文件的信息。

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

.rodata: 只读数据,比如printf语句中的格式串和开关语句的跳转表

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

.bss: 未初始化的全局C变量。它仅仅是一个占位符,比如int a[10],标识它大小为40个字节,在加载到存储器后,才初始化空间,并且初始值为0。不占用磁盘空间,exe比较小。

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

.rel.text: 一个.text节中位置的列表,当连接器把这个目标文件和其他文件结合时,需要修改这些位置。

.rel.data: 被模块引用或定义的任何全局变量和重定位信息。

.debug: 一个调试符号表。-g开启才有。

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

.strtab:一个字符串表,其内容包括.symtab和.debug节中的符号表,及节头部中的节名字。 其中,每个部分都称为节。以null结尾的字符串序列。

 

这里面的重点是符号和符号表,各个部分都是围绕它来进行。下面我将详细解释。

符号和符号表(.symtab)

每个可重定位目标文件m都有一个符号表。   它包含m所定义和引用的符号信息。

由m定义并能被其他模块引用的全局符号。对应C语言中具有文件作用域并具有外部链接的变量,不带static的函数和全局变量。

由其他模块定义并被模块m引用的全局符号。对应在定义在其它模块中的函数和变量。

只被模块m定义和引用的本地符号。对应C语言中具有文件作用域但是具有内部链接的变量,如被static声明的全局变量。

注意:本地连接器符号和本地过程变量是不一样的。符号表中不对应于任何本地非静态程序变量。这些变量在运行中堆和栈中创建。对于本地static变量。编译器在.data和.bss中为每个定义分配空间,并在符号表中创建一个有唯一名字的本地连接器符号。比如在同一个模块的两个函数中都定义了x。



编译器在.data中为每个整数分配空间,并引出两个唯一的本地链接器符号给汇编器,比如x.1和x.2。

 

符号表由汇编器构造,使用编译器输出的符号。

.symtab节中包含ELF符号表。符号表包括一个条目的数组,下图展示了每个条目的格式。


值得注意的是section。每个符号都和目标文件某个节相连,即用section表示。该字段也是一个到节头部表的索引,一般为整形数字。有三个特殊的伪节:ABS,UNDEF,COMMON。ABS代表不该重定位的符号,UNDEF代表未定义的符号,也就是本模块使用,其它地方定义。COMMON代表还未被分配位置初始化的数据目标。

 

 

符号解析

链接器解析符号引用的方法是将它与它输入的可重定位目标文件的符号表中一个确定符号定义联系起来。

这里要注意:符号解析,指的是符号的解析,链接器收到的是可重定位目标文件,里面有符号表,符号表中有符号,这个符号就需要解析,如果是本地符号,那么肯定对应的是某个小节,如果是外部符号,那么就要到其他的模块(ld后面的其他输入)里面去找了。本地符号,编译器保证了唯一性,但外部符号,则有一些规则。

编译器值允许每个模块中每个本地符号只有一个定义。编译器还确保静态本地变量,它们也会有本地链接器符号,拥有唯一的名字。

对于外部符号,编译器会假设该符号是在其他某个模块中定义的,生成一个链接器符号表条目,并把它交给链接器处理。

对外部符号的解析很棘手,还因为多个目标文件,可能会定义相同的符号。

函数和已初始化的全局变量是强符号(static类型的函数已不在此列,那是本地符号了),未初始化的全局变量是弱符号。

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

不允许有过个强符号。

如果有一个强符号和多个弱符号,那么选择强符号。

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

比如


由于两个x都是强符号,链接错误。

注:c++和java中的重载函数,编译器是使用函数名和参数列表组合成对链接器来说唯一的名字。这种编码过程叫毁坏mangling,相反的过程叫恢复demangling。比如Foo::bar(int,long)被毁坏成bar__3Fooil。

这里的第二条,值得注意,特别的,一个强的一个弱的,符号重复,也就是变量名或者函数名重复,但有不同的类型的时候。这种情况有时会出问题。

链接器的输入,到目前为止,我们讲的都是可重定位目标文件。但还有一种格式,叫做静态库。

 

与静态库链接

静态库是一种特殊的称为存档的文件。——在Unix系统中,静态库以一种称为存档的特殊文件格式存放在磁盘中。

存档文件是一组连接起来的可重定位目标文件的集合,有一个头部用来描述每个成员目标文件的大小和位置。有后缀.a标识。

如果不使用静态库,要用什么方法向用户提供c标准里的库函数?

:编译器辨认出对每个标准函数的调用。——c标准函数太多,编译器太复杂,同时一个标准函数变了,那么编译器就需要推出一个新的版本。

所有的c标准函数都放入一个可重定位目标文件中。——那么每一个可执行目标文件,都将包含所有的c标准函数,体积过大。维护这个大的可重定位目标文件也麻烦,任意一个小的修改都要完整的编译所有c标准函数。

一个c标准函数一个可重定位目标文件。——程序员费事,每一个标准调用,程序员编译的时候就加入一个可重定位目标文件。

综上,静态库是这么做的,相关的标准函数被编译成一个可重定位目标文件,这样c标准函数就变成了一个个可重定位目标文件。然后将这些目标文件封装成一个文件,这就是静态库了。

在链接时,链接器将只拷贝被程序引用的目标模块,虽然有目标模块中有其他的一些函数没用到,但这是可以接受的。

gcc -static,这个-static参数告诉编译器驱动程序,链接器应该构建一个完全链接的可执行目标文件,可以加载到存储器并运行,在加载时无需更进一步的链接。

符号解析时,链接器维持3个集合:E——可重定位目标文件的集合;U——引用了但尚未定义的符号集合;D——引用了也定义了的符号集合。

初始时,3个集合都是空的。

然后链接器开始一个一个的扫描输入文件,从左到右。

如果输入文件是目标文件,那么输入文件加入E,并解析输入文件中的符号,根据结果来修改U和D,然后下一个。

如果输入文件是静态库文件,那么链接器从静态库文件中找U中的符号,如果静态库中的一个目标文件有U中某个符号的定义,那么久将这个目标文件加入到E中。对静态库中每个目标文件都反复进行这个过程,知道UD不变化了。这里的反复,应该不是简单的顺序找一遍,应该是找了很多遍吧。同时,如果第一个输入文件是静态库文件,也就是说UDE都是空,那么编译应该也能通过吧,这里应该要求静态库是完备的,静态库中的目标文件的结合时完备的。

从上面也可以看出,目标文件和静态库文件的处理是不同的,目标文件总是完全的加入UDE,而静态库文件则不一定完全的加入UDE。

在命令行中(链接器的输入中),如果一个符号的库,出现在引用这个符号的目标文件之前,那么引用就不能被解析。这个好理解,符号引用了,只会冲后面的输入找定义,而不会找前面的。

 

重定位

链接器完成符号解析之后,就可以开始重定位了。两步。下面的两步其实很好理解,经过符号解析之后,静态库没有,只剩下可重定位目标文件了。可重定位目标文件就是ELF头和节和节头部表。这里的重定位呢,首先让节具有存储器地址,符号具有存储器地址。然后呢,代码节和数据节中呢,有对各个符号的引用,所以想在要将他们指向上一步分配的存储器地址上面去。这样才能正确执行吗。

重定位节和符号定义——所有相同类型的节合并为同一类型的新的聚合节。将运行时存储器地址赋给—聚合节—每个模块定义的每个节—每个符号。完成这一步之后,程序中的每个指令和全局变量都有唯一的运行时存储器地址了。

重定位节中的符号引用——这一步中,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。——这一步依赖于重定位条目,这个条目就是.rel.text和.rel.data两个节。

汇编器生成目标模块,就是ELF格式的可重定位目标模块,模块中有符号,符号中有外部符号,汇编器不知道外部符号的位置,他会在符号表中标识其为UND类型,这就是ELF可重定位目标文件,他不知道,就标为UND,然后,他会生成一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。代码的重定位条目在.rel.text,数据的重定位条目在.rel.data中。重定位条目只在两个.rel中,所以,下面第一行的节,就是指.data和.text两个节。第二行指:text节中指函数名,data节中指变量名。

offset是需要被修改的引用的节偏移——意思是这个引用要被修改,这个引用在某个节中,这个引用在这个节中的偏移是offset。

symbol标识被修改的引用应该指向的符号。——上面一行,指出text或者data的那一行需要修改,这一行指出这一行中需要修改的符号。

type告知链接器如何修改新的引用。——R_386_PC32,相对地址——R_386_32,绝对地址。



为什么是-4?

那么现在,如果.text的存储器地址确定了,是ADDR(.text),swap这个符号的存储器地址也确定了,是ADDR(swap),那么,.text中需要修改的那个swap的32的字的地址就是ADDR(.text)+7。这个地址就是运行时的地址了,在运行时这个位置的地址,这个地址定义为refaddr,call下一行的地址为refaddr+4,call计算转移地址时,是用call下一行的地址,加上后面的32位数字算出来的,也就是refaddr+4+x,这个就将是跳转的地址,而跳转的地址已经知道了,就是ADDR(swap),所以x也就知道了:ADDR(swap)-4-refaddr。

重定位绝对地址容易很多。

 



对于重定位,什么时候是相对引用,什么时候是绝对引用?

相对引用一般针对函数,因为函数在text段,不同时候调用,引用地址不一样,需要增加或者减少地址不确定。绝对引用一般是全局指针变量,因为全局指针变量在data段,指针值是确定的。

 

 

可执行目标文件

链接器将多个可重定位目标文件合并成可执行目标文件。结构如下图。

 

 

 

ELF头部描述文件的总体格式,它还包括文件的入口点,.init节定一了一个init函数,程序的初始化代码调用它。因为已经重定位了,所以不需要.rel段。

 

 

加载可执行目标文件

当输入./p。调用加载器将可执行目标文件加载到存储器中,然后跳转到程序的第一条指令或者入口点。这个过程叫加载。

加载器如何工作?

Unix系统中的每个程序都运行在一个进程上下文中,有自己的虚拟地址空间。当外壳运行一个程序时,父外壳进程生成一个子进程,它是父进程的一个复制品。子进程通过execve系统调用启动加载器。加载器删除子进程现在的虚拟存储器段,并创建一组新的代码、数据、堆和栈段。新的栈和堆段被初始化为零。通过将虚拟地址空间中在页映射到可执行文件的页大小的片(chunk),新的代码和数据段被初始化为可执行文件的内容。最后,加载器跳转到_start地址,它最终会调用应用程序的main函数。

 

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


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

 

当加载器运行时,它创建如上图所示的存储器映像。在可执行文件中段头部表的指导下,加载器将可执行文件的相关内容拷贝到代码和数据段。接下来,加载器跳转到程序的入口点,也就是符号_start的地址。在_start地址处的启动代码(startup code)是在目标文件ctrl.o中定义的,对所有的C程序都是一样的。

1.  /*每个C程序中启动例程crtl.o的伪代码*/  

2.  0x080480c0 <_start> : /*Entry point in .text*/  

3.      call __libc_init_first /*Startup code in .text*/  

4.      call _init             /*Startup code in .init*/  

5.      call atexit           /*Startup code in .text*/  

6.      call main           /*Application main routine*/  

7.      call _exit          /*Returns control to OS*/  

8.      /*Control never reaches here*/  

 

动态链接共享库

静态库有一些明显的缺点:首先,静态库在更新时,使用该库的程序需要与更新的库进行重新链接。其次,由于使用静态库的程序在链接时都会拷贝静态库里被应用程序引用的目标模块,像printf和scanf这样的函数的代码在运行时都会被复制到每个运行进程的文本段中,这造成了冗余,浪费了稀缺的存储器资源。

为了解决静态库的这些缺陷,共享库(share library)出现了。共享库是一个目标,在运行时,可以加载到任意的存储器地址,并和一个在存储器中的程序链接起来。这个过程称为动态链接(dynamic linking),是由一个叫做动态链接器(dynamic linker)的程序来执行的。

共享库也称为共享目标(shareobject),在Unix系统中通常用.so后缀来表示。微软的操作系统大量地利用了共享库,它们称为DLL(动态链接库)。

共享库是以两种不同的方式来“共享”的(在Windows中分别称为“隐式链接”和“显示链接”)。首先,在任何给定的文件系统中,对于一个库只有一个.so文件。所有引用该库的可执行目标文件共享这个.so文件中的代码和数据,而不是像静态库的内容那样被拷贝和嵌入引用它们的可执行的文件中。基准,在存储器中,一个共享库的.text节 一个副本可以被不同的正在运行的进程共享。

其中一种共享方式就是隐式链接,其基本的思路是当创建可执行文件时,静态执行一些链接,然后在程序加载时,动态完成链接过程。

PS:这种使用方式相对于静态链接库来说也有缺点。因为使用动态链接库的程序在创建可执行文件时并非完全链接,因此在程序加载时,需要进一步完成链接过程。这样会使可执行文件的启动下降。

当加载器加载和运行一个编译包含共享库的可执行文件时,加载器会注意到一个.interp节,然后,加载器会加载和运行一个动态链接器。(动态链接器本身就是一个共享目标)。

动态链接器执行重定位完成链接任务。最后,动态链接器将控制传递给应用程序。

 

关于java jni

将本地c函数,编译到共享库中,static片段中动态链接和加载共享库,然后一个正在运行的java函数要调用相应函数。

 

 

关于c++模板

模板实例化template instantiation)是指在编译或链接时生成函数模板或类模板的具体实例源代码。ISO C++定义了两种模板实例化方法:隐式实例化(当使用实例化的模板时自动地在当前代码单元之前插入模板的实例化代码)、显式实例化(直接声明模板实例化)。在C++语言的不同实现中,模板编译模式(模板初始化的方法)大致可分为三种:

·        Borland模型(包含模板编译模式):编译器生成每个编译单元中遇到的所有的模板实例,并存放在相应的目标文件中;链接器合并相同的模板实例,生成可执行文件。为了在每次模板实例化时模板的定义都是可见的,模板的声明与定义放在同一个.h文件中。这种方法的优点是链接器只需要处理目标文件;这种方法的缺点是由于模板实例被重复编译,编译时间被加长了,而且不能使用系统的链接器,需重新设计链接器

·        Cfront/查询模型(分离Separation)模板编译模式):AT&T公司C++编译器Cfront为解决模板实例化问题,增加了一个模板仓库,用以存放模板实例的代码并可被自动维护。当生成一个目标文件时,编译器把遇到的模板定义与当前可生成的模板实例存放到模板仓库中。链接时,链接器的包装程序(wrapper)首先调用编译器生成所有需要的且不在模板仓库中的模板实例。这种方法的优点是编译速度得到了优化,而且可以直接使用系统的链接器;这种方法的缺点是复杂度大大增加,更容易出错。使用这种模型的源程序通常把模板声明与非内联的模板成员分别放在.h文件与模板定义文件中,后者单独编译

·        混合(迭代)模型:g++目前是基于Borland模型完成模板实例化。g++未来将实现混合模型的模板实例化,即编译器编译单元中的模板定义与遇到的当前可实现的模板实例存放在相应的目标文件中;链接器的包装程序(wrapper)调用编译器生成所需的目前还没有实例化的模板实例;链接器合并所有相同的模板实例。使用这种模型的源程序通常把模板声明与非内联的模板成员分别放在.h文件与模板定义文件中,后者单独编译

ISO C++标准规定,如果隐式实例化模板,则模板的成员函数一直到引用时才被实例化;如果显式实例化模板,则模板所有成员立即都被实例化,所以模板的声明与定义在此处都应该是可见的,而且在其它程序文本文件使用了这个模板实例时用编译器选项抑制模板隐式实例化,或者模板的定义部分是不可见的,或者使用template<> type FUN_NAME(type list)的语句声明模板的特化但不实例化。

g++的模板实例化,目前分为三种方式:[6]

·        不指定任何特殊的编译器参数:按Borland模型写的源代码能正常完成模板实例化,但每个编译单元将包含所有它用到的模板实例,导致在大的程序中无法接受的代码冗余。需要用GNU链接器删除各个目标文件中冗余的模板实例,不能使用操作系统提供的链接器

·        使用-fno-implicit-templates编译选项:在生成目标文件时完全禁止隐式的模板实例化,所有模板实例都显式的写出来,可以存放在一个单独的源文件中;也可以存放在各个模板定义文件中。如果一个很大的源文件中使用了各个模板实例,这个源文件不用-fno-implicit-templates选项编译,就可以自动隐式的生成所需要的模板实例。在生成库文件时这个编译选项特别有用。

·        使用-frepo编译选项:在生成每个目标文件时,把需要用到的当前可生成的模板实例存放在相应的.rpo文件中。链接器包装程序(wrapper)—collect2将删除.rpo文件中冗余的模板实例并且修改相应的.rpo文件,使得编译器可以利用.rpo文件知道在那里正确放置、引用模板实例,并重新编译生成受影响的目标文件。由操作系统的通用的链接器生成可执行文件。这对Borland模型是很好的模板实例化方法。对于使用Cfront模型的软件,需要修改源代码,在模板头文件的末尾加上#include <tmethods.cc>。不过MinGW中不包含链接器包装程序collect2,故不使用此方法。对于库(library),建议使用显式实例化方法。

·        另外,g++扩展了ISO C++标准,用extern关键字指出模板实例在其它编译单元中显式声明(这已经被C++11标准接受);用inline关键字实例化编译器支持的数据(如虚表)但不实例化模板成员;用static关键字实例化模板的静态数据成员但不实例化其它非静态的模板成员。

·        g++不支持模板实例化的export关键字(此关键字的这个用法已在C++11标准里被取消)。

 

                                               

参考文献

Csapp 第二版

维基百科

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值