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

贴一篇旧文:2012 年 6 月写的读书笔记(原文链接)。这个笔记是写给自己看的,里面掺进了太多自己的设想,有可能会对读者造成误导。不过文章太长了,没有时间修订。欢迎吐槽。

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

有两种方式构建软件:一种是把它设计得如此简单以至于明显没有缺陷,另一种是把它设计得如此复杂以至于没有明显的缺陷;前一种的难度大得多。

——Hoare 于图灵奖演讲《皇帝的旧衣》

0 关于编译的闲扯

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

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

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

  • 程序的机器码从硬盘直接“复制”到内存(没有考虑可能带来的问题);
  • 程序从一个固定的点(如虚拟地址 0x80000000)开始执行,操作系统只要一条跳转指令就行了;
  • 程序需要获取环境变量和参数,那么规定规定 argc 存储在 0x70000000,紧接着是指向参数字符串的 argc 个指针(argv);环境变量也可以人为规定一个地址;
  • 这个固定的点由编译器放一段“桩代码”(stub),唯一的作用就是把argc和**argv压栈,调用main函数;从main函数返回时将栈恢复到初始状态,将main函数的返回值返回给操作系统。

我最初设想的编译过程: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 等压缩格式的问题)。郭家华提出了三个问题:

  • 如果只修改了一个非核心部分的源文件,按照我的设计需要把所有文件重新编译一遍,而 Makefile 能够根据时间戳和依赖关系判断哪些文件是受到影响需要重新编译的,减少了编译所需时间;
  • 内核的一个重要机制是内核模块,如果没有 Makefile,模块就无从谈起,整个内核的可扩展性会大打折扣;当然,可以采用一种 “Makefile 简化版”来定义内核模块,不过 Makefile 已经足够好了;
  • 尽管命名是严谨的,但几千万行的内核难免有 extern 的符号“擦枪走火”的问题,显式规定依赖关系更好。

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

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

0.3 ABI

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

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

  • 内置类型(int、float等)的大小
  • 存储的大小端
  • 对齐方式(按4字节还是8字节对齐)
  • 组合类型(如struct、union)的存储方式(如位域的存储顺序)
  • 外部符号的命名方式与解析方式
  • 参数传递的方式、返回值传递的方式
  • 堆栈的分布方式,如局部变量和参数的入栈顺序
  • 函数调用中,寄存器由调用者还是被调用者保存现场,包括哪些寄存器

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

1 链接

要实现一个由很多C文件组成的项目的编译,需要首先把每个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?)。

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

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

  • 代码和数据显然是要的,而且必须分开,因为代码的属性一般是只读、可执行,数据的属性一般是读写、不可执行。
  • 程序里的所有数据都是可读写的吗?用 const 声明的变量、字符串常量并不需要写,那么在加载的时候将其映射为只读,可以增强程序的安全性;嵌入式系统的烧写器可以将其写入 ROM,节约宝贵的 RAM 空间。看来需要一个只读数据段。
  • 在计算机竞赛中,我们常常在程序的开头声明一个 a[2000][2000] 的全局大数组,这个数组显然不能存储在数据段里,不然可执行文件的大小就有数MB。因此未初始化数据存储在 BSS 段中。操作系统中装载可执行文件的例程在进程初始化时,分配这么大的一块内存空间就行了。
  • 前面提到的重定位表也是需要存储的。
  • 要支持单步执行等调试工具,还要增加新的段以存储源代码行号(.line)等信息。
  • 编译器版本信息(.comment)、额外的编译器信息(.note)
  • 后面要提到的动态链接信息(.dynamic)
  • 程序初始化与终结代码,用于C++全局构造与析构。把这些代码放在代码段的开头、结尾行不行呢?不行,因为多个目标文件链接起来时,这些初始化操作仍然要放在新目标文件的开头结尾,而链接器是无法分辨代码段中哪些是初始化代码的。逻辑上不同的东西不应该放在一起;一种信息只要不太浪费空间、影响性能,就可以被保留;如果图省事把信息丢弃了,那么后面的处理过程再想用到就不可能了。

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

2.2 符号表

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

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

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

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

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

typedef struct _zval_struct {
    zvalue_value value; // 变量的值
    zend_uint refcount; // 引用计数,用于写时复制
    zend_uchar type; // 变量类型
    zend_uchar is_ref; // 是否是引用(如果是引用,则写时不复制)
} zval;

typedef union _zvalue_value {
    long lval; // 整数类型(包括bool)、资源类型(用整数表示资源序号,类似C中的文件描述符)
    double dval; // 浮点类型
    struct {
        char *val;
        int len;
    } str; // 字符串类型
    HashTable * ht; // 关联数组类型(关联数组类似Python中的字典)(用hash表存储)
    zend_object_value obj; // 对象类型
} zvalue_value;

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

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

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

struct _zend_op {
    opcode_handler_t handler; // 处理函数,与opcode对应
    znode result; // 操作结果
    znode op1; // 操作数1
    znode op2; // 操作数2,不是每个操作都会同时使用result、op1、op2
    ulong extended_value; // 执行过程中需要的其他信息,是一组flags
    uint lineno; // 源码中的行号(调试和错误处理时用)
    zend_uchar opcode; // 操作类型,可以认为是指令(例如FETCH_W是以写的方式获取变量到“寄存器”,ASSIGN是赋值,ECHO是显示)
}

typedef struct _znode {
    // 操作数类型:常量、变量(用户可见的)、临时变量(引擎内部的)、编译后的变量(有点像寄存器,为了避免每次使用变量都去hash表里查询,效率太低)
    int op_type;
    union {
        zval constant; // 常量
        zend_uint var; // 变量(可见、临时)
        zend_uint opline_num; /* Needs to be signed */
        zend_op_array *op_array;
        zend_op *jmp_addr;
        struct {
            zend_uint var; /* dummy */
            zend_uint type;
        } EA; // 编译后的变量
    } u;
} znode;

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

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

  • 对于可重定位文件,重定位入口的偏移是重定位入口所要修正的位置的第一个字节(而非指令的第一个字节)相对于段起始的偏移(而非相对可重定位文件开头的偏移);对于可执行文件或共享对象文件(动态链接用),重定位入口的偏移就是所要修正位置的第一个字节的虚拟地址,这在动态链接时会用到。
  • 如何知道其他文件中符号的名称呢?要知道,机器码中只有内存地址,没有符号名。事实上,所有导出(extern)符号都存储在目标文件的符号表中。
  • 重定位表中事实上并不存储外部符号的名称字符串,而是存储了重定位入口的类型(指定寻址偏移的不同修改策略)和此符号在符号表中的下标。

2.3 弱符号与弱引用

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

__attribute__((weak))

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

  • 不允许同名强符号被多次定义;(“定义”与“声明”是两码事)
  • 如果一个符号在一次定义中是强符号,在其他定义中是弱符号,则选择强符号;
  • 如果一个符号在所有定义中都是弱符号,则选择占用空间最大的那个。

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

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

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

__attribute__((weakref))

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

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

3 动态链接

3.1 装载的困境

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

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

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

3.2 运行时装载的设想

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

  • 所有动态链接库保留重定位信息。(现在的系统也是这样的)
  • 操作系统维护一个“动态链接库表”,记录了系统中的所有动态链接库及其导出函数的声明。
  • 操作系统提供一个执行动态链接库函数的系统调用:execfunc(char *function_name, …)
    • execfunc 第一个参数是指向 function_name 的指针
    • 随后的参数是原函数的参数(类似C语言的可变参数机制);
    • function_name是可执行文件中的一个字符串常量;
    • execfunc 的返回值存放在 ABI 约定的地方(如整数类型、浮点类型分别规定一个寄存器传参);
    • execfunc 不是一个C语言意义的函数,因其返回值类型不确定。
  • 操作系统提供一个查询动态链接库表的系统调用:queryfunc(char *function_name),返回此函数的参数和返回值类型,以便编译器处理。
  • 编译器遇到一个不能“内部决议”的函数名:
    • 查询动态链接库表;
    • 把调用动态链接库的函数的 call 指令的目标地址改成 execfunc(暂不考虑系统调用机制的细节);
    • 增加一个字符串常量 function_name 表示函数名;
    • 在 call 指令前加入一条将字符串常量指针入栈的指令(假设ABI规定参数是通过栈传递的,入栈顺序也与本例相符);
    • 将 execfunc 的返回值按原程序的要求处理(如赋值到其他内存单元或寄存器)。
  • 程序正常加载执行,就像没有动态链接一样。
  • 当程序执行到原来是调用动态链接库的函数时,事实上调用的是 execfunc。
  • execfunc 顾名思义:
    • 从操作系统的动态链接库表中找出此函数所在的动态链接库;
    • 如果此动态链接库尚未加载,找到加载这个动态链接库的一块内存空间;对目标文件进行重定位;加载重定位后的目标文件;
    • 以自己的可变参数为参数调用动态链接库函数(如果C语言不容易实现,操作系统完全可以用汇编语言实现这段代码)
    • 将动态链接库函数的返回值返回给调用者(C语言同样没有“动态返回值类型”,因此也要用汇编语言来)
  • 这样,原程序的每次动态链接库调用从 execfunc 里绕了一圈,但总算是能完成任务。当然,execfunc 和 queryfunc不必要是操作系统的机制,也可以是C编译器的内部机制,只是这些函数的实现必须存在于每个需要动态链接的可执行文件里;把加载器写在可执行文件里是很大的开销,而且兼容性不好。

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

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

  • 操作系统仍然要维护动态链接库表。
  • 操作系统提供一个加载动态链接库函数的系统调用:void loadfunc(char *function_name)
  • loadfunc 第一个参数是指向 function_name 的指针;
    • function_name 是可执行文件中的一个字符串常量;
    • 操作系统仍然提供查询动态链接库表的系统调用。
  • 编译器遇到一个不能“内部决议”的函数名:
    • 查询动态链接库表;
    • 把调用动态链接库的函数的 call 指令的目标地址改成loadfunc;
    • 增加一个字符串常量 function_name 表示函数名;
    • 在 call 指令前加入一条将字符串常量指针入栈的指令(假设参数是通过栈传递的,其他参数已在这条指令之前入栈)。
  • 程序正常加载执行,就像没有动态链接一样。
  • 当程序执行到原来是调用动态链接库的函数时,事实上调用的是 loadfunc。
  • loadfunc 的重头戏来了:
    • 字符串常量指针(第一个参数)出栈,栈中的其他元素(如返回地址)作相应调整;
    • 从操作系统的动态链接库表中找出此函数所在的动态链接库;
    • 如果此动态链接库尚未加载,找到加载这个动态链接库的一块内存空间;对目标文件进行重定位;加载重定位后的目标文件;
    • 下面是几条关键的汇编指令:设置代码段为可写;(有点 hacker 的意味了)
    • 基于调用栈中的返回地址,修改返回地址所对应指令(即 call loadfunc)为 call function_name;(修改寻址偏移)
    • 将 call function_name 的前一条指令(即字符串常量指针入栈)修改为 nop;(再也不需要它了)
    • 修改返回地址为其前一条指令;(使返回后调用 function_name)
    • 设置代码段为只读(可有可无)。
  • loadfunc 返回后,就会调用 function_name,而此时参数已经准备好,而函数的返回值部分我们压根就没碰,第一次调用将顺利进行;
  • 以后对 function_name 的调用,除了在参数入栈与 call 之间比往常多几条 nop 指令以外,看不出任何区别,loadfunc 已退居幕后。

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

以上这些是可能性,不过不是事实。据《程序设计语言原理》,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 是同一模块中其他文件中定义的全局变量,还是一个跨模块调用,因此只好统一按照地址无关的方式将其放入全局偏移表。

数据中的跨模块地址引用

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

extern int a;
int *b = &a;

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

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

3.4 动态链接器

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

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

  • Windows 应用程序的相互调用大多通过 COM(Component Object Model),用于实现诸如 ActiveX、.NET 等按需调用组件的机制(例如在浏览器中调用 Media Player 播放视频、在 PHP 中操作 Word 文档),而COM的基础便是动态链接库。
  • 而在以模块化著称的 Linux 中,内核对动态链接工作只是起了“推一把”的作用。内核在装载 ELF 可执行文件后就返回到用户空间,将控制权交给程序入口。对于静态链接的可执行文件,程序的入口就是 ELF 文件头中 e_entry 指定的入口;对于动态链接的可执行文件,如果不采用特殊机制,则动态链接的难题就要全部留给可执行文件:动态链接机制是比较复杂的,在每个可执行文件中保留一份动态链接器显然是一种浪费。那么动态链接器在文件系统中的路径是不是操作系统规定的呢?

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

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

  • 动态链接器的入口函数 _dl_start() 执行“自举”过程,即自己帮自己做重定位工作。这个过程中在动态链接器内部的相对寻址是没有问题的,然而绝对寻址是不行的,因此这部分需要格外小心谨慎。
  • 载入程序的符号表。完成自举之后,就可以自由调用程序中的函数、访问全局变量了。
  • _dl_start_final() 收集一些基本的运行数值
  • _dl_sysdep_start()进行一些平台相关的处理
  • _dl_main() 判断指定的用户入口地址,如果是动态链接器本身,则它被当作一个可执行文件被运行。

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

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

3.5 运行时装载

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

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

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

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

  • 操作系统在创建进程时,列出可能加载的各动态链接库,为它们预分配虚拟地址空间;
  • 根据这张分配表对可执行文件进行重定位,并加载;
  • 负责加载动态链接库的系统调用将动态链接库取出;
  • 如果新加载的动态链接库依赖其他尚未预分配地址空间的动态链接库,则需要为它们预分配空间;
  • 现在动态链接库的调用者和依赖者的地址已经确定,对它进行重定位,加载到已经预分配的地址。
  • 这样,依靠预分配地址空间,已加载的可执行文件和动态链接库都无需在加载后修改代码段。

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

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

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

  • 打开动态库(dlopen)
  • 查找符号(dlsym)
  • 错误处理(dlerror)
  • 关闭动态库(dlclose) void * dlopen(const char *filename, int flag);
  • filename:动态库的绝对路径,或相对 /lib、/usr/lib 的相对路径。
  • flag:符号的解析方式。运行时加载有两种方法:一种是当模块被加载时完成所有的函数加载工作(RTLD_NOW),另一种是当函数被第一次用到时才进行绑定,类似我在3.2节中提出的方案(RTLD_LAZY)。使用 RTLD_NOW 有利于在调试程序时发现符号未定义方面的错误,让错误尽早暴露出来;而实际使用时 RTLD_LAZY 可以加快加载动态库的速度,实现真正的 “按需加载”。 返回值:被加载模块的 handle,指向模块的符号表。有趣的是,如果 filename 参数为0,返回的就是全局符号表的handle,即在运行时根据函数名查到其地址并执行,据此可以实现类似高级语言“反射”的机制。 void * dlsym(void *handle, char *symbol);
  • handle:dlopen 返回的符号表 handle。
  • symbol:要查找的符号名字。如果在当前模块的符号表中未找到,就会按照广度优先的顺序搜索它依赖的共享对象中的符号。 返回值:如果是函数,返回函数地址;如果是变量,返回变量地址;如果是常量,返回常量的值;如果符号未找到,返回 NULL。

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

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

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

3.6 延迟绑定

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

  • 地址无关代码在模块间的每次函数调用和地址访问都要经过GOT(全局偏移表)进行间接访问;
  • 在程序运行过程中并不是动态链接库中的每个函数都能用到,白白浪费了模块装载、重定位的时间。

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

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

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

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

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

  • 第一项保存.dynamic段的地址,描述了本模块动态链接的相关信息;
  • 第二项保存本模块ID,由动态链接器在装载模块时初始化;
  • 第三项保存 _dl_runtime_resolve() 的地址,由动态链接器在装载模块时初始化。

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

3.7 动态链接库版本

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

class __declspec(dllexport) StringFind {
	char *p;			// 字符串
	public:
		StringFind(char *p);
		~StringFind();
		int Find(char *p);	// 查找字符串并返回找到的位置
		int Length();		// 返回字符串长度
};

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

class __declspec(dllexport) StringFind {
	char *p;			// 字符串
	int length;			// 字符串长度
	public:
		StringFind(char *p);
		~StringFind();
		int Find(char *p);	// 查找字符串并返回找到的位置
		int Length();		// 返回字符串长度
		char* Substring(int pos, int len);	// 返回字符串从pos处开始长度为len的子串
};

厂商将新版的 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:

  • 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 目标文件中的数据结构

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

  • 包含了代码和数据,可以被用来链接的可重定位文件(.o)
  • 包含了可以直接执行的程序,可执行文件(a.out)
  • 供动态链接器使用的“动态库”,共享目标文件(.so)
  • 这个不容易想到,进程意外终止时将地址空间的内容和一些其他信息转储到Core Dump文件

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

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

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

(全文完)

《《程序员的自我修养》读书笔记》有2个想法

  1. 普林斯顿的大神,可以请教你关于符号表的一部分疑问吗?
    既然符号表存了程序的符号信息,这个符号信息是否可以被程序自身读取?

    1. 额,我不是普林斯顿的………

      这个符号信息原则上是可以被程序自身读取,但需要 binutils 库才行。也就是程序链接上 binutils 的库,然后写代码解析自己代码段的信息。

发表评论

电子邮件地址不会被公开。 必填项已用*标注