C 调用约定:谁在可变参数函数和普通函数中清理堆栈?

C calling convention: who cleans the stack in variadic functions vs normal functions?

有一些调用约定(例如 pascalstdcall),但就我而言,C 确实使用 cdecl(C 声明)。这些约定中的每一个都在调用者将参数加载到堆栈的方式上略有不同,分别由哪个(调用者/被调用者)执行 cleanup.

关于清理,这是我的问题。我不明白:三个不同的东西吗?

  1. 堆栈清理
  2. 将指针移回倒数第二个堆栈帧
  3. 堆栈恢复

或者我应该如何查看它们?

此外,这个问题的目标基本上是可变参数函数如何在像 Pascal 或 stdcall 这样的调用约定中工作,其中被调用者应该清除/清理/恢复(我不知道哪个操作)堆栈- 但他不知道会收到多少个参数。

编辑

为什么参数入栈的顺序如此重要?您仍然有第一个参数(不是来自省略号的稳定参数),它为您提供有关 - 例如 - 可变参数数量的信息。还有一个“守护者”,可以添加到省略号标点符号中,作为变量部分结束的标记,独立于调用约定。 In this link 如果调用者和被调用者在弄乱它们之前都保存了它们的状态,为什么它们都应该恢复这些寄存器的值?在调用函数之前,不应该只有其中一个(例如调用者)将它们保存在堆栈中吗?另外,在同一个 link

"So, the stack pointer ESP might go up and down, but the EBP register remains fixed. This is convenient because it means we can always refer to the first argument as [EBP + 8] regardless of how much pushing and popping is done in the function."

压入的变量和局部变量在内存中是连续的。使用 EBP 推荐他们的优势在哪里?它们之间永远不会有一些动态偏移,即使堆栈大小发生变化。

为了更好地理解 堆栈框架 ,我阅读了其中一份资料 this site(只是开头)。 然后我继续 yt 并找到了这些 stack overview and call stack tutorials but they somehow missed the part I needed. What does exactly happends when you call the function(我不明白指令“调用地址”后跟下一条指令 a push 值到堆栈,这意味着 return 值) .谁控制 return 地址的内容?呼叫者,召集者?被叫者?当被调用者 returns 时,程序通过执行一条指令继续,该指令是从寄存器读取操作或什么?

as far as I am concerned, C does use cdecl

尽管有它的名字,但 cdecl 约定对于 C 代码并不通用,甚至在 x86 体系结构上也是如此。它的优点是定义和实现简单,但不使用 CPU 寄存器进行参数传递,效率更高。即使在 register-starved x86 上也会有所不同,但在具有更多可用寄存器的体系结构上会产生更大的差异,例如 x86_64.

Talking about the cleanup, here is my question. I do not understand: are there three different things?

  1. stack clean
  2. moving the pointer back to the penultimate stack frame
  3. stack restoration

Or how should I see them?

我倾向于将 (1) 和 (3) 解释为同一事物的不同表达方式,但可以想象有人会对它们进行区分。 (3)和相关的写法是我遇到最多的。 (2)不一定是同一件事,因为可能有两个个相关的栈参数需要恢复:栈帧的底部(见下),和栈顶。如果堆栈帧包含的信息多于参数和局部变量值,则堆栈帧基数很重要,例如前一个堆栈帧的基数。

Also, the target of this question is basically how could variadic function works in calling conventions like Pascal or stdcall where the callee should clear / clean / restore (I don't know which operation) the stack - but he doesn't know how many parameters it will receive.

堆栈不一定是全图

如果被调用方不知道如何找到其调用方堆栈的顶部,以及(如果有必要)其调用方堆栈帧的底部,则它无法恢复堆栈。但实际上,这通常是硬件辅助的。

以x86(cdecl为其设计)为例,CPU有栈(帧)基址和当前栈指针的寄存器。调用者的堆栈基址存储在堆栈中与被调用者的堆栈基址的已知偏移量 (0) 处。无论参数的数量如何,被调用者通过将栈顶移动到它自己的栈底并弹出那里的值以获得调用者的栈底来恢复栈。

然而,可以想象,在某处使用的调用约定无法将堆栈恢复到选定的先前状态,只能一次弹出一个元素,但没有明确传达数字被调用函数的参数,这需要被调用者恢复调用者的堆栈。这样的调用约定不支持可变函数。

Why is it so important the order in which parameters are pushed on to the stack?

该顺序在任何一般意义上都重要,但调用者和被调用者必须就此达成一致,调用者和被调用者可能会单独编译。否则,被调用者无法将传递的值与它们预期的参数相匹配。因此,无论调用约定在何种程度上依赖于堆栈,它都必须准确指定在那里传递哪些参数以及以何种顺序传递。

关于堆栈帧:这更多 material C 未指定并且至少在某种程度上有所不同。但是,从概念上讲,函数调用的堆栈帧是为该调用提供执行上下文的堆栈部分。它通常为局部变量提供存储,并且它可能包含其他信息,例如 return 地址和/或调用者的堆栈帧指针的值。它还可能包含适用于执行环境的其他 per-function-call 信息。详细信息是正在使用的调用约定的一部分。

请注意,实际上没有主流系统使用 callee-pops-args 可变参数函数约定。 它们都使用 caller-pops,因此被调用者不需要知道参数的数量。 callee-pops 并非不可能,但通常不值得这么麻烦。

例如在 Windows 的 32 位代码中,我认为 stdcall 是许多 Windows DLL 函数的默认值,但可变参数使用 cdecl。 (Non-Windows x86 系统,如 Linux 和 MacOS 通常默认使用 caller-pops 调用约定,用于所有函数。所以这实际上只适用于 32 位 Windows 如果我们再说主流系统。)

因此 printf 不必计算格式字符串引用的参数的大小(或接收调用者传递的计数)然后模拟 ret 12ret 8 或其他。 ret n 仅在具有立即操作数的机器代码中可用,因此您不能执行 ret ecx 或其他操作。可以通过多种方式模拟 variable-count ret n,例如最不坏的方法之一是将 return 地址复制到堆栈的较高位置并在普通 ret 之前调整 ESP。但与仅使用 caller-pops 约定相比,这仍然非常低效。

此外,这会使程序变得脆弱:将未使用的 arg 传递给 printf 在 ISO C 中是未定义的行为,但某些代码依赖于它被默默地忽略(意外或因为类型不匹配)。

Windows 还确保调用者和被调用者同意被调用者将弹出多少堆栈 space 通过“装饰”像 _foo@12 这样的函数的 asm 符号名称 [=20] =]. (三个 int args = 12 个字节的堆栈 space 对于纯 stack-args 约定)。所以如果你声明错误(或者根本不声明,隐式声明使用更大的类型),你会得到一个 link 错误而不是 hard-to-debug 错误,这可能只发生在优化构建。 (如果使用 EBP 作为帧指针的调试构建恰好在出现任何错误之前纠正了堆栈不匹配。)

调用约定不匹配和其他 asm 错误导致“低于”C/C++ 级别的破坏,并且可能很难调试,特别是对于那些只在调试器中查看 C 变量或使用 debug-prints. (滥用 GNU C 内联汇编也是如此。)


正如@johnfound 所说,调用约定的关键点是调用者和被调用者同意 规则。只要双方同意,任何明确的规则都有效。

良好(高效)的调用约定(例如 x86-64 System V, and to a lesser extent Windows x64 and 32-bit fastcall/vectorcall) will pass the first few args in registers, avoiding the store/reload to the stack or any stack manipulation for simple functions. Efficient calling conventions also have a good mix of call-preserved and call-clobbered registers。简单的调用约定传递堆栈上的所有内容,调用者或被调用者负责弹出 args。更简单的调用约定(如 Irvine32 for asm beginners)保留所有寄存器。

有关详细信息,请参阅 Agner Fog's calling conventions guide