ELF 对象文件格式剖析

作者:Eric Youngdale

上个月,我们达到了开始剖析一些真正的 ELF 文件的阶段。为此,我将使用 readelf 实用程序,这是我在首次尝试理解 ELF 格式本身时编写的。后来,当我添加对 ELF 的支持时,它成为调试链接器的宝贵工具。readelf 的源代码应该在 tsx-11.mit.edu 的 pub/linux/packages/GCC/src 或 pub/linux/BETA/ibcs2 中。

让我们从一个非常简单的程序开始——我们上个月使用的 hello world 程序。

largo% cat hello.c
main()
{
        printf("Hello World\n");
}
largo% gcc-elf -c hello.c

在我的笔记本电脑上,gcc-elf 命令调用 ELF 版本的 gcc——一旦 ELF 成为默认格式,您将能够使用常规的 gcc 命令,该命令生成 ELF 文件 hello.o。每个 ELF 文件都以标头(/usr/include/linux/elf.h 中的 struct elfhdr)开头,并且 readelf 实用程序可以显示所有字段的内容

largo% readelf -h hello.o
ELF magic:7f 45 4c 46 01 01 01 00 00 00 00 00
        00 00 00 00
Type, machine, version = 1 3 1
Entry, phoff, shoff, flags = 0 0 440 0
ehsize, phentsize, phnum = 52 0 0
shentsize, shnum, shstrndx = 40 11 8

ELF magic 字段只是一种明确标识其为 ELF 文件的方法。如果文件在 magic 字段中不包含这 16 个字节,则它不是 ELF 文件。type、machine 和 version 字段将其标识为 i386 的 ET_REL 文件(即,目标文件)。ehsize 字段只是 sizeof(struct elfhdr)

每个 ELF 文件都包含一个表,该表描述文件中的节。shnum 字段指示有 11 个节;shoff 字段指示节头表从文件中的字节偏移量 440 开始。shentsize 字段指示每个节的条目长度为 40 个字节。在整个 ELF 中,各种结构的大小始终明确声明。这允许灵活性;可以根据某些硬件平台的要求扩展结构,并且标准 ELF 工具不必了解这一点也能够理解二进制文件。此外,它还为较新版本的标准将来扩展结构留出了空间。

largo% readelf -S hello.o
There are 11 section headers, starting at offset 1b8:
[0]             NULL            00000000 00000 00000 00 / 0 0 0 0
[1] .text       PROGBITS        00000000 00040 00014 00 / 6 0 0 10
[2] .rel.text   REL             00000000 00370 00010 08 / 0 9 1 4
[3] .data       PROGBITS        00000000 00054 00000 00 / 3 0 0 4
[4] .bss        NOBITS          00000000 00054 00000 00 / 3 0 0 4
[5] .note       NOTE            00000000 00054 00014 00 / 0 0 0 1
[6] .rodata     PROGBITS        00000000 00068 0000d 00 / 2 0 0 1
[7] .comment    PROGBITS        00000000 00075 00012 00 / 0 0 0 1
[8] .shstrtab   STRTAB          00000000 00087 0004d 00 / 0 0 0 1
[9] .symtab     SYMTAB          00000000 000d4 000c0 10 / 0 a a 4
[a] .strtab     STRTAB          00000000 00194 00024 00 / 0 0 0 1

列表 1. hello.o 的节表

每个节头只是一个 struct ELF32_Shdr。您可能会注意到名称字段只是一个数字——这不是指针,而是指向 .shstrtab 节的偏移量(我们可以从文件头中的 shstrndx 字段找到 .shstrtab 节的索引)。因此,我们在 .shstrtab 节中指定的偏移量处找到每个节的名称。让我们转储此文件的节表;见图 1。您会注意到几乎所有我们已经讨论过的内容的节。每个节都有一个标识符,用于指定节包含的内容(通常,您永远不必实际知道节的名称或将其与任何内容进行比较)。

在类型之后,是一系列数字。第一个是此节应加载到的虚拟内存中的地址。由于这是一个 .o 文件,因此它不打算加载到虚拟内存中,并且此字段未填充。接下来是节在文件中的偏移量,然后是节的大小。在此之后是一系列数字——我不会为您详细解析这些数字,但它们包含诸如节的所需对齐方式、一组标志,这些标志指示节是只读、可写和/或可执行的。

readelf 程序能够执行反汇编

largo% readelf -i 1 hello.o
0x00000000  pushl       %ebp
0x00000001  movl        %esp,%ebp
0x00000003  pushl       $0x0
0x00000008  call        0x08007559
0x0000000d  addl        $4,%esp
0x00000010  movl        %ebp,%esp
0x00000012  popl        %ebp
0x00000013  ret

.rel.text 节包含文件 .text 节的重定位,我们可以按如下方式显示它们

largo% readelf -r hello.o
Relocation section data:.rel.text (0x2 entries)
Tag: 00004 Value 00301 R_386_32    (0 )
Tag: 00009 Value 00b02 R_386_PC32  (0 printf)

这表明 .text 节有两个重定位。正如预期的那样,printf 有一个重定位,指示我们必须将 printf 的地址修补到从 .text 节开头偏移量 9 处,这恰好是 call 指令的操作数。还有一个重定位,以便我们向 printf 传递正确的地址。

现在让我们看看当此文件链接到可执行文件时会发生什么。节表现在看起来像 列表 2

您首先会注意到比简单的 .o 文件中更多的节。这主要是因为此文件需要 ELF 共享库 libc.so.1。

此时我应该提到运行 ELF 程序时发生的机制。内核会查找二进制文件并将其加载到用户的虚拟内存中。如果应用程序链接到共享库,则该应用程序还将包含应使用的动态链接器的名称。然后,内核将控制权转移到动态链接器,而不是应用程序。动态加载器负责首先初始化自身,将共享库加载到内存中,解析所有剩余的重定位,然后将控制权转移到应用程序。

回到我们的可执行文件,.interp 节仅包含一个 ASCII 字符串,该字符串是动态加载器的名称。目前,这始终是 /lib/elf/ld-linux.so.1(动态加载器本身也是一个 ELF 共享库)。

接下来您会注意到 3 个节,分别称为 .hash、.dynsym 和 .dynstr。这是一个最小的符号表,供动态链接器在执行重定位时使用。您会注意到这些节已映射到虚拟内存中(虚拟地址字段为非零)。在映像的最后是常规符号表和字符串表,并且加载器不会将这些表映射到虚拟内存中。.hash 节只是一个哈希表,用于我们可以快速定位 .dynsym 节中的给定符号,从而避免对符号表进行线性搜索。通常可以通过使用哈希表在一次或两次尝试中找到给定的符号。

我想提到的下一个节是 .plt 节。这包含当我们调用共享库中的函数时使用的跳转表。默认情况下,.plt 条目都由链接器初始化,使其不指向正确的目标函数,而是指向动态加载器本身。因此,当您第一次调用任何给定的函数时,动态加载器会查找该函数并修复 .plt 的目标,以便下次使用此 .plt 插槽时,我们调用正确的函数。在进行此更改后,动态加载器会自行调用该函数。

此功能称为延迟符号绑定。其想法是,如果您有大量的共享库,动态加载器可能需要花费大量时间来查找所有函数以初始化所有 .plt 插槽,因此最好推迟将地址绑定到函数,直到我们实际需要它们为止。如果您最终只使用共享库中的一小部分函数,这将是一个很大的优势。可以指示动态加载器在将控制权转移到应用程序之前将地址绑定到所有 .plt 插槽——这可以通过在运行程序之前设置环境变量 LD_BIND_NOW=1 来完成。事实证明,这在某些情况下很有用,例如当您调试程序时。另外,我应该指出 .plt 位于只读内存中。因此,用于跳转目标的地址实际上存储在 .got 节中。.got 还包含程序中使用的所有全局变量的一组指针,这些全局变量来自共享库。

.dynamic 节包含动态加载器使用的一些速记注释。您会注意到节表本身未加载到虚拟内存中,并且实际上动态加载器尝试解析它以弄清楚需要做什么对于性能不利。.dynamic 节本质上只是节头表的精简版本,其中仅包含动态加载器完成其工作所需的内容。

您会注意到,由于节头表未加载到内存中,因此内核和动态加载器在将文件加载到内存中时都无法使用该表。添加了一个程序头表的速记表,以提供节表的精简版本,其中仅包含将文件加载到内存中所需的信息。对于上面的文件,它看起来像

largo% readelf -l hello
Elf file is Executable
Entry point 0x8000400
There are 5 program headers, starting at offset 34:
PHDR       0x00034 0x08000034 0x000a0 0x000a0 R E
Interp     0x000d4 0x080000d4 0x00017 0x00017 R
Requesting program interpreter \
[/lib/elf/ld-linux.so.1]
Load       0x00000 0x08000000 0x00515 0x00515 R E
Load       0x00518 0x08001518 0x000cc 0x000d4 RW
Dynamic    0x0054c 0x0800154c 0x00098 0x00098 RW
Shared library: [libc.so.4] 1

如您所见,程序头包含指向动态加载器名称的指针、有关要加载到虚拟内存中的文件部分(以及应加载到的虚拟地址)的指令、内存段的权限,以及最后指向动态加载器将需要的 .dynamic 节的指针。请注意,所需的共享库列表存储在 .dynamic 节中。

我不会在这里为您剖析 ELF 共享库——库看起来与 ELF 可执行文件非常相似。如果您有兴趣,可以获取 readelf 实用程序并剖析您自己的库。

在本文的开头,我说我们切换到 ELF 的原因之一是使用 ELF 更容易构建共享库。我现在将演示如何操作。考虑两个文件

largo% cat hello1.c
main()
{
        greet();
}
largo% cat english.c
greet()
{
        printf("Hello World\n");
}

我们的想法是从 english.c 构建一个共享库,并将 hello1 与其链接。生成共享库的命令是

largo% gcc-elf -fPIC -c english.c
largo% gcc-elf -shared -o libenglish.so english.o

这就是全部。现在我们编译并链接 hello1 程序

largo% gcc-elf -c hello1.c
largo% gcc-elf -o hello1 hello1.o -L. -lenglish

最后我们可以运行程序。通常,动态加载器仅在某些位置查找共享库,并且当前目录不是它通常查找的位置之一。因此,要运行该程序,您可以使用如下命令

largo% LD_LIBRARY_PATH=. ./hello1
Hello World

环境变量 LD_LIBRARY_PATH 告诉动态加载器在其他位置查找共享库(出于安全原因,此功能对于 setuid 程序已禁用)。

为了避免必须指定 LD_LIBRARY_PATH,您有几个选项。您可以将共享库复制到 /lib/elf,但也可以按以下方式链接您的程序

largo% gcc-elf -o hello1 hello1.o\ /home/joe/libenglish.so
largo% ./hello1
Hello World

要构建更复杂的共享库,过程实际上并没有太大差异。您要放入共享库中的所有内容都应使用 -fPIC 进行编译;当您编译完所有内容后,只需使用 gcc -shared 命令将它们全部链接在一起。

该过程如此简单主要是因为我们在运行时将地址绑定到函数。对于 a.out 库,地址在链接时绑定。这意味着必须采取许多特殊措施来确保 .plt 和 .got 有足够的空间用于将来扩展,并确保我们将变量保持在从一个库版本到下一个库版本相同的地址。用于构建 a.out 库的工具可帮助确保所有这些,但这使得构建过程复杂得多。

ELF 提供了 a.out 不易提供的另一项功能。dlopen() 函数可用于将共享库动态加载到用户的内存中,然后您可以调用动态加载器以查找此共享库中的符号——换句话说,您可以调用在这些模块中定义的函数。此外,动态加载器用于解析模块本身中的任何未定义符号。

用一个例子来解释这一点可能最容易。给定以下源文件

#include <dlfcn.h>
main(int argc, char * argv[])
{
   void (*greeting)();
   void * module;
   if( argc > 2 ) exit(0);
   module = dlopen(argv[1], RTLD_LAZY);
  if(!module) exit(0);
   greeting = dlsym(module, "greet");
   if(greeting) {
     (*greeting)();
   }
  dlclose(module);
 }

您可以编译、链接和运行它(使用之前构建的共享库 english.so)

largo% gcc-elf -c hello2.c
largo% gcc-elf -o hello2 hello2.o -ldl
largo% ./hello2 ./libenglish.so
Hello World

为了稍微扩展这个例子,您可以生成其他语言的问候语的其他模块。因此,从理论上讲,仅仅通过提供一组包含应用程序特定语言部分的共享库,就可以为某些应用程序添加多语言支持。在上面的示例中,我展示了如何找到共享库中函数的地址。但是 dlsym() 函数也将返回数据变量的地址,因此您可以同样轻松地从共享库中检索文本字符串的地址。

在准备结束时,我应该提到 readelf 的一些我尚未演示的选项。readelf -s 转储符号表,readelf -f 转储 .dynamic 节。

最后,我应该提一下时间表。当我们第一次将 ELF 推进到可用状态(去年 9 月)时,我们决定花费相对较长的时间来测试它并解决所有问题。那时,我认为大约 4 到 6 个月的时间可以让人们彻底测试它,此外,我们还希望为某些应用程序提供适应 ELF 的机会(例如,最新版本的 insmod 和 Wine 现在支持 ELF)。在我写这篇文章时,尚未确定公开版本的确切日期,但 ELF 可能会在您阅读本文时公开发布。

在这些文章中,我试图为您提供 ELF 文件格式的入门指南。我介绍的许多材料对大多数用户没有太多实际价值(除非您想破解链接器),但我的经验是,有很多人对它的工作原理感到好奇,我希望我已经提供了足够的信息来满足大多数人。

有关 ELF 文件格式的更多信息,您可以从多个来源获取 ELF 规范——您可以尝试 ftp.intel.com 的 pub/tis/elf11g.zip。规范也以印刷形式提供。请参阅 SYSTEM V 应用程序二进制接口 (ISBN 0-13-100439-5) 和 SYSTEM V 应用程序二进制接口,Intel386 架构处理器补充 (ISBN 0-13-104670-5)。

Eric Youngdale 从事 Linux 工作已超过三年,并一直积极参与内核开发。他在开发许多新的 ELF 支持之前开发了当前的 a.out Linux 共享库。可以通过 eric@aib.com 联系到他。

加载 Disqus 评论