简单的方法是只读取一个字节,对其进行解码,然后确定它是否是一条完整的指令。如果没有读取另一个字节,则在必要时对其进行解码,然后确定是否已读取完整的指令。如果不继续读取/解码字节,直到读取完整的指令。
这意味着如果指令指针指向给定的字节序列,则只有可能的方法来解码该字节序列的第一条指令。之所以出现歧义,是因为要执行的下一条指令可能不位于紧随第一条指令之后的字节处。这是因为字节序列中的第一条指令可能会更改指令指针,因此除了后续指令之外的其他指令也会被执行。
RET(retn
)示例中的指令可能是函数的结尾。函数通常以 RET 指令结尾,但不一定如此。一个函数可能有多个 RET 指令,但这些指令都不在函数末尾。相反,最后一条指令将是某种 JMP 指令,它跳回到函数中的某个位置,或者完全跳回另一个函数。
这意味着在您的示例代码中,如果没有更多上下文,就不可能知道 RET 指令后面的任何字节是否会被执行,如果是的话,哪个字节将是以下函数的第一条指令。函数之间可能有数据,或者该 RET 指令可能是程序中最后一个函数的结尾。
x86 指令集尤其具有相当复杂的格式,其中包括可选前缀字节、一个或多个操作码字节、一两个可能的寻址形式字节,以及可能的位移和立即字节。前缀字节可以添加到几乎任何指令的前面。操作码字节决定有多少操作码字节以及指令是否可以具有操作数字节和立即数字节。操作码还可能表明存在位移字节。第一个操作数字节确定是否存在第二个操作数字节以及是否存在位移字节。
Intel 64 和 IA-32 架构软件开发人员手册中有下图显示了 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 变得普遍之前。)