CPU如何知道下一条指令需要读取多少个字节,考虑到不同长度的指令?

8

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

disassembling

那么我的问题是:

  1. CPU 如何执行这个指令?例如,在图中,当我们到达 C3 之后,CPU 如何知道下一个指令应该读取多少字节?

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

  3. 如果 CPU 可以以某种方式知道它应该读取下一个指令的字节数或基本上如何解释下一个指令,为什么我们不能静态地进行反汇编?


3
为什么你不问 CPU 如何知道 55push rbp 还是 push ebp(取决于是 32 位还是 64 位模式)?CPU 必须知道如何解码和解释指令字节,并在这个过程中确定指令的长度。 - RbMm
使用与软件反汇编器相同的流程,但在硬件中进行。从一个起点开始,逻辑上逐个解码以找到指令的结尾,然后从那里解码下一个指令。 - Peter Cordes
1
它事先不知道,但如果有完整的指令或需要读取更多字节,则会知道。是的,您也可以静态地执行此操作。您无法简单地计算动态跳转和类似内容的目标地址。在您的示例中,您不知道 jmp eax 将去哪里。 - Jester
1
CPU按照指令逐条执行。它首先执行的是“jmp eax”。如果你不知道它会跳到哪里,就无法预测CPU接下来会做什么。如果这些指令永远不会被执行到,CPU也不会关心它们。它永远不会对它们进行任何操作,因此它们可能只是随机字节。 - Jester
1
你可以一直反汇编到 retn,这是另一个动态跳转,你不知道它会带你去哪里。顺便说一下,在极端情况下,相同的字节可能会被多次执行,但指令边界不同,因此 CPU 以不同的方式执行它,因此没有单一正确的反汇编。 - Jester
显示剩余9条评论
5个回答

8

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

这意味着,如果指令指针指向给定的字节序列,则只有一种可能的方法来解码该字节序列的第一个指令。歧义只是因为要执行的下一个指令可能不位于紧随第一个指令之后的字节中。这是因为字节序列中的第一个指令可能会更改指令指针,从而执行除以下指令以外的其他指令。

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

这意味着在你的示例代码中,没有更多上下文的情况下,无法知道RET指令后面的任何字节是否会被执行,如果执行,哪个字节将是以下函数的第一条指令。在函数之间可能存在数据,或者该RET指令可能是程序中最后一个函数的结尾。
x86指令集特别是具有一种相当复杂的格式,包括可选前缀字节、一个或多个操作码字节、一个或两个可能的寻址格式字节以及可能的位移和立即字节。前缀字节可以添加到几乎任何指令中。操作码字节确定有多少操作码字节以及指令是否可以有操作数字节和立即字节。操作码还可以指示是否存在位移字节。第一个操作数字节确定是否存在第二个操作数字节以及是否存在位移字节。
英特尔64和IA-32架构软件开发者手册中有这张图显示了x86指令的格式:

X86 Instruction Format

类似Python伪代码的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

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


然而,x86 CPU实际上并不像我上面描述的那样解码指令,它们只是按字节一次读取指令。相反,现代CPU会将整个15个字节读入缓冲区,然后并行解码字节,通常在一个周期内完成。当完全解码指令、确定其长度并准备读取下一条指令时,它会将剩余的不属于指令的字节移动到缓冲区中。然后,它会读取更多的字节以再次填充缓冲区至15个字节,并开始解码下一条指令。
现代CPU还会执行另一项工作,这超出了我上面所写的内容,即推测性执行指令。这意味着CPU会解码指令并试图在执行先前的指令完成之前暂时执行它们。这反过来意味着CPU可能会解码RET指令后面的指令,但只有在它无法确定RET将返回到哪里时才会这样做。因为尝试解码和暂时执行不打算执行的随机数据可能会导致性能损失,所以编译器通常不会将数据放在函数之间。虽然它们可能会用NOP指令填充这个空间,这些指令永远不会被执行,以便为了性能而对齐函数。
(很久以前,人们曾经把只读数据放在函数之间,但这是在x86 CPU可以进行指令的推测执行之前普及的。)

但是根据另一个问题,代码段中不应该有任何数据,因为它并没有提供任何好处!链接:https://dev59.com/r7Pma4cB1Zd3GeqPlyUX(他们正在询问同一篇论文) - OneAndOnly
很久以前可能还没有I/D缓存分离,其中只含指令的行中稀疏数据会浪费数据缓存空间。(也包括分裂iTBL/dTBL的TLB容量) 但总之,@OneAndOnly:没错,通常解码编译器输出很容易;一个指令的结尾标志着下一个指令的开始,即使是无条件跳转也是如此。他们通常用NOP或“int3”填充。您只有在处理混淆的二进制文件时才会遇到问题。您链接的论文希望能够可靠地处理任何可执行文件,因此不能做出任何假设。 - Peter Cordes
@OneAndOnly 如我在答案中所解释的,现代编译器不再将数据放在代码段中,因此代码段中不再出现数据。但是很久以前的编译器会将只读数据放在代码段中,因为那时程序只有一个只读段。只有当CPU进化到将数据放在代码段中会导致性能问题的状态时,才会创建专用的只读数据段。然而,旧的编译器在这些CPU变得普遍(大约在90年代中期至晚期)之后仍然继续使用,因此在2000年之前编译的许多x86程序中会看到混合的代码和数据,之后也会有一些。 - Ross Ridge
@OneAndOnly 例如,我现在正在查看编译于2007年的游戏的反汇编代码,并且其中在函数之间存在用于 switch 语句的跳转表。这相对来说很不错。过去常常将这些跳转表放在函数中间,在使用它们的间接跳转之后。 - Ross Ridge

4

在x86架构中,指令编码的形式使得解码器可以从每个字节中了解接下来还有多少个字节。


例如,我来向您展示一下解码器如何解码这条指令流。

55

解码器看到 55 并知道这是一个单字节指令 push ebp。因此,它解码 push ebp 并继续执行下一条指令。
push ebp
89

解码器看到的是89,这是mov r/m32,r32指令。紧接着该指令后面跟着一个modr/m字节,用于指定操作数。
push ebp
89 e5

modr/m字节为e5,表示ebp是r/m操作数,esp是r操作数,因此该指令为mov ebp, esp

push ebp
mov ebp, esp
8b

这个指令是mov r32,r/m32,后面同样跟着一个modr/m字节。

push ebp
mov ebp, esp
8b 45

这个modr/m字节有一个eax操作数和一个操作数,其中的格式为[ebp + disp8],并带有一个8位位移,该位移在下一个字节中给出。

push ebp
mov ebp, esp
8b 45 0c

位移为0c,因此指令为mov eax, [ebp + 0xc]
push ebp
mov ebp, esp
mov eax, [ebp + 0xc]
03

这个指令是add r,r/m32,后面再跟一个modr/m字节。

push ebp
mov ebp, esp
mov eax, [ebp + 0x0c]
03 45

与之前相同,r 操作数为 eax,而 r/m 操作数为 [ebp + disp8]。位移为 08

push ebp
mov ebp, esp
mov eax, [ebp + 0x0c]
add eax, [ebp + 0x08]
01

这个指令是add r/m32, r,后面跟着一个modr/m字节。

push ebp
mov ebp, esp
mov eax, [ebp + 0x0c]
add eax, [ebp + 0x08]
01 05

这个modr/m字节指示了一个r操作数为eax和一个r/m操作数为[disp32]。下一个四个字节是位移,分别为00 00 00 00

push ebp
mov ebp, esp
mov eax, [ebp + 0x0c]
add eax, [ebp + 0x08]
add [0x00000000], eax
5d

5d指令是pop ebp,是一条单字节指令。

push ebp
mov ebp, esp
mov eax, [ebp + 0x0c]
add eax, [ebp + 0x08]
add [0x00000000], eax
pop ebp
c3

c3 指令是一个单字节指令,其操作为 ret。该指令会将控制权转移到其他地方,因此解码器从这里停止解码。

push ebp
mov ebp, esp
mov eax, [ebp + 0x0c]
add eax, [ebp + 0x08]
add [0x00000000], eax
pop ebp
ret

在实际的x86处理器中,采用了复杂的并行解码技术。这是可能的,因为处理器可以作弊并预先读取可能或可能不是任何指令的指令字节。


3

CPU的一部分是指令解码器(详情请参见维基百科有关中央处理器的文章)。指令解码器的任务是确定从指令指针所指向的地址开始的字节数,这些字节是当前指令的一部分,并将其解码为其组成部分。

现在有些体系结构(主要是微控制器)所有的指令大小都相同。在64位英特尔/AMD架构(x86-64,也称为AMD64)上,指令大小在1到15个字节之间变化,指令编码非常复杂(详情请参见此处)。


3
静态反汇编是不可判定的,因为反汇编器不能区分一组字节是代码还是数据。你提供的例子很好:在RETN指令之后,可能会有另一个子程序,也可能是一些数据,然后是一个例程。在实际执行代码之前,没有办法确定哪个是正确的。
当在指令获取阶段读取操作码时,操作码本身编码了一种指令,并且顺序控制器已经知道要从中读取多少字节。这里没有歧义。在你的例子中,在获取了C3但在执行它之前,CPU将调整其EIP寄存器(指令指针)以读取其认为将成为下一条指令的内容(以0F开头的指令),但是在执行指令C3(它是“从子例程返回”指令)期间,EIP被更改,所以它将不会到达指令0F 88 52。只有当代码的其他部分跳转到该指令的位置时,才会到达该指令。如果没有代码执行这样的跳转,则它将被视为数据,但确定特定指令是否将被执行的问题是不可判定的。
一些聪明的反汇编器(我想IDA Pro就是这样)从已知存储代码的位置开始,并假设所有后续字节也是指令,直到发现跳转或返回。如果找到一个跳转,并且通过读取二进制代码可以知道跳转的目的地,则继续扫描那里。如果跳转是有条件的,则扫描分为两个路径:未执行跳转和已执行跳转。
在扫描了所有分支之后,剩余的所有内容都被认为是数据(这意味着不会检测到中断处理程序、异常处理程序和从运行时计算的函数指针调用的函数)。

所以可能会有数据存在.text节中,而不是数据节中?有任何参考资料吗?因为我使用过的所有二进制文件都没有数据存在.text节中。我找到的唯一参考资料是这篇论文,但没有找到其他相关内容。你知道其他支持这个说法的参考资料吗? - OneAndOnly
当然,你可以将数据放在.text中。除了它可能是只读的之外,没有什么神奇的地方。此外,.text只是一个名称。你可以为你的部分(至少代码和数据)取任何你喜欢的名字,重要的是它们的属性。一些部分确实有定义好的名称,各种工具和加载器依赖于这些名称。 - Jester
请记住,并非所有的二进制代码都来自于编译自某种高级语言的程序。您可以编写汇编代码,并在例程之间放置数据。 - mcleod_ideafix
@Jester 但我认为整个部分都获得相同的权限,例如只读权限?因为当您在二进制文件上使用readelf时,您可以看到例如.text部分是可执行的,因此如果我们在其中有数据,那么这将使数据也变为可执行! - OneAndOnly
1
是的,这确实使得数据能够被执行,但这并不意味着实际上会发生执行。如果您不跳转到数据,则CPU无论数据是否可执行都不会关心。 - Jester
@OneAndOnly 在x86中发现数据与代码混合是不寻常的。这没有性能优势(不像ARM或其他具有短程PC相对寻址模式的ISA)。在“正常”的编译器输出上,从.text部分的开头开始并假设另一个指令紧接在此指令之后是可以正常工作的,即使它是无条件跳转。 x86指令根据起始点唯一解码,并且编译器永远不会将分支跳转到先前从不同起始点解码的指令中间。这只对试图打破反汇编的混淆代码有困难。 - Peter Cordes

1
你的主要问题似乎是以下这个:

如果CPU可以知道下一条指令需要读取多少字节,或者基本上如何解释下一条指令,为什么我们不能静态地做到呢?

论文中描述的问题与“跳转”指令有关(这不仅意味着jmp,还包括intretsyscall和类似指令):
这些指令的目的是在完全不同的地址继续程序执行,而不是继续执行下一条指令。(函数调用和while()循环是程序执行不会继续执行下一条指令的例子。)
你的示例以jmp eax指令开始,这意味着寄存器eax中的值决定了在jmp eax指令之后执行哪个指令。
如果eax包含字节0F的地址,CPU将执行jcc指令(图片中的左侧案例);如果它包含88的地址,它将执行mov指令(图片中的中间案例);如果它包含52的地址,它将执行push指令(图片中的右侧案例)。由于您不知道程序在执行时eax将具有哪个值,因此无法知道将发生哪种情况。
(我被告知在20世纪80年代甚至有商业程序在运行时发生不同的情况:在您的示例中,这意味着有时会执行jcc指令,有时会执行mov指令!)
当我们到达C3后,它如何知道下一个指令应该读取多少字节?
CPU如何知道在执行一个指令后应该增加PC多少?

C3不是一个好的示例,因为retn是一个“跳转”指令:程序执行会在其他地方继续,所以“C3之后的指令”永远不会被执行。

然而,你可以用另一个只有一个字节长的指令(如52)替换C3。在这种情况下,下一条指令将以字节0F而不是8852开头,这是很明确的。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接