在x86机器代码中调用绝对指针

问题描述 投票:7回答:2

什么是call在x86机器代码中的绝对指针的“正确”方法?有没有一种方法可以在一条指令中完成它?

我想做的事:

我正在尝试基于“子程序线程”构建一种简化的迷你JIT(仍然)。它基本上是字节码解释器中最短的步骤:每个操作码都是作为一个单独的函数实现的,因此每个基本的字节码块都可以“JIT”到它自己的新程序中,如下所示:

{prologue}
call {opcode procedure 1}
call {opcode procedure 2}
call {opcode procedure 3}
...etc
{epilogue}

因此,我们的想法是每个块的实际机器代码只能从模板中粘贴(根据需要扩展中间部分),并且需要“动态”处理的唯一位是将每个操作码的函数指针复制到正确的位置作为每个调用指令的一部分。

我遇到的问题是了解如何使用模板的call ...部分。 x86似乎没有考虑到这种用法,并且有利于相对和间接调用。

看起来我可以使用FF 15 EFBEADDE2E FF 15 EFBEADDEDEADBEEF假设调用函数(基本上通过将东西放入汇编程序和反汇编程序并查看产生有效结果的内容,而不是通过了解它们的作用来发现这些),但我不明白关于细分和特权以及相关信息的内容足以看出差异,或者这些与不常见的call指令的行为有何不同。英特尔架构手册还建议这些仅在32位模式下有效,在64位模式下“无效”。

有人可以解释这些操作码以及我是如何或者是否会为此目的使用它们或其他人?

(通过寄存器使用间接调用也有明显的答案,但这似乎是“错误的”方法 - 假设实际存在直接调用指令。)

x86 jit machine-code
2个回答
6
投票

这里的所有内容也适用于jmp到绝对地址,并且指定目标的语法是相同的。问题是关于JITing,但我还包括NASM和AT&T语法以扩大范围。

另请参阅Handling calls to far away intrinsic functions in a JIT了解分配“附近”内存的方法,以便您可以使用rel32从JITed代码中调用提前编译的函数。


x86没有正常(近)calljmp的编码到指令中编码的绝对地址除了你不想要的jmp far之外,没有绝对的直接调用/ jmp编码。见Intel's insn set ref manual entry for call。 (另请参阅x86 tag wiki以获取文档和指南的其他链接。)大多数计算机体系结构use relative encodings for normal jumps喜欢x86,BTW。

最好的选择(如果你可以使位置相关的代码知道它自己的地址)是使用正常的call rel32E8 rel32直接近调用编码,其中rel32字段是target - end_of_call_insn(2的补码二进制整数)。

有关手动编码How does $ work in NASM, exactly?指令的示例,请参阅call;在JITing期间这样做应该同样容易。

在AT&T语法中:call 0x1234567 在NASM语法中:call 0x1234567 也适用于具有绝对地址的命名符号(例如,使用equ.set创建)。 MASM没有等价物,它显然只接受标签作为目的地,因此人们有时会使用低效的变通方法来解决该工具链(和/或目标文件格式重定位类型)的限制。

这些在位置相关的代码(不是共享库或PIE可执行文件)中组装和链接得很好。但不是在x86-64 OS X中,文本部分映射到4GiB以上,因此无法使用rel32到达低地址。

在要调用的绝对地址范围内分配JIT缓冲区。例如使用Linux上的mmap(MAP_32BIT)来分配低2GB的内存,其中+ -2GB可以到达该区域中的任何其他地址,或者在跳转目标附近的某处提供非NULL提示地址。 (不要使用MAP_FIXED;如果你的提示与任何现有的映射重叠,最好让内核选择一个不同的地址。)

(Linux非PIE可执行文件映射在低2GB的虚拟地址空间中,因此它们可以使用带有符号扩展的32位绝对地址的[disp32 + reg]数组索引,或者将静态地址放在带有mov eax, imm32的寄存器中,以实现零扩展绝对值。 2GB,而不是低4GB.But PIE executables are becoming the norm,所以不要假设你的主要可执行文件中的静态地址是低32,除非你确保与-no-pie -fno-pie建立+链接。而OS X之类的其他操作系统总是将可执行文件放在4GB以上。)


If you can't make call rel32 usable

但是如果你需要使位置无关的代码不知道它自己的绝对地址,或者你需要调用的地址距离调用者的距离大于+ -2GiB(可能是64位,但最好放置代码足够接近),你应该使用寄存器 - 间接call

; use any register you like as a scratch
mov   eax, 0xdeadbeef               ; 5 byte  mov r32, imm32
     ; or mov rax, 0x7fffdeadbeef   ; for addresses that don't fit in 32 bits
call  rax                           ; 2 byte  FF D0

或AT&T语法

mov   $0xdeadbeef, %eax
# movabs $0x7fffdeadbeef, %rax      # mov r64, imm64
call  *%rax

显然你可以使用任何寄存器,例如r10r11,这些寄存器被调用破坏但不用于x86-64系统V中的arg传递.AL =可变参数函数的XMM args数,因此在AL =中需要一个固定值在调用x86-64 System V调用约定中的可变参数函数之前为0。

如果你真的需要避免修改任何寄存器,可以将绝对地址保持为内存中的常量,并使用具有RIP相对寻址模式的内存间接call,如

NASM call [rel function_pointer];如果你不能破坏任何reg AT&T call *function_pointer(%rip)


请注意,间接调用/跳转会使您的代码容易受到Specter攻击,特别是如果您在同一进程中将JIT作为不受信任代码的沙箱的一部分进行JIT操作。 (在这种情况下,单独的内核补丁不会保护你)。

您可能需要使用"retpoline"而不是正常的间接分支来降低Spectre的性能。

间接跳跃也会比直接跳跃(call rel32)稍差一些的错误预测惩罚。普通直接call insn的目的地一旦被解码就知道了,一旦它检测到那里有一个分支,就会在管道的早期被解码。

间接分支通常在现代x86硬件上很好地预测,并且通常用于调用动态库/ DLL。这并不可怕,但call rel32肯定更好。

然而,即使是直接的call也需要一些分支预测来完全避免管道泡沫。 (在解码之前需要进行预测,例如,假设我们刚刚获取了这个块,那么下一个取出阶段将取出哪个块.jmp next_instruction slows down when you run out of branch-predictor entries的序列)。 mov +间接call reg即使具有完美的分支预测也会更糟,因为它的代码大小更大且uop更多,但这是一个非常小的效果。如果一个额外的mov是一个问题,如果可能的话,内联代码而不是调用它是一个好主意。


有趣的事实:call 0xdeadbeef将汇编但不会链接到Linux上的64位静态可执行文件,除非您使用链接描述文件将.text节/文本段放在更接近该地址的位置。 .text部分通常在0x400080中以静态可执行文件(或non-PIE dynamic executable)开始,即在虚拟地址空间的低2GiB中,其中所有静态代码/数据都存在于默认代码模型中。但是0xdeadbeef处于低32位的高半部分(即低4G而不是低2G),因此它可以表示为零扩展的32位整数但不是符号扩展的32位。并且0x00000000deadbeef - 0x0000000000400080不适合签名的32位整数,它将正确地扩展到64位。 (你可以通过从低地址回绕的负rel32到达的地址空间部分是64位地址空间的前2GiB;通常地址空间的上半部分保留供内核使用。)

它与yasm -felf64 -gdwarf2 foo.asm组合好,objdump -drwC -Mintel显示:

foo.o:     file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <.text>:
    0:   e8 00 00 00 00       call   0x5   1: R_X86_64_PC32        *ABS*+0xdeadbeeb

但是当ld试图将它实际链接到静态可执行文件中.text开始于0000000000400080时,ld -o foo foo.ofoo.o:/tmp//foo.asm:1:(.text+0x1): relocation truncated to fit: R_X86_64_PC32 against '*ABS*'

在32位代码中,call 0xdeadbeef组装和链接就好了,因为rel32可以从任何地方到达任何地方。相对位移不必符号扩展到64位,它只是32位二进制加法,它可以包围或不包含。


Direct far call encodings (slow, don't use)

您可能会注意到calljmp的手册条目中存在编码,其中绝对目标地址编码在指令中。但那些只存在于“远”call / jmp,它也将CS设置为一个新的代码段选择器,这是慢(see Agner Fog's guides)

CALL ptr16:32(“调用far,绝对,操作数中给出的地址”)有一个6字节的段:偏移编码到指令中,而不是将其作为数据从正常寻址模式给出的位置加载。所以这是对绝对地址的直接调用。

call也推动CS:EIP作为返回地址而不仅仅是EIP,所以它甚至不能与只推动EIP的普通(近)call兼容。这对于jmp ptr16:32来说不是问题,只是缓慢而且要弄清楚要为分段部分添加什么。

更改CS通常仅对从32位模式更改为64位模式有用,反之亦然。通常只有内核会这样做,尽管你可以在大多数普通操作系统的用户空间中执行此操作,这些操作系统在GDT中保留32位和64位段描述符。不过,这将是一个愚蠢的计算机技巧而不是有用的东西。 (64位内核使用iretsysexit返回32位用户空间。大多数操作系统在启动期间只使用远jmp一次,以便在内核模式下切换到64位代码段。)

主流操作系统使用平面内存模型,您永远不需要更改cs,并且没有标准化cs值将用于用户空间进程。即使你想使用远jmp,你也必须弄清楚在段选择器部分中放入什么值。 (JITing很简单:只需用cs读取当前的mov eax, cs。但是很难在早期编译中移植。)


call ptr16:64不存在,远程编码仅存在于16位和32位代码中。在64位模式下,你只能使用一个10字节的call内存操作数远程m16:64,如call far [rdi]。或推段:堆栈上的偏移量并使用retf


1
投票

只用一条指令就不能这样做。一个不错的方法是使用MOV + CALL:

0000000002347490: 48b83412000000000000  mov rax, 0x1234
000000000234749a: 48ffd0                call rax

如果要调用的过程的地址发生更改,请更改从偏移量2开始的八个字节。如果调用0x1234的代码的地址发生更改,则无需执行任何操作,因为寻址是绝对的。