如何使用CPU本身来确定x86-64指令操作码的长度?

6
我知道有一些可以“解析”二进制机器码/操作码以确定x86-64 CPU指令的长度。
但是我想知道,既然CPU本身有内部电路来确定这一点,是否有一种方法可以使用处理器本身从二进制代码中获取指令大小?(甚至可能是一个黑客技巧?)

有点儿。你想让它自动化处理吗? - harold
@harold:是的,从程序/代码中了解这一点会很好。 - MikeF
2
主要的技巧是将指令放在一个无法访问的页面之前,尝试不同的偏移量,直到指令获取触发页面错误。但是要使其稳健性很棘手。 - harold
@harold:有趣。谢谢。你所说的“指令获取”,是指“执行它”对吧? - MikeF
例如,安装蹦床(用于热补丁二进制函数)- 仅了解指令长度是不够的。首先可以在函数的开头进行jmp/call。那么这个jmp只能是2/5字节吗?这会改变指令流。还可以在开头使用rip寻址指令。它不能简单地移动而不修复指令中的相对(rip)偏移量(并且只能移动到+/- 2GB的距离)。因此,仅仅知道指令长度对于您的任务来说是不够的。 - RbMm
1个回答

8
Trap Flag (TF)在EFLAGS/RFLAGS中可使CPU单步执行,即运行一条指令后发生异常。 因此,如果您编写调试器,则可以使用CPU的单步执行功能来查找代码块中的指令边界。但只有运行它时,如果出现故障(例如,从未映射的地址加载),您将收到该异常而不是TF单步执行异常。
(大多数操作系统都有附加到其他进程并进行单步调试的工具,例如Linux的ptrace,因此您可以创建一个非特权沙箱进程,在其中可以遍历一些未知的机器码字节...)
或者如@Rbmn所指出的那样,您可以使用操作系统协助的调试工具来单步执行自己。
@Harold和@MargaretBloom还指出,您可以在页面末尾(后跟未映射页面)放置字节并运行它们。查看是否会得到#UD、页面故障或#GP异常。 #UD:解码器看到了一个完整但无效的指令。 未映射页面上的页面错误:在决定它是非法指令之前,解码器撞到了未映射的页面。 #GP:该指令因特权或其他原因而失效。
为了排除解码+运行作为完整指令,然后在未映射的页面上故障,从未映射页面前只使用1个字节,并继续添加更多字节,直到不再收到页面错误。

破解x86 ISA by Christopher Domas 对这种技术进行了更详细的讲解,包括使用它来查找未记录的非法指令,例如9a13065b8000d7 是一个7字节的非法指令; 这时它就停止了页面错误。 (objdump -d 只会显示 0x9a (bad) 并解码其余的字节,但显然实际的英特尔硬件不满足于它是坏的,直到它获取了另外6个字节)。


HW性能计数器例如instructions_retired.any也暴露了指令计数,但是如果不知道指令的结束位置,你就不知道在哪里放置rdpmc指令。使用0x90 NOP进行填充并查看执行的总指令数可能不起作用,因为你必须知道在哪里切断和开始填充。
我想知道,为什么英特尔和AMD不引入一种指令来实现这个功能。
通常情况下,调试时需要完全反汇编指令,而不仅仅是找到指令边界。因此,需要一个完整的软件库。
在某些新的操作码后面放置微码反汇编器是没有意义的。
此外,硬件解码器只能作为代码获取路径中前端的一部分进行有线连接,而不能将其用于提供任意数据。它们已经在大多数周期中解码指令,并且没有被连线用于处理数据。在ALU执行单元中复制该硬件来添加解码x86机器码字节的指令几乎肯定会被执行,而不是通过查询解码-uop缓存或L1i(在标记L1i中的指令边界的设计中)或通过实际前端预解码器发送数据并捕获结果,而不是将其排队等待前端的其余部分。
我能想到的唯一真正的高性能用例是仿真,或支持新指令,比如Intel的软件开发模拟器(SDE)。但如果你想在旧CPU上运行新指令,整个重点在于旧CPU 不知道这些新指令。

与CPU花费的时间相比,反汇编机器代码所花费的时间非常少,而CPU花费的时间则主要用于浮点数运算或图像处理等特殊用途。我们有SIMD FMA和AVX2 vpsadbw之类的东西来加速CPU花费大量时间进行的这些特殊用途,但对于我们可以轻松通过软件完成的事情,没有必要添加这样的东西。

记住,指令集的目的是使创建高性能代码成为可能,而不是变得过于元和专注于解码本身。

在专用的复杂性上端,SSE4.2字符串指令在Nehalem中被引入。它们可以做一些很酷的东西,但很难使用。https://www.strchr.com/strcmp_and_strlen_using_sse_4.2(还包括strstr,这是一个真正的应用案例,其中pcmpistri可以比SSE2或AVX2更快,不同于strlen / strcmp,其中普通的pcmpeqb / pminub如果使用有效(请参见glibc的手写汇编)效果非常好。)总之,即使在Skylake中,这些新指令仍然是多uop的,并且并没有被广泛使用。我认为编译器很难自动向量化,大多数字符串处理是用语言完成的,在这些语言中,很难将几个具有低开销的内部函数紧密地集成在一起。
安装蹦床(用于热补丁二进制函数)。
即使如此,这也需要解码指令,而不仅仅是找到它们的长度。
如果函数的前几个指令字节使用RIP相对寻址模式(或jcc rel8/rel32,甚至jmp或call),将其移动到其他地方将会破坏代码。(感谢@Rbmn指出这个角落案例。)

是的,谢谢。问题在于执行这些指令,我本来希望能够避免。但这仍然是一个想法。 - MikeF
嗯,彼得,有一些用例不需要完全反汇编。例如,安装跳板(用于热补丁二进制函数)。在这种情况下,人们不需要知道正在修补的某个函数开头的几条指令的确切反汇编。 - MikeF
1
因此,如果您编写调试器,可以使用CPU的单步执行功能。在Windows中,这不是必需的,进程可以自己处理异常。我曾经做过这件事情 - 设置VEH(AddVectoredExceptionHandler)并为线程设置跟踪标志。在VectoredHandler中再次设置TRACE_FLAG,如果要继续跟踪,则返回EXCEPTION_CONTINUE_EXECUTION,如果不设置标志并返回EXCEPTION_CONTINUE_SEARCH,则表示我们想要停止自身跟踪(异常已由SEH处理)。 - RbMm
1
@MikeF - 例如,安装跳跃板(用于热修补二进制函数)-仅知道指令长度是不够的。首先可以在函数的开头进行jmp/call。那么那个jmp可能只有2/5个字节?这会改变指令流。还可以在开头使用rip寻址指令。它不能简单地移动而不修复指令中的相对(rip)偏移量(并且只能移动到+/- 2GB的距离)。因此,仅仅知道指令长度对于您的任务来说是不够的。 - RbMm
2
有关如何确定特权/故障指令的指令边界的相关/有趣阅读材料,请参见以下链接:https://www.blackhat.com/docs/us-17/thursday/us-17-Domas-Breaking-The-x86-Instruction-Set-wp.pdf。 - Margaret Bloom
显示剩余19条评论

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