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,并且该函数不会在任何地方内联。
即使在调试版本中也是不安全的,因为编译器可能 push
ed 了一些 call-preserved 需要在离开函数之前弹出的寄存器(以及在 运行 leave
销毁栈帧)。
@mevets 展示的蹦床想法可能会被简化:如果 args 有一个合理的固定大小上限,您可以从传入的 args 中复制 64 或 128 个字节的 potential-args 到 args 中f1
。几个 SIMD 向量就可以做到。然后您可以正常调用 f1
,然后从您的 asm 包装器调用 tail-call f2
。
如果有潜在的注册参数,将它们保存到栈中 space 在你复制的参数之前,并在尾调用之前恢复它们。
我有两个函数,在 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,并且该函数不会在任何地方内联。
即使在调试版本中也是不安全的,因为编译器可能 push
ed 了一些 call-preserved 需要在离开函数之前弹出的寄存器(以及在 运行 leave
销毁栈帧)。
@mevets 展示的蹦床想法可能会被简化:如果 args 有一个合理的固定大小上限,您可以从传入的 args 中复制 64 或 128 个字节的 potential-args 到 args 中f1
。几个 SIMD 向量就可以做到。然后您可以正常调用 f1
,然后从您的 asm 包装器调用 tail-call f2
。
如果有潜在的注册参数,将它们保存到栈中 space 在你复制的参数之前,并在尾调用之前恢复它们。