实例化编译器和链接器
‘壹’ 谁知道链接器做什么
前几天,在组内分享了关于链接器的一些东西,在这里总结一下。讨论的背景主要是基于C/C++,Linux平台相关。链接器相关的一些基本问题 学习或者了解链接器,有一些基本的问题需要关心:链接器做些什么;链接器和体系结构;程序是怎样生成的。下面做简要介绍。链接器做些什么 链接器之所以存在或者产生,基本上是由于程序开发的模块化。这里讲的模块,主要是编译概念上的模块,通常他们按照功能划分,比如一个.c或者.cpp文件就是一个编译单元,就是一个模块,编译后就产生一个.o目标文件。为了最终生成一个可执行文件、静态库或者动态库,就需要把各个编译单元按照特定的约定组合到一起。这里特定的约定指的就是“目标文件格式”,它定义了目标文件、库文件和可执行文件的格式,这里组合这一过程就叫做链接。
一个编译模块中,通常是函数的定义和全局数据的定义,数据类型的定义通常在头文件中,编译时会被包含在编译模块中。函数和数据由符号来标识,一般符号有全局和静态之分,全局符号可以被其他模块引用,而静态符号只能在本模块中引用。编译各个模块时,编译器会解析该模块。重要的一项工作就是建立符号表,符号表中包含了本模块有哪些符号可以被其他模块引用(导出符号),还包括本模块引用(导入符号,即未定义符号)、但在其他模块中定义的符号。每一个符号都关联一个地址,这个地址指明了该符号在本模块中的偏移地址(通常是一个从0开始的地址)。
链接器在链接过程中,会扫描各个模块的符号表,得到一个“全局符号表”,链接器由此决定一个符号在哪里被定义,在哪里被引用。并且,将符号引用处替换为定义处的地址,这一过程就叫做符号解析。
链接器的一项终极目标就是生成可执行文件。通常,可执行文件和普通目标文件的重要区别就是地址空间的使用。主流操作系统中,可执行文件都是基于虚拟地址空间的,即每个可执行文件都有相同且独立的地址空间,并且文件中各个段(代码段,数据段,以及进程空间中的堆栈段)都有相似的布局。而普通目标文件却使用从零开始的地址空间,这样一来,模块M中的符号m就可能和模块N中的符号n拥有“相同”的地址。在链接器链接各个模块时,会从各个模块中“提取”类型相同的段进行合并,并将合并后的段写入可执行文件中。这一过程被称为存储空间的分配。值得一提的是,栈、堆以及未初始化的数据这些“运行时”需要的空间不会在可执行文件中占据磁盘空间,但它们占用相应的地址空间。
由于存在上述“合并”过程,前面提到的符号解析就涉及到另外一个过程:重定位。由于各个模块中的函数/数据地址会被重新排放,那么对这些符号的引用也必须被相应地调整。这一调整过程被称作重定位。
符号解析,存储空间分配,还有重定位,这三个过程是一个有机的整体,是“同时”进行的,且这三个过程也是模块化所带来的必须要解决的问题。链接器和体系结构 在我们编写普通应用程序时,不需要过多的关系体系结构的问题,但对于链接器学习者/编写者来说,相当大的工作都必须围绕体系结构展开,比如目标平台的ABI,内存地址,指令格式,寻址方式等等,下面做大致介绍。
另外,多字节数据的字节序也是链接器需要考虑的。字节序是关于如何使用线性的连续字节来表示多字节数据的问题,有Little-endian和Big-endian两种字节序。小端序是指将多字节数据的低权重字节放在内存的低地址处,大端序则正好相反。直观讲,从内存的低地址向高地址方向看,先看到多字节数据的低权重字节的就是小端序,否则就是大端序。至于为什么会存在两种字节序,两者有何优劣,我觉得这只是个“个人喜好”问题,就好象剥鸡蛋先磕破哪一头,蹲厕所时脸里还是脸朝外一样,事实上,直到前些天,我才亲眼看到,真的是有“茅房拉屎脸朝内”的人的。
指令格式和寻址方式也会影响链接器的工作,因为在符号重定位的时候,链接器需要修改指令中操作数部分,所以需要知道每种指令的指令格式及寻址方式,以便对指令做出适当的调整。程序是怎样生成的 事情渐渐明了了,编译器前端对语言进行词法、语法分析,建立语法树,编译器后端在语法树的基础上针对特定的平台生成指令,并按照特定的格式输出到磁盘中的目标文件。链接器按照前面所说的过程生成最终的可执行文件或程序库。(这里的程序库特指动态库,因为静态库只是目标文件的简单集合,理论上不需要链接器的参与)本文只针对链接器进行简单的讨论,关于编译器的功能和相关原理,有大量的资料可以参考。目标文件格式 目标文件格式是指令、数据在磁盘中存储形式的一种约定。它描述了指令、数据的存储格式和布局,并且针对不同类型的文件(普通目标文件,可执行文件,动态库)有不同描述侧重点。另外它还描述了一些供外部程序使用的元信息,例如普通目标文件中的符号表的内容和组织形式,待重定位符号的信息;可执行文件中各个段的信息,程序的入口点(程序从何处开始执行),哪些符号需要在运行时解析,这些符号包含在哪些动态库中,以及解析这些库需要哪种动态链接器等等;动态库为了实现同时被多个进程链接需要什么样的组织形式,本身又引用了其他动态库的哪些符号等等。通常,不同的平台都会有自己的目标文件的格式标准:COM:DOS最初采用的一种非常简单的格式,除了指令、数据之外,基本不包含其他信息。PE:Windows当前采用的目标格式,继承自COFF,是一种主流的现代目标格式,相比COM有更强大的功能支持。a.out:最初UNIX平台采用的目标格式,简单且功能强大,但对于C++这样的高级语言支持不足。ELF:当前Linux/Unix平台采用的主流格式,继承自a.out,且对高级语言支持很友好。 除了ABI,目标格式的不同,也是在一种操作系统下编译的程序无法在另一种操作系统中执行的原因。程序库 终于到库了,研究库很有趣,也相当实用。概念上,库可以分为静态库,动态库,且这两种库都可以实现为“共享库”,但在实现上,静态共享库由于需要考虑态度的问题、实现太过复杂且得不偿失,现实中很少有这种类型的库。所以,应用中只存在两种库:静态库和动态共享库,下面分别做简要介绍,关于在Linux中如何创建这两种库,可以参考我之前写的一篇博客,或者其他更详尽更优秀的资料。静态库 在功能特性上,静态库是指这样一种库,在链接时,其中被引用的代码、数据被“复制”到引用该库的程序中。在格式上,静态库十分简单,他是普通目标文件的集合,是一种简单的拼接。事实上,Linux平台下静态库.a文件使用独立的归档工具ar建立,为了使链接器能够有效地查找库中包含的各个目标文件以及符号,经常还需要一个叫做ranlib的工具在.a文件中建立索引。
在链接时,链接器想普通目标文件一样使用静态库,仅仅多了在库中查找符号及对应目标文件的过程。动态链接库 动态链接库和静态库差异较大,Linux平台,它由ELF格式直接支持。但由于共享库的特殊性,它需要一些特殊特性的支持:
PIC(Position Independent Code),位置无关代码。动态共享库需要在运行时动态地加载到内存,为了在各个进程中调用共享库中的代码和数据,就需要将该库映射到不同进程的进程空间。由于各个进程的地址空间使用情况不尽相同,很难将共享库映射到各进程相同的位置。这样一来,就对共享库的代码提出了挑战,它需要能够在不同的地址区间上都正确的执行。位置无关代码就是因此而提出的。
位置无关代码的基本思想是这样的:将共享中对绝对地址的引用转化为相对地址的形式。对于函数调用,实现起来很简单,因为代码是只读的,指令间的相对地址也是固定的,只需要将函数调用转化会相对地址即可。但对于数据的引用就复杂很多了,由于各个进程都需要访问共享库中的数据,而这些进程通常是毫无关联的,一个进程对共享库数据的修改不应该被其他进程看到。一种好的方法就是让各个进程都拥有自己的一份数据拷贝。但这又引出一个问题,共享库是被动态映射的,数据空间也只能在映射时才需要分配,那么在共享库代码中如何引用这些数据,以达到不同进程使用相同的代码访问不同的数据呢?
于是另外一种结构被引入了,即GOT(Global Offset Table),全局偏移量表。它的基本思想也是相对地址引用。在共享库的数据段加入GOT,GOT的表项保存各个数据的地址。由于共享库中指令和数据段的相对地址在链接后是固定的,这样在指令中就可以使用相对地址来找到GOT的起始地址,然后根据各个数据在GOT的偏移量来找到其对应的地址。而该地址是在共享库被映射到进程空间时,由动态链接器在相应的进程空间中分配并设置的。
接下来的问题就是,进程中如何调用共享库中的代码呢?ELF使用一种延迟加载的方法,即当进程调用共享库中一个函数时,才解析该函数的地址,且只有第一次才解析,第一次解析之后的调用就不会被再次解析,而是将之前解析到的地址保存下来。这里又引入一种机制,叫做PLT(Procere Linkage Table),它和GOT一起(使用共享库的进程也有一个GOT)引入了一个函数调用的间接层。
类似共享库的GOT,进程的GOT表项保存了本进程引用的共享库中的函数地址,但在第一次对该函数的调用之前,该表项保存的并不是函数的地址,而是指向PLT中一个指令的地址。为了方便说明问题,假设进程中main函数引用了libmath.so中的函数add,那么PLT大致是这个样子的,1 2 3 4 5 6 7 8 9 10 11 12 13 .plt0:0x080483d0: pushl 0x8049ff80x080483d6:jmp*0x8049ffc...add@plt:0x080483e0 <+0>:jmp*0x804a0000x080483e6 <+6>:push $0x00x080483eb <+11>:jmp0x80483d0... main:...0x080484ec <+24>:call0x80483e0 <add@plt> # 对add函数的调用 ... 第十二行对add函数的调用跳转到第五行,这是一条jmp指令,0x804a000是它的地址操作数,该地址即是add在GOT中的地址项,最初该地址处保存的地址是0x080483e6,即jmp指令的下一条push指令。于是最初jmp指令执行后没有任何效果,直接执行下一条指令。push指令将add在重定位项的索引入栈,通过该重定位项可以得到add符号本身(即字符串add)。然后又是一条jmp指令它跳转到第二行,又是一个push指令,接下来又是jmp指令,这个jmp指令也使用了GOT中的一个表项,该表项存储的是动态链接器(ld.so)的加载/解析函数。在解析函数中,查找add符号在共享库中的地址,将该地址填入add对应的GOT表项,然后跳转至add函数开始执行。到下一次调用add函数,第五行的jmp指令就直接跳转到add函数了。
动态链接基本就是这个过程了。在这个过程中有许多有意思的指令,将栈玩弄于鼓掌,像变魔术一样,有兴趣的话可以使用gdb等相关工具调试一下。一些工具 玩弄二进制,很多实用工具是离不了的,最重要的就是GNU Binutils二进制工具链了。包括查看ELF文件信息的readelf,对目标文件、可执行文件、共享库、core内存转储文件等反汇编的objmp,重量级的调试器gdb,查看共享库使用情况的ldd等等。一些参考资料 了解链接器工作原理,现成的资料并不多,《Linkers and Loaders》算是经典了,中文版也可以买得到,翻译得还算中规中矩。另外,《程序员的自我修养:链接、装载与库》写的很浅显,不错的一本书。
为了更好的理解链接器,必须对ELF的细节有所了解,Executable and Linkable Format这份文档可以参考。
研究二进制,汇编知识是必须的,如果是Linux平台,了解些GNU 汇编(AT&T汇编)是再好不过了,不过讲解GNU汇编的资料更是少上加少,布鲁姆的《汇编语言程序设计》虽然内容不多,但对非专业汇编程序员也足够用了,这本书是有英文电子版的。
有了相关工具和入门资料,剩下的就是折腾了。
‘贰’ C++模板:这个程序为什么通不过编译(C++11)
DoIt 是个模板方法. 要告诉编译器当成 模板方法对待.
B<T>::templateDoIt<Opr>();
改成这样就可以了...
‘叁’ 编译器跟编辑器有什么区别。还有什么是链接器
链接器(Linker)是一个程序,将一个或多个由编译器或汇编器生成的目标文件外加库链接为一个可执行文件。
是软体程序,一般是指用来修改电脑档案的编写软件,但也有人称 PE2、HE4(汉书)……等文书软件为编辑器。常见的编辑器有文本编辑器、网页编辑器、源程序编辑器、图像编辑器,声音编辑器,视频编辑器等。
简单讲,编译器就是将“一种语言(通常为高级语言)”翻译为“另一种语言(通常为低级语言)”的程序。一个现代编译器的主要工作流程:源代码 (source code) → 预处理器 (preprocessor) → 编译器 (compiler) → 目标代码 (object code) → 链接器 (Linker) → 可执行程序 (executables)
‘肆’ C++模板隐式实例化是编译器进行实例化还是链接器进行实例化
据我所知一般是编译器生成实例化代码,链接器删除重复代码
‘伍’ C++模板类实例化,编译器报错
第一种方法意味着在使用模板的转换文件中不但要包含模板声明文件,还要包含模板定义文件。在上例中,就是第一个示例,在array.h中用行内函数定义了所有的成员函数。或者在main.cpp文件中也包含进array.cpp文件。这样编译器就能看到模板的声明和定义,并由此生成array<int, 50>实例。这样做的缺点是编译文件会变得很大,显然要降低编译和链接速度。
第二种方法,通过显式的模板实例化得到类型。最好将所有的显式实例化过程安放在另外的文件中。在本例中,可以创建一个新文件templateinstantiations.cpp:
// templateinstantiations.cpp
#include "array.cpp"
template class array <int, 50>; // 显式实例化
‘陆’ 编译器有什么用
简单讲,编译器就是将“一种语言(通常为高级语言)”翻译为“另一种语言(通常为低级语言)”的程序。一个现代编译器的主要工作流程:源代码 (source code) → 预处理器 (preprocessor) → 编译器 (compiler) → 目标代码 (object code) → 链接器(Linker) → 可执行程序 (executables)
高级计算机语言便于人编写,阅读交流,维护。机器语言是计算机能直接解读、运行的。编译器将汇编或高级计算机语言源程序(Source program)作为输入,翻译成目标语言(Target language)机器代码的等价程序。源代码一般为高级语言 (High-level language), 如Pascal、C、C++、Java、汉语编程等或汇编语言,而目标则是机器语言的目标代码(Object code),有时也称作机器代码(Machine code)。
对于C#、VB等高级语言而言,此时编译器完成的功能是把源码(SourceCode)编译成通用中间语言(MSIL/CIL)的字节码(ByteCode)。最后运行的时候通过通用语言运行库的转换,编程最终可以被CPU直接计算的机器码(NativeCode)。
‘柒’ 模板实例化失败可能得原因
编译出错,链接出错,或程序实例化。程序员在使用模板类时最常犯的错误是将模板类视为某种数据类型。
所谓类型参量化(parameterized types)这样的术语导致了这种误解。模板当然不是数据类型,模板就是模板,恰如其名:编译器
使用模板,通过更换模板参数来创建数据类型。这个过程就是模板实例化(Instantiation)。
从模板类创建得到的类型称之为特例(specialization)。模板实例化取决于编译器能够找到可用代码来创建特例(称之为实例化要素,
point of instantiation)。
要创建特例,编译器不但要看到模板的声明,还要看到模板的定义。模板实例化过程是迟钝的,即只能用函数的定义来实现实例化。
编译器可以解析模板定义并检查语法,但不能生成成员函数的代码。它无法生成代码,因为要生成代码,需要知道模板参数,即需要
一个类型,而不是模板本身。
‘捌’ 简述一下编译器和链接器的作用
1、编译器:
编译器对源文件进行编译,就是把源文件中的文本形式存在的源代码翻译成机器语言形式的目标文件的过程,在这个过程中,编译器会进行一系列的语法检查。如果编译通过,就会把对应的CPP转换成OBJ文件。
2、链接器:
当链接器进行链接的时候,首先决定各个目标文件在最终可执行文件里的位置。然后访问所有目标文件的地址重定义表,对其中记录的地址进行重定向(加上一个偏移量,即该编译单元在可执行文件上的起始地址)。
然后遍历所有目标文件的未解决符号表,并且在所有的导出符号表里查找匹配的符号,并在未解决符号表中所记录的位置上填写实现地址。最后把所有的目标文件的内容写在各自的位置上,再作一些另的工作,就生成一个可执行文件。
‘玖’ C++ primer 模板实例化
举个例子来说:
template <class _Tx>
_Tx Max_T(_Tx a, _Tx b) { return a >b ? a, b; }
这是个模板的声明,用途是返回两个对象中较大的那一个,使用模板形参_Tx,,这里可以 是任何基本类型比如int, char, double等
请看下面代码:
void main(void)
{
int a = 1, b = 2;
int max = Max_T(a, b);
printf("%d\n", max);
}
int max = Max_T(a, b);这一句表示模板的使用,意思是在代码中使用了模板
编译器有个预处理器,负责将模板实例成代码(当然它还干其它的工作),再交给编译器去编译。
简单来说是这个样子:当编译器发现存在这样的模板调用代码时Max_T(a, b);它会先用int 来实例化模板的_Tx形参,然后生成一个函数:@xxxMax_Txxx(int a, int b)这个名字是装饰名(这里不作解释,如果函数体内部还有对形参_Tx的引用,也全部用int替换掉),生成函数之后然后用@xxxMax_Txxx(a, b)来替换模板调用Max_T(a, b);的这一行代码,这样就生成了实际的代码,这一步叫做模板的实例化,这一步完成之后,编译器的编译单元就可以像正常代码一样去编译这个代码了,最后生成目标代码,然后交给链接器去生成可执行文件(扯远了),整个过程就是这样,现在明白了否
‘拾’ c++;明确的实例化有什么作用
明确的实例化就是让编译器按这个参数编译一份可链接的类出来。 (否则模板类的原则是不使用就不编译)
明确定义就是所谓“特化”吧, 大致相当于在指定特殊参数下对类“重载”
如果你写一个跟本身毫无差别的特化, 某种角度说,跟明确实例化的结果很接近, 但是在编译器自动匹配调用时的模板参数时,这两者可能又有些差别。 这个匹配过程比较复杂,就很难说清楚了。
“调用模板就要实例化,既然这个不调用又有什么意义啊? ” 在分开编译的时候这个就会有意义。 因为分开的话,编译模板类的cpp的时候编译器还不知道你要怎么调用呢