7

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

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

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

或者我应该怎么看他们?

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

编辑

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

“因此,堆栈指针 ESP 可能会上下波动,但 EBP 寄存器保持固定。这很方便,因为这意味着我们始终可以将第一个参数称为 [EBP + 8],而不管在其中完成了多少推送和弹出操作功能。”

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

我读过的材料之一是这个网站(只是开始),以便更好地了解stack frame的确切含义。然后我继续阅读并找到了这些堆栈概述调用堆栈教程,但他们不知何故错过了我需要的部分。当你调用函数时到底发生了什么(我不明白指令“调用地址”后跟a push堆栈上的下一个指令值,这意味着返回值)。谁控制退货地址?呼叫者,召集者?被调用者?当被调用者返回时,程序继续执行一条指令,该指令是从寄存器读取操作还是什么?

4

2 回答 2

4

就我而言,C确实使用cdecl

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

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

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

或者我应该怎么看他们?

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

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

堆栈不一定是全貌。

如果被调用者不知道如何找到其调用者堆栈的顶部,并且如果有必要,它不知道如何找到其调用者的堆栈框架的底部,则被调用者无法恢复堆栈。但在实践中,这通常是硬件辅助的。

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

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

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

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

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

于 2020-11-02T17:20:18.667 回答
0

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

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

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

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

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

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


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

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

有关详细信息,请参阅Agner Fog 的调用约定指南

于 2020-11-03T11:24:28.997 回答