Object Files

来自osdev
Zhang3讨论 | 贡献2021年12月23日 (四) 06:17的版本
跳到导航 跳到搜索

目标文件基本上由编译和汇编的代码、数据以及使其内容可用所需的所有附加信息组成。 在构建操作系统的过程中,您将使用大量的对象文件。 虽然对于常见的开发任务,您不需要知道它们的确切细节,但是当您想要创建或使用具有各种细节的任务时,细节可能非常重要。

注: 术语“对象文件”与“面向对象编程”的高级概念无关。 对象文件比OOP的最早形式(大约1966年的“参与者模型”)早了十多年,这个术语在1958年或更早的时候在IBM使用。

核心概念

目标文件是包含“目标代码”的三种文件类型之一, 一种机器代码的修改形式,其中包括允许链接和重新定位最终加载的可执行文件的附加信息。

在大多数情况下,编译器或汇编程序将生成目标代码作为其最终结果,而不是真正的可执行二进制文件。 虽然大多数汇编程序和一些编译器都有生成原始二进制映像的选项,但这通常仅适用于引导加载程序、只读内存芯片和其他特殊用途的可执行文件。 实际上,今天几乎所有系统都使用对象文件和可重定位可执行文件。 即使是目前常用的最简单的文件格式[1],不是纯二进制可执行映像; MS-DOS加载器将段的前0x100字节用于[2], 因此,段图像的该部分被排除在文件中。

今天的大多数系统都有更为复杂的对象格式,其中地址信息被某种存根或符号取代, 以及包含有关外部可见函数、变量等的相对位置的信息。 这有助于实现

  • 链接,其中两个或多个对象和/或库文件组合形成一个可执行文件,以及
  • 加载,其中地址存根由加载程序替换为代码将驻留在进程内存中的实际内存位置。

对象文件、可执行文件和库文件

Wikipedia认为可执行文件是对象文件的一个子集,基于两者都包含对象代码而不是二进制图像,两者存在显著差异。 在某些系统中,它们是完全不同的格式(COFF vs PE),或者具有不同的字段(ELF程序/节标题)。 关键的区别在于,在可执行文件中,存在程序的完整目标代码(除了共享库中可能存在的代码,如下所述),而目标文件只是生成它们的特定模块的目标代码。 这意味着非可执行文件不包含可加载代码。

如前所述,这并不一定意味着“可执行”文件是执行的实际二进制图像; 在大多数现代系统中,这是在加载步骤中产生的。 在许多情况下,可执行文件仍然包含目标代码,而不是纯机器代码,地址位置可能在加载之前无法解析, 但它们“确实”包括工作程序的所有静态链接代码。 某些链接器(例如 LD,Unix/Linux链接器,在生成可执行文件时由GCC隐式调用)具有以下选项:- or even default to, as ld does - 在链接时解析地址,但即使在这种情况下, the executable files generated usually contain additional information to facilitate the loading process - e.g., a separate read-only data section, a definition of the writable data area (sometimes called the .bss section), a section defining the stack area, etc. - 并且可能有使用共享库的链接信息。

第三种类型的目标代码文件是“库文件”,该文件包含多个程序使用的元素,可供一般使用。 大多数程序使用的大多数函数、变量和其他元素都保存在库中。 库与常规对象文件的区别主要在于(在大多数系统上),它们的排列方式使链接器可以从文件中提取库的独立元素, 因此,只有程序使用的元素才会包含在由它们生成的可执行文件中。

在今天的大多数系统上,库有两种类型,“静态库”,它们在链接时直接链接到可执行文件中, 和“共享库”(在Windows世界中也称为“动态链接库”或DLL),它们在运行时加载并链接到使用它们的程序。 主要区别在于,顾名思义,共享库可以由多个程序同时共享,从而降低内存使用率。 但是,当第一次使用可执行文件中的元素时,除了加载可执行文件外,还必须加载共享库, 然后在运行时将其链接到使用它们的程序。 共享库通常是缓存的,以减少加载开销, 并且通常在实际使用它们中的元素之前不会加载,这意味着如果没有调用使用共享库的程序部分,则根本不需要加载库。 然而,权衡是这样的,通常不可能由多个程序共享的代码通常被链接为静态库,而只有非常常见的元素(例如,标准C和C++库)被动态链接。

重新安置

对象文件的大部分包含代码及其相关数据。 在源代码中,代码包含对其他函数和数据存储的引用。 在目标文件中,这样的引用被转换成指令和重定位对,因为编译器无法提前知道代码将在哪里结束。 例如,x86上的函数调用如下所示(在对象文件中):

14: e8 fc ff ff ff call 15 <sprintf+0x15> 15: R_386_PC32 vsnprintf

反汇编包含用于调用的操作码(e8)加上偏移量-4(fc ff)。 如果要执行此命令,它将调用地址15,这看起来像是指令执行了一半。 第二行(重定位条目)列出了位置15(The-4)处的地址应固定为vsnprintf地址的位移。 这意味着它应该得到被调用函数的地址减去重新定位的地址。 但是,空白输入差异将不起作用,因为调用地址是相对于下一条指令的,而不是操作码中间偏移字节的开始。 这就是-4的作用:重新定位的结果被添加到被填充的字段中。 通过从地址中减去4(加-4),位移变为相对于指令末尾的位移,调用在它应该到达的地方结束。 在可执行文件中:

804a1d4: e8 07 00 00 00 call 804a1e0 <vsnprintf> 804a1d5: R_386_PC32 vsnprintf 804a1d9: c9 leave

(...) 0804a1e0 <vsnprintf>:

调用所需的位移是vsnprintf的地址减去下一条指令的地址, i.e. 0x804a1e0 - 0x804a1d9 = 0x7, 这是调用字节中的值(07 00)。 这相当于目标地址减去重新定位地址加上存储的值: 0x804a1e0 - 0x804a1d5 + -4 = 0x7.

重新定位代码

创建可执行文件时,默认情况下会将其设置为使用特定地址。 如果在同一地址空间中需要多个对象文件,并且它们可能重叠,则可能会出现问题, 或者您想要执行地址空间随机化,您可能会发现重新定位可执行文件是一个选项。

由于重新定位只在构建可执行文件时需要,而在运行可执行文件时不需要,因此它们通常不会出现在链接文件中。 相反,您需要特别告诉链接器在必要时发出重定位。 对于GCC交叉编译器,这可以通过-q开关来完成。 请注意,-i-r开关具有类似的描述,但会导致链接器生成一个对象文件而不是一个可执行文件。

通过发现差异,重新定位本身就相当简单。 首先将节加载到您选择的位置,然后针对每个重新定位条目:

  • 计算应用重新定位的原始地址
  • 计算重新定位现在应用的地址(其移动量与从原始位置移动原始节的量相同)
  • 对重新定位的目的地执行相同的操作
  • 计算重定位值-绝对重定位的目标,相对重定位的目标减去原点。
  • 使用原始位置计算重新定位值。
  • 从新值中减去旧值
  • 将结果添加到内存中的原始重新定位值。

如果截面相对移动,则重新定位可以变得非常简单,只需将位移添加到绝对重新定位即可。 由于源和目标的移动量相同,因此不会更改相对位置。

常见错误

  • Passing -i or -r to ld. 除了某些有限的情况外,它不起作用,因为它生成的文件中根本没有应用重新定位。
  • Assuming code and data are continuous. 尝试使PE文件与多引导兼容时的陷阱。 节通常是页面对齐的(4k),但PE文件是扇区对齐的(512b)。因此,如果一个部分的大小不是4k的倍数,那么当二进制文件中的间隙被消除时,与数据部分的相对地址将减少512字节的倍数。 更糟糕的是,在不同的可加载部分之间有元数据部分是完全有效的,这可能会延迟地址。
  • Loading as a flat binary. 所有非平面二进制文件的可执行文件都有一个前端头。 明目张胆地加载一个文件并从头开始将执行头文件而不是代码。 同样,有一个教程试图摆脱这一点。
  • Assuming the entry point is at the start. 链接器在加载对象文件的顺序上有一定的自由度,编译器也是如此。 这意味着main不需要位于代码部分的开头。