【问题标题】:How does the CPU know how many bytes it should read for the next instruction, considering instructions have different lenghts?考虑到指令的长度不同,CPU 怎么知道它应该为下一条指令读取多少字节?
【发布时间】:2019-05-30 21:43:44
【问题描述】:

所以我正在阅读一篇论文,在其中,他们说静态反汇编二进制代码是无法确定的,因为一系列字节可以表示为如图所示的尽可能多的方式(它的 x86)

所以我的问题是:

  1. 那么 CPU 是如何执行的呢?比如图中,当我们到达C3之后,它怎么知道下一条指令应该读取多少字节?

  2. CPU 如何知道在执行一条指令后它应该增加 PC 多少?它是否以某种方式存储当前指令的大小并在它想要增加 PC 时添加它?

  3. 如果 CPU 能够以某种方式知道它应该为下一条指令读取多少字节,或者基本上如何解释下一条指令,为什么我们不能静态地执行呢?

【问题讨论】:

  • 它不提前知道,但它会知道是否有完整的指令或需要读取更多字节。是的,你也可以静态地做到这一点。你不能做的是简单地为动态跳转等计算目标地址。在您的示例中,您不知道jmp eax 会去哪里。
  • @Jester 那么我们如何才能静态地做到这一点,例如我们如何知道图片中三种可能性中的哪一种是正确的?你是说这篇论文关于静态反汇编无法确定的二进制文件是错误的吗?论文链接:utdallas.edu/~kxh060100/wartell12ccs.pdf。是的,我知道“不知道跳转的目的地”,但我问的是简单地反汇编代码。
  • 因为它以jmp eax 开头并且您不知道它的地址,所以拆解其余部分毫无意义。控制不会到达这些字节。
  • cpu 是按指令工作的。它做的第一件事是jmp eax。如果你不知道它会去哪里,你就无法判断cpu接下来会做什么。如果执行时从未到达以下字节,则 cpu 不关心它们。它永远不会对它们做任何事情,因此它们也可能只是随机字节。
  • 然后你可以反汇编直到retn 这是另一个你不知道会带你去哪里的动态跳转。 PS:在极端情况下,相同的字节可能会以不同的指令边界多次执行,因此 cpu 以不同的方式执行它,因此没有一个正确的反汇编。

标签: assembly x86 cpu


【解决方案1】:

简单的方法是只读取一个字节,对其进行解码,然后确定它是否是一条完整的指令。如果没有读取另一个字节,则在必要时对其进行解码,然后确定是否读取了完整的指令。如果不继续读取/解码字节,直到读取完整的指令。

这意味着如果指令指针指向给定的字节序列,则只有可能的方法来解码该字节序列的第一条指令。之所以会产生歧义,是因为下一条要执行的指令可能不在紧跟第一条指令的字节处。这是因为字节序列中的第一条指令可能会更改指令指针,因此执行以下指令以外的其他指令。

您示例中的 RET (retn) 指令可能是函数的结尾。函数通常以 e RET 指令结尾,但不一定如此。一个函数可能有多个 RET 指令,但没有一个位于函数末尾。相反,最后一条指令将是某种 JMP 指令,它跳转回函数中的某个位置,或者完全跳转到另一个函数。

这意味着在您的示例代码中,如果没有更多上下文,就不可能知道 RET 指令之后的任何字节是否会被执行,如果是,哪些字节将是以下函数的第一条指令.函数之间可能有数据,或者这个 RET 指令可能是程序中最后一个函数的结尾。


x86 指令集尤其具有相当复杂的格式,包括可选前缀字节、一个或多个操作码字节、一或两个可能的寻址形式字节,以及可能的位移和立即字节。前缀字节可以添加到几乎任何指令之前。操作码字节确定有多少操作码字节以及指令是否可以具有操作数字节和立即字节。操作码也可能表明有位移字节。第一个操作数字节确定是否有第二个操作数字节以及是否有位移字节。

Intel 64 and IA-32 Architectures Software Developer's Manual 有这张图显示了 x86 指令的格式:

用于解码 x86 指令的类似 Python 的伪代码如下所示:

# read possible prefixes

prefixes = []
while is_prefix(memory[IP]):
    prefixes.append(memory[IP))
    IP += 1

# read the opcode 

opcode = [memory[IP]]
IP += 1
while not is_opcode_complete(opcode):
    opcode.append(memory[IP])
    IP += 1

# read addressing form bytes, if any

modrm = None
addressing_form = []    
if opcode_has_modrm_byte(opcode):
    modrm = memory[IP]
    IP += 1
    if modrm_has_sib_byte(modrm):
        addressing_form = [modrm, memory[IP]]
        IP += 1
    else:
        addressing_form = [modrm]

# read displacement bytes, if any

displacement = []
if (opcode_has_displacement_bytes(opcode)
    or modrm_has_displacement_bytes(modrm)):
    length = determine_displacement_length(prefixes, opcode, modrm)
    displacement = memory[IP : IP + length]
    IP += length

# read immediate bytes, if any

immediate = []
if opcode_has_immediate_bytes(opcode):
    length = determine_immediate_length(prefixes, opcode)
    immediate = memory[IP : IP + length]
    IP += length

# the full instruction

instruction = prefixes + opcode + addressing_form + displacement + immediate

上面的伪代码遗漏的一个重要细节是指令长度限制为 15 个字节。可以构造 16 字节或更长的有效 x86 指令,但如果执行此类指令将生成未定义的操作码 CPU 异常。 (还有其他细节我遗漏了,比如如何在 Mod R/M 字节内编码部分操作码,但我认为这不会影响指令的长度。)


然而,x86 CPU 实际上并不像我上面描述的那样解码指令,它们只解码指令,就好像它们一次读取每个字节一样。相反,现代 CPU 会将整个 15 个字节读入缓冲区,然后并行解码字节,通常在一个周期内。当它完全解码指令、确定其长度并准备读取下一条指令时,它会移动缓冲区中不属于指令的剩余字节。然后它读取更多字节以再次将缓冲区填充到 15 个字节并开始解码下一条指令。

现代 CPU 会做的另一件事是推测性执行指令,这不是我上面写的内容所暗示的。这意味着 CPU 将解码指令并尝试在执行完之前的指令之前尝试执行它们。这反过来意味着 CPU 可能最终会解码 RET 指令之后的指令,但前提是它无法确定 RET 将返回的位置。由于尝试解码和暂时执行不打算执行的随机数据可能会降低性能,因此编译器通常不会将数据放在函数之间。尽管出于性能原因,为了对齐功能,他们可能会使用永远不会执行的 NOP 指令填充此空间。

(很久以前,他们曾经在函数之间放置只读数据,但这是在 x86 CPU 可以推测执行指令变得普遍之前。)

【讨论】:

  • 但是基于另一个问题,代码部分不应该有任何数据,因为它没有提供任何好处!链接:stackoverflow.com/questions/55607052/…(他们在问同一篇论文)
  • 很久以前可能也是在拆分 I/D 缓存之前,其中包含主要指令的行中的稀疏数据会浪费数据缓存中的空间。 (还有 TLB 容量,用于拆分 iTLB / dTLB)。但无论如何,@OneAndOnly:没错,通常解码编译器输出很容易;一条指令的结束标识下一条指令的开始,即使在无条件跳转之后也是如此。它们通常使用 NOP 或 int3 填充。您只会遇到混淆二进制文件的问题。您链接的论文希望可靠任何可执行文件上运行,因此无法做出假设。
  • @OneAndOnly 正如我在回答中解释的那样,现代编译器中的数据不再出现在代码部分中,但很久以前编译器曾经将只读数据放在代码部分中,因为这是唯一的读取 -只有节节目了。仅当 CPU 进入将数据放入代码段会导致性能问题的状态时,才会创建专用的只读数据段。然而,在这些 CPU 普及后很长时间(90 年代中后期左右),旧的编译器仍在继续使用,因此您会在 2000 年之前编译的大量 x86 程序中看到混合的代码和数据。
  • @OneAndOnly 例如,我现在正在查看 2007 年编译的游戏的反汇编代码,它在函数之间有用于 switch 语句的跳转表。哪个比较好。在使用它们的间接跳转之后,将这些跳转表放在函数中间是很常见的。
【解决方案2】:

静态反汇编是不可判定的,因为反汇编器无法辨别一组字节是代码还是数据。您提供的示例是一个很好的示例:在 RETN 指令之后,可能是另一个子程序,或者可能是一些数据,然后是一个程序。在实际执行代码之前,无法确定哪个是正确的。

在指令获取阶段读取操作码时,操作码本身会编码一种指令,并且定序器已经知道要从中读取多少字节。没有歧义。在您的示例中,在获取 C3 之后但在执行它之前,CPU 将调整其 EIP 寄存器(指令指针)以读取它认为它将是下一条指令(以 0F 开头的指令),但是在指令 C3 的执行期间(它是RETN指令),EIP被更改为RETN是“从子程序返回)所以它不会到达指令0F 88 52。只有当代码的其他部分跳转到该指令的位置时才会到达该指令. 如果没有代码执行这样的跳转,则将其视为数据,但确定特定指令是否执行的问题不是可判定的问题。

一些聪明的反汇编程序(我认为 IDA Pro 会这样做)从已知存储代码的位置开始,并假设所有后续字节也是指令,直到找到跳转或 ret。如果找到一个跳转并且通过读取二进制代码知道跳转的目的地,那么扫描会继续在那里。如果跳转是有条件的,则扫描分支为两条路径:不跳转和跳转。

扫描完所有分支后,剩下的所有内容都被视为数据(这意味着不会检测到中断处理程序、异常处理程序以及从运行时计算的函数指针调用的函数)

【讨论】:

  • 所以 .text 部分内可能有数据?而不是数据部分?有什么参考吗?因为我使用过的所有二进制文件在 .text 部分中都没有数据。我找到的唯一参考是这篇论文,找不到其他任何东西。你知道支持这一说法的任何其他参考资料吗?
  • 当然,您可以将数据放入.text。没有什么神奇的,除了它可能是只读的。 .text 也只是一个名字。您可以随意命名您的部分(至少是代码和数据),重要的是它们的属性。有些部分确实定义了各种工具和加载器所依赖的名称。
  • 请记住,并非所有二进制代码都来自最初用某种高级语言编写的编译程序。您可以编写汇编代码并在例程之间放置数据。
  • @Jester 但我认为整个部分都获得了相同的权限,例如只读?因为当您在二进制文件上使用 readelf 时,您可以看到例如 .text 部分是可执行的,因此如果我们在其中有数据,那么这将使数据也可执行!
  • 是的,它确实使放置在那里的数据可执行,但这并不意味着实际会执行。如果你不跳转到你的数据,那么 cpu 不在乎它是否是可执行的。
【解决方案3】:

您的主要问题似乎是以下问题:

如果 CPU 能够以某种方式知道它应该为下一条指令读取多少字节,或者基本上如何解释下一条指令,为什么我们不能静态地进行呢?

论文中描述的问题与“跳转”指令有关(不仅指jmp,还包括intretsyscall等类似指令):

此类指令的目的是在完全不同的地址处继续执行程序,而不是在下一条指令处继续执行。 (函数调用和while() 循环是程序执行不会在下一条指令处继续的示例。)

您的示例以指令jmp eax 开头,这意味着寄存器eax 中的值决定在jmp eax 指令之后执行哪条指令。

如果eax包含字节0F的地址,CPU会执行jcc指令(图中左边的情况);如果包含88的地址,则执行mov指令(图中中号);如果它包含52的地址,它将执行push指令(图中右侧)。

因为你不知道程序执行时eax会有哪个值,所以你无法知道这三种情况会发生哪一种。

(有人告诉我,在 1980 年代甚至有商业程序在运行时发生了不同的情况:在您的示例中,这意味着有时会执行 jcc 指令,有时会执行 mov 指令!)

当我们到达C3 之后,它怎么知道它应该为下一条指令读取多少字节?

CPU如何知道在执行一条指令后它应该增加多少PC?

C3 不是一个很好的例子,因为retn 是一个“跳跃”指令:“C3 之后的指令”永远不会到达,因为程序在其他地方继续执行。

但是,您可以将C3 替换为另一个字节长的指令(例如52)。在这种情况下,下一条指令将从字节0F 开始,而不是8852 开始。

【讨论】:

    猜你喜欢
    • 2020-02-03
    • 2011-05-24
    • 2014-09-25
    • 1970-01-01
    • 2013-06-27
    • 1970-01-01
    • 2018-10-23
    • 2014-08-07
    • 2019-07-21
    相关资源
    最近更新 更多