【问题标题】:to how many subroutines can x86 processors call? [duplicate]x86 处理器可以调用多少个子程序? [复制]
【发布时间】:2021-11-09 10:27:49
【问题描述】:

我正在编写一个小程序来使用 printf("\219") 打印多边形,以查看我正在做的任何事情是否适合我的内核。但它需要调用很多函数,我不知道 x86 处理器是否可以接受这么多子例程,我在谷歌中找不到结果。所以我的问题是它会接受这么多的函数调用吗?最大值是多少。 (我的意思是这样的:-)

function a() {b();}
function b() {c();}
function c() {d();}
...

我用过 5 个这样的关卡(你明白我的意思吧?)

【问题讨论】:

  • 你写的代码不是你cpu的指令。虽然这也不是 c++ 代码。您可能想了解函数内联
  • 你能引导我到这个函数内联的链接吗?
  • isocpp.org/wiki/faq/inline-functions,但我不确定您是否真的在询问 c++....(理解 c++ 中的 inline 需要了解一些历史资料)
  • 在 C++ 中内联函数并不能保证它会在汇编中内联。
  • 我认为现代 x86(不是旧的 8086)可以处理更多功能,然后您可以在几个小时内输入。应该没问题:)

标签: c++ gcc x86 stack-overflow callstack


【解决方案1】:

您的函数深度不受处理器的限制,而是受堆栈大小的限制。在幕后,对 C++ 函数的调用通常会转换为 x86 中的 call 指令,这些指令将四个(或八个,对于 x64 程序)字节压入程序堆栈以获取返回指针。有时调用被优化并且根本不接触堆栈。函数也可能将额外的字节(例如本地函数状态)压入堆栈。


要获得可以调用的函数的确切数量,您需要反汇编代码以计算每个函数推送到堆栈的字节数(由于返回地址,最少 4/8,但可能更多),然后find the maximum stack size 除以函数帧大小。

【讨论】:

  • 他没有递归:P
  • @MarekR 糟糕,已修复
  • fwiw,对于 OP 示例,不一定有一个 call 指令:godbolt.org/z/xjabadoKr。并非在 C++ 代码中对函数的每个调用都会转换为 call 指令
  • @463035818_is_not_a_number 你说的没错,我稍微改了一下措辞
  • @463035818_is_not_a_number:是的,要阻止它内联或优化它们到jmp 尾调用中,您可以使用-O1 进行编译,并在每个函数上使用__attribute__((noipa)) 来阻止优化器查看内部它同时优化其他功能。看我的回答。 (我在这个答案出现之前就开始研究它;如果这已经开始了,也许就不会开始了,但是哦。)
【解决方案2】:

callret 指令并不特殊; 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 ebppush 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

【讨论】:

    猜你喜欢
    • 2017-06-05
    • 2012-04-19
    • 1970-01-01
    • 1970-01-01
    • 2017-05-07
    • 2020-12-05
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多