How To Write Shared Libraries 中文翻译

概述

如今,动态链接库无处不在。开发者有很多原因来使用并且创建他们。然而,这是个问题,因为在许多平台必须应用一些额外的技术才能生成体面的代码

1 前言

长时间依赖,程序收集通用的代码到库中,这样他们就可以被复用。这节省了开发者的时间并且减少了错误,因为被服用的代码已经被调试过一遍。随着系统同时运行成百个进程,在链接时服用这些代码仅仅解决了问题中的一部分。许多程序会使用他们导入到库中的相同片段的代码。随着现代操作系统中的内存管理系统,在运行时共享代码也变得可能。这是由加载代码到物理内存仅一次并且通过虚拟内存在多个进程中复用。这样的库被称为动态库。

这个概念非常的新。操作系统设计者使用他们之前使用的基础设施对其系统进行了扩展。对操作系统的扩展可以对用户透明的完成。但是部分用户必须直接处理最初产生的问题。

最主要的方面是二进制格式。这是用于描述应用程序代码的格式。提供内存转储就足够的日子已经一去不复返了。多进程系统需要识别包含程序的文件的不同部分,例如文本、数据和调试信息部分。为此,二进制格式很早就引入了。常用于早期的 Unix 时代是诸如 a.out 或 COFF 之类的格式。这些二进制格式的设计没有考虑到共享库,这可以清楚地展示。

1.1 一些历史

最初用于 Linux 的二进制格式是 a.out变体。当引入动态库某些设计决定需要在a.out的限制中进行工作。主要接受的限制是在加载时和之后不执行重定位。共享库必须以它们的形式存在运行时在磁盘上使用。这对构建和使用共享库的方式施加了主要限制:每一个共享库必须有固定的加载地址。否则它将无法生成无需重定位的共享库。

必须分配固定加载地址,这需要在没有重叠和冲突的情况下发生,并且有一些通过允许共享库的增长来实现未来的安全。因此,有必要建立一个中央权力机构,地址范围的分配本身就是一个主要问题。但情况变得更糟:给定一个 Linux 系统
今天有数百个 DSO(动态共享对象)应用程序可用的地址空间和虚拟内存变得严重碎片化。这会限制可以动态分配的内存块的大小,这会造成不可避免的某些应用程序的问题。到今天甚至会发生要分配的地址范围被分配完,至少在 32 位机器上。

我们仍然没有涵盖 a.out 共享库的所有缺点。由于使用共享库的应用程序在更改后不必重新链接它使用的共享库,入口点,即函数和变量地址,一定不能改变。这只能被保证如果入口点代码和实际代码点分开,因为否则a函数大小的限制将被硬编码。函数存根表在 Linux 上是实际使用的解决方案。静态链接器获取来自特殊文件的函数存根每个地址(文件扩展名为.sa)。在运行时以 .so.X.Y.Z 结尾的文件被使用并且它必须对应于使用的 .sa 文件。这反过来要求存根table中的分配的entry总是必须用于相同的函数。必须小心处理表的分配。引入新接口意味着附加到表格中。永远不可能淘汰表条目。避免使用带有链接到新版本的程序的旧共享库。必须在应用程序中保留一些记录:.so.X.Y.Z 名称的 X 和 Y 部分后缀被记录并且动态链接器确保满足最低要求。

该计划的好处是由此产生的计划运行速度非常快。即使是第一次调用,在这样的共享库中调用函数也非常有效。它可以仅用两次绝对跳转即可实现:第一个从用户代码到存根,第二个来自存根到函数的实际代码。这大概是比任何其他共享库实现都快,但是它的速度代价太高了:

  1. 需要集中分配地址范围;
  2. 碰撞是可能的(很可能)造成灾难性的结果;
  3. 地址空间严重碎片化

由于所有这些以及更多的原因,Linux 很早就转换了使用 ELF(可执行链接格式)作为二进制文件格式。ELF 格式由通用规范 (gABI) 定义,特定于处理器的扩展到该规范(psABI) 添加。事实证明,摊销成本为函数调用几乎与 a.out 相同,但是限制消失了。

1.2 转向 ELF

对于程序员来说,切换到的主要优势ELF 是创建 ELF 共享库或在ELFspeak DSO 中变得非常容易。生成应用程序和生成 DSO 之间的唯一区别在于最终链接命令行。一个额外的选项(–shared in GNU ld 的情况)告诉链接器生成 DSO而不是应用程序,后者是默认值。事实上,DSO 只不过是一种特殊的二进制;不同之处在于它们没有固定的加载地址,因此需要动态链接器实际上成为可执行的。使用位置独立可执行文件 (PIE),差距缩小得更多

这与 GNU Libtool 的引入一起,后面会介绍,已导致程序员广泛采用 DSO。正确使用 DSO 可以提供帮助节省大量资源。但必须遵守一些规则遵循以获得任何好处,还有一些规则必须遵循以获得最佳结果。解释这些规则将是本文很大一部分的主题。

并非所有 DSO 的使用都是为了节省资源。DSO 今天也经常被用作结构程序。程序的不同部分是放入单独的 DSO。这可以是一个非常强大的工具,尤其是在开发阶段。无需重新链接整个程序,只需重新链接已更改的 DSO。这通常要快得多。

一些项目甚至决定保留许多独立的 DSO在部署阶段,即使 DSO 不在其他程序中重用。在许多情况下,这样做肯定是有用的:DSO 可以单独更新,从而减少必须更新的数据量运输。但 DSO 的数量必须保持在合理的水平。但是,并非所有程序都这样做,并且我们稍后会看到为什么这会成为一个问题。

在我们开始讨论所有这些之前,需要对 ELF 及其实现有一些了解。

1.3 ELF 是如何实现的?

静态链接的应用程序的处理非常简单。这样的应用有内核知道的固定的加载地址。加载过程包括在新创建的进程的适当地址空间中提供二进制文件并传输控制到应用程序的入口点。一切别的是在创建可执行文件时由静态链接器完成的。

动态链接的二进制文件,相比之下,在从磁盘加载时并不完整。因此内核不可能立即将控制权转移给应用程序。取而代之的是一些显然必须完整的其他帮助程序被加载为。这个帮助程序是动态链接器。任务动态链接器的就是它,动态完成通过加载所需的 DSO(依赖项)并执行重定位来链接应用程序。然后最后控制权可以转移到程序中。

在大多数情况下,这不是动态链接器的最后一个任务
情况,不过。ELF 允许与关联的重定位要延迟的符号,直到需要该符号。这惰性重定位方案是可选的,下面讨论的针对启动时执行的重定位的优化也会立即影响惰性重定位。所以我们忽略启动完成后的下面的一切。

1.4 启动:在内核中

程序的开始执行始于内核,通常在 execve 系统调用中。当前执行的代码替换为新程序。这意味着地址空间内容被包含着程序的文件的内容替换。这不会通过简单地映射(使用 mmap)文件的内容来实现。ELF文件是结构化的,通常至少有三个文件中不同类型的区域:

  • 执行的代码;这个地区通常不是可写;
  • 被修改的数据;这个地区通常不是可执行;
  • 运行时未使用的数据;因为不需要,它不应该在启动时加载。

现代操作系统和处理器可以保护内存区域以允许和禁止读取、写入和为每一页内存单独执行1。它是最好将尽可能多的页面标记为不可写因为这意味着页面可以在使用相同的应用或者DSO页面的进程之间共享。写保护还有助于检测和防止数据甚至代码的无意或恶意修改。

为了让内核找到不同的区域,或段(用 ELF 的说法),和他们的访问权限,ELF 文件格式定义了一个除其他外,只包含这些信息的表格。ELF 程序头表,正如它所说,必须存在于每个可执行文件和 DSO中。它由 C 类型 Elf32 Phdr 表示
和 Elf64 Phdr 的定义如图 1 所示。
在这里插入图片描述

定位程序头数据结构需要另一个数据结构,ELF Header。ELF Header是在文件中具有固定位置的唯一数据结构,从偏移量零开始。它的C数据结构可以见图2. e_phoff 字段指示了program header table开始的位置,从文件的开头计数。e_phnum字段指示了program header table中条数的数量,e_phentsize字段包含每个条目的大小。最后一个值很有用仅作为二进制文件的运行时一致性检查。

在这里插入图片描述
不同的段由程序header entries表示,用p_type字段中PT_LOAD的值。p_offsetp_filesz 字段指明了段在文件中开始的位置以及它有多长。p_vaddrp_memsz 字段指定段在进程虚拟地址空间中的位置以及大小。p_vaddr 字段本身的值不一定需要是最终加载地址。DSO 可以加载到虚拟地址空间中的任意地址。但是段的相对位置很重要。对于预链接的 DSO,实际值p_vaddr 字段是有意义的:它指定了预先链接的DSO的地址。但即使这样也不意味着动态连接器在必要情况下不能忽略掉这个信息。

文件中的size可以小于他在内存中占用的地址空间。内存区域中开头的p_filesz个字节会被使用文件段里的数据初始化,不同的是,会被初始化为0。这可用于处理 BSS 部分2.未初始化变量的sections会按照C语言标准被初始化为0。以这种方式处理未初始化的变量的优点是文件大小可以减少,因为不必存储初始化值,没有数据需要从磁盘拷贝到内存,通过操作系统mmap接口提供的内存已经被初始化为0。

p_flags最终告诉内核什么权限用于内存页。该字段是一个bitmap,下表中给出的位被定义。flags被直接映射为mmap可以理解的flags


使用适当的权限和指定的地址映射所有 PT_LOAD 段之后,或者为动态对象自由的分配一个没有固定加载地址的地址后,下一个阶段就可以开始里。动态链接的可执行文件的虚拟地址空间被设置好了。但是二进制还没有完成。内科需要让动态连接器完成剩下的工作,为此动态连接器需要被以相同的防治加载到可执行文件本身。(即,寻找program header中可加载的段)。不同的是动态链接器本身必须是完整的,且应该是可以自由重定位的。

二进制文件实现动态链接器不是在内核中硬编码的。取而代之的是程序头应用程序包含一个带有标签 PT_INTERP 的条目。p_offset字段包含了一个偏移,这个偏移里存储着一个以NULL结尾的字符串,它指示了这个文件的名字。对于这个被命名的文件的唯一的要求就是,它的加载地址不与任何一个可能被他使用的可执行文件的加载地址冲突。一般来说,这意味着动态链接器没有固定加载地址并可以在任何地方加载;这正是动态二进制文件所允许的。

一旦动态链接器也被映射到待启动进程的内存,我们就可以启动动态链接器了。请注意,它不是控制转移到的应用程序的入口点。只是动态链接器已准备好运行。在立即调用动态链接器之前,这里还会再执行一个步骤。必须以某种方式告诉动态链接器在哪里可以找到应用程序,以及在应用完成后转移控制权到哪里。一个结构为此而存在。内核将一组标签值(tag-value)对放在新进程的堆中。这个辅助向量除了前面提到的两个值之外,还包含几个允许动态链接器避免多次系统调用的值.elf.h 头文件定义了一些带有 AT 前缀的常量。这些是辅助向量条目中的标签。

设置辅助向量后,内核终于准备将控制转移到用户模式中的动态链接器。动态连接器的入口点被定义在ELF Header 中的e_entry字段。

1.5 在动态链接器中启动

程序启动的第二阶段发生在动态链接器。其任务包括:

  • 确定并加载依赖项;
  • 重新定位应用程序和所有依赖项;
  • 初始化应用程序和依赖项,以正确的顺序

下面我们将只针对重定位处理过程进行更详细地讨论。对于另外两个点,获得更好的性能的方式很明显:更少的依赖。每个参与对象只初始化一次但必须进行一些拓扑排序。确认和加载进程也随着依赖的数量增长而扩展,在大多数(所有?)实现中,这不能线性地扩展。

重定位过程通常是动态链接器工作花销最大的一部分。这是一个渐进复杂度至少为O(R+nr)的过程,R是相对重定位的次数,r 是命名重定位的数量,n 是参与 DSO 的数量(加上主要的可执行文件)。ELF哈希表函数的不足和各种ELF拓展修改里符号查询功能,可能会增加这个因素到O(R+rnlogs),s是符号的数量。这应该表明,为了提高性能,如果重定位和符号数量能尽可能多地减少,则意义重大。解释完重定位后,我们将会对实际数字进行一些估计。

1.5.1 重定位过程

在这种情况下,重定位意味着调整应用程序和作为依赖项加载的DSO,到他们自己和其他所有人都加载地址。那里有两个依赖的种类:

  • 对自己的object中对已知位置的依赖。这些与特定符号无关,因为链接器知道对象中的相对位置的位置。请注意,应用程序没有相对重定位,因为代码的加载地址在链接时是已知的,因此静态链接器能够执行重定位。
  • 基于符号的依赖关系。定义的参考通常是,


  1. ↩︎
  2. ↩︎
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值