Jump/tailcall 到另一个函数

Jump/tailcall to another function

我有两个函数,在 C++ 中看起来像这样:

void f1(...);
void f2(...);

我可以更改 f1 的正文,但是 f2 是在另一个我无法更改的库中定义的。我 绝对 必须(尾部)在 f1 内调用 f2,并且我必须将提供给 f1 的所有参数传递给 f2,但据我所知,这在纯 C 或 C++ 中是不可能的。不幸的是,f2 没有其他选择可以接受 va_list。对 f2 的调用发生在函数的最后,所以我需要某种形式的尾调用。

我决定用汇编弹出当前函数的堆栈帧,然后跳转到f2(它实际上是作为函数指针接收的,并且在一个变量中,所以这就是为什么我首先将它存储在一个寄存器):

__asm {
    mov eax, f2
    leave
    jmp eax
}

在 MSVC++ 中,在 Debug 中,它一开始似乎可以工作,但它以某种方式与其他函数的 return 值混淆,有时会崩溃。在 Release 中,它总是崩溃。

这个汇编代码是不是不正确,或者编译器的一些优化以某种方式破坏了这个代码?

编译器不会在您挖掘时做出任何保证。蹦床功能可能有效,但您必须在它们之间保存状态,并进行大量挖掘。

这是一个框架,但您需要了解很多有关调用约定、class 方法调用等的知识... /

* argn, ..., arg0, retaddr */
trampoline:
    push < all volatile regs >
    call <get thread local storage >
    copy < volatile regs and ret addr > to < local storage >
    pop < volatile regs >
    remove ret addr
    call  f2
    call < get thread local storage >
    restore < volatile regs and ret addr>
    jmp f1
    ret

你必须用纯 asm 编写 f1 才能成为 guaranteed-safe。

在所有主要的 x86 调用约定中,被调用者 "owns" args,并且可以修改保存它们的 stack-space。 (无论 C 源是否更改它们以及是否声明它们 const)。

例如如果在禁用优化的情况下编译,void foo(int x) { x += 1; bar(x); } 可能会修改保存 x 的 return 地址上方的堆栈 space。使用相同的参数进行另一个调用需要再次存储它们,除非您知道被调用者没有踩到它们。同样的论点适用于从一个函数的末尾调用。

我查了on the Godbolt compiler explorer; MSVC 和 gcc 实际上都在调试版本中修改堆栈上的 x。 gcc 在推送 [ebp+8].

之前使用 add DWORD PTR [ebp+8], 1

实际上,编译器可能不会真正利用可变参数函数的这一点,因此根据函数的 定义 ,如果你能说服你,你可能会逃脱它他们打个尾巴。

请注意 void bar(...); 不是 C 中的有效原型,但是:

# gcc -xc on Godbolt to force compiling as C, not C++
<source>:1:10: error: ISO C requires a named argument before '...'

它在 C++ 中有效,或者至少 g++ 接受它而 gcc 不接受。 MSVC 在 C++ 模式下接受它,but not in C mode。 (Godbolt 有一个完全独立的 C 模式和一组不同的编译器,您可以使用它来让 MSVC 将代码编译为 C 而不是 C++。我不知道 command-line 选项可以将它转换为 C 模式gcc 有 -xc-xc++)

的方式

无论如何,f1 的末尾写 f2(); 可能有效(在优化的构建中),但这是令人讨厌的并且完全欺骗了编译器关于传递了什么参数。当然只适用于没有注册参数的调用约定。 (但是您显示的是 32 位 asm,因此您很可能正在使用没有寄存器参数的调用约定。)

在这种情况下,任何体面的编译器都会使用 jmp f2 来优化 tail-call,因为它们都 return void。 (对于 non-void,你会 return f2();


顺便说一句,如果 mov eax, f2 有效,那么 jmp f2 也有效。

不过,您的代码无法在优化的构建中运行,因为您假设编译器遗留了 stack-frame,并且该函数不会在任何地方内联。

即使在调试版本中也是不安全的,因为编译器可能 pushed 了一些 call-preserved 需要在离开函数之前弹出的寄存器(以及在 运行 leave 销毁栈帧)。


@mevets 展示的蹦床想法可能会被简化:如果 args 有一个合理的固定大小上限,您可以从传入的 args 中复制 64 或 128 个字节的 potential-args 到 args 中f1。几个 SIMD 向量就可以做到。然后您可以正常调用 f1,然后从您的 asm 包装器调用 tail-call f2

如果有潜在的注册参数,将它们保存到栈中 space 在你复制的参数之前,并在尾调用之前恢复它们。