《程序员的自我修养》读书笔记

李博杰 (bojieli AT gmail DOT com) 最后修改:2012-06-01

近来在郭家华的推荐下,读了LUG书库的《程序员的自我修养——链接、装载与库》一书,有种相见恨晚的感觉。然而快到期末考试了,没有时间把全书读完,因此只写了一部分。

我读技术类图书有个习惯,或者说是毛病:经常是先想想如果是自己设计这个系统会采用怎样的一种机制,然后再去读书中所讲的实现方式。由于计算机应用系统的设计不是什么算法难题,一般都能设计出一套像模像样的机制;然而从结构的优雅性角度考虑,我设计的机制有时充斥着一些与UNIX文化不符的元素。本文撷取链接、装载与库中的几个设计点,与大家分享我个人的想法与UNIX/Linux大师的设计。

There are two ways of constructing a software design. One is to make it so simple that there are obviously no deficiencies; the other is to make it so complicated that there are no obvious deficiencies. The first method is far more difficult. – The Emperor's Old Clothes, CACM February 1981

有两种方式构建软件:一种是把它设计得如此简单以至于明显没有缺陷,另一种是把它设计得如此复杂以至于没有明显的缺陷;前一种的难度大得多。——Hoare于图灵奖演讲《皇帝的旧衣》

0 关于编译的闲扯

 

0.1 可执行文件 ≠ 编译 + 汇编

我们知道,C语言经过编译器能生成汇编码,再经过汇编器能生成机器码。这对于我们在C语言学习阶段所编写的几十行、数百行的小程序似乎是没有问题的。反正整个源代码位于同一个.c文件中,只要处理库文件就行了。

针对这样的应用场景,我设想的执行过程:

我最初设想的编译过程:printf等标准库函数肯定是有源代码的,那么只要把这些库函数的源代码复制到源文件里就可以编译了,不会出现函数名找不到的问题。这时,可执行文件 = 编译 + 汇编。

当然,库函数的源码可能很长,每次编译太浪费时间了。另外一些商业编译器可能不愿意提供源代码。因此需要提前把这些库编译好,再把这些编译好的函数拼装在一起。这时,可执行文件似乎不是编译和汇编就能生成的了,这些提前编译好的库该如何与新编译的二进制文件整合在一起呢?

我改进的编译过程:每个标准库函数编译成一段机器码,存储在以函数名命名的文件里。

如果编译器仅仅需要解决对标准库函数的“预编译”的话,这样的机制似乎已经足够了;然而实际的项目不可能只有一个C文件。一个最简单的办法是把所有C文件“一视同仁”,全部粘贴到一个大的C文件中就行了。但C语言不同于面向对象语言,在函数之上再无封装层次,这样函数名就不得不起得很冗长,以免发生不同模块的函数冲突。

0.2 封装

由于没有用C语言编过超过1000行的程序,直到读Linux内核源代码,我才了解到C语言extern关键字的作用其实很大。被这个关键字修饰的函数才能被跨文件调用。可以理解为“文件”成了一种“命名空间”或“类”,include的其他文件就是与它有继承关系的类,声明为extern的变量和函数就是protected的,其他变量和函数都是private的。当然,C语言的include机制比起面向对象语言的继承机制还差得远,但有Makefile这个强大的助手,源代码文件之间的逻辑关系是可以理顺的。

我和郭家华曾争论过Makefile的必要性。我当时认为,内核中对变量命名的规则是比较严谨的,因此让每个文件include内核中的n-1个其他文件,就不用费心思写Makefile了(暂不考虑生成bzImage等压缩格式的问题)。郭家华提出了三个问题:

更严肃地说,这是一个封装方面的问题。我一直认为程序的一个模块应该对其他模块“充分开放”,这样才便于了解对方模块的内部原理,编写“有针对性”的代码;我知道这违反了封装的基本原则。《人月神话》第一版中也阐述了这种对封装的忧虑,然而在二十周年纪念版中Brooks转变了观点,认为封装是正确的。微软的Office系列程序对彼此的内部有着密切(或称杂乱)的了解,这在某种程度上使得系列软件的集成度更高,然而在软件整体的稳定性上付出了沉重的代价,更不用说与其他软件的互操作性了。编写一个有着杂乱内部了解的程序是信手拈来的,然而随后的维护工作会使人抓狂。M$善于先做一个“能用就行”的系统,然后在其基础上修修补补,这更多是基于商业上的考虑;与完美主义的UNIX文化格格不入。

事实上,我自己编写的程序之所以没有变成一团乱麻,是因为在开始设计时已经定义了较为清晰的边界,只是没有把这些边界显式规定下来。但在多人合作中,靠文档规定这些接口和边界,远不如使用编程语言内置的机制,用逻辑的形式(如include、类的继承、Makefile)把各模块的接口和关系明确定义。“制度是死的,人是活的”,靠自觉维护代码的内部边界,不如立下制度,“一百年不改变”,遇到实在绕不过去的大变动再去修改制度。经过一年的思考,我认为软件工程界的普遍观点是正确的。

0.3 ABI

扯远了。前面提到了函数调用,其实这里已经引出了二进制接口的问题。我最初学习C语言时,老师说C语言的函数调用方式是调用者把参数压栈,函数内把参数从栈中取出来;现在想,如果函数的参数个数很少,整个函数又不适合内联,则反复压栈的开销是较大的,为什么不规定第一个参数在第一个寄存器,第二个参数在第二个寄存器……呢?其实栈传参和寄存器传参就是两种不同的方式。容易想象不同的编译器如果采用不同的传参方式,甚或同一种编译器的不同编译参数指定了不同的传参方式,则生成的二进制文件是无法链接到一起使用的。

ABI(Application Binary Interface)的问题还包括(我不懂C++,所以有关C++的没有列出):

C语言标准的制定者采取了回避的策略,在C99标准中这些问题都被定义成“implementation-defined”。像内置类型大小这样的参数在limits.h等头文件中有定义,而更多的问题是对开发者不透明的。随着时代的推移,UNIX系统下编译器的ABI向LSB(Linux Standard Base)和Intel Itanium C++ ABI靠拢,留下了GCC和MSVC两个短时间内难以妥协的阵营。

1 链接

要实现一个由很多C文件组成的项目的编译,需要首先把每个C文件编译成一种“中间格式”,再把它们组合起来。这种中间格式就是“目标码”,组合的过程就是“链接”。链接过程初看简单,但仔细一想有很多问题:

第一个问题不难解决。尽管每个函数的虚拟内存地址不能确定,但其相对调用点的偏移是可以确定的。处理器的寻址模式有相对寻址,那么在同一文件内的目标码之间进行相对寻址即可。

第二个问题:让C编译器事先载入所有待编译的源代码、“预编译”、安排所有符号的位置、“正式编译”并不是一个好主意。首先,无法把库编译成二进制文件发行,每次都必须将所有代码连同库的源码一同编译,太耗时,这是无法接受的;其次,C编译器承担了过多的责任,成为一个庞大而臃肿的整体,不利于编译系统的模块化。在UNIX中,高效的进程生成、丰富而简洁的进程间通讯机制(如管道、重定向、socket)、一切皆文件的统一性理念鼓励让众多协作的小工具(如cc、as、ld、make)组成一个内部边界清晰的大型系统。

由于机器码中没有“函数”“变量”的概念,一切都是内存地址,单个文件编译出的机器码本身难以表示对外部函数和变量的引用,在指令内部相对寻址偏移的一丁点空间也很难同时描述清楚所引用的是哪个外部符号和相对符号的偏移。

我的想法是,既然从机器码查符号的方式走不通,那就反过来建立索引,在“中间格式”中存储一张表,存储所有机器码中引用了外部函数和变量的指令位置、所引用符号的名称,而机器码中相应指令的相对寻址偏移只需存储相对变量基址的偏移(不能简单填0,数组元素int a[10]的地址就要在符号a地址上加上10*sizeof(int))。

在链接时:

这就是传说中的“重定位”(Relocation)过程,而这张“引用表”就是“重定位表”,“中间文件”则是“可重定位文件”。

我当时还在为这个“引用表”存储在哪里担忧,如何把这些数据与机器码分开呢?所幸可执行文件格式的设计者早就考虑了这个问题:目标文件中不只存在要载入内存的代码和数据。

2 目标文件

 

2.1 目标文件结构

目标文件不能只是一堆机器码。很多文件格式有文件开头的magic number,例如脚本文件的第一行是“#!/path/to/interpreter”,微软的Word 97/2003文档开头7个字节是D0CF11E。这些magic number一方面是为了使用file等命令查询文件类型,在Linux桌面环境中调用相关的程序打开文件;对于可执行文件有更重要的意义:Linux中的execve系统调用会读取文件的前128个字节,匹配合适的可执行文件装载过程,例如看到“#!”两个字节开头的文件就知道是应该调用#!后面的解释器来解释执行,看到“0x7F e l f”四个字节开头的文件就知道是ELF可执行文件,看到“cafe”四个字节开头的文件就知道是Java可执行文件(为什么用cafe而不是java?)。

ELF文件的头部除了magic number,还需要指定ELF文件类型(可重定位?可执行?共享目标文件?UNIX中文件的类型不是通过扩展名判断的)、ELF版本(在文件格式中加入版本信息有助于提高可扩展性)、运行平台、ABI、段表描述符等多种信息。如果不符合当前环境,内核会拒绝执行,而不是执行到一半发现错误再不明不白地退出,这也是一种错误预防机制。

目标文件中需要存储哪些信息呢?

由于目标文件中要存储多种类型的信息,需要一种分节机制,将每种信息放在一节里,逻辑清晰。这里的“节”(section)也常被称为“段”,不过不要与内存中的“segment”混淆。分段有利也有弊,一个麻烦之处就是ELF文件存储的偏移信息要同时指定段名和在此段中的偏移。ELF文件中除文件头外最重要的结构就是段表(section header table)了,它以结构体数组的形式描述了ELF各个节(段)的信息,例如段名、段长、在文件中的偏移、读写权限等。

2.2 符号表

在关于重定位机制的讨论中,我最初的想法就是在重定位表中保存符号名的字符串。被高级语言惯坏了的我们可以将字符串作为基本数据类型,但在C语言的结构体中变长字符串需要用指针指向一段结构体以外的空间来存储。那么字符串放在每段的最后,还是集中放在整个目标文件的最后,还是散落在任意的位置?不论怎样,乱堆乱放的字符串都对文件格式的统一性造成了破坏。程序中还要在内存中的字符串指针、文件中字符串的偏移量之间来回转换,没有统一的机制简直是一场噩梦。

ELF文件格式中,存在两个专门的字符串表(section):.strtab用于普通字符串,如符号名;.shstrtab用于段表中用到的字符串,如段名。(我不明白段表为什么得到了特殊待遇)字符串的存储方式很简单,每个字符串末尾用“\0”作为分界。注意到段名本身也是存储在字符串表中的,那么找到字符串表所在段就成了一个“鸡生蛋,蛋生鸡”的问题。事实上,ELF文件头中的eshstrndx就是.shstrtab段在段表中的下标,而段表在文件中的偏移是ELF文件头中eshoff指定的。ELF文件格式中这种一环扣一环的事情还真不少。

有了保存字符串的机制,存储各种符号就只需要指定其在字符串表中的下标(即第几个字符串)了。这样机器码中“放不下”的函数名、变量名就可以放到字符串表中,这需要做一个从符号在机器码中的位置到字符串表中符号名的映射。这个映射就是“符号表”(.symtab section)。事实上,符号表中的每一个结构体不仅描述了符号所在段、符号在段内的偏移、符号名在字符串表中的下标,还描述了符号类型(数据对象、函数、段、文件名等)、符号所对应数据类型的大小等。

符号表在动态语言的解释过程中是起到关键作用的。以PHP为例,“变量的变量”(即 varname=100; $$a的值为100)“执行动态生成代码”等“可怕”的功能,在PHP解释器中是用一个从变量名到内存中存储地址的映射实现的。

事实上,PHP的Zend引擎内部使用结构体zval来表示变量。用强类型实现弱类型并不复杂:

这不是一个很难想到的解决方案,当时设想做C语言在线解释器时就提出了类似的方案,以在弱类型的javascript中表示强类型的C变量。

在Zend引擎中,变量(zval)存储在hash表形式的符号表中,其key为变量名,value为zval。全局符号表保存了顶层作用域(即不在任何类、函数内)的变量,每个函数和类的方法在执行期间还有自己的一个符号表。调用一个函数或类的方法时,会为它创建一个符号表并设为活动(active)符号表,所有函数内定义的变量都保存在这个符号表中,从函数返回时销毁这个符号表。在函数或方法之外时,才会使用全局符号表。PHP有个很奇怪的规定,在函数内使用全局变量需要用global声明,恐怕是与符号表有关吧。

PHP的函数是全局的,因此并不存储在符号表里。函数分为内部函数(internal function)和用户函数(user function),内部函数(用C写成的PHP核心及扩展中的函数)存储在函数表里,用户函数(用PHP写的函数)指向它的Opcode(中间码)序列。由于本文重点不是PHP,有兴趣的读者请自行参阅zendinternalfunction、zendoparray、zendfunction三个结构体。类中的方法是有作用域(仅对类的实例有效)的,因而上述三个结构体中都有一个指向“类”(zendclassentry)的指针。执行一个函数时,如果在内部函数表中找到了作用域内的函数,则直接调用之;不然,在用户函数中寻找作用域内的函数,并调用zendexecute执行其opcode。

回到C语言目标文件中的符号表,有个目前看来并不严重的问题:同名extern符号在不同文件中代表同一个符号,重复定义是不允许的;然而,汇编语言的程序可没有extern机制,如果一个汇编语言程序定义了main函数,那么所有与之链接的C程序都不能定义main函数。不像PHP中每个作用域都有一个动态的符号表,目标文件中的符号表只能有一个,而且必须在编译时确定。为此,UNIX下的C语言规定所有全局符号名前加上下划线,称为符号修饰;目前MSVC保留着这个传统,而GCC默认已经去掉了。然而,在C++语言中,符号管理就没有这么简单了。首先,C++允许多个不同参数类型的函数拥有一样的名字,这要求修饰后的符号名反映参数的类型信息;其次,不同的命名空间、类中可以有同名符号,这要求修饰后的符号名反映命名空间、类的信息。具体的符号修饰策略就见仁见智了。

了解了目标文件的格式,前面的链接机制还要完善几处细节:

2.3 弱符号与弱引用

我们在编写程序时,有时希望函数拥有可变个数的参数,例如一个函数后面几个参数很少用到,则平时不写以使用默认值,需要时再填上这些参数。这可以用C语言的可变参数机制实现。类似地,我们可能希望从一个库中选出某些功能模块,制成若干有不同功能的版本,而不改变库的链接特性;或者用自定义的库函数覆盖库中的函数。这些在C++中可以用类的继承和函数的重载很好地实现,但C语言的使用者们怎么办?GCC提供了“弱符号”机制,需要在符号定义前加入

关键字。与此相对的普通符号被称为“强符号”。

编译器将未初始化的全局变量定义作为弱符号处理,以便链接器在链接过程中确定其大小并最终在BSS段中分配空间。因此,同名全局变量只能被初始化一次,而未初始化的同名全局变量可以在若干个文件中出现。这也是必要的:某个公共头文件定义了一些公用的全局变量,每个源文件都直接或间接包含之,则源文件编译成的每个可重定位文件都包含这些全局变量的定义,这些可重定位文件要能正常链接才行。

弱符号在ELF文件的符号表中“符号所在段”设为SHNCOMMON。与之对应,在当前ELF文件中未定义的符号设为SHNUNDEF,包含绝对的值的符号(包括强符号、文件名等)设为SHN_ABS。

符号的定义有强弱,那么符号的引用呢?GCC还提供了“弱引用”机制,在符号声明前加入

关键字,则如果该符号未定义,GCC将不报错,而用一个特殊值(0)替代之,程序将有机会在不提供此功能的情况下运行,而不是无法链接成功,使得程序的功能更便于裁剪和组合。

弱引用在ELF文件的符号表中“符号绑定信息”设为STBWEAK。与之对应,对目标文件外部可见(如使用extern声明的)全局符号设为STBGLOBAL,对外不可见的局部符号设为STB_LOCAL。

3 动态链接

 

3.1 装载的困境

我们已经知道可执行文件是分为若干段的,而这些段中有的是数据,有的是代码,还有的是字符串表等不需要在运行时装载入内存的信息。因此,开始运行一个进程并不是将文件读入内存,并跳转到起始地址这么简单。当然,一个段是否需要装入并不是很难判断的。

一种最粗糙的方法是,把程序依赖的所有库在链接时放进可执行文件,装载时只要将各虚拟内存中的section映射到物理内存的segment就行了。但这样存在严重的问题:例如每个C程序都依赖libc库,那么按照这种方式,libc库就会在磁盘里存在成千上万份拷贝,每个进程在内存中也有一份libc的拷贝,造成极大的浪费;而且程序要更新一个模块,就要重新下载整个可执行文件。

在虚拟存储发明后,有了硬件的支持,程序的逻辑地址和物理地址被“脱钩”,只要维护虚拟地址到物理地址的映射就行了。假设硬件没有虚拟存储机制,我的设想是采用“所有可执行文件动态装载”的机制;操作系统创建进程时,在物理内存中找到一块可用空间,将可执行文件按照欲装载的位置进行重定位,就是对所有绝对寻址偏移进行修正;不需要重定位表的原因是进程之间不需要互相调用。当然,虚拟存储的意义还包括提供进程间的逻辑隔离和用户态、内核态的权限区分,这些离开硬件支持是难以解决的。

3.2 运行时装载的设想

事实上,在开始设想动态链接机制时,我的思路完全是类似动态语言的。(本节所有讨论忽略了虚拟内存机制)

事实上,我写的一组PHP程序就实现了类似的动态加载机制,不过没有让PHP解释器修改代码,而是利用了PHP的错误处理机制,将非法函数调用所抛出的异常截获,从列表中查找并加载相应的库,然后回到原位置重新执行触发异常的PHP代码。作为动态语言,PHP对外部文件的加载本身就是动态的,而函数的调用也是从hash表中“现用现查”,因而这种机制还算合适。

然而,在C语言中,这样的机制会为每次动态链接库的函数调用平添不少开销。如果程序执行的过程中能够修改代码段,则有一种“按需加载”,只加载一次的机制:

当然,为了加载动态链接库而把代码段变成可写似乎不太和谐 :)

以上这些是可能性,不过不是事实。据《程序设计语言原理》,1936~1945年,德国科学家Konrad Zuse用电磁继电器设计了一系列复杂的计算机和Plankalkul语言(1972年才发表)。这种语言惊人地完整:基本数据类型是字节,以此为基础构造出整数和浮点数据类型,还包括了数组和记录(可以嵌套)。在控制结构方面,此语言包括了类似for的迭代语句和类似if的选择语句。当然,这种语言的标记法(我们看来)很奇怪。Zuse的“样例程序”包括数组排序、测试图的连通性、计算平方根、对简单的逻辑公式进行语法分析、长达49页的国际象棋算法等。我有点怀疑这是不是1972年有人伪造的。如果不是,按照书中的说法,“我们现在只能猜想,如果Zuse不是工作于1945年的德国,他的工作顺利地得以发表,程序设计语言将向什么方向发展”。也许现代编译系统、操作系统的设计正是众多可能性中较好的一种吧。

3.3 地址无关代码

我们不妨跳出“动态链接”的文字陷阱,不是在执行过程中加载,而是在程序执行前,也就是进程初始化过程中加载动态链接库。最简单的方式,当然是让所需的所有动态链接库在进程初始化时被重定位、加载到进程的虚拟地址空间。

前面的讨论中,一直是把动态链接库当作普通的目标文件的,那么是否可以直接使用目标文件进行动态链接呢?

初看起来似乎没有问题。然而我们一直忽略了虚拟内存的存在。装载的过程中需要对动态链接库的代码进行重定位,装载后的代码事实上已经依赖于其所在的地址了。这就意味着要实现一份动态链接库代码被多个进程共享,则在这些进程的虚拟地址空间中,对动态链接库的引用必须使用同样的虚拟内存地址。

一种方法是每装载一个动态链接库,就在系统范围内为它预留地址空间(不与其他动态库重叠),以后创建的进程如果要使用这个动态链接库,就使用预定的地址装入(即将相同的虚拟地址映射到相同的物理地址)。但这样系统内装载的动态链接库总数不能太多,不然就“撑满”了3GB的用户态虚拟地址空间。对于可能运行很多异构任务,而用户地址空间只有3GB的32位系统而言,这种预留地址空间的方式是无法接受的。当然,对64位系统,似乎地址空间又取之不尽用之不竭了。

现在看来,直接拿目标文件重定位的路子走不通。其实我们的目的很简单,希望动态链接库中共享的指令部分在装载时不需要因为装载地址的改变而改变,所以实现的基本想法就是把指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分在多个进程之间可以共享,而需要读写的数据部分在每个进程中自然有独立的副本。这就是传说中的“地址无关代码”(PIC,Position Independent Code)。

我们来分析不同类型的地址引用:

模块内部的函数调用、跳转

不难发现,这些指令只要使用相对寻址模式,就是地址无关的。

模块内部的数据访问

指令中放不下绝对地址,因而对数据的引用只能使用相对寻址;然而,数据访问不像内存访问那样可以指定相对Program Counter的偏移,因而编译器采用了一种很巧妙的办法:函数调用时call指令会将返回地址(call的下一条指令)压栈,那么根据这个返回地址相对寻址就能找到模块内任意数据。看来,这也是地址无关的。

模块之间的函数调用、跳转

要调用另一个模块的函数,而函数的地址在模块载入前尚未确定,模块本身的代码又不能修改。如果把相关指令放在数据段,数据段又不能执行。ELF的做法是在数据段中建立一个指针数组(全局偏移表),存储每个外部函数的地址;在调用外部函数时,通过全局偏移表中的对应条目定位到另一模块的函数;载入其他模块后,只要将全局偏移表中对应函数的地址填上即可。

模块之间的数据访问

模块之间的数据访问类似函数调用,同样是通过全局偏移表来确定目标地址。全局偏移表事实上存储的是符号的偏移,无所谓它是数据还是函数。

模块与可执行文件间的函数调用、跳转

这与模块之间的函数调用没有什么不同,只要通过可执行文件中的符号表找到外部符号并将其重定位即可。

模块与可执行文件间的数据访问

在编译可执行文件时,一般并未生成地址无关代码,因而被extern声明的共享库变量作为未初始化数据被放在BSS段;然而,这些数据在共享库中也有一份(可能是已初始化的)副本。共享库做出妥协,在动态链接时将全局偏移表中的相应条目指向BSS段中的数据,可能还要用共享库中的副本将其初始化。事实上,在生成地址无关代码时,编译器并不知道extern是同一模块中其他文件中定义的全局变量,还是一个跨模块调用,因此只好统一按照地址无关的方式将其放入全局偏移表。

数据中的跨模块地址引用

例如这样一段常见的代码:

数据中的跨模块地址引用与跨模块数据访问的根本区别在于前者存储在数据段,后者存储在代码段。代码段不能任意修改,从而需要用全局偏移表来间接访问;而数据段每个进程一份,是可以修改的。因此只需在动态链接信息中记录这种类型的地址引用,并在动态链接时修改数据段对应位置的值(将数据段中b的值修改为共享库中a的地址)。

使用GCC生成地址无关的共享库,只要加入“-fPIC”参数即可。对于可执行文件,可以使用“-fPIE”达到地址无关的效果。

3.4 动态链接器

我们已经看到,动态链接是个比较复杂的东西。刚才的讨论给我们一种假象,即这些工作都是操作系统完成的。事实上,在Windows中,动态链接器确实是内核的一部分,而整个Windows系统是高度依赖动态链接库的:

所有系统调用都被包装成WINAPI,而WINAPI是在kernel32.dll、ntdll.dll等动态链接库中被定义的;

ELF文件的.interp(interpreter)段保存的就是一个字符串,即动态链接器的路径。内核会将ELF文件的.interp段指定的动态链接器读入内存,并映射到用户地址空间,然后把控制权交给动态链接器。后面的事情,动态链接器如何“自食其力”呢?

/lib/ld-x.y.z.so(x,y,z是版本号)就是这样一个神奇的东西。

如果用户入口地址是动态链接器,则对程序依赖的共享对象进行装载、符号解析和重定位,也就是我们前面提到的动态链接过程。 显然,动态链接器本身必须是静态链接的,不能依赖其他动态链接库,不然没人帮它解决依赖。动态链接器自身是PIC(地址无关代码),一是因为自举过程中需要进行重定位,而对数据段进行重定位比对代码段进行重定位简单;二是因为PIC的代码可以共享物理地址,这样各程序在内存中只要一份动态链接器的副本,节约内存。

从上面的描述容易看出,动态链接器也是可以直接运行的。内核只是寻找.interp段,没有找到就直接跳到eentry,找到了就载入动态链接器并跳到动态链接器的eentry。

3.5 运行时装载

动态链接解决了不同进程的共享库指令重复占用空间的问题,但在初始化时完成所有动态链接有一个缺陷:程序的运行具有局部性,很多模块是在近期是不会被用到的,一次全部加载进来未免浪费。这个问题本质上是进程的“内政”,与动态链接无关。

为了实现运行时按需装载模块,早期程序员将模块按照它们的调用关系组织成树形结构,采用“覆盖装入”节约内存空间。它利用了某些模块不可能共存的约束,使得某些模块可以共享同一块地址区域,从而节约了内存空间。这种方式需要程序员花费大量精力为模块安排覆盖装入结构,每个可执行文件的头部还要加入一块“覆盖管理器”。

那么能否实现一种系统调用,在操作系统级别支持程序运行过程中加载一个动态链接库呢?这就像是PHP语言中可以在任意位置include其他文件。相比“调用时自动装载”,把运行时加载的任务由操作系统转移到程序员,操作系统不会自动加载,程序员使用之前要自行声明;相比“覆盖装入”,不需要每个程序都附带一块覆盖装入器代码,而且用虚拟内存映射的方式比用固定内存地址分配更灵活。有了运行时装载机制,前面对性能影响较大的“自动装载”和对编程复杂度影响较大的“覆盖装入”可以休矣。

我们首先考虑不修改已装载的代码,因而加载动态模块的过程不能涉及已经加载代码的重定位。也就是说,加载可执行文件时需要预知可能加载的动态链接库中每个函数的入口地址。这并不难实现:

这种“预留地址空间”设计不仅存在地址空间不足的问题,还需要程序员显式指定“所有可能加载的动态链接库”列表,并不方便。

运行时装载的设计者采用了一种“偷懒”的方式:提供一些接口,让程序员自行查找需要使用的符号,然后通过函数或变量的地址(函数指针、变量指针)间接调用它们。

在Linux中,内核同样不“越俎代庖”地管这些事,运行时装载机制是通过动态链接器(/lib/libdl.so.2)提供的API,包括:

dlclose用于卸载模块,注意这里的卸载和dlopen中的装载都是可以重复进行的,每个模块有一个引用计数。

dlerror用于判断上次dlopen、dlsym、dlclose调用是否成功。dlsym返回NULL(0),不一定意味着符号未找到,有可能恰好是一个值为0的常量。

运行时装载与初始化时装载,区别主要是后者是对程序员透明的,在第一行代码执行前就已经完成了共享库的装载;前者是在程序内部显式调用动态链接器提供的API。例如Web服务器可以不重新启动就根据新配置加载新的模块;浏览器可以在遇到有Flash的网页时再加载所需的插件。

3.6 延迟绑定

动态链接比静态链接灵活得多,但它是以牺牲一部分性能为代价的。动态链接的程序性能一般比静态链接的程序低1%~5%。主要原因有两点:

事实上早在3.2节,就提出了“函数第一次被调用时被装载”的思想,这在ELF中被称为“延迟绑定”。

当我们调用某个外部模块的函数时,动态链接的做法是通过全局偏移表(GOT)间接跳转。一种最简单的方法是开始时让GOT中相应的条目指向一个“桩函数”,这个桩函数完成加载工作,修改GOT中的跳转地址为已加载的外部函数地址,再调用这个函数。这类似3.2节的设想,不过直接修改代码段变成了修改数据段中的GOT,这样一来代码段可以在不同进程间共享,二来减少了代码段可写可能带来的安全风险。

ELF的实现方式与之类似,不过又加了一层:每个外部函数都有一个对应的桩函数,函数调用就是对桩函数的调用,在桩函数内部通过GOT实现跳转、实现运行时装载。这样的“桩函数”称为PLT(Procedure Linkage Table)项。

  1. 链接器将GOT中func所对应的项初始化为上面的“push index”指令的地址,使得首次执行此函数时相当于什么都没有做。从第二次调用此函数开始,就会通过func@GOT直接调用外部函数并直接返回,而不会执行“push index”及以下的几条指令。
  2. index是func这个符号在重定位表“.rel.plt”中的下标。将index压入堆栈。
  3. 将当前模块的ID压入堆栈。(模块ID是动态链接器分配的)
  4. 以moduleID,index为参数,调用动态链接器的dlruntime_resolve(),完成符号解析和重定位,并将func的真正地址填入func@GOT。

在实际实现中,ELF将GOT拆分为“.got”和“.got.plt”两个表,其中“.got”保存全局变量引用的地址,“.got.plt”保存函数引用的地址。.got.plt的前三项有特殊意义:

为了减少代码重复,ELF把上面例子中的最后两条指令放到PLT中的第一项(PLT0)中,并规定每项的长度为16字节,恰好存放jmp *(func@GOT), push index, jmp PLT0三条指令。

3.7 动态链接库版本

动态链接库当然不是一成不变的,它也需要更新。《COM本质论》中有一个生动的例子:假设有个程序员实现了一个O(1)的字符串查找算法,其头文件为:

受到各大厂商的好评后,程序员决定再接再厉:Length()成员函数内部直接调用了strlen()函数返回字符串长度,效率很低,程序员决定加入一个length成员保存字符串长度;又增加了一个SubString成员函数用于取得字符串的子串:

厂商将新版的DLL打成一个补丁升级包,以覆盖旧版的DLL;很快他们收到了铺天盖地的抱怨。原因主要来自:新版的StringFind对象占用空间是8个字节,而原先的程序主模块只给它分配了4个字节,访问的length成员事实上不属于StringFind对象,出现错误的数据访问,导致程序崩溃。

在Windows平台下,Component Object Model(COM)就是微软为了解决这些程序兼容性问题(不仅是版本问题)而开发的一套复杂的机制。在.NET中,一个程序集包括一个Manifest文件,描述了这个程序集(由若干可执行文件或动态链接库组成)的名称、版本号、各种资源及其依赖的各种资源(包括DLL等)。Windows系统目录下有个WinSxS(Windows Side by Side)目录,每个版本的DLL在WinSxS目录下都有一个以平台类型、编译器、动态链接库名称、公钥、版本号命名的独立的目录,保证多个版本的动态链接库不会冲突。当然,这就要求动态链接库与主程序的编译环境完全相同,Windows中没有类似“源”的公共运行库下载仓库,因此程序发布时往往要带上对应的运行库。

事实上,DLL的设计目的并不是“共享对象”,而是促进程序的模块化,使得各模块之间能够松散地组合、重用、升级。运行时加载机制使得各种功能模块能以插件的形式存在,这是ActiveX等技术的基础。利用DLL的数据段可以在不同进程间共享的特性,DLL还是Windows中进程通信的一种方式(尽管第三者也可以共享他们的DLL,从而有安全漏洞)。在UNIX传统中,这样的模块化通常是每个模块一个进程,而进程的协同是通过管道、socket等进程间通信手段实现的,这样的方式需要程序员投入更多精力,但能提供更好的封装性。由于Windows传统中的程序多是封闭开发的软件,内部接口容易统一,因而模块之间大多采用编程更直接的函数调用,服务器与客户端之间的通信也较多采用远程过程调用(RPC)而非透明的文本协议。

在Linux中,共享库的版本问题通过文件名中包含版本号这一简单途径得以解决。共享库的命名规则是libname.so.x.y.z:

那么动态链接器如何知道程序需要哪个版本的共享库呢?Linux采用SO-NAME的命名机制记录库的依赖关系。SO-NAME就是libname.so.x,只保留主版本号。利用“SO-NAME相同的两个共享库,次版本号大的兼容次版本号小的”这一特性,系统会为每个共享库创建一个以SO-NAME命名的软链接,主版本号相同的共享库只保留次版本号最高的那个。这样,所有使用共享库的模块在编译链接时只要指定主版本号(SO-NAME)而无需指定详细的版本号;及时删除过时的冗余共享库,节约了磁盘空间。

Linux中软件包的依赖关系很大程度上就是共享库的依赖关系,由于共享库通常是开源或公开提供下载的,软件包管理器会自动从“源”中获取并安装所需的共享库,而无需让软件包背上一个共享库的大包袱。当系统中安装一个新的共享库(就是把共享库放到/lib、/usr/lib或/usr/local/lib,具体由/etc/ld.so.conf指定)时,需要使用ldconfig工具遍历共享库目录,创建或更新SO-NAME软链接,使它们指向最新的共享库;更新SO-NAME的缓存(/etc/ld.so.cache),加快共享库的查找过程。

符号版本问题是否宣告解决了呢?如果动态链接器在进行链接时,只进行主版本号的判断,则若某个程序依赖次版本号更高的共享库,动态链接器就可能查不出版本冲突,从而带来本节开头的问题。此外“相同主版本号的共享库,次版本号需要向后兼容”,因而只要接口做了一点不向后兼容的改变,就必须升级主版本号。Linux采用了更细粒度的版本机制——在可执行文件和共享库中,每个导入或导出的符号都对应一组主、次版本号,同名符号可以有多个版本。这样,一个Version 1.2的共享库内部可以同时存在1.2版和1.1版的库函数,动态链接器也会尽量为可执行文件中的函数引用找到合适版本的库函数来链接,即使1.2版与1.1版的这个库函数互不兼容,使用这两版共享库的程序仍然能正常链接。

GCC为指定符号版本提供了.symver汇编宏指令。例如改变strstr的接口而不升级主版本号:

asm(".symver old_strstr, strstr@VERS_1.1");
asm(".symver new_strstr, strstr@VERS_1.2");

int old_strstr(char *haystack, char *needle);			// 返回needle在haystack中第一次出现的offset,未找到返回-1
int new_strstr(char *haystack, char *needle, bool direction);	// direction用于指定从前向后查找还是从后向前查找

3.8 目标文件中的数据结构

根据前面对编译、静态链接和动态链接的讨论,目标文件的分类其实已经比较明显了:

Windows的PE(Portable Executable)文件格式和Linux的ELF(Executable Linkable Format)文件格式都是COFF(COmmon File Format)文件格式的变种。

下面按字母顺序列出了ELF的一些常见段(我没有一一验证,尤其是与C++有关的部分,如有错误请指正):

.bss未初始化数据(全局变量) 
.comment编译器版本信息 
.ctors全局构造函数指针 
.data已初始化数据(全局变量、静态变量) 
.data.rel.ro只读数据,与.rodata类似,不同的是它在重定位时会被改写,然后置为只读 
.debug调试信息,使用gcc的-g或-ggdb参数 
.dtors全局析构函数指针 
.dynamic动态链接信息,存储了动态链接的符号表地址、字符串表地址及大小、哈希表地址,共享对象的SO-NAME、搜索路径,初始化代码地址,结束代码地址,依赖的共享对象文件名,动态链接重定位表地址、重定位入口数量等。 
.dynstr动态链接符号的符号名(字符串表) 
.dynsym与动态链接相关的符号表。需要注意,.symtab中往往保存了所有符号,而.dynsym中只保存动态链接时需要的符号,不保存仅在模块内部使用的符号。 
.ehframe|与C++异常处理相关| |.ehframehdr|与C++异常处理相关| |.fini|程序退出时执行的代码,相当于main()的“析构函数”| |.finiarray程序或共享对象退出时需要执行的函数指针 
.gnu.version动态链接符号版本,.dynsym中的每个符号对应一项(该符号所需版本在.gnu.versiond中的序号)| |.gnu.versiond动态链接符号版本的定义(definitions),每个版本的标志位、序号、共享库名称、主次版本号
.gnu.versionr|动态链接符号版本的需求(requirements),依赖的共享库名称和版本序号| |.got|全局偏移量表(用于动态链接的间接跳转或引用)| |.got.plt|Procedure Linkage Table,即运行时链接的“桩函数”| |.hash|符号表的hash表,用于加快符号查找| |.init|main()执行前的初始化代码,相当于main()的“构造函数”| |.initarray程序或共享对象初始化时需要执行的函数指针 
.interp动态链接器的文件路径 
.line调试用的行号信息,使用gcc的-g或-ggdb参数 
.note编译器、链接器、操作系统加入的平台相关的额外信息 
.note.ABI-tag指定程序的ABI 
.preinitarray|早于初始化阶段前执行的函数指针,在.initarray之前执行  
.rel.data静态链接文件中,数据段的重定位表 
.rel.dyn动态链接文件中,对数据引用(.got、.data)的重定位表 
.rel.plt动态链接文件中,对函数引用(.got.plt)的重定位表 
.rel.text静态链接文件中,代码段的重定位表 
.rodata只读数据(常量、字符串常量) 
.shstrtab保存了各段名称的字符串表 
.strtab字符串表,通常是符号表中的符号名对应的字符串 
.symtab符号表,静态链接时需要的符号信息 
.tbss每个线程一份的未初始化数据(.bss是各线程共享的) 
.tdata每个线程一份的已初始化数据(.data是各线程共享的) 
.text代码段(为什么不叫.code?) 来源: https://lug.ustc.edu.cn/wiki/user/boj/linkers-and-loaders