考虑到指令具有不同的长度,CPU 如何知道下一条指令应该读取多少字节?

How does the CPU know how many bytes it should read for the next instruction, considering instructions have different lenghts?

所以我在读一篇论文,他们说静态反汇编二进制代码是不可判定的,因为一系列字节可以用多种可能的方式表示,如图所示(它的 x86 )

所以我的问题是:

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

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

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

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

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

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

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

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

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

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

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


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

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

Python-like pseudo-code 用于解码 x86 指令看起来像这样:

# 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

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


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

现代 CPUs 会做的另一件事是推测性地执行指令,这不是我上面写的内容所暗示的。这意味着 CPU 将解码指令并在完成执行之前的指令之前试探性地尝试执行它们。这反过来意味着 CPU 可能最终解码 RET 指令之后的指令,但前提是它不能确定 RET 将 return 到哪里。由于尝试解码和暂时执行不打算执行的随机数据可能会导致性能下降,因此编译器通常不会将数据放在函数之间。尽管出于性能原因,他们可能会使用永远不会执行的 NOP 指令来填充此 space 以对齐函数。

(很久以前,他们曾经在函数之间放置 read-only 数据,但这是在可以推测性执行指令的 x86 CPUs 变得普遍之前。)

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

if the CPU can somehow know how many bytes it should read for the next instruction or basically how to interpret the next instruction, why cant we do it statically?

文中描述的问题与"jumping"条指令相关(不单指jmp,还有intretsyscall 和类似说明):

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

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

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

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

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

When we reach after C3, how does it know how many bytes it should read for the next instruction?

How does the CPU know how much it should increment the PC after executing one instruction?

C3 不是一个很好的例子,因为 retn 是一个 "jumping" 指令:"instruction after C3" 永远不会到达,因为程序在其他地方继续执行。

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