ELF 对象文件格式:简介
既然我们即将公开发布 ELF 文件格式编译器和实用程序,现在是解释 a.out 和 ELF 之间的区别,并讨论它们将如何对用户可见的合理时机。既然我正在做这件事,我还将引导您了解 ELF 文件格式的内部结构,并向您展示它的工作原理。我意识到 Linux 用户的范围从 Unix 新手到使用 Unix 系统多年的用户——因此,我将从一个相当基础的解释开始,这对于更有经验的用户可能用处不大,因为我希望这篇文章对尽可能多的人有用。
人们经常问我们为什么要费心使用新的文件格式。 脑海中浮现出几个原因——首先,当前的共享库构建起来可能有些麻烦,特别是对于像 X Window 系统这样跨越多个目录的大型软件包。 其次,当前的 a.out 共享库方案不支持 dlopen() 函数,该函数允许您告诉动态加载器加载额外的共享库。 为什么选择 ELF? Unix 社区似乎正在将这种文件格式标准化; SVr4 的各种实现,如 MIPS、Solaris、Unixware 目前都在使用 ELF; 据报道,SCO 将在不久的将来切换到 ELF; 并且有传言说其他供应商也在切换到 ELF。 一个有趣的题外话——Windows NT 使用基于 COFF 文件格式的文件格式,COFF 文件格式是 Unix 社区正在放弃而转向 ELF 的 SVr3 文件格式。
让我们从头开始。 用户通常会遇到三种类型的 ELF 文件——.o 文件、常规可执行文件和共享库。 虽然所有这些文件都有不同的用途,但它们的内部结构文件却非常相似。 因此,我们可以从一般描述开始,然后讨论这三种文件类型的具体细节。 下个月,我将演示 readelf 程序的使用,该程序可用于显示和解释 ELF 文件的各个部分。
所有不同的 ELF 文件类型(以及 a.out 和许多其他可执行文件格式)之间的一个通用概念是节的概念。 这个概念非常重要,值得花一些时间来解释。 简而言之,节是相似类型信息的集合。 每个节代表文件的一部分。 例如,可执行代码总是放在一个名为 .text 的节中; 用户初始化的所有数据变量都放在一个名为 .data 的节中; 而未初始化的数据则放在一个名为 .bss 的节中。
原则上,人们可以设计一种可执行文件格式,其中所有内容都混杂在一起——MS-DOS 二进制文件就是如此。 但是将可执行文件分成节具有重要的优势。 例如,一旦您将可执行文件的可执行部分加载到内存中,这些内存位置就不需要更改。(原则上,程序可执行代码可以修改自身,但这被认为是极差的编程实践。)在现代机器架构上,内存管理器可以将内存的某些部分标记为只读,这样任何修改只读内存位置的尝试都会导致程序崩溃并转储核心。 因此,我们不仅要说我们不希望特定的内存位置发生变化,还可以指定任何修改只读内存位置的尝试都是致命错误,表明应用程序中存在错误。 话虽如此,通常您不能单独设置内存每个字节的只读状态——相反,您可以单独设置称为页面的内存区域的保护。 在 i386 架构上,页面大小为 4096 字节——因此您可以指示地址 0-4095 是只读的,而字节 4096 及以上是可写的,例如。
鉴于我们希望可执行文件的所有可执行部分都在只读内存中,而所有可修改的内存位置(例如变量)都在可写内存中,因此将可执行文件的所有可执行部分分组到一个内存节(.text 节),并将所有可修改的数据区域分组到另一个内存区域(以下称为 .data 节)是最有效的。
用户已初始化的数据变量和用户未初始化的数据变量之间存在进一步的区别。 如果用户未指定变量的初始值,则在可执行文件中浪费空间来存储该值是没有意义的。 因此,初始化的变量被分组到 .data 节中,而未初始化的变量被分组到 .bss 节中,.bss 节是特殊的,因为它不占用文件中的空间——它只说明未初始化的变量需要多少空间。
当您要求内核加载并运行可执行文件时,它首先查看映像头,以获取有关如何加载映像的线索。 它在可执行文件中找到 .text 节,将其加载到内存的适当部分,并将这些页面标记为只读。 然后,它在可执行文件中找到 .data 节,并将其加载到用户的地址空间中,这次是在读写内存中。 最后,它从映像头中找到 .bss 节的位置和大小,并将适当的内存页面添加到用户的地址空间中。 即使用户未指定放置在 .bss 中的变量的初始值,按照惯例,内核会将所有这些内存初始化为零。
通常,每个 a.out 或 ELF 文件还包括一个符号表。 其中包含文件中定义或引用的所有符号(程序入口点、变量地址等)的列表、与符号关联的地址以及指示符号类型的某种标签。 在 a.out 文件中,这或多或少是存在的信息的范围; 正如我们稍后将看到的,ELF 文件包含更多信息。 在某些情况下,可以使用 strip 实用程序删除符号表。 优点是,剥离后可执行文件更小,但您失去了调试剥离后的二进制文件的能力。 对于 a.out,始终可以从文件中删除符号表,但对于 ELF,您通常需要在文件中包含一些符号信息才能使程序加载并运行。 因此,对于 ELF,strip 程序将删除符号表的一部分,但它永远不会删除所有符号表。
最后,我们需要讨论重定位的概念。 假设您编译一个简单的“hello world”程序
main( ) { printf("Hello World\n"); }
编译器生成一个对象文件,其中包含对函数 printf 的引用。 由于我们尚未定义此符号,因此它是外部引用。 此函数的执行代码将包含调用 printf 的指令,但在对象代码中,我们尚不知道执行此函数调用的实际位置。 汇编器注意到函数 printf 是外部的,因此它生成一个重定位,其中包含多个组件。 首先,它包含一个指向符号表的索引——这样,我们就知道正在引用哪个符号。 其次,它包含一个指向 .text 节的偏移量,该偏移量引用调用指令操作数的地址。 最后,它包含一个标签,指示实际存在的重定位类型。 当您链接此文件时,链接器会遍历重定位,查找外部函数 printf 的最终地址,然后将此地址补丁回调用指令的操作数中,以便调用指令现在指向实际的函数 print。
a.out 可执行文件没有重定位。 内核加载器无法解析任何符号,并且会拒绝任何运行此类二进制文件的尝试。 a.out 对象文件当然会有重定位,但链接器必须能够完全解析这些重定位才能生成可用的可执行文件。
到目前为止,我所描述的一切都适用于 a.out 和 ELF。 现在,我将列举 a.out 的缺点,以便更清楚地了解我们为什么要切换到 ELF。
首先,a.out 文件的标头(struct exec,在 /usr/include/linux/a.out.h 中定义)包含的信息有限。 它只允许存在上述节,并且不直接支持任何其他节。 其次,它仅包含各个节的大小,但不直接指定节在文件中的起始偏移量。 因此,链接器和内核加载器对于各个节在文件中的起始位置有一些不成文的理解。 最后,没有内置的共享库支持——a.out 是在共享库技术开发之前开发的,因此基于 a.out 的共享库实现必须滥用和误用一些现有节才能完成所需的任务。
大约 6 个月前,默认文件格式从 ZMAGIC 切换到 QMAGIC 文件。 这两种都是 a.out 格式,唯一的真正区别是链接器和内核之间不同的不成文理解。 这两种格式的可执行文件在文件开头都有一个 32 字节的标头,但对于 ZMAGIC,.text 节从字节偏移量 1024 开始,而对于 QMAGIC,.text 节从文件开头开始,并包括标头。 因此,ZMAGIC 浪费磁盘空间,但更重要的是,ZMAGIC 使用的 1024 字节偏移量使内核内的有效缓冲区缓存管理更加困难。 对于 QMAGIC 二进制文件,从文件偏移量到表示给定内存页面的块的映射更自然,并且应该允许内核中的一些性能增强。 ELF 二进制文件的格式也很自然,与未来可能对缓冲区缓存进行的更改兼容。
我说过 a.out 中缺少共享库支持——虽然这是事实,但设计与 a.out 一起使用的共享库实现并非不可能。 当前的 Linux 共享库当然是一个例子; 另一个例子是 SunOS 风格的共享库,BSD-du-jour 当前正在使用它。 SunOS 风格的共享库包含许多与 ELF 共享库相同的概念,但 ELF 允许我们丢弃一些将共享库实现嫁接到 a.out 上所需的非常愚蠢的技巧。
在我们开始实际描述 ELF 的工作原理之前,值得花一些时间讨论与共享库相关的一些一般概念。 这样,当我们开始剖析 ELF 文件时,就更容易了解发生了什么。
首先,我应该稍微解释一下什么是共享库; 令人惊讶的是,许多人将共享库视为某种黑匣子,对内部发生的事情没有很好的理解。 大多数用户至少意识到,如果他们搞砸了共享库,系统可能会变得几乎无法使用。 这导致大多数人以某种敬畏的态度对待它们。
如果我们稍微回顾一下,我们回忆起非共享库(也称为静态库)包含程序可能希望使用的有用过程。 因此,程序员不需要从头开始做所有事情,而是可以使用一组标准的、定义良好的函数。 这使程序员能够更有效率。 不幸的是,当您链接到静态库时,链接器必须提取您需要的所有库函数,并使其成为可执行文件的一部分,这可能会使可执行文件变得相当大。
共享库背后的想法是,您将以某种方式获取静态库的内容(不是字面上的内容,但通常是从同一源代码树生成的内容),并将其预链接到某种特殊的程序中。 当您将程序链接到共享库时,链接器仅记录您正在调用共享库中的函数这一事实,因此它不会从共享库中提取任何可执行代码。 相反,链接器将指令添加到可执行文件中,这些指令告诉可执行文件中的启动代码,还需要一些共享库,因此当您运行程序时,内核首先将可执行文件插入到您的地址空间中,但是一旦您的程序启动,所有这些共享库也会添加到您的地址空间中。 显然,必须存在某种机制来确保当您的程序调用共享库中的函数时,它实际上会分支到共享库中的正确位置。 我将在稍后讨论 ELF 的机制。
现在我们已经解释了共享库,我们可以开始讨论与 ELF 下共享库的实现方式相关的一些一般概念。 首先,ELF 共享库是位置无关的。 这意味着您可以将它们或多或少地加载到内存中的任何位置,它们都可以工作。 当前的 a.out 共享库被称为固定地址库:每个库都有一个特定的地址,必须在该地址加载才能工作,尝试将其加载到其他任何位置都是愚蠢的。 ELF 共享库通过多种方式实现其位置无关性。 主要区别在于,您应该使用编译器开关 -fPIC 编译您想要插入共享库的所有内容。 这告诉编译器生成旨在位置无关的代码,并尽可能避免通过绝对地址引用数据。
然而,位置无关性并非没有代价。 当您编译某些内容以使其成为 PIC 时,编译器会保留一个机器寄存器(i386 上的 %ebx)以指向称为全局偏移表(或简称 GOT)的特殊表的开头。 保留此寄存器意味着编译器在优化代码方面的灵活性会降低,这意味着完成相同的工作需要更长的时间。 幸运的是,我们的基准测试表明,对于大多数普通程序,最坏情况下的性能下降小于 3%,并且在许多情况下远小于此。
ELF 的另一个特性是其共享库在运行时解析符号和外部符号。 这是使用符号表和必须在映像开始执行之前执行的重定位列表来完成的。 虽然这听起来可能很慢,但 ELF 中内置的许多优化使其相当快。 我应该提到的是,当您将 PIC 代码编译到共享库中时,通常只有很少的重定位,这是性能影响不大另一个原因。 从技术上讲,可以从未使用 -fPIC 编译的代码生成共享库,但在共享库可用之前需要执行大量重定位,这是 -fPIC 很重要的另一个原因。
当您引用共享库中的全局数据时,汇编代码不能像使用非 PIC 代码那样简单地从内存中加载值。 如果您尝试这样做,代码将不是位置无关的,并且重定位将与您尝试从变量加载值的指令相关联。 相反,编译器/汇编器/链接器创建 GOT,GOT 只是一个指针表,每个指针指向共享库中定义或引用的每个全局变量。 每次库需要引用给定的变量时,它首先从 GOT 加载变量的地址(记住 GOT 的地址始终存储在 %ebx 中,因此我们只需要一个到 GOT 的偏移量)。 一旦我们有了这个地址,我们就可以取消引用它以获得实际值。 这样做的好处是,要建立全局变量的地址,我们只需要在一个地方存储地址,因此每个全局变量只需要一个重定位。
对于函数,我们必须做类似的事情。 允许用户重新定义可能在共享库中的函数至关重要,如果用户这样做,我们希望强制共享库始终使用用户定义的版本,而永远不要使用共享库中函数的版本。 由于函数可能会在共享库中被使用很多次,因此我们使用称为过程链接表(或 PLT)的东西来引用所有函数。 从某种意义上说,这只不过是一个跳转表的花哨名称,一个跳转指令数组,每个指令对应一个您可能需要跳转到的函数。 因此,如果一个特定函数从共享库中的数千个位置调用,控制将始终通过一个跳转指令传递。 这样,您只需要一个重定位来确定实际调用哪个版本的给定函数,并且从性能的角度来看,这几乎是您能得到的最好的结果。
下个月,我们将使用这些信息来剖析真实的 ELF 文件,解释有关 ELF 文件格式的具体细节。
Eric Youngdale Eric Youngdale 从事 Linux 工作已超过两年,并且一直积极参与内核开发。 他还开发了当前的 Linux 共享库。