call 和 ret 指令并不特殊; CPU 不知道它的嵌套有多深。 (并且不知道调用另一个函数和递归之间的区别。)正如in my answer here 所述,函数是一个高级概念,asm 为您提供了实现工具。
如果堆栈空间用完,CPU 只知道压入返回地址是否会导致页面错误。 (“堆栈溢出”)。通常是递归太深的结果,或者自动存储中的巨大数组(C++ 本地变量)或alloca。
(如 cmets 中所述,并非每个 C 函数调用都会导致 call 指令;内联可以完全优化它是一个单独的函数这一事实。您想要内联小函数,并且C++ 标准库中模板类的设计依赖于这一点来提高效率。另外,尾调用可以只是一个jmp,让下一个函数接管这个堆栈空间而不是获取新空间。)
Linux 通常为用户空间进程使用 8 MiB 堆栈。 (ulimit -s)。 Windows 通常为 1 MiB。在内核内部,您经常使用较小的堆栈。例如Linux 内核线程堆栈当前为 x86-64 的 16 kiB。以前对于 32 位 x86,只有 4 kiB(一页),但有些代码有更多的本地变量,占用了堆栈空间。
相关:How does the stack work in assembly language?。当ret 执行时,它只是弹出到程序计数器中。当堆栈指针指向您要跳转的某个位置时,由程序员(或编译器)创建运行 ret 的 asm。 (通常是返回地址。)
在现代 Linux 系统中,最小的堆栈帧是 16 字节,因为 ABI 指定在 call 之前保持 16 字节的堆栈对齐。所以最好的情况是在溢出堆栈之前你可以有一个 512k 的调用深度。 (除非您处于一个以超大线程堆栈启动的线程中)。
如果您在 32 位模式下使用旧版本的 i386 System V ABI,该版本仅需要 4 字节堆栈对齐(例如 gcc -mpreferred-stack-boundary=2 而不是默认的 4),而函数只是调用而不使用任何其他堆栈空间,8 MiB 的堆栈将为您提供 8 MiB / 4B = 2 Mi 调用深度。
在现实生活中,主线程堆栈上的一些 8MiB 空间已被环境变量用完,argv[] 已经在进程入口点的堆栈上(由内核复制到那里),然后更多_start 调用一个调用main 的函数。
为了让它最终能够真正返回,而不是最终出错,你需要一个巨大的链,或者一些带有终止条件的递归,比如
void recurse(int n) {
if (n == 1)
return;
recurse(n - 1);
}
并通过一些优化进行编译,但不足以让编译器将其转换为 do{}while(--n); 循环或优化掉。
如果您想要所有不同的功能,没关系,代码大小限制至少为 2GiB,call rel32 / ret 总共需要 6 个字节。 (未优化的代码也将默认为 push ebp 或 push rbp,因此如果您想满足拥有所有堆栈空间的 4 字节堆栈帧目标,则必须避免使用 32 位代码只是返回地址)。
以 GCC 为例(请参阅 How to remove "noise" from GCC/clang assembly output? 了解 __attribute__((noipa)) 选项)
__attribute__((noipa)) // don't let other functions even notice that this is empty, let alone inline it
void foo(void){}
__attribute__((noipa))
void bar(){
foo();
}
void baz(){
bar();
}
使用 GCC (Godbolt compiler explorer) 编译到这个 32 位 asm,只是调用它而不使用任何堆栈空间来做其他事情:
## GCC11.2 -O1 -m32 -mpreferred-stack-boundary=2
foo():
ret
bar():
call foo()
ret
baz():
call bar()
ret
g++ -O2 会将这些 call/ret 函数优化为类似jmp foo 的尾调用,它不会推送返回地址。这就是为什么我只使用-O1 或-Og。当然,在现实生活中,您确实希望内联和优化。打败它只是为了这个愚蠢的计算机技巧来实现最长的有限调用深度,如果你让它更长,实际上会崩溃。
你可以无限重复这种模式; GCC 允许长符号名称,因此拥有许多不同的唯一函数名称不是问题。您可以拆分多个文件,而另一个文件中只有一个函数的原型。
如果您减少 -falign-functions 调整设置,您可能可以将其降低到每个函数 6 个字节(无填充)或 8 个(按 8 对齐,因此 2 个字节的填充),低于对齐每个函数的默认值16 个标签,每个函数浪费 10 个字节。
递归:
我让 GCC 制作了递归的 asm,返回地址之间没有间隙:
void recurse(int n){
if (!--n)
return;
recurse(n);
}
## G++11.2 -O1 -mregparm=3 -m32 -mpreferred-stack-boundary=2
recurse(int):
sub eax, 1 # --n
jne .L6 # skip over the ret if the result is non-zero
.L4:
ret
.L6:
call recurse(int) # push a 4-byte ret addr and jump to top
jmp .L4 # silly compiler, should put another ret here. But we told it not to optimize too much
注意-mregparm=3 选项,因此前最多 3 个参数在寄存器中传递,而不是在低效 i386 System V 调用约定中的堆栈上传递。 (它是很久以前设计的;x86-64 SysV 调用约定要好得多。)
使用-O2,此函数优化为仅ret。 (它把调用变成了一个循环的尾调用,然后它可以看到它是一个没有副作用的非无限循环,所以它只是将其删除。)
当然在现实生活中你想要这样的优化。与循环相比,asm 中的递归很糟糕。如果您担心健壮的代码和调用深度,请不要一开始就编写递归函数,除非您知道递归深度会很浅。如果调试版本没有将您的递归转换为迭代,您不希望内核崩溃。
只是为了好玩,我让 GCC 通过说服它在 call 上执行条件分支来收紧 asm,即使在 -O1 上,函数中只有一条 ret 指令,所以尾部重复不会无论如何都无关紧要。这意味着快速路径(递归)涉及一个 not 采用的宏融合条件分支加上一个调用。
void recurse(int n){
if (__builtin_expect(!!--n, 1))
recurse(n);
}
Godbolt
## GCC 11.2 -O1 -mregparm=3 -m32 -mpreferred-stack-boundary=2
recurse(int):
sub eax, 1
je .L1
call recurse(int)
.L1:
ret