如何确定x86指令的长度?

12

我在研究汇编语言中的不同指令,但对于如何确定不同操作数和操作码的长度感到困惑。

这是一些需要通过经验来了解的东西吗?还是有办法找出哪种操作数/运算符组合占用了多少字节?

例如:

push %ebp ; takes up one byte
mov %esp, %ebp ; takes up two bytes

问题是:

在看到特定指令时,如何推断其操作码需要多少字节?


编写x86反汇编器是一项非常具有挑战性的任务。没有简单的规则可以知道指令的长度,因为没有系统可以确定操作码接收哪些操作数。前缀使这更加复杂,因为它们可以影响指令所取的操作数。 - fuz
2
学习x86汇编甚至是一项非常复杂的任务。构建反汇编器比这更复杂大约10倍。如果没有对x86汇编和其他十几个事物的深入理解,开始编写反汇编器几乎没有任何意义。 - Ondrej Tucny
你的意思是整个指令的机器码(包括前缀和操作数),而不仅仅是操作码。 - Peter Cordes
1
如何编写反汇编器? - phuclv
7个回答

12

在没有数据库的情况下,x86没有硬性规定,因为指令编码非常复杂(且操作码本身可以从1到3个字节不等)。 您可以查阅Intel® 64和IA-32体系结构软件开发人员手册2A文档(第2章:指令格式)以了解指令及其操作数如何编码:

输入图片描述


是的,在其他架构中,指令按类型分类(寄存器-寄存器,存储-寄存器,存储-存储等),每种类型都有明确定义的长度。 - Dr. belisarius

11

既然你对这个话题感兴趣,那我就给你一个概述。x86指令由最多五个部分组成,长度最长可达15个字节:

prefixes opcode operand displacement immediate

可以生成长度超过15个字节的编码,但CPU将其拒绝。除了操作码以外,所有五个部分都是可选的。您可以按照以下方式找到它们的长度:

  • 一条指令可以有任意数量的传统前缀。它们是:f0lockf2repnef3repe2ecs36ss3eds26es64fs65gs66操作数大小覆盖,和67地址大小覆盖。然而,每组中只能识别一个前缀,如f0f2f3中的一个,以及262e363e6465中的一个。如果提供了每组多个前缀,则CPU的行为不同。VEX和EVEX编码指令可能只有段覆盖和地址大小覆盖传统前缀,因为其他前缀都包含在VEX和EVEX前缀中。
  • 在长模式下(仅限于此),一条指令可以在所有传统前缀之后立即有一个REX前缀。 REX前缀是404f中的一个。在其他模式下,这些字节是指令,而不是前缀,您的解码器必须考虑到这一点。与传统前缀一样,VEX或EVEX编码指令不能具有REX前缀。
  • 字节c4c5可以引入用于编码某些现代指令的VEX前缀。在长模式下,它们始终这样做,但在其他模式下,您必须检查其后面的字节:将其解释为modr/m字节,如果它编码了一个r,r操作数对,则它是一个VEX前缀,否则它是leslds的操作码。以c4开头的VEX前缀为两个字节长,以c5开头的VEX前缀为三个字节长。 VEX前缀还编码省略了VEX编码指令中的0f0f 380f 3a操作码前缀。请注意,通常使用VEX前缀不是可选的。例如,pdep被编码为VEX.NDS.LZ.F2.0F38.W0 F5 /r(例如c4 e2 7b f5 c0表示pdep eax,eax,eax),但相应的传统指令f2 0f 38 f5 r/m32(例如f2 0f 38 f5 c0表示pdep eax,eax)无效。请注意,相同的操作码可以存在具有VEX前缀和不带VEX前缀的情况,两者可能意味着不同的事情。例如,0f 77emms,但VEX.128.0F.WIG 77(即c5 f8 77)是vzer 在前缀后面,接下来出现的是操作码。原本,操作码总是一个字节,但当它们用尽了空间,现在它可以是单个字节或由0f0f 380f 3a前缀修饰的单个字节。如果指令被VEX编码,则这些前缀将不存在。请注意,某些前缀可能会更改所编码的指令。例如,操作码0f b8jmpe(进入IA-64模式),但f3 0f b8不是repe jmpe,而是popcnt
    操作码和前缀决定了所编码的指令。从这里开始,大多数情况都很顺利。根据指令的不同,可能会跟随一个modr/m字节。根据modr/m字节和地址覆盖前缀,可能会跟随一个sib byte和一个、两个或四个位移字节。最后,根据指令、操作数大小覆盖前缀和REX前缀,可能会跟随一个、两个、四个、六个或八个立即字节
    在Stack Overflow答案的范围内,这就是我能给出的描述。因此TL;DR:它真的很复杂。

阅读了您的回答后,我认为编写反汇编程序是学习x86汇编语言的长路漫漫。感谢提供的信息。 - moien
@moien 这是个好主意。在我所知道的所有架构中,x86 指令编码最为复杂。 - fuz
@LưuVĩnhPhúc 我记得在某个地方读到过AVX-512在32位模式下不可用。也许我对此有所错误。 - fuz
1
是的,我已经查看了英特尔手册,在指令格式部分有表2-32. 32位模式下的EVEX编码寄存器规范 - phuclv
1
我已经在这里提出了一个相关的问题:[https://dev59.com/H1UM5IYBdhLWcg3wa_y7] - phuclv
显示剩余3条评论

9
术语:"操作码"是指选择操作的指令部分,不包括操作数或修改操作(例如操作数大小)的非强制前缀。将“操作码”用于整个指令是不正确的,尽管一些人在谈论shellcode时经常这样做。

这是你应该从经验中了解的东西吗?

通过查看机器代码或特别是优化代码大小的经验,您将开始记住重复查找的内容,并学习如何查看汇编行并知道指令的长度,而无需记忆字节是什么。
操作数编码规则不取决于操作码,因此您只需要记住操作码长度以及不使用ModR/M字节对操作数进行编码的特殊短格式。然后单独记住操作数编码规则。
对我来说,我喜欢用x86机器码回答像这样的代码高尔夫问题。 (另请参见x86 / x64机器码高尔夫技巧)。我使用NASM编写程序,计划/了解每个指令的长度,并让汇编器生成实际机器码的十六进制转储作为列表。对于代码高尔夫有用的短指令,我不记得最近有过任何指令长度错误,但我很幸运能够记住我觉得有趣或经常使用的细节(如x86指令集)。 (我确实不得不尝试rorx以查看它的长度。)
我不会手动输入机器码字节;要做到这一点,我必须在手册中查找每个指令。x86没有用于PC相对寻址的短编码,因此在机器码中找到/创建有用的常量(可以兼作数据)不是一件容易的事情,所以记忆指令编码的任何数字细节通常对于代码高尔夫并非有用。
当优化性能时,一般来说,如果其他条件相同,较小的尺寸通常更好,因此关注代码大小和特别是对齐肯定是性能的一部分。
“还有没有方法可以找出哪个操作数/运算符组合占用了多少字节?”这在手册中有很好的说明。除了一些特殊情况的1字节指令外,(几乎)所有操作数编码都相同。
大多数x86指令的机器码编码遵循以下模式(Intel在@Mehrdad's answer中提供了更好的图示):
[prefixes] opcode ModR/M [extra addressing-mode bytes] [immediate]

没有明确操作数的指令没有ModR/M字节,只有操作码字节。

x86操作码对于大多数常见指令是1字节,特别是自8086以来就存在的指令。后来添加的指令(例如386中的bsfmovsx)通常使用带有0f转义字节的2字节操作码。如果您在SO上逗留,您会看到很多关于8086的问题(尤其是emu8086);这就是我知道哪些指令在8086上不可用的主要原因。如果您宁愿直接记住哪些指令具有2字节操作码而不需要了解历史细节,那完全可以。或者每次都在手册中查找:P

例如: 0f b6 c0 movzx eax,al,因此 0F B6 是 mov r32, r/m8 的操作码,而 C0 是 ModR/M 字节,将 eax 编码为目标寄存器(/r 字段 = 0),将源设置为寄存器直接模式(前两位为 11)并将其设置为 al/m 字段 = 0)。我在所有示例中都使用英特尔语法(mnemonic dst,src1 [,src2,...] ),因为这与英特尔和AMD的手册匹配。据我所知,没有任何使用AT&T语法的详细指令编码手册。即使是在谈论8086存在的内容时,我也使用32或64位示例。当然,8086只有16位真实模式,但相同的操作码和编码在64位模式下使用(这是我们现在关心的)。

Intel的指令集参考手册(SDM vol.2)包含1、2、3字节操作码映射(附录A.3),因此您可以看到操作码编码选择中的一些模式。或者对于任何给定的指令,查看该手册中列出的完整描述以及编码。(还可以查看一些漂亮的在线提取,每个指令一页,如https://github.com/HJLebbink/asm-dude/wikihttp://felixcloutier.com/x86/。HJ Lebbink的页面标记每个指令的引入时间,因此您可以看到8086用于add,386用于新形式的移位,以及movzx)。

请注意,一些单操作数指令,如 shlnot,使用 ModR/M 字节的 /r 字段作为额外的操作码位。此外,大多数带立即数的指令仍然是破坏性的,因为它们使用 /r 字段作为操作码位。imul r32, r/m32, imm32 (386) 是这个规则的例外,它具有一个立即数,并使用完整的 ModR/M 字节作为两个操作数。(请注意,ModR/M 只能表示寄存器或内存操作数;对于 add r/m32, imm8 的编码使用操作码来表示存在一个立即数。但主操作码字节被多个指令共享,所以 /r 字段被用作操作码的一部分,这就是为什么我们没有 add r/m32, r32, imm8。但对于 ADD / SUB 操作,我们可以使用 lea ecx, [rax + 1] 作为复制并添加的替代方法。)

操作数编码:

大多数带立即数操作数的指令与寄存器/内存源版本长度相同,加上编码立即数所需的字节。 立即数可以是imm8或imm32,因此-128..127范围内的值更紧凑。 (在16位模式下,它可以是imm8或imm16)。

对于直接寄存器或没有位移的最简单的单寄存器寻址模式(除了[esp]),只需要ModR/M字节。 因此,add eax,ecxadd eax,[ecx]一样长,均为2个字节。 需要使用SIB(比例/索引/基础)字节来进行索引寻址模式(以及以esp / rsp为基本寄存器的模式)。

寻址模式中的常量位移需要额外的1或4个字节(扩展符号的disp8或disp32),加上ModR/M +可选SIB。

AVX512 EVEX通过disp8按照向量宽度进行缩放,因此vaddps zmm31, zmm30, [rsi + 256]仅为7个字节(4字节EVX+操作码=0x58+modrm+disp8),但vaddps zmm31,zmm30,[rsi + 16]为11个字节:它必须使用disp32来编码+16,因为它不是64的倍数。但是,使用xmm寄存器的相同指令可以使用disp8

有关详细信息,请参见英特尔手册。


最常见指令的特殊简短形式

为了节省代码大小,8086(以及后来的x86)为一些非常常见的指令提供了没有ModR/M字节的特殊编码。如果指令不属于这些指令之一,则使用ModR/M字节。

  • 对于 AL/AX/EAX 寄存器,可以使用 add/adc/sub/cmp/test/and/or/xor 等指令与与其大小相同的立即数进行运算,例如 and eax,imm32 (5 个字节) 或者 and al,imm8 (2 个字节)。但是对于 and eax, imm8 并没有特殊编码,仍然需要使用 3 个字节的 and r/m32, imm8 编码。在处理 8 位数据时,使用 al 可以很好地减小代码大小,尤其是如果您避免或不关心 部分寄存器暂停或错误依赖 导致的性能问题。
  • 带有计数为 1 的移位/旋转操作:8086 没有 imm8 旋转操作,只有使用 cl 或者隐式 1 进行旋转的操作码,因此存在像 shl r/m32,1 这样隐含着 1 的操作。

    使用 imm8 编码会对性能产生影响:P6 家族可能导致的延迟, 因为它直到执行时才检查 imm8 是否为零。但是在 Sandybridge-family 和 Skylake 等处理器上,rol r32,1 短格式需要 2 个微操作,而 rol r32, imm8(即使 imm8 为 1)需要 1 个微操作。使用 rcl r32,1 的短格式远比使用 imm8 更快(在 Skylake 上为 3 个微操作与 8 个微操作)。

在几个指令字节的低3位编码寄存器,有效地将8个字节的操作码编码空间用于使这些指令的寄存器-操作数形式缩短1个字节。
  • mov r8, imm8: 通用的mov r/m8, imm8编码需要3个字节,而使用mov r8, imm8只需要2个字节。
  • mov r32, imm32: 使用mov r/m32, imm32编码需要6个字节,而使用mov r32, imm32只需要5个字节。有趣的是,在x86-64中,短格式操作码的REX.W=1版本是唯一可以使用64位立即数的指令。10个字节的mov r64, imm64。使用r/m32操作码的REX.W=1版本仍然使用32位立即数(像往常一样进行符号扩展),因此最好以这种方式对其进行编码mov rax, -1,占用7个字节,而不是5个字节的mov eax,-1。(或者如果优化代码大小,请参见高效地将CPU寄存器中的所有位设置为1。)
  • push/pop register:使用pop r/m32编码需要2个字节,而使用push/pop register只需要1个字节。
  • push/pop 段寄存器(除了FS/GS)。虽然没有这些的r/m16编码。
  • inc r32 / dec r32 (仅限16/32位模式:0x4X字节是x86-64中的REX前缀,因此inc eax必须使用2字节的inc r/m32编码)。
  • xchg eax, reg:这就是0x90 nop的由来:短格式的xchg eax,eax(或在16位模式下,xchg ax,ax)。在x86-64中,90 nop不再是xchg eax,eax,因为这会将EAX零扩展为RAX。相反,它有自己的指令集手册条目

    xchg reg,reg从未被编译器使用,并且通常不比3个mov指令更快,因此如果我们可以将这7个操作码字节用于更有用的未来扩展,那将是很好的(或者如果nop移动到不同的操作码,则为8个字节...)。在8086中,当累加器“更特殊”时,它更有用,例如cbw将AL符号扩展为AX是唯一(好的)方法,因为movsx不存在。只有1个操作数的mul / imul可用。

xchg eax, r32 在编程竞赛中仍然很出色,例如 在x86 32位机器代码中用8个字节求最大公约数。此外,还可以查看我的其他编程竞赛答案,其中包含各种代码大小技巧(大多以性能为代价;这是编程竞赛的目的)。

我认为这涵盖了所有单字节特殊情况的指令,同时还具有r/m32编码。


这个答案不是详尽的,我没有详细讨论更多最近的指令,并且有很多针对罕见指令的特殊情况。 REX前缀或操作数大小前缀何时需要的规则非常简单。以下是一些更通用的规则:
  • SSE1 / SSE3 ABCps指令具有2字节的操作码(0F xx)
  • SSE2整数/双精度指令通常具有3字节的操作码(66 0F xx或类似操作码)
  • SSSE3 / SSE4.x指令具有4字节的操作码(3个强制前缀)

VEX编码指令可以使用2字节的VEX前缀,如果SSE版本是SSE3或更早,并且第二个源寄存器不是“高”寄存器(xmm/ymm8-15)。相同指令的XMM和YMM版本始终具有相同的大小。(但是在不关心或希望高半部分清零时,优先使用xmm进行隐式零扩展而不是显式ymm。)

vpxor  ymm8,ymm8,ymm5    ; 2-byte VEX
vpxor  ymm7,ymm7,ymm8    ; 3-byte VEX
vpxor  ymm7,ymm8,ymm7    ; 2-byte VEX

因此,我们可以使用“高”寄存器作为目标或第一个源,而无需使用3字节的VEX,但不能将其用作第2个源(总共第3个操作数)。对于可交换的操作,通过将low8作为第2个源,可以节省大小。

请注意,对于像{{link1:vblendvps}}这样的4操作数指令,第4个操作数编码在imm8中。 因此,它仍然是第3个操作数(第2个源),而不是最后一个操作数,影响所需的VEX前缀大小。 但是,blendvps是SSE4.1,因此它始终需要一个3字节的VEX前缀来表示前缀字段的66.0F3A编码。


2
操作码的长度至少考虑了两个标准:
1. 操作码的频率(如果在程序中经常使用,则将其放在1个字节上,如果可能的话); 2. 操作码所需的信息(如果需要绝对地址,则该代码无法编码为唯一字节)。
此外,在最初的8088到最新的英特尔处理器(三十年)之间创建了许多新指令,有些指令虽然在程序中经常出现,但不能编码为单个字节,因为整个256个值都被保留。
除了另一个答案提供的链接(列出了代码的大小),还可以查看处理器历史。请参考processors history

1
通常,在汇编语言编程时,这不是你需要从一条指令到下一条指令了解的内容。如果它真的很重要(例如,如果你试图将某些特定代码适应于受限空间),你可以查看汇编器的列表输出或反汇编列表。

1

从我的6510汇编时代开始,答案通常涉及操作数地址和偏移量。 6510的操作码始终为1个字节。 地址始终为两个字节。 如果操作码需要一个地址,则我知道总大小为三个字节。 如果指定了两个地址,则我知道总大小为5个字节。

至于偏移量,它们占用的空间取决于分支的长度。 所以考虑这个:

bne FooBar

如果“Foobar”偏移指向的地址比128个字节更远,则需要完整的地址作为操作数。完整的地址不再是一个偏移量,而且地址需要占用两个字节。
因此,在后一种情况下,有时很难确定操作码+操作数是否需要2个或3个字节。
所以我猜有时候你可以知道,有时候却不那么明显。

1
6502处理器的指令最大长度为3个字节,因此你只能有一个16位操作数。而且条件分支没有长版本,所以它们始终是2个字节。 - Jens Björnhager

1
你可以查阅英特尔开发手册来确定大小。
如果你想要理解特定的可执行文件,可能会更容易一些:使用gdb或者objdump -d
在gdb中,你可以使用disas /rs <location>命令,它会将源代码与汇编代码和机器码交叉显示。

1
实际上,我经常做的是nasm -f elf64 foo.asm && objdump -drwC -Mintel foo.o。我有一个shell脚本(在一个SO答案中发布的asm-link),它可以执行这个操作;它实际上还使用ld将其链接到一个静态可执行文件中,这对于构建单文件程序作为实验非常方便,并且即使我没有包含代码来正常退出,也可以在GDB中逐步执行它们。所以使用asm-link -dn foo.asm来使用NASM进行汇编,并使用objdump进行反汇编。 - undefined
刚刚发现gdb可以同时处理汇编和机器码,这对我来说将是最佳选择,因为GDB是交互式的。而且我注意到我的机器上的obj dump只支持x86架构,而gdb支持多种架构,我还可以保存我偏好的x86汇编风格。 - undefined
llvm-objdump -d 是多架构的,如果你需要的话,这与 GNU Binutils 不同,后者只支持在配置时间选择的一个架构。GDB 是交互式的,这对于将指令放入文本文件中进行汇编+反汇编,或者查看你正在处理的函数中指令长度来说是一个不足之处。 - undefined
1
GDB没有缺少任何功能,只是启动一个交互式的GDB会话相对来说有点麻烦(不太方便),与运行一个脚本来组装和反汇编文本文件相比。例如,cat > foo.asm / 输入一些内容并按下控制-D / asm-link -dn foo.asm 总共只需要大约10秒钟,并且不会占用太多终端历史记录空间。(特别是因为我可以使用alt+.在下一个命令行中调用foo.asm。) - undefined
嗯,我刚试了一下,你完全可以像这样将输入导入到gdb中:echo "disas schedule" | gdb vmlinux。但是没错,当gdb需要加载源代码时,速度会慢很多。 - undefined
显示剩余2条评论

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