链接器做什么
前几天,在组内分享了关于链接器的一些东西,在这里总结一下。讨论的背景主要是基于C/C++,Linux平台相关。
链接器相关的一些基本问题
学习或者了解链接器,有一些基本的问题需要关心:链接器做些什么;链接器和体系结构;程序是怎样生成的。下面做简要介绍。
链接器做些什么
链接器之所以存在或者产生,基本上是由于程序开发的模块化。这里讲的模块,主要是编译概念上的模块,通常他们按照功能划分,比如一个.c或者.cpp文件就是一个编译单元,就是一个模块,编译后就产生一个.o目标文件。为了最终生成一个可执行文件、静态库或者动态库,就需要把各个编译单元按照特定的约定组合到一起。这里特定的约定指的就是“目标文件格式”,它定义了目标文件、库文件和可执行文件的格式,这里组合这一过程就叫做链接。
一个编译模块中,通常是函数的定义和全局数据的定义,数据类型的定义通常在头文件中,编译时会被包含在编译模块中。函数和数据由符号来标识,一般符号有全局和静态之分,全局符号可以被其他模块引用,而静态符号只能在本模块中引用。编译各个模块时,编译器会解析该模块。重要的一项工作就是建立符号表,符号表中包含了本模块有哪些符号可以被其他模块引用(导出符号),还包括本模块引用(导入符号,即未定义符号)、但在其他模块中定义的符号。每一个符号都关联一个地址,这个地址指明了该符号在本模块中的偏移地址(通常是一个从0开始的地址)。
链接器在链接过程中,会扫描各个模块的符号表,得到一个“全局符号表”,链接器由此决定一个符号在哪里被定义,在哪里被引用。并且,将符号引用处替换为定义处的地址,这一过程就叫做符号解析。
链接器的一项终极目标就是生成可执行文件。通常,可执行文件和普通目标文件的重要区别就是地址空间的使用。主流操作系统中,可执行文件都是基于虚拟地址空间的,即每个可执行文件都有相同且独立的地址空间,并且文件中各个段(代码段,数据段,以及进程空间中的堆栈段)都有相似的布局。而普通目标文件却使用从零开始的地址空间,这样一来,模块M中的符号m就可能和模块N中的符号n拥有“相同”的地址。在链接器链接各个模块时,会从各个模块中“提取”类型相同的段进行合并,并将合并后的段写入可执行文件中。这一过程被称为存储空间的分配。值得一提的是,栈、堆以及未初始化的数据这些“运行时”需要的空间不会在可执行文件中占据磁盘空间,但它们占用相应的地址空间。
由于存在上述“合并”过程,前面提到的符号解析就涉及到另外一个过程:重定位。由于各个模块中的函数/数据地址会被重新排放,那么对这些符号的引用也必须被相应地调整。这一调整过程被称作重定位。
符号解析,存储空间分配,还有重定位,这三个过程是一个有机的整体,是“同时”进行的,且这三个过程也是模块化所带来的必须要解决的问题。
链接器和体系结构
在我们编写普通应用程序时,不需要过多的关系体系结构的问题,但对于链接器学习者/编写者来说,相当大的工作都必须围绕体系结构展开,比如目标平台的ABI,内存地址,指令格式,寻址方式等等,下面做大致介绍。
一个体系结构的ABI,即二进制程序接口,主要包括硬件和操作系统两个层面。内存字长,对齐方式,子程序调用时的约定(参数传递方式,返回值传递方式),系统调用如何进行,目标文件格式等等,都属于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(Procedure 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 0x8049ff8 0x080483d6: jmp *0x8049ffc ... add@plt: 0x080483e0 <+0>: jmp *0x804a000 0x080483e6 <+6>: push $0x0 0x080483eb <+11>: jmp 0x80483d0 ... main: ... 0x080484ec <+24>: call 0x80483e0 <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内存转储文件等反汇编的objdump,重量级的调试器gdb,查看共享库使用情况的ldd等等。
一些参考资料
了解链接器工作原理,现成的资料并不多,《Linkers and Loaders》算是经典了,中文版也可以买得到,翻译得还算中规中矩。另外,《程序员的自我修养:链接、装载与库》写的很浅显,不错的一本书。
为了更好的理解链接器,必须对ELF的细节有所了解, Executable and Linkable Format这份文档可以参考。
研究二进制,汇编知识是必须的,如果是Linux平台,了解些GNU 汇编(AT&T汇编)是再好不过了,不过讲解GNU汇编的资料更是少上加少,布鲁姆的《汇编语言程序设计》虽然内容不多,但对非专业汇编程序员也足够用了,这本书是有英文电子版的。
有了相关工具和入门资料,剩下的就是折腾了。
最后,附上之前做的幻灯片,就像这篇文章一样,臭长,枯燥。