简单的方法是只读取一个字节,对其进行解码,然后确定它是否是一条完整的指令。如果没有读取另一个字节,则在必要时对其进行解码,然后确定是否读取了完整的指令。如果不继续读取/解码字节,直到读取完整的指令。
这意味着如果指令指针指向给定的字节序列,则只有可能的方法来解码该字节序列的第一条指令。之所以会产生歧义,是因为下一条要执行的指令可能不在紧跟第一条指令的字节处。这是因为字节序列中的第一条指令可能会更改指令指针,因此执行以下指令以外的其他指令。
您示例中的 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 可以推测执行指令变得普遍之前。)