二进制的生命周期(翻译)

几乎每个人会去编写一个程序,接着编译,然后运行该程序并查看您辛勤编码的成果 。 嘴周看到程序正常运行起来会感觉很棒! 但是,要使这些所有工作顺利进行,我们还要感谢其他人。那就是您的编译器(当然,假设您使用的是编译语言,而不是解释性语言),它在幕后会做很多工作。

在本文中,我将尝试向您展示如何将您编写的源代码转换为计算机可以实际运行的代码, 我在这里选择Linux作为我的主机,并选择C作为编程语言,不用纠结语言,这里的概念一通百通,可以应用于许多编译语言。

注意: 如果要按照本文中的说明进行操作,则必须确保在本地计算机上安装了gccelfutils

让我们从一个简单的C程序开始,看看编译器如何转换它

image

该程序创建两个变量,将它们加起来并在屏幕上打印结果。很简单吧?

但是,让我们看看这个看似简单的程序必须经过什么才能最终在您的系统上执行。

编译器通常具有以下五个步骤(最后一步是操作系统的一部分)-

image

让我们详细介绍每个步骤。

image

第一步是预处理步骤,由预处理器完成。预处理程序的工作是处理代码中存在的所有预处理程序指令。 这些指令以开头。 但是在处理它们之前,它首先从代码中删除了所有注释,因为这些注释仅提高人类易读性。 然后,它找到所有的命令,并执行命令所"说"的内容。

在上面的代码中,我们仅使用了#include指令,该指令只是对处理器说,可以复制stdio.h文件并将其粘贴到当前位置的该文件中。

您可以通过将-E标志传递给gcc编译器来查看预处理器的输出

gcc -E sample.c

您将获得类似以下内容的信息


image

image

令人困惑的是,第二步也称为编译。编译器从预处理器获取输出,并负责执行以下重要任务。

  • 将输出传递给词法分析器,以识别文件中存在的各种标记。 令牌只是程序中存在的文字,例如intreturnvoid0等。 词法分析器还将令牌的类型与每个令牌相关联,无论令牌是字符串文字,整数,浮点数,if令牌等。
  • 将词法分析器的输出传递给语法分析器,以检查程序是否以满足程序所用语言的语法规则的方式编写。例如,在分析此行代码时,它将引发语法错误
b = a + ;
  • 将语法分析器的输出传递给语义分析器,该语义分析器将检查程序是否满足语言的语义,例如类型检查和变量在首次使用之前就已声明,等等
  • 如果程序在语法上是正确的,则将源代码转换为指定目标体系结构的汇编指令。 默认情况下,它会为其运行的计算机生成程序集。 但是假设您正在为嵌入式系统构建程序,那么您可以传递目标计算机的体系结构,gcc将为该计算机生成程序集

要查看此阶段的输出,请将-S标志传递给gcc编译器。

gcc -S sample.c

根据您的环境,您将获得类似以下的内容


image

如果您不懂汇编语言,乍一看,一切都会让人感到恐惧,但还不错。与通常的高级语言代码相比,理解汇编代码要花更多的时间,但是如果有足够的时间,您肯定可以阅读。

让我们看看这个文件包含什么。

所有以.开头的行都是汇编程序指令。.file表示源文件的名称,可用于调试目的。我们的源代码%d\n中的字符串文字现在位于.rodata节中(ro表示只读),因为它是只读字符串。 编译器将此字符串命名为LC0,以便以后在代码中引用它。 每当您看到以.L开头的标签时,即表示这些标签在当前文件本地,而其他文件不可见。

.globl声明main是一个全局符号,这意味着可以从其他文件中调用main.type声明main是一个函数。 然后进行主要功能的组装。 您可以忽略以cfi开头的指令。 它们用于在异常情况下展开调用堆栈。 我们将在本文中忽略它们,但是您可以在此处了解更多信息。

现在,让我们尝试了解主功能的反汇编。

image
  • 11行:您必须知道,在调用函数时,会为该函数创建一个新的堆栈框架。 为了使之成为可能,我们需要某种方法来知道新函数返回时调用方函数框架指针的开始。 这就是为什么我们将存储在rbp寄存器中的当前帧指针推入堆栈的原因。
  • 14行:将当前的堆栈指针移至基本指针。 这成为我们当前的功能框架指针。 图1示出了推入rbp寄存器之前的状态,图2示出了推入前一帧指针并将堆栈指针移至当前帧指针之后的状态。
  • 16行:我们的程序中有3个局部变量,所有类型均为int。 在我的机器上,每个int占用4个字节,因此我们在堆栈上需要12个字节的空间来保存我们的局部变量。 我们为堆栈上的局部变量创建空间的方式是将堆栈指针递减我们局部变量所需的字节数。 递减,因为堆栈从较高的地址增长到较低的地址。 但是在这里您看到我们递减的是16,而不是12。原因是,空间是在16个字节的块中分配的。 因此,即使您有1个局部变量,也会在堆栈上分配16个字节的空间。 出于某些架构上的性能原因而执行此操作。 请参阅图3,以查看堆栈现在的布局。
  • 17-22行: 这段代码非常简单。 编译器已将插槽rbp-12用作变量a的存储空间,将rbp-8用作b的存储空间,并将rbp-4用作c的存储空间。 它将值1和2分别移动到变量a和b的地址。 为了准备加法,它将b值移至edx寄存器,将a寄存器的值移至eax寄存器。 相加的结果存储在eax寄存器中,该寄存器随后被传送到c变量的地址。
  • 23-27行:然后,我们准备进行printf调用。 首先,将c变量的值移至esi寄存器。 然后,将字符串常量%d\n的地址移至edi寄存器。 现在,esiedi寄存器保存我们的printf调用的参数。 edi持有第一个参数,而esi持有第二个参数。 然后,我们调用printf函数来打印格式为整数值的变量c的值。 这里要注意的是,此时未定义printf符号。 我们将在本文稍后看到如何解决这个printf符号。
  • .size告知主要功能的大小(以字节为单位)。 .-main是一个表达式,其中。 符号表示当前行的地址。 因此,该表达式的值等于主函数的行的地址-current_address_,从而为我们提供了主函数的大小(以字节为单位)。
  • .ident只是告诉汇编器在.comment部分添加以下行。.note.GNU-stack用于告知该程序的堆栈是否可执行。 通常,此伪指令的值为空字符串,这表明堆栈不可执行。
image

现在,我们的程序是以汇编语言编写的,但仍然是处理器无法理解的语言。 我们必须将汇编语言转换为机器语言,并且该工作由汇编器完成。 汇编器获取您的汇编文件并生成一个目标文件,该文件是一个二进制文件,其中包含您程序的机器指令。

让我们将程序集文件转换为目标文件,以查看实际过程。 要获取程序的目标文件,请将c标志传递给gcc编译器。

gcc -c sample.c

您将得到一个扩展名为.o的目标文件。 由于这是一个二进制文件,因此您将无法在常规文本编辑器中将其打开以查看其内容。 但是我们有可用的工具来找出那些目标文件中的内容。

目标文件可能具有许多不同的文件格式。 我们将特别关注一种在Linux上使用的ELF文件格式。

ELF文件包含以下信息-

  • ELF标头
  • 程序头表
  • 节标题表
  • 上表引用的其他一些数据

ELF标头包含有关目标文件的一些元信息,例如文件的类型,生成二进制文件的机器,版本,标头的大小等。要查看标头,只需将-h标志传递给eu-readelf实用程序。

image

从上面的清单中可以看出,该文件没有任何程序标题,这很好。 程序头仅存在于可执行文件和共享库中。 在下一步中链接文件时,我们将看到程序头。

但是我们确实有13个部分。 让我们看看这些部分是什么。 使用-S标志。

image

您无需了解以上所有内容。 但是从本质上讲,它为每个section列出了各种信息,例如section的名称,section的大小以及section距文件开头的偏移量。 我们使用的重要部分如下:

  • 文字部分包含我们的机器代码
  • rodata部分包含我们程序中的只读数据。它可能是您在程序中使用的常量或字符串文字。这里只包含%d\n
    数据部分包含我们程序的初始化数据。这是空的,因为我们没有任何初始化数据
  • bss部分类似于data部分,但包含我们程序的未初始化数据。未初始化的数据可以是声明为int arr [100]的数组,该数组将成为本节的一部分。关于bss部分需要注意的一点是,与其他部分根据其内容占用空间不同,bss部分仅包含该部分的大小,而没有其他内容。原因是在加载时,所需要做的只是在本节中需要分配的字节数。这样,我们减小了最终可执行文件的大小
  • strtab部分列出了程序中包含的所有字符串
  • symtab节是符号表。它包含了我们程序的所有符号(变量名和函数名)。
  • rela.text部分是重定位部分。稍后再详细介绍。

您也可以查看这些部分的内容,只需将相应的部分编号传递给eu-readelf程序即可。 您也可以使用objdump工具。 它还可以为您提供某些部分的分解。

让我们更详细地讨论rela.text部分。 记住我们在程序中使用的printf函数。 现在,printf是我们自己尚未定义的东西,它是C库的一部分。 通常,当您编译C程序时,编译器将以某种方式编译它们,以使您调用的C函数不会与可执行文件捆绑在一起,从而减小了最终可执行文件的大小。 取而代之的是,表由所有这些符号组成,称为重定位表,该表随后由装入程序中的某些内容填充。 稍后我们将讨论有关加载器部分的更多信息,但是现在,重要的是,如果您查看rela.text部分,您会在此处找到列出的printf符号。 让我们在这里确认一次。

image

您可以忽略第二个重定位部分.rela.eh_frame。 它与异常处理有关,在这里我们对它没有太大兴趣。 让我们在这里看到第一部分。 在那里,我们可以看到两个条目,其中之一是我们的printf符号。 该条目的意思是,此文件中使用了一个符号,其名称为printf,但尚未定义,该符号位于此文件中距.text节开始的偏移量0x31处。 现在,在.text部分中检查偏移量0x31处的内容。

image

在这里您可以看到偏移量为0x30的调用指令。 e8代表调用指令的操作码,后跟从偏移量0x310x34的4个字节,应该与我们现在没有的printf函数实际地址相对应,所以它们仅为00。 (稍后,我们将看到该位置实际上并不保存printf地址,而是使用称为plt的表间接调用该地址。稍后我们将介绍这一部分)

image

到目前为止,我们所做的所有工作都在一个源文件上进行。 但实际上,这种情况很少见。 在实际的生产代码中,您有数十万个源代码文件,您需要编译和创建可执行文件。 现在,在这种情况下,我们将如何比较到目前为止的步骤?

好吧,所有步骤都将保持不变。 所有源代码文件将分别进行预处理,编译,组装,最后我们将获得单独的目标代码文件。

现在,每个源代码文件都不会孤立地编写。 它们必须具有某些函数,这些全局变量必须在某个文件中定义,并在其他文件的不同位置使用。

链接器的工作是收集所有目标文件,遍历每个目标文件并跟踪每个文件定义的符号以及使用的符号。 它可以在每个目标文件的符号表中找到所有这些信息。 收集了所有这些信息之后,链接器将创建一个目标文件,将每个目标文件中的所有部分组合到相应的部分中,并重新放置所有可以解析的符号。

在我们的例子中,我们没有源文件的集合,只有一个文件,但是由于我们使用C库中的printf函数,因此我们的源文件将与C库动态链接。 现在,我们链接程序并进一步调查输出。

gcc sample.c

在这里我将不做详细介绍,因为它也是我们上面看到的ELF文件,只有一些新的部分。这里要注意的一件事是,当我们看到从汇编程序获得的目标文件时,所看到的地址是相对的。但是,在链接了所有文件之后,我们几乎知道了所有内容的去向,因此,如果您检查这些阶段的输出,则它也包含绝对地址。

在此阶段,链接器已识别出程序中正在使用的所有符号,使用这些符号的人以及定义这些符号的人。链接程序仅将符号定义的地址映射到符号的用法。但是在完成所有这些操作之后,此时仍然存在一些尚未解析的符号,其中之一就是我们的printf符号。通常,这些符号既可以是外部定义的变量,也可以是外部定义的函数。链接器还会创建一个重定位表,该重定位表与汇编程序创建的重定位表相同,其中的条目仍未解析。

此时,您应该知道一件事。您从其他库中使用的功能和数据可以进行静态链接或动态链接。静态链接意味着将这些库中的函数和数据复制并粘贴到可执行文件中。而如果您进行动态链接,则不会将这些功能和数据复制到可执行文件中,从而减小了最终的可执行文件大小。

为了使libray具有动态链接的功能,该库必须是共享库(so文件)。通常,许多程序使用的公共库是共享库,其中之一就是我们的libc库。 libc被许多程序使用,如果每个程序开始静态链接到它,那么在任何时候,同一代码的副本将占据内存中的大量空间。具有动态链接可以解决此问题,并且在任何时候,只有libc的一个副本会占用内存中的空间,并且所有程序都将从该共享库中引用。

为了使动态链接成为可能,链接器还会创建两个在汇编器生成的目标代码中不存在的节。 这些是.plt(过程链接表)和.got(全局偏移表)部分。 我们将在加载可执行文件时介绍这些部分,因为这些部分在实际加载可执行文件时会很有用。

image

现在是时候实际运行我们的可执行文件了。

当您在GUI中单击文件或从命令行运行该文件时,将间接调用execev系统调用。 正是这个系统调用,内核在其中开始将可执行文件加载到内存中的工作。

记住上面的程序头表。 这是非常有用的地方。

image

内核如何知道在文件中的哪里找到该表?好了,可以在ELF标头中找到该信息,该标头始终从文件的偏移量0开始。完成此操作后,内核将查找所有类型为LOAD的条目,并将它们加载到进程的内存空间中。

从上面的清单中可以看到,有两个类型为LOAD的条目。您还可以查看每个细分中包含哪些部分。

现代操作系统和处理器根据页面来管理内存。您的计算机内存分为固定大小的块,当任何进程要求一些内存时,操作系统都会为该进程分配一定数量的页面。除了有效管理内存的好处外,这还具有提供安全性的好处。操作系统和内核可以为每个页面设置保护位。保护位指定特定页面是只读页面,可以写入页面还是可以执行页面。保护位设为“只读”的页面无法修改,因此可以防止有意或无意地修改数据。

只读页面还有一个好处,即同一程序的多个运行进程可以共享同一页面。由于页面是只读的,因此任何正在运行的进程都不能修改这些页面,因此,每个进程都可以正常工作。

要设置这些保护位,我们必须以某种方式告诉内核,哪些页面必须标记为只读,哪些页面可以写入和执行。这些信息存储在上面每个条目的标志中。

注意第一个LOAD条目。它标记为R和E,这意味着可以读取和执行这些段,但是不能对其进行修改,如果您向下看并看到这些段中的哪些部分,则可以在其中看到两个熟悉的部分,.text和。 rodata。因此,我们的代码和只读数据只能被读取和执行,而不能被修改,这应该发生。

同样,第二个LOAD条目包含已初始化和未初始化的数据GOT表(稍后会详细介绍),它们被标记为RW,因此可以读写,但无法执行。

加载这些段并设置它们的权限后,内核会检查是否存在.interp段。在静态链接的可执行文件中,不需要此段,因为该可执行文件包含它所需的所有代码,但是对于动态链接的可执行文件,此段很重要。该段包含.interp节,其中包含动态链接器的路径。 (您可以通过将-static标志传递给gcc编译器并检查生成的可执行文件中的头表来检查静态链接的可执行文件中是否没有.interp段。)

在我们的例子中,它将找到一个,并指向/lib64/ld-linux-x86-64.so.2路径中的动态链接器。与我们的可执行文件类似,内核将通过读取标头,查找其段并将其加载到当前程序的内存空间中来开始加载这些共享库。在不需要所有这些的静态链接的可执行文件中,内核将控制权交给我们的程序,这里内核将控制权交给了动态链接器,并将主函数的地址压入堆栈,以便在动态链接器之后完成工作,它知道将控制权移交给哪里。

现在,我们应该了解已经跳过太长时间的两个表,过程链接表和全局偏移表,因为它们与动态链接器的功能密切相关。

程序中可能需要两种类型的重定位。变量重定位和函数重定位。对于外部定义的变量,我们将该条目包括在GOT表中,而外部定义的函数将这些条目包括在两个表中。因此,从本质上讲,GOT表具有所有外部定义变量和函数的条目,而PLT表仅具有函数的条目。下面的示例将清楚我们有两个函数条目的原因。

让我们以printf函数为例,看看这些表是如何工作的。 在我们的主要功能中,我们来看一下printf函数的调用说明。

400556:    e8 a5 fe ff ff           callq   0x400400

该调用指令正在调用.plt部分的地址。 让我们看看那里是什么。

image

对于每个外部定义的函数,我们在plt部分中都有一个条目,并且所有外观都相同,并且除第一个条目外,都有三条指令。 这是一个特殊的条目,我们将在以后使用。

在那里,我们找到了跳转到地址0x601018包含的值的信息。 这些地址是GOT表中的一个条目。 让我们看看这些地址的内容。

image

这就是魔术发生的地方。除了第一次调用printf函数外,此地址处的值将是C库中printf函数的实际地址,我们只需跳转到该位置即可。但是第一次,其他事情发生了。

首次调用printf函数时,此位置的值是printf函数的plt条目中下一条指令的地址。从上面的清单中可以看到,它是以小字节序格式存储的400406。在plt条目中的此位置,我们有一个push指令,该指令将0压入堆栈。每个plt条目都有相同的推送指令,但它们推送的编号不同。 0表示重定位表中printf符号的偏移量。然后,在推入指令之后跟随跳转指令,该跳转指令跳转到第一个plt条目中的第一个指令。

从上面记住,当我告诉您第一个条目很特殊时。这是因为在这里调用动态链接器来解析外部符号并重新定位它们。为此,我们跳转到got表中地址601010中包含的地址。这些地址应包含用于处理重定位的动态链接程序例程的地址。现在,这些条目用0填充,但是当程序实际运行且内核调用动态链接器时,链接器将填充此地址。

调用例程时,链接器将从外部共享对象中解析更早推送的符号(在本例中为0),并将符号的正确地址放入get表中。因此,从现在开始,当调用printf函数时,我们不必查阅链接器,我们可以直接从plt跳转到C库中的printf函数。

此过程称为延迟加载。一个程序可能包含许多外部符号,但它可能不会在该程序的一次运行中调用它们。因此,符号解析被推迟到实际使用,这为我们节省了一些程序启动时间。

从上面的讨论中可以看到,我们不必修改plt部分,而只需修改gott部分。这就是为什么plt节位于第一个LOAD段中并标记为只读,而gett节位于第二个LOAD段中并标记为Write。

这就是动态链接器的工作方式。我已经跳过了很多细节,但是如果您有兴趣了解更多详细信息,那么可以查看这篇文章

让我们回到程序加载中。 我们已经完成了大部分工作。 内核已经加载了所有可加载的段,已经调用了动态链接器。 剩下的就是调用我们的主要功能。 链接器完成后就完成了该工作。 当它调用我们的main函数时,我们在终端中获得以下输出-

3

感谢您阅读我的文章。 如果您喜欢我的文章或对我有任何其他建议,请在下面的评论部分中告诉我。 而且,请随时分享:)

原文链接:https://kishuagarwal.github.io/life-of-a-binary.html

推荐阅读更多精彩内容

  • 一、温故而知新 1. 内存不够怎么办 内存简单分配策略的问题地址空间不隔离内存使用效率低程序运行的地址不确定 关于...
    SeanCST阅读 5,741评论 0 27
  • 转自http://blog.csdn.net/navyhu/article/details/47023317理解链...
    扎Zn了老Fe阅读 615评论 0 0
  • 动态链接,在可执行文件装载时或运行时,由操作系统的装载程序加载库。大多数操作系统将解析外部引用(比如库)作为加载过...
    小5筒阅读 3,413评论 0 3
  • 总结自书籍《程序员的自我修养—链接、装载与库》 1. Hello World运行中被隐藏的过程 HoelloWor...
    Mr希灵阅读 1,159评论 3 20
  • 官网 中文版本 好的网站 Content-type: text/htmlBASH Section: User ...
    不排版阅读 2,689评论 0 5