第四章:ELF

@高效码农  September 3, 2023

我们现在已经非常了解了execve。在大多数路径的末尾,内核将到达包含要启动的机器代码的最终程序。通常,在实际跳转到代码之前需要一个设置过程 - 例如,程序的不同部分必须加载到内存中的正确位置。每个程序需要不同数量的内存来处理不同的事情,因此我们有标准文件格式来指定如何设置要执行的程序。虽然 Linux 支持许多此类格式,但迄今为止最常见的格式是ELF(可执行和可链接格式)。

记号笔在纸上画画。 画面中,一个巫师精灵正在冥想,一手拿着 gnu 的头,另一只手拿着 Linux 企鹅。 小精灵的声音逐渐减弱,说道:“好吧,实际上,Linux 只是内核,操作系统是……”该图画上用红色标记标注了标题:“你听说过架子上的小精灵!现在,做好准备吧。” .. GNU/Linux 上的 elf。” 这幅画的署名是“尼基”。

(感谢Nicky Case绘制的可爱图画。)

旁白:精灵无处不在吗?

当您在 Linux 上运行应用程序或命令行程序时,它很可能是 ELF 二进制文件。然而,在 macOS 上,事实上的格式是Mach-O。Mach-O 与 ELF 执行所有相同的操作,但结构不同。在 Windows 上,.exe 文件使用可移植可执行文件格式,这也是具有相同概念的不同格式。

在Linux内核中,ELF二进制文件由处理程序处理binfmt_elf,该处理程序比许多其他处理程序更复杂并且包含数千行代码。它负责从 ELF 文件中解析出某些详细信息,并使用它们将进程加载到内存中并执行。

我运行了一些命令行功夫来按行数对 binfmt 处理程序进行排序:

外壳会话

$ wc -l binfmt_* | sort -nr | sed 1d
    2181 binfmt_elf.c
    1658 binfmt_elf_fdpic.c
     944 binfmt_flat.c
     836 binfmt_misc.c
     158 binfmt_script.c
      64 binfmt_elf_test.c

文件结构

在更深入地了解如何binfmt_elf执行 ELF 文件之前,让我们先看一下文件格式本身。ELF 文件通常由四部分组成:

2023-08-31T08:19:53.png
该图显示了 ELF 文件结构的概述,包含四个连续部分。 第 1 部分,ELF 标头:有关二进制文件的基本信息以及 PHT 和 SHT 的位置。 第 2 部分,程序头表 (PHT):描述如何以及在何处将 ELF 文件的数据加载到内存中。 第 3 节,节头表 (SHT):可选的数据“映射”,以协助调试。 第 4 部分,数据:所有二进制数据。 PHT 和 SHT 指向本节。

ELF 头

每个 ELF 文件都有一个ELF 标头。它的非常重要的工作是传达有关二进制文件的基本信息,例如:

  • 它设计用于运行什么处理器。ELF 文件可以包含不同处理器类型(例如 ARM 和 x86)的机器代码。
  • 二进制文件是否要作为可执行文件单独运行,或者是否要作为“动态链接库”由其他程序加载。我们很快就会详细介绍什么是动态链接。
  • 可执行文件的入口点。后面的部分具体指定将 ELF 文件中包含的数据加载到内存中的位置。入口点是一个内存地址,指向整个进程加载后第一条机器代码指令在内存中的位置。

ELF 标头始终位于文件的开头。它指定程序头表和节头的位置,可以是文件中的任何位置。这些表又指向文件中其他位置存储的数据。

程序头表

程序头表是一系列条目,其中包含有关如何在运行时加载和执行二进制文件的特定详细信息。每个条目都有一个类型字段,说明它指定的详细信息 - 例如,PT_LOAD意味着它包含应加载到内存中的数据,但PT_NOTE意味着该段包含不一定要加载到任何地方的信息文本。

2023-08-31T08:20:32.png
显示四种不同的常见程序头类型的表格。 类型1,PT_LOAD:要加载到内存中的数据。 类型 2,PT_NOTE:自由格式文本,如版权声明、版本信息等。类型 3,PT_DYNAMIC:有关动态链接的信息。 类型 4,PT_INTERP:“ELF 解释器”位置的路径。

每个条目指定有关其数据在文件中的位置的信息,有时还指定如何将数据加载到内存中的信息:

  • 它指向 ELF 文件中数据的位置。
  • 它可以指定数据应加载到内存中的虚拟内存地址。如果该段不打算加载到内存中,则通常将其留空。
  • 有两个字段指定数据的长度:一是文件中数据的长度,二是要创建的内存区域的长度。如果内存区域的长度比文件中的长度长,则多余的内存将用零填充。这对于可能需要在运行时使用静态内存段的程序来说是有益的;这些空内存段通常称为BSS段。
  • 最后,标志字段指定如果将其加载到内存中,则应允许执行哪些操作:PF_R使其可读,PF_W使其可写,并PF_X意味着应允许其代码在 CPU 上执行。

节标题表

头表是一系列包含信息的条目。这部分信息就像一张地图,将ELF文件内部的数据绘制成图表。它使调试器等程序可以轻松了解数据不同部分的预期用途。

2023-08-31T08:21:00.png
一张古老的藏宝图,上面有岛屿、河流、棕榈树和罗盘。 有些岛标有 ELF 节名称,例如“.text”、“.data”、“.shstrtab”和“.bss”。 该图的标题是“节头表就像二进制数据的映射”。

例如,程序头表可以指定要一起加载到内存中的大量数据。该单个PT_LOAD块可能包含代码和全局变量!没有理由必须单独指定这些才能运行程序;CPU 只是从入口点开始向前推进,在程序请求的时间和地点访问数据。然而,用于分析程序的调试器之类的软件需要准确地知道每个区域的开始和结束位置,否则它可能会尝试将一些“hello”文本解码为代码(因为这不是有效的代码,因此会发生爆炸)。该信息存储在节头表中。

虽然节头表通常是包含的,但它实际上是可选的。ELF 文件可以在完全删除节头表的情况下完美运行,想要隐藏代码功能的开发人员有时会故意从 ELF 二进制文件中删除或破坏节头表,以使它们更难解码

每个部分都有一个名称、一个类型和一些指定其使用和解码方式的标志。按照惯例,标准名称通常以点开头。最常见的部分是:

  • .text:要加载到内存并在CPU上执行的机器代码。SHT_PROGBITS键入SHF_EXECINSTR标志以将其标记为可执行文件,该SHF_ALLOC标志表示它已加载到内存中以供执行。(不要被这个名字搞糊涂了,它仍然只是二进制机器代码!我总是觉得它有点奇怪,.text尽管它不是可读的“文本”。)
  • .data:初始化数据硬编码在可执行文件中以加载到内存中。例如,包含一些文本的全局变量可能位于此部分中。如果您编写低级代码,这就是静态的部分。它也具有类型SHT_PROGBITS,这仅意味着该部分包含“程序的信息”。它的标志是SHF_ALLOCSHF_WRITE将其标记为可写内存。
  • .bss:我之前提到过,一些分配的内存一开始就被清零是很常见的。在ELF文件中包含一堆空字节是一种浪费,因此使用了一种称为BSS的特殊段类型。在调试过程中了解 BSS 段很有帮助,因此还有一个节头表条目指定要分配的内存长度。它的类型为SHT_NOBITS,并且标记为SHF_ALLOCSHF_WRITE
  • .rodata:这就像.data除了它是只读的之外。在运行的一个非常基本的 C 程序中printf("Hello, world!"),字符串“Hello world!” 将在一个.rodata部分中,而实际的打印代码将在一个.text部分中。
  • .shstrtab:这是一个有趣的实现细节!节本身的名称(例如.text.shstrtab)不直接包含在节标题表中。相反,每个条目都包含 ELF 文件中包含其名称的位置的偏移量。这样,节头表中的每个条目都可以具有相同的大小,从而使它们更易于解析 - 名称的偏移量是固定大小的数字,而在表中包含名称将使用可变大小的字符串。所有这些名称数据都存储在其自己的名为.shstrtab, 类型的部分中SHT_STRTAB

数据

程序和节头表条目都指向 ELF 文件中的数据块,无论是将它们加载到内存中,还是指定程序代码所在的位置,或者只是命名节。所有这些不同的数据都包含在 ELF 文件的数据部分中。

2023-08-31T08:21:29.png
该图演示了 ELF 文件的不同部分如何引用数据块内的位置。 描绘了一个连续的数据集合,在最后淡出,包含一些清晰可辨的东西,例如 ELF 解释器的路径、部分标题“.rodata”和字符串“Hello, world!” 几个示例 ELF 部分漂浮在数据块上方,箭头指向其数据。 例如,PHT 和 SHT 条目示例中的数据部分都指向相同的“Hello, world!” 文本。 SHT 条目的标签也存储在数据块中。

链接的简要说明

回到binfmt_elf代码:内核关心程序头表中的两类条目。

PT_LOAD段指定所有程序数据(如.text.data段)需要加载到内存中的位置。内核从 ELF 文件中读取这些条目,将数据加载到内存中,以便 CPU 可以执行程序。

内核关心的另一种类型的程序头表条目是PT_INTERP,它指定“动态链接运行时”。

在讨论什么是动态链接之前,我们先来谈谈一般性的“链接”。程序员倾向于在可重用代码库的基础上构建他们的程序 - 例如,我们之前讨论过的 libc。将源代码转换为可执行二进制文件时,称为链接器的程序通过查找库代码并将其复制到二进制文件中来解析所有这些引用。此过程称为静态链接,这意味着外部代码直接包含在分发的文件中。

然而,有些库非常常见。您会发现几乎所有程序都使用 libc,因为它是通过系统调用与操作系统交互的规范接口。在计算机上的每个程序中包含一个单独的 libc 副本将是对空间的严重利用。此外,如果可以在一处修复库中的错误,而不必等待每个使用该库的程序进行更新,那可能会很好。动态链接就是解决这些问题的方法。

foo如果静态链接程序需要调用的库中的函数bar,则该程序将包含整个foo. 然而,如果它是动态链接的,它只会包含一个引用“我需要foo来自库bar”。当程序运行时,bar希望安装在计算机上,并且该foo函数的机器代码可以按需加载到内存中。如果计算机安装的库bar被更新,则程序下次运行时将加载新代码,而不需要对程序本身进行任何更改。

2023-08-31T08:22:03.png
显示静态链接和动态链接之间差异的图表。 左侧显示静态链接,其中一些名为“foo”的代码的内容被分别复制到两个程序中。 附有文字说明库函数在构建时从开发人员的计算机复制到每个二进制文件中。 右侧显示了动态链接:每个程序都包含“foo”函数的名称,箭头指向程序外部,指向用户计算机上的 foo 程序。 它与随附的文本配对,说明二进制文件引用库函数的名称,这些函数在运行时从用户的计算机加载。

野外动态链接

在 Linux 上,动态可链接库bar通常打包到扩展名为 .so(共享对象)的文件中。这些 .so 文件是 ELF 文件,就像程序一样 — 您可能还记得 ELF 标头包含一个字段来指定该文件是可执行文件还是库。此外,共享对象.dynsym在节头表中有一个节,其中包含有关从文件导出并可以动态链接到哪些符号的信息。

在 Windows上类似的库bar被打包到 .dll(动态链接库)文件中。macOS 使用 .dylib(动态链接)扩展名。就像 macOS 应用程序和 Windows .exe 文件一样,它们的格式与 ELF 文件略有不同,但概念和技术相同。

两种类型的链接之间的一个有趣的区别是,使用静态链接时,只有使用的库部分包含在可执行文件中,从而加载到内存中。通过动态链接,整个库被加载到内存中。这最初听起来可能效率较低,但它实际上允许现代操作系统通过将库加载到内存中一次然后在进程之间共享该代码来节省更多空间。由于不同的程序需要不同的状态,因此只能共享代码,但仍然可以节省数十到数百兆字节的 RAM。

执行

让我们跳回到运行 ELF 文件的内核:如果它正在执行的二进制文件是动态链接的,则操作系统不能立即跳转到二进制文件的代码,因为可能会丢失代码 - 请记住,动态链接的程序仅引用他们需要的库函数!

要运行二进制文件,操作系统需要找出需要哪些库,加载它们,用实际的跳转指令替换所有命名的指针,然后启动实际的程序代码。这是非常复杂的代码,与 ELF 格式深入交互,因此它通常是一个独立的程序而不是内核的一部分。ELF 文件在程序头表的条目中指定它们要使用的程序的路径(通常类似于/lib64/ld-linux-x86-64.so.2) 。PT_INTERP

读取ELF头并扫描程序头表后,内核可以为新程序设置内存结构。它首先将所有段加载PT_LOAD到内存中,填充程序的静态数据、BSS 空间和机器代码。如果程序是动态链接的,内核将不得不执行ELF解释器PT_INTERP),因此它还将解释器的数据、BSS和代码加载到内存中。

现在内核需要设置指令指针,以便CPU返回用户态时恢复。如果可执行文件是动态链接的,内核会将指令指针设置为内存中 ELF 解释器代码的开头。否则,内核将其设置为可执行文件的开头。

内核几乎准备好从系统调用返回(记住,我们仍然在execve)。它将argcargv和 环境变量压入堆栈,供程序在启动时读取。

现在寄存器已被清除。在处理系统调用之前,内核将寄存器的当前值存储到堆栈中,以便在切换回用户空间时恢复。在返回用户空间之前,内核将堆栈的这一部分清零。

最后,系统调用结束,内核返回用户态。它恢复现在已清零的寄存器,并跳转到存储的指令指针。该指令指针现在是新程序(或 ELF 解释器)的起点,并且当前进程已被替换!



评论已关闭