我的编程语言编译为C,我想实现尾递归优化。这里的问题是如何在没有从当前函数“返回”的情况下将控制传递给另一个函数。
如果将控件传递给同一个函数,则非常容易:
void f() {
__begin:
do something here...
goto __begin; // "call" itself
}
正如您所看到的,没有返回值和参数,这些参数在一个由全局变量处理的单独堆栈中传递。
另一种选择是使用内联汇编:
#ifdef __clang__
#define tail_call(func_name) asm("jmp " func_name " + 8");
#else
#define tail_call(func_name) asm("jmp " func_name " + 4");
#endif
void f() {
__begin:
do something here...
tail_call(f); // "call" itself
}
这与goto
类似,但是当goto将控制传递给函数中的第一个语句时,跳过编译器生成的“条目代码”,jmp
是不同的,它的参数是 function 指针,你需要添加4或8个字节来跳过输入代码。
以上两者都可以工作,但前提是被调用者和调用者对被调用者的入口代码分配的局部变量使用相同数量的堆栈。
我正在考虑使用内联汇编手动执行leave
,然后替换堆栈上的返回地址,然后执行legal
函数调用,如f()
。但我的所有尝试都崩溃了。您需要以某种方式修改BP
和SP
。
再次,如何为x64实现这一点? (同样,假设函数没有参数并返回void
)。没有内联装配的便携式方式更好,但是接受装配。也许可以使用longjump
?
也许您甚至可以在堆栈上推送被叫方地址,替换原来的返回地址而只是ret
?
答案 0 :(得分:4)
不要试图自己这样做。一个好的C编译器可以在很多情况下执行尾部调用消除,并且会这样做。相比之下,使用内联汇编的黑客很有可能以难以调试的方式出错。
例如,请参阅godbolt.org上的this snippet。要在此处复制它:
我使用的C代码是:
int foo(int n, int o)
{
if (n == 0) return o;
puts("***\n");
return foo(n - 1, o + 1);
}
这编译为:
.LC0:
.string "***\n"
foo:
test edi, edi
je .L4
push r12
mov r12d, edi
push rbp
mov ebp, esi
push rbx
mov ebx, edi
.L3:
mov edi, OFFSET FLAT:.LC0
call puts
sub ebx, 1
jne .L3
lea eax, [r12+rbp]
pop rbx
pop rbp
pop r12
ret
.L4:
mov eax, esi
ret
请注意尾部调用已被删除。唯一的call
是puts
。
答案 1 :(得分:1)
由于您不需要参数和返回值,如何将所有函数合并为一个并使用标签而不是函数名称?
f:
__begin:
...
CALL(h); // a macro implementing traditional call
...
if (condition_ret)
RETURN; // a macro implementing traditional return
...
goto g; // tail recurse to g
这里棘手的部分是RETURN和CALL宏。要返回,你应该保留另一个堆栈,一堆setjump缓冲区,所以当你返回时你调用longjump(ret_stack.pop()),当你调用时你做ret_stack.push(setjump(f))。这是诗意的演绎,你需要填写细节。
gcc可以在这里使用计算goto进行一些优化,它们比longjump更轻量级。编写vms的人也有类似的问题,而且即使在MSVC上,see example here似乎也有基于asm的解决方案。
最后这种方法即使节省了内存,也可能会让编译器感到困惑,因此会导致性能异常。你可能最好生成一些类似便携式汇编程序的语言,llvm也许吧?不确定,应该是计算goto的东西。
答案 2 :(得分:1)
这个问题的古老方法是使用蹦床。本质上,每个编译的函数都返回一个函数指针(也许是一个arg计数)。顶级是一个紧密的循环,从您的main
开始,只需调用返回的函数指针 ad infinitum 。你可以使用longjmp
来逃避循环的函数,即终止程序。
请参阅this SO Q&A.或Google“recursion tco trampoline。”
对于另一种方法,请参阅Cheney on the MTA,其中堆栈只会增长直到它已满,从而触发GC。一旦程序转换为延续传递样式(CPS),这就起作用,因为在该样式中,函数永远不会返回;所以,在GC之后,堆栈都是垃圾,可以重复使用。
答案 3 :(得分:0)
我会建议一个黑客。编译器用来转换函数调用的x86 call
指令会在堆栈上推送返回地址,然后执行跳转。
你可以做的是一些堆栈操作,使用一些内联汇编和可能的一些宏来节省一些头痛。您基本上必须覆盖堆栈上的返回地址,您可以在调用的函数中立即执行该操作。你可以有一个包装函数来覆盖返回地址并调用你的函数 - 然后控制流将返回到包装器,然后包装器移动到你指向它的位置。