【问题标题】:C calling convention: who cleans the stack in variadic functions vs normal functions?C 调用约定:谁在可变参数函数和普通函数中清理堆栈?
【发布时间】:2021-02-14 20:32:12
【问题描述】:

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

谈到清理,这是我的问题。我不明白:有三种不同的东西吗?

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

或者我应该怎么看它们?

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

编辑

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

"所以,堆栈指针ESP可能会上下波动,但EBP寄存器 保持固定。这很方便,因为这意味着我们可以随时参考 将第一个参数设为 [EBP + 8],无论推多少和 弹出是在函数中完成的。”

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

我读过的材料之一是this site(只是开始),以便更好地了解堆栈框架。 然后我继续寻找这些stack overviewcall stack 教程,但他们不知何故错过了我需要的部分。 What does exactly happends when you call the function(我不明白指令“调用地址”后面跟着下一条指令a push 堆栈上的值,这意味着返回值)。谁控制退货地址?呼叫者,召集者?被调用者?当被调用者返回时,程序继续执行一条指令,该指令是从寄存器中读取操作还是什么?

【问题讨论】:

  • 恢复 == 恢复?回答您的问题:在从函数返回的上下文中,所有 3 种描述本质上是相同的。请注意,这个问题也适用于其他上下文,例如线程上下文切换,其中堆栈恢复有些不同。
  • @dxiv,谢谢,非常有趣的事情。不错的阅读
  • @CătălinaSîrbu 您的其他评论听起来更适合该问题。目前还不清楚这个问题的更广泛背景是什么。一旦你谈到“堆栈”,你就离开了 C 标准领域,一旦你开始讨论堆栈指针和框架,它就是关于一些特定的实现和/或 ABI。非常笼统地说,在完全受控的环境中,可变参数函数可能“猜测”它传递的参数数量,或者至少它们消耗的堆栈空间。但这需要大量脆弱的假设。
  • 这些答案可能会有所帮助:How exactly does the callstack work?How does a system call work。如果您真的想更深入地挖掘,那么我建议您阅读 Abraham Silberschatz 的操作系统概念。

标签: c x86 variadic-functions calling-convention cdecl


【解决方案1】:

就我而言,C 确实使用cdecl

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

谈到清理,这是我的问题。我不明白: 有三种不同的东西吗?

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

或者我应该怎么看它们?

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

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

堆栈不一定是全貌。

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

以 x86(为其设计了 cdecl)为例,CPU 具有堆栈(帧)基址和当前堆栈指针的寄存器。调用者的堆栈基存储在距被调用者堆栈基的已知偏移量 (0) 的堆栈上。不管参数的数量是多少,被调用者通过将栈顶移动到它自己的栈底来恢复栈,并从那里弹出值以获得调用者的栈底。

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

为什么将参数压入堆栈的顺序如此重要?

顺序在任何一般意义上都重要,但是对于可能单独编译的调用者和被调用者来说,就它达成一致是必不可少的。否则,被调用者无法将传递的值与其预期的参数匹配。因此,无论调用约定在多大程度上依赖于堆栈,它都必须精确地指定在那里传递的参数以及传递的顺序。

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

【讨论】:

  • 恢复 EBP 可能使其成为调用者的帧指针这一事实基本上是无关紧要的。一些旧系统需要 用于堆栈展开以进行调试的帧指针,可能还有诸如 C++ 异常之类的东西,但这似乎是一个不必要的复杂讨论。在您自己的函数中将 EBP 设置为帧指针并将其用作堆栈帧的固定参考点,这是您可以在保留 EBP 调用的任何调用约定(所有标准约定)中执行的操作,而无需任何其他人做出假设关于那个,或者你对调用者做出假设。
【解决方案2】:

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

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

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

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

Windows 还确保调用者和被调用者就调用者将弹出多少堆栈空间达成一致,方法是“装饰”像 _foo@12 这样的 asm 符号名称,用于像 int foo(int, int, int) 这样的函数。 (三个int args = 12 字节的堆栈空间,用于纯堆栈参数约定)。因此,如果您声明它错误(或根本不声明它,并且隐式声明使用更大的类型),您将收到链接错误,而不是可能仅在优化构建中发生的难以调试的错误。 (如果使用 EBP 作为帧指针的调试构建恰好在出现任何问题之前纠正了堆栈不匹配。)

调用约定不匹配和其他 asm 错误会导致“低于”C / C++ 级别的损坏,并且可能很难调试,尤其是对于仅在调试器中查看 C 变量或使用调试打印的人来说。 (滥用 GNU C 内联汇编也是如此。)


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

良好(高效)的调用约定(例如x86-64 System V,以及在较小程度上的 Windows x64 和 32 位 fastcall/vectorcall)将传递寄存器中的前几个参数,避免存储/重新加载到堆栈或任何堆栈操作简单的功能。高效的调用约定也很好地混合了call-preserved and call-clobbered registers。简单的调用约定传递堆栈上的所有内容,调用者或被调用者负责弹出参数。甚至更简单的(如 asm 初学者的 Irvine32)保留所有寄存器。

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

【讨论】:

  • 请注意,在 C 语言中,您可以向 printf 传递比格式字符串引用更多的参数。 Stack cleanup in stdcall (callee-pops) for variable arguments。因此,如果没有一些隐藏的额外参数来告诉它要弹出多少堆栈空间,就不可能使用 stdcall 。但是传递该 arg 至少需要一条指令,因此您不妨只使用 cdecl 代替。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2011-10-12
  • 2011-01-26
  • 1970-01-01
相关资源
最近更新 更多