Object Files

来自osdev
跳到导航 跳到搜索

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

注: 术语“目标文件(object file)”与“面向对象编程(object-oriented programming)”的高级概念无关。(译者注:Object英文可翻译为对象或目标,本文翻译为目标) 目标文件比OOP的最早形式(大约1966年的“Actors model”)早了十多年,Object这个术语在1958年或更早的时候在IBM使用。

核心概念

目标文件是包含“目标代码”(可执行机器码)的三种文件类型之一,(译者注:这里原作者的意思可能是“可执行程序、库文件、目标文件”三种类型) 一种机器代码的修改形式,其中包括允许链接和重新定位最终加载的可执行文件的附加信息。

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

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

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

目标文件、可执行文件和库文件

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

如前所述,这并不一定意味着“可执行”文件是执行的完整二进制映像; 在大多数现代系统中,这是在加载步骤中产生的。 在许多情况下,可执行文件仍然包含目标代码,而不是纯机器代码,地址位置可能在加载之前无法解析, 但它们“确实”包括工作程序的所有静态链接代码。 某些链接器(例如ld,Unix/Linux链接器,在生成可执行文件时由GCC隐式调用)具有以下选项:- 或者甚至是缺省情况,就像ld所做的那样- 在链接时解析地址,但即使在这种情况下, 生成的可执行文件通常包含其他信息,以方便加载过程 - 例如,单独的只读数据区段、可写数据区域的定义(有时称为.bss section(译者注:以下Section翻译为节或不翻译)), 定义堆栈区域等的部分。 - 并且可能有使用共享库的链接信息。

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

在今天的大多数系统上,库有两种类型,“静态库”,它们在链接时直接链接到可执行文件中, 和“共享库”(在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(偏移-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开关具有类似的描述,但会导致链接器生成一个目标文件而不是一个可执行文件。

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

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

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

常见错误

  • 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函数不需要位于代码部分的开头。