12

callx86机器代码中绝对指针的“正确”方法是什么?有没有一个好方法可以在一条指令中做到这一点?

想做什么:

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

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

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

我遇到的问题是了解call ...模板部分使用什么。x86 似乎没有考虑到这种用法,而是偏爱相对和间接调用。

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

有人可以解释这些操作码以及我将如何或是否将它们或其他人用于此目的吗?

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

4

2 回答 2

12

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

另请参阅在 JIT 中处理对远处内部函数的调用,了解分配“附近”内存的方法,以便您可以使用JITrel32代码调用提前编译的函数。


x86 没有正常(近)calljmp指令中编码的绝对地址的编码 没有绝对的直接调用/jmp 编码,除非jmp far您不想要。请参阅英特尔的 insn set ref 手册条目以获取call. (有关文档和指南的其他链接,另请参阅x86 标签 wiki 。)大多数计算机体系结构使用相对编码进行正常跳转,例如 x86、BTW。

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

请参阅$ 如何在 NASM 中工作,究竟是什么?call手动编码指令的示例;在 JITing 时这样做应该同样容易。

在 AT&T 语法中: call 0x1234567
在 NASM 语法中:call 0x1234567

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

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

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

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


如果你不能使call rel32可用

但是如果你需要制作不知道自己绝对地址的位置无关代码,或者如果你需要调用的地址距离调用者超过+-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 System V 中的参数传递的寄存器。AL = 可变参数函数的 XMM 参数数,因此您需要在 AL=0 之前有一个固定值x86-64 System V 调用约定中对可变参数函数的调用。

如果您确实需要避免修改任何寄存器,则可以将绝对地址保留为内存中的常量,并使用call带有 RIP 相对寻址模式的内存间接寻址,例如

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


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

您可能需要“retpoline”而不是普通的间接分支来减轻 Spectre 的影响,但会以性能为代价。

call rel32与直接 ( )相比,间接跳转的分支错误预测惩罚也会稍差。正常直接callinsn 的目的地在它被解码后就被知道,一旦它检测到那里有一个分支,就在管道中更早地知道它。

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

不过,即使是直接call也需要一些分支预测来完全避免管道泡沫。(在解码之前需要进行预测,例如,假设我们刚刚提取了这个块,那么提取阶段接下来应该提取哪个块。jmp next_instruction 当您用完分支预测器条目时,一系列减慢速度)。 mov+ 间接call reg即使使用完美的分支预测也更糟,因为它的代码大小更大且微指令更多,但这是一个非常小的影响。如果有额外mov的问题,如果可能的话,内联代码而不是调用它是一个好主意。


有趣的事实:call 0xdeadbeef在 Linux 上将汇编但不会链接到 64 位静态可执行文件,除非您使用链接器脚本将.text节/文本段放置在更靠近该地址的位置。该.text部分通常从0x400080静态可执行文件(或非 PIE 动态可执行文件)开始,即在低 2GiB 的虚拟地址空间中,所有静态代码/数据都位于默认代码模型中。但是0xdeadbeef在低 32 位的高半部分(即在低 4G 而不是低 2G),所以它可以表示为一个零扩展的 32 位整数而不是符号扩展的 32 位。并且0x00000000deadbeef - 0x0000000000400080不适合正确扩展到 64 位的有符号 32 位整数。(你可以用负数到达的地址空间部分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 开头的静态可执行文件时0000000000400080ld -o foo foo.ofoo.o:/tmp//foo.asm:1:(.text+0x1): relocation truncated to fit: R_X86_64_PC32 against '*ABS*'

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


直接远call编码(慢,不要使用)

您可能会在手册条目中注意到,call并且jmp有绝对目标地址编码到指令中的编码。但是那些只存在于“远” call/jmp也设置CS为新的代码段选择器,这很慢(参见 Agner Fog 的指南)

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

Farcall还推送 CS:EIP 作为返回地址而不仅仅是 EIP,因此它甚至与call只推送 EIP 的普通(近)不兼容。这不是问题jmp ptr16:32,只是缓慢并弄清楚要为段部分放什么。

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

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


call ptr16:64不存在,远直接编码仅存在于 16 位和 32 位代码。在 64 位模式下,您只能call使用 10 字节的m16:64内存操作数 far- ,例如call far [rdi]. 或将 segment:offset 推入堆栈并使用retf.

于 2016-04-09T00:54:29.613 回答
1

你不能只用一条指令来做到这一点。一个不错的方法是使用 MOV + CALL:

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

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

于 2016-04-08T23:30:16.420 回答