指令解码器如何区分前缀和主操作码?

9
我正试图理解x86指令编码格式。我读过的所有来源仍然让这个主题很困惑。我开始有点明白了,但我遇到了一个难以理解的问题,那就是CPU指令解码器如何区分操作码前缀和操作码。
我知道指令的整个格式基本上取决于操作码(当然,在操作码中定义了额外的位字段)。有时指令没有前缀,而操作码是第一个字节。解码器怎么知道呢?
我假设指令解码器能够区分它们,因为操作码字节和前缀字节不会共享相同的二进制值。因此,解码器可以判断字节中的唯一二进制数是指令还是前缀。例如(在这个例子中,我们将坚持单字节操作码),REX或LOCK前缀不会与架构指令集中的任何操作码共享相同的字节值。

3
传统上,前缀字节与操作码字节不同,因此状态机只需记住它遇到的前缀,直到它遇到操作码字节。x86机器码是一个不自我同步的字节流(例如,ModRM或立即数可以是任何字节)。多字节的VEX和EVEX前缀并不那么简单,例如会与LES和LDS(在64位模式之外)的无效编码重叠。 - Peter Cordes
2
解码器是一个状态机。它知道指令从哪里开始,前缀的值以及前缀是可选的并且它们首先出现。单字节操作码不会与前缀重叠,而两个和三个字节的操作码以 0f 开头,这不是前缀。因此,解码器可以确定操作码何时开始。然而,英特尔重新使用前缀将其含义更改为操作码。解码器考虑到了这一点。例如,0f 58 是所有 add{ps,pd,ss,sd} 的操作码,具体来说,f2 0f 58addsd,而 66 0f 58addpd。有趣的是,f2 66 0f 58o16 addsd 而不是 repne addpd - Margaret Bloom
1
解码器在捕获这些前缀时有一个未记录的(但易于反转)算法。这表明手册中列出的某些操作码实际上是前缀+操作码。注意:在语法上,向操作码添加不必要的前缀是有效的,但这是保留使用。 - Margaret Bloom
2
现在,我们更倾向于将 f2f366 视为提供额外操作码位而不是真正的前缀。对于 SIMD 指令,有两个这样的操作码位(最多编码一个 f2/f3/66),但对于标量指令,66 可以与 f2f3 结合使用。甚至有一些指令将 66 前缀与 REX.W 结合使用。 - fuz
1
@MargaretBloom 看起来这是实现定义的行为,因为66 0f 58 /raddpd,而当两个前缀同时存在时,它也可以合理地被解码为那个指令。是的,当然它们是前缀,但应该将它们视为提供额外的操作码位(而不是像段前缀那样以系统化的方式修改指令)。 - fuz
显示剩余5条评论
1个回答

8
传统的(单字节)前缀与操作码字节不同,所以状态机可以记住它看到了哪些前缀,直到它遇到一个操作码字节。
用于双字节操作码的0f转义字节并不是真正的前缀。它必须与第二个操作码字节连续。因此,在0f之后,任何字节都是操作码,即使它像f2这样本来是前缀。(这也适用于在SSSE3及更高版本或VEX/EVEX前缀中编码了其中一个转义序列的0f 3a或0f 38双字节转义之后)。
如果您查看操作码映射表,没有条目会在单字节前缀和操作码之间产生歧义。(例如http://ref.x86asm.net/coder64.html,注意如何单独列出2字节0F..操作码)。

解码器需要了解当前模式(和其他内容)才能进行操作;例如,x86-64已删除了1字节的inc/dec reg操作码以用作REX前缀。(x86 32位操作码与x86-x64不同或完全删除)。我们甚至可以利用这种差异编写多语言机器代码,在32位与64位模式下解码时运行方式不同,甚至可以区分所有3种模式大小

x86机器代码是一个字节流,不是自同步的(例如,ModRM或立即数可以是任何字节)。CPU总是知道从哪里开始解码,无论是跳转目标还是上一条指令结束后的下一个字节。这就是指令的开头(包括前缀)。

Bytes在内存中只是字节,只有在CPU解码后才变成指令。(虽然在正常程序中,从.text部分顶部简单地反汇编确实会给出程序的指令。自修改和混淆代码不是正常的。)
AVX / AVX-512:多字节前缀与操作码重叠
32位模式下,多字节VEX和EVEX前缀并不那么简单。例如,VEX前缀与LES和LDS的无效编码重叠,除了64位模式外的其他模式。(LES和LDS的c4和c5操作码在64位模式下始终无效,除非作为VEX前缀。)
在传统/兼容模式下,当AVX (VEX前缀)和AVX-512 (EVEX前缀)出现时,没有任何多余的空闲字节,因为它们已经被用作操作码或前缀, 所以唯一的扩展空间是将仅适用于有限ModRM字节集的操作码编码。例如,LES / LDS需要一个内存源,而不是寄存器 - 这就是为什么在VEX前缀中某些位被反转的原因,因此c4c5后面的字节的高2位在32位模式下始终是1而不是0。这就是ModRM中的“模式”字段,11表示寄存器。

(有趣的事实:VEX前缀在16位真实模式下不能被识别,显然是因为一些软件使用了与LES / LDS相同的无效编码作为故意的陷阱,需要在#UD异常处理程序中解决。但是,在16位受保护模式下可以识别VEX前缀。)


AMD64通过删除诸如AAM、LES/LDS(以及用作REX前缀的单字节inc/dec reg编码)等指令来释放几个字节,但CPU供应商仍然关心32位模式,并没有添加任何仅在64位模式下可用的扩展,这些扩展可以简单地利用那些免费操作码字节。这意味着要找到将新指令编码塞入越来越小的32位机器代码中的方法。(通常通过强制前缀,例如rep bsr = lzcnt在具有该功能的CPU上,这会给出不同的结果。)
因此,支持AVX / BMI1/2的现代CPU中的解码器必须查看多个字节,以确定这是否是有效AVX或其他VEX编码指令的前缀,或者在32位模式下是否应将其解码为LES或LDS。(我猜还要查看其余指令以确定是否应#UD)。

但是现代CPU在并行查找指令边界时通常一次查看16或32个字节。(然后再将那些指令字节组按照并行方式提供给实际的解码器。) https://www.realworldtech.com/sandy-bridge/4/

同样适用于AMD XOP使用的前缀方案,这很像VEX。

Agner Fog在2009年的博客文章Stop the instruction set war(AVX宣布不久后,在第一批支持它的硬件之前)中列出了未来扩展的剩余未使用编码空间表格,并注明它被“分配”给AMD、Intel或Via。

相关/示例


机器码技巧:多种方式解码同一字节

(这与前缀无关,但通常了解规则如何适用于奇怪的情况可以帮助理解事物的确切工作方式。)

软件反汇编器确实需要知道起始点。如果混淆代码混合了代码和数据,并且实际执行跳转到您假定可以按顺序解码而不遵循跳转的位置,那么这可能会有问题。

幸运的是编译器生成的代码不会这样做,因此天真的静态反汇编(例如通过objdump -dndisasm,而不是IDA)将找到实际运行程序时的相同指令边界。

这不是运行混淆的机器码的问题;CPU只是按照指令执行,从来不关心跳转前的字节。在没有运行/单步执行程序的情况下反汇编是困难的,特别是存在自修改代码和跳转到一个天真的反汇编器认为是早期指令中间的位置的可能性时。
混淆的机器码甚至可以有一条指令以一种方式解码,然后跳回到该指令的中间,使得后面的字节成为操作码(或前缀+操作码)。现代CPU带有uop缓存或在I-cache中标记指令边界,如果这样做会运行缓慢(但正确),因此它更像是一种有趣的代码高尔夫技巧(以速度为代价的极端代码大小优化)或混淆技术。

举个例子,可以查看我的codegolf.SE x86机器码回答高尔夫定制菲波那切数列。我会摘录与CPU循环回到cfib.loop相对应的反汇编代码,但请注意第一次迭代的解码方式不同。因此,我在循环外只使用1个字节而不是2个字节,以有效地跳转到第一次迭代的中间位置。有关完整描述和其他反汇编,请参见链接的答案。

0000000000401070 <cfib>:
  401070:       eb                      .byte 0xeb      # jmp rel8 consuming the 01 add opcode as a rel8
0000000000401071 <cfib.loop>:
  401071:       01 d0                   add    eax,edx
# loop entry point on first iteration, jumping over the ModRM byte (D0) of the ADD
    (entry on first iteration):
  401073:       92                      xchg   edx,eax
  401074:       e2 fb                   loop   401071 <cfib.loop>
  401076:       c3                      ret 

您可以使用消耗更多后续字节的操作码来实现此操作,例如3D <dword> cmp eax, imm32。当CPU看到3D操作码字节时,它将获取接下来的4个字节作为立即数。如果您稍后跳转到这些4个字节,它们将被视为前缀/操作码,并且无论这些字节之前如何解码为指令的不同部分,一切都将正常工作(除了性能问题)。CPU必须保持每次解码和执行1个指令的幻觉,而不是性能。

我从@Ira Baxter在Can assembled ASM code result in more than a single possible way (except for offset values)?中的答案中学到了这个技巧。


1
@DanielCatalano 对于传统前缀来说,这是正确的。然而,VEX和EVEX前缀与其他指令共享它们的编码,必须通过查看指令的第二个字节来区分它们。 - fuz
这应该是上一个评论的一部分,但网站正在维护中并且崩溃了!“0F”字节是操作码的一部分,只是告诉解码器下一个字节也是操作码字节。例如,“00”操作码是“ADD”,而“0f 00”则使操作码为“SLDT”。因此,“0f”字节基本上就像是一个额外的最高有效二进制位,它将可用操作码的数量加倍。由于字符限制,将有更多的评论。 - Daniel Catalano
1
传统前缀包括段前缀、REP前缀(f2f3)、地址和操作数大小覆盖前缀(6667)、LOCK前缀(f0)以及REX前缀系列(404f)。 - fuz
1
@DanielCatalano 是的,0f 不是一个前缀。它实际上是引入了一个双字节操作码。前缀只在操作码之前被识别,而不是在操作码之后(显然是这样)。还有以 0f 380f 3a 开头的三字节操作码。请注意,在 VEX 和 EVEX 前缀中,0f0f 380f 3a 的“有效数字”都被卷入了 VEX/EVEX 前缀中。这就是为什么 AVX 指令通常不会比它们的 SSE 对应指令更长,尽管有一个必需的两字节 VEX 前缀的原因。 - fuz
2
我还有其他关于x86编码的问题,这些问题不适合在这个特定的问题中讨论,例如EVEX、VEX和SSE是什么。我将标记此问题为已解决,并发布更多关于那些其他事情的问题。再次感谢大家抽出时间帮助我理解这个复杂的指令格式! - Daniel Catalano
显示剩余10条评论

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