0. 译者的话
原文是由 Ulrich Drepper 发布于下面的链接中
https://www.akkadia.org/drepper/dsohowto.pdf
因为需要制作一个c++的共享库,译者进行了很多检索,发现目标都指向于这篇文章。由于这个方面系统性介绍的中文资料难觅踪影,所以萌发了翻译此文章的念头。
0.1 关于作者
Ulrich Drepper 是“GNU C标准库”项目glibc的首席开发者和负责人。自2017年4月起,他一直在Red Hat工作。
他是德国人。
以下为正文
如何编写共享库
How To Write Shared Libraries
Ulrich Drepper
drepper@gmail.com
December 10, 2011
Copyright c 2002-2010, 2011 Ulrich Drepper
All rights reserved. No redistribution allowed.
摘要
今天,共享库的应用已经很普及。开发者出于多种原因而使用它们,像编写应用程序代码一样创建共享库。然而,之所以编写共享库会是个问题,是因为在许多平台上,必须应用一些额外的技术才能生成合适的代码。而生成优化的代码则需要更多的知识。本文介绍了所需要的技术和需要了解的规则。此外,本文还介绍了应用程序二进制接口(ABI)稳定性的概念,并展示了如何管理ABI稳定性。
1. 前言
很长一段时间以来,程序员们把通用的代码收集进库中,这样就可以重用代码。这节省了开发时间并减少了错误,因为重用的代码只需要调试一次。在同时运行数十个或数百个进程的操作系统中,在链接时重用代码只解决了部分问题。许多进程将使用它们从库中导入的相同代码段。利用现代操作系统中的内存管理系统,还可以在运行时共享代码。在物理内存中只加载一份库代码,并通过虚拟内存在多个进程中重用这份代码,这样就可以实现在运行时共享代码。这种库称为共享库。
这个概念并不是很新。 操作系统设计人员实现了对操作系统的扩展,这通过他们以前使用的基础架构实现。操作系统的扩展过程对用户是透明的。但是由用户直接处理这部分工作会产生问题。
主要问题是二进制格式造成的。二进制格式是用于描述应用程序代码的。仅仅提供内存转储就足够了的日子已经早就过去了。多进程系统需要识别程序文件的不同部分,例如文本部分,数据部分和调试信息部分。为了这个目的,在很早之前就引入了二进制格式。在Unix早期通常使用的是诸如a.out或COFF之类的格式。很显然,这些早期的二进制格式的设计并未考虑共享库。
1.1 一点历史
最初用于Linux的二进制格式是a.out的变体。在引入共享库时,某些设计决策必须要在a.out的限制之下使共享库可以工作。主要的限制是在程序装载时和装载后都没有重定位机制。共享库必须直接以运行时在内存中的形式存放在磁盘中。这是构建和使用共享库的一个主要限制:每个共享库必须拥有固定的加载地址; 否则将无法生成不必重定位的共享库。
固定的加载地址必须统一分配,分配地址时必须保证各个共享库之间没有重叠和冲突,还要保证未来共享库容量增长之后也不发生冲突。因此,必须有一个分配授权中心来为各个共享库进行地址范围分配,而这事本身就是一个大问题。还有更糟的情况:在一个拥有数百个DSO(动态共享对象)的Linux系统中,应用程序可用的地址空间和虚拟内存会严重碎片化。这将限制可动态分配的内存块的大小,某些应用程序将无法避免遇到问题。甚至会发生分配授权中心用尽地址的情况,至少在32位机器上会出现这个情况。
我们仍然没有涵盖a.out共享库的所有缺点。由于使用共享库的应用程序在共享库升级更新之后不必重新链接,因此入口点(即函数和变量地址)不得更改。只有当入口点与实际代码分开时才能保证这一点,否则函数尺寸的限制将被硬编码。用函数stub表来间接调用函数的实际实现是Linux上使用的解决方案。静态链接器从特殊文件(文件扩展名为.sa)获取每个函数stub的地址。在运行时,使用以.so.X.Y.Z结尾的文件,它必须与.sa文件相对应。这又要求stub表中的已分配条目必须始终用于同一函数。必须仔细处理stub表的分配。引入新接口时就追加在stub表后面。stub表中的条目永远都不能删除。为避免将旧的共享库与链接到较新版本共享库的程序一起使用,必须在应用程序中保留一些记录:记录.so.XYZ后缀名称的X和Y部分,动态链接器确保最小要求得到满足。
该方案的好处是最终的程序运行速度非常快。即使是第一次调用,在这样的共享库中调用函数也非常有效。它只能用两个绝对跳转来实现:第一个从用户代码到stub,第二个从stub到函数的实际代码。这可能比任何其他共享库的实现都要更快,但是换取到这个速度的代价实在太高:
- 需要分配授权中心来分配地址范围;
- 有可能发生碰撞(地址重叠和冲突),带来灾难性后果;
- 地址碎片化更加严重。
由于所有这些原因以及更多原因,Linux在早期转向使用ELF(可执行链接格式 Executable Linkage Format)作为二进制格式。 ELF格式由添加了特定于处理器的扩展(psABI)的通用规范(gABI)定义。 事实证明,函数调用的摊销成本几乎与a.out相同,但限制不复存在。
1.2 转向 ELF
对于程序员来说,转换到ELF的主要优点是创建ELF共享库或ELF-speak DSO变得非常容易。生成应用程序和DSO之间的唯一区别在于最终链接命令行。用一个选项(在GNU ld下为--shared)告诉链接器要生成DSO而不是应用程序,不用选项用默认值就生成应用程序。实际上,DSO只不过是一种特殊的二进制文件; 不同之处在于它们没有固定的加载地址,因此需要动态链接器来加载执行。 使用位置独立可执行文件(PIE),差异会更大。
ELF与后面将描述的GNU Libtool的引入,导致程序员们广泛采用DSO。正确使用DSO有助于节省大量资源。但是要得到任何好处必须遵循一些规则才行,而且必须遵循更多的规则才能获得最佳结果。解释这些规则将成为本文很大一部分的主题。
使用DSO并非都是为了节省资源。 如今,DSO也经常用作构建程序的一种方式。 程序的不同部分被放入单独的DSO中。 这可能是一个非常强大的工具,尤其是在开发阶段。不需要重新链接整个程序,只需重新链接已更新的DSO。这通常要快得多。
即使DSO未在其他程序中重复使用,一些项目也决定在部署阶段保留许多单独的DSO。 在许多情况下,这当然是一件有用的事情:DSO可以单独更新,减少必须传输的数据量。 但DSO的数量必须保持在合理的水平。 但是,并非所有程序都要这样做,我们稍后会看到为什么这可能引起问题。
在我们开始讨论所有这些之前,需要对ELF及其实现有一些了解。
1.3 ELF是如何实施的?
处理静态链接的应用程序非常简单。内核知道静态链接的应用程序的固定加载地址。加载过程简单,使新建进程的二进制文件在适当的内存空间中可用,并将控制权转移到应用程序的入口点。创建可执行文件时,其他所有内容都由静态链接器完成。
相反,动态链接的二进制文件在从磁盘加载时不完整。因此内核不可能立即将控制权转移到应用程序。在此之前,很显然需要加载helper程序。这个helper程序就是动态链接器。动态链接器的任务是加载动态链接的应用程序所需的DSO(依赖项)并且执行重定位。然后控制权才可以转移到程序中。
但是,在大多数情况下,这不是动态链接器的最后一项任务。 ELF允许在符号被调用时才完成与符号关联的重定位。这种“懒”重定位方案是可选的,下面讨论的在启动时立即执行重定位的优化也会影响“懒”重定位。所以我们在后面忽略了完成启动之后的所有内容。
1.4 程序启动:在内核中的情况
一个程序的启动是从内核中开始的,通常是在execve系统调用中。 当前正在执行的代码被替换为新程序。 这意味着内存地址空间的内容被包含新程序的文件内容替换。 仅仅通过简单地映射(使用mmap)文件的内容是不行的。因为ELF文件是结构化的,文件中通常至少有三种不同的区域:
- 执行代码,这个区域一般是不能写入的
- 运行时要更新的数据,这个区域一般是不能当作代码来运行的
- 运行时不需要的数据,因为不需要所以在程序启动时这个区域不会加载
现代操作系统和处理器可以保护存储器区域是否允许读取,写入和执行,这种保护是对每个单独的内存页(注1)而言的。最好将尽可能多的页面标记为不可写,因为只读页面可以在使用相同应用程序或者DSO的进程之间共享。写保护还有助于检测和防止数据甚至代码的无意或恶意修改。
注1:内存页是操作系统的内存子系统所操作的最小实体。内存页的尺寸大小因不同体系架构而不同,甚至是相同体系架构的不同操作系统也有可能有不同的内存页尺寸大小。
为了使内核能够找到ELF文件结构中不同的区域(ELF段)及其访问权限,ELF文件格式定义了一个表,其中仅包含此信息。每个可执行文件和DSO中必须存在所谓的ELF程序头表(ELF Program Header table)。它由C类型Elf32_Phdr和Elf64_Phdr表示,其定义如图1所示。
图1:ELF程序头表 C数据结构
typedef struct
{
Elf32_Word p_type;
Elf32_Off p_offset;
Elf32_Addr p_vaddr;
Elf32_Addr p_paddr;
Elf32_Word p_filesz;
Elf32_Word p_memsz;
Elf32_Word p_flags;
Elf32_Word p_align;
} Elf32_Phdr;
typedef struct
{
Elf64_Word p_type;
Elf64_Word p_flags;
Elf64_Off p_offset;
Elf64_Addr p_vaddr;
Elf64_Addr p_paddr;
Elf64_Xword p_filesz;
Elf64_Xword p_memsz;
Elf64_Xword p_align;
} Elf64_Phdr;
要找到程序头数据结构,需要另一个数据结构——ELF头。 ELF头是唯一一个在偏移零点开始,在可执行文件中具有固定位置的数据结构。其C数据结构如图2所示。e_phoff字段指定从文件开头开始计算的程序头表(ELF Program header table,即图1的表)的起始位置。 e_phnum字段包含程序头表中的条目数,e_phentsize字段包含每个条目的尺寸大小,这个值仅作为二进制文件的运行时一致性检查。
图2:ELF头 C数据结构
typedef struct {
unsigned char e_ident[EI_NIDENT];
Elf32_Half e_type;
Elf32_Half e_machine;
Elf32_Word e_version;
Elf32_Addr e_entry;
Elf32_Off e_phoff;
Elf32_Off e_shoff;
Elf32_Word e_flags;
Elf32_Half e_ehsize;
Elf32_Half e_phentsize;
Elf32_Half e_phnum;
Elf32_Half e_shentsize;
Elf32_Half e_shnum;
Elf32_Half e_shstrndx;
} Elf32_Ehdr;
typedef struct {
unsigned char e_ident[EI_NIDENT];
Elf64_Half e_type;
Elf64_Half e_machine;
Elf64_Word e_version;
Elf64_Addr e_entry;
Elf64_Off e_phoff;
Elf64_Off e_shoff;
Elf64_Word e_flags;
Elf64_Half e_ehsize;
Elf64_Half e_phentsize;
Elf64_Half e_phnum;
Elf64_Half e_shentsize;
Elf64_Half e_shnum;
Elf64_Half e_shstrndx;
} Elf64_Ehdr;
不同的段由程序头条目表示,p_type字段中具有PT_LOAD值。 p_offset和p_filesz字段指定段开始的文件位置以及段的长度。 p_vaddr和p_memsz字段指定段在进程的虚拟地址空间中的位置以及内存区域的大小。 p_vaddr字段本身的值不一定是最终加载地址。DSO可以在虚拟地址空间中的任意位置加载,但是段的相对地址很重要。对于预链接的DSO,p_vaddr字段的实际值是有意义的:它指定了DSO被其它程序预先链接的地址。但即使这样,也并不意味着动态链接器在必要时不能忽略此信息。
文件的大小可能小于内存中占用的地址空间。 内存区域的第一个p_filesz字节是从文件中段的数据读取并初始化的,大出来的内存空间用零初始化。 这可以用于处理BSS段(注2),未初始化变量的段,根据C标准用零初始化。 以这种方式处理未初始化的变量具有以下优点:可以减小文件大小,因为不必存储初始化值,不必将数据从盘复制到存储器,并且OS通过mmap接口提供的存储器已经初始化为零。
注2:BSS段仅包含NUL字节。因此,它们不必在文件中表示。加载器只需知道大小,它就可以分配足够的内存并用NUL填充它
最后,p_flags告诉内核内存页面使用什么权限。该字段是位图,其中定义了下表中给出的位。标志直接映射到mmap可以理解的标志。
p_flags | 值 | mmap标志 | 描述 |
PF_X | 1 | PROT_EXEC | 执行权限 |
PF_W | 2 | PROT_WRITE | 写权限 |
PF_R | 4 | PROT_READ | 读权限 |
在使用适当的权限和指定的地址映射所有PT_LOAD段之后,或者在为没有固定加载地址的动态对象自由地分配地址之后,就可以开始下一阶段。去设置动态链接的可执行文件本身的虚拟地址空间。但二进制文件并不完整。内核必须让动态链接器完成剩下的工作,为此动态链接器必须以与可执行文件本身相同的方式加载(即,在程序头中查找可加载的段)。不同之处在于动态链接器本身必须是完整的,并且应该可以自由重定位。
由哪个二进制代码具体实现动态链接器在内核中没有硬编码(内核没有限制由谁来做动态链接器)。相反,应用程序的程序头包含带有PT_INTERP标记的条目。此条目的p_offset字段包含一个以NUL结束的字符串的偏移量,该字符串指定动态链接器的文件名。对这个文件的唯一要求是它的加载地址不会与可能与它一起运行的可执行文件的加载地址冲突。通常,这意味着动态链接器不能有固定的加载地址,可以在任何内存位置加载; 这就不会造成地址冲突。
一旦动态链接器也被映射到待启动进程的内存中,我们就可以启动动态链接器。请注意,不是把控制权转移到应用程序的入口点。此时还只有动态链接器准备好了可以运行。要启动动态链接器,还需要执行一个步骤。必须以某种方式告知动态链接器,让它可以找到应用程序的位置以及应用程序完成后必须将控制权转移到何处。为了这个目的使用了一种结构化的方式——内核在新进程的堆栈上放置一组标记-值对。此辅助向量(标记-值对组)除了前面提到的两个东西(应用程序位置、程序结束后的控制权)之外还包含几个值,这些值可以让动态链接器避免掉多个系统调用。 elf.h头文件定义了许多带有AT_前缀的常量。这些是辅助向量中条目的标记。
在设置辅助向量之后,内核最终准备好在用户模式下将控制转移到动态链接器。入口点在动态链接器的ELF头的e_entry字段中定义。
1.5 动态链接器中的程序启动过程
程序启动的第2阶段发生在动态链接器中,包括以下任务:
- 检测并且加载依赖项
- 对应用程序和所有依赖项进行重定位
- 以正确的顺序初始化应用程序和各依赖项
在下文中,我们只更详细地讨论重定位处理。对于其他两点,提高性能的方式很明显:减少依赖项的数量。 每个参与对象只需初始化一次,但必须进行一些拓扑排序。 识别和加载过程也随着依赖项的数量而扩展; 在大多数(所有?)实现中,这不会是线性扩展。
重定位过程通常(注3)是动态链接器工作中花销最大的部分。 这是一个渐近至少为O(R + nr)的过程,其中R是相对重定位的数量,r是命名重定位的数量,n是参与DSO的数量(加上主可执行文件)。 ELF哈希表函数和修改符号查找功能的各种ELF扩展的开销可能会将因子增加到O(R + rn log s),其中s是符号的数量。 这应该表明,为了提高性能,尽可能减少重新定位和符号的数量是很重要的。 在解释了重定位过程后,我们将对实际数字进行一些估算。
注3:这里,我们忽略了pre-linking(预链接)支持在许多情况下可以有效地减少甚至消除重定位开销。
1.5.1 重定位的过程
在此上下文中的重定位是指将应用程序和作为依赖项加载的DSO调整为它们自己和所有其他加载地址。 有两种依赖关系:
-
对知道是在自己的对象中的依赖关系的定位,这与特定符号无关,因为链接器知道对象中相对位置的定位。
请注意,应用程序没有相对重定位,因为代码的加载地址在链接时就已经知道,因此静态链接器能够执行重定位。 -
基于符号的依赖关系。 定义的引用通常是在与定义不同的对象中(有时也不一定是这样)。
相对重定位的实现很容易。链接器可以在程序链接时计算对象文件中目标的偏移量。对于此偏移量,动态链接器只需添加对象的加载地址,并将结果存储在一个由重定位指明的地方。在运行时,动态链接器必须仅花费非常小且恒定的时间,这个时间不会随着DSO数量增加而增加。
基于符号的重定位要复杂得多。 ELF符号解析过程设计得非常强大,因此它可以处理许多不同的问题。但是,所有这些强大的功能都增加了复杂性和运行时的开销。以下描述的读者可能会质疑引起这个过程的决策。我们不能在这里争论; 读者可参考ELF的讨论。事实上,符号重定位是一个代价高昂的过程,DSO参与的越多或DSO中定义的符号越多,符号查找所需的时间就越长。
任何重定位的结果都将和引用一起存储在对象中的某个位置。 理想情况下,这个位置通常位于数据段中。 如果用户,编译器或链接器重定位错误地生成代码,则可能会修改掉文本或只读段。 如果按照ELF规范的要求标记了对象动态段(dynamic section)的DT_FLAGS条目中的DF_TEXTREL(或旧二进制文件中存在DT_TEXTREL标志),动态链接器将正确地处理此问题。 但结果是修改后的页面无法与使用同一对象的其他进程共享。 修改过程本身也很慢,因为内核必须重新组织相当多的内存管理数据结构。
1.5.2 符号重定位
动态链接器必须对在运行时使用的符号,以及同一对象中在链接时未知的所有符号执行重定位作为引用。由于在某些体系结构上生成代码的方式,可以延迟一些重定位的处理,直到实际需要使用到有问题的引用;在许多体系结构中对函数的调用都是如此。在使用对象之前,所有其他类型的重定位必须处理完成。我们将忽略延迟重定位处理,因为这只是一种延迟工作的方法,重定位最终必须完成,因此我们将其纳入我们的开销分析。通过将环境变量LD_BIND_NOW设置为非空值来使在使用对象之前执行所有重定位。通过向链接器命令行添加-z now选项,可以为单个对象禁用延迟重定位。链接器将在动态段的DT_FLAGS条目中设置DF_BIND_NOW标志以标记DSO禁用延迟重定位。但是,如果不重新链接DSO或编辑二进制文件,则无法撤消此设置,因此只有在真正需要时才应使用此选项。
从一开始就为每个加载对象中的每个符号的重定位进行实际的查找。请注意,在不同对象中可以有许多对相同符号的引用。对于每个对象,查找的结果可以是不同的,因此除了为每个对象中的符号查找结果进行缓存之外没有捷径可走,因为不止一个重定位引用会指向相同的符号。以下步骤中提到的查找范围是已加载对象的子集的有序列表,对于每个对象本身可以是不同的。查找范围的计算方式非常复杂,并且在这里并不重要,感兴趣的读者可以看ELF规范和第1.5.4节。重要的是,范围的长度通常直接取决于加载的对象的数量。这是减少加载对象数量的另一个因素,即提高性能。