论证传递如何运作?

我想知道如何将参数传递给C中的函数。 存储的值在哪里以及如何检索它们? 可变参数传递如何工作? 此外,因为它是相关的:返回值怎么样?

我对CPU寄存器和汇编器有基本的了解,但还不足以让我彻底了解GCC向我吐出的ASM。 一些简单的带注释的例子将非常受欢迎。

考虑这段代码:

int foo (int a, int b) { return a + b; } int main (void) { foo(3, 5); return 0; } 

gcc foo.c -S编译它给出了汇编输出:

 foo: pushl %ebp movl %esp, %ebp movl 12(%ebp), %eax movl 8(%ebp), %edx leal (%edx,%eax), %eax popl %ebp ret main: pushl %ebp movl %esp, %ebp subl $8, %esp movl $5, 4(%esp) movl $3, (%esp) call foo movl $0, %eax leave ret 

所以基本上调用者(在本例中为main )首先在堆栈上分配8个字节以容纳两个参数,然后将两个参数放在堆栈上相应的偏移量( 40 ),然后发出call指令对foo例程的控制。 foo例程从堆栈中的相应偏移量中读取其参数,恢复它,并将其返回值放入eax寄存器中,以便调用者可以使用它。

这是特定于平台的,也是“ABI”的一部分。 事实上,一些编译器甚至允许您在不同的约定之间进行选择。

例如,Microsoft的Visual Studio提供了__fastcall调用约定,该约定使用寄存器。 其他平台或调用约定仅使用堆栈。

变量参数以非常类似的方式工作 – 它们通过寄存器或堆栈传递。 在寄存器的情况下,它们通常按类型递增。 如果你有类似的东西(int a,int b,float c,int d),PowerPC ABI可能会把r3, b放在r4中, d放在r5中, c放在fp1中(我忘了float寄存器开始的地方,但是你明白了)。

返回值再次以相同的方式工作。

不幸的是,我没有很多例子,我的大部分程序集都在PowerPC中,你在程序集中看到的只是代码直接用于r3,r4,r5,并将返回值放在r3中。

你的问题比任何人都可以合理地尝试在SOpost中回答,更不用说它的实现定义也是如此。

但是,如果您对x86答案感兴趣,我建议您观看这个题为编程范例的斯坦福CS107讲座,其中您提出的问题的所有答案将在前6-8个讲座中详细解释(并且非常雄辩) 。

这取决于您的编译器,您正在编译的目标体系结构和操作系统,以及您的编译器是否支持更改调用约定的非标准扩展。 但有一些共性。

C调用约定通常由操作系统的供应商建立,因为它们需要决定系统库使用的约定。

更新的CPU(例如ARM或PowerPC)往往具有由CPU供应商定义的调用约定,并且在不同的操作系统之间兼容。 x86是一个例外:不同的系统使用不同的调用约定。 对于16位8086和32位80386来说,曾经有比x86_64更多的调用约定(尽管即使这不是一个)。 32位x86 Windows程序有时在同一程序中使用多个调用约定。

一些观察:

  • 支持多个具有不同调用约定的不同ABI的操作系统的示例是Linux for x86_64,其中一些ABI遵循与相同架构的其他操作系统相同的约定。 这可以托管三个不同的主要ABI(i386,x32和x86_64),其中两个与同一CPU的其他操作系统相同,还有几个变体。
  • 有一个系统调用约定用于所有内容的规则的一个例外是16位和32位版本的MS Windows,它inheritance了MS-DOS中一些扩展的调用约定。 Windows C API使用与同一平台的“C”调用约定不同的调用约定( STDCALL ,最初为FAR PASCAL ),并且还支持FORTRANFASTCALL约定。 所有四个在16位操作系统上都有NEARFAR变体。 因此,几乎所有Windows程序在同一程序中至少使用两种不同的约定。
  • 具有大量寄存器的体系结构(包括经典RISC和几乎所有现代ISA)使用其中几个寄存器来传递和返回函数参数。
  • 具有很少或没有通用寄存器的体系结构通常在堆栈上传递参数,由堆栈指针指向。 CISC体系结构通常具有调用和返回的指令,这些指令将返回地址存储在堆栈上。 (RISC体系结构通常将返回地址存储在“链接寄存器”中,如果它不是叶子函数,被调用者可以手动保存/恢复。)
  • 一个常见的变体是尾部调用,其返回值也是调用者返回值的函数,跳转到下一个函数(因此它返回到我们的父函数)而不是调用它,然后在它返回后返回。 将args放置在正确的位置必须考虑已经在堆栈上的返回地址,其中调用指令将放置它。 对于尾递归调用尤其如此,它们在每次调用时具有完全相同的堆栈帧。 尾递归调用通常等效于循环:更新一些已更改的寄存器,然后跳回到入口点。 它们不需要创建新的堆栈帧,或者有自己的返回地址:您可以简单地更新调用者的堆栈帧并使用其返回地址作为尾调用。 即尾递归很容易优化成循环。
  • 但是,只有少数寄存器的一些体系结构定义了一个可以在寄存器中传递一个或两个参数的替代调用约定。 这是MS-DOS和Windows上的FASTCALL
  • 一些较旧的ISA,例如SPARC,有一组特殊的“窗口”寄存器,因此每个函数都有自己的输入和输出寄存器组,当它进行函数调用时,调用者的输出成为被调用者的输入,并且当返回一个值时,反之亦然。 现代超标量设计认为这比它的价值更麻烦。
  • 一些非常古老的架构在其调用约定中使用了自修改代码,并且第一版“计算机编程艺术”遵循该模型的抽象语言。 它不再适用于具有指令缓存的大多数现代CPU。
  • 其他一些非常古老的架构没有堆栈,通常无法再次调用相同的function,重新进入它,直到它返回。
  • 具有大量参数的函数几乎总是将大多数参数放入堆栈中。
  • 将参数放在堆栈上的C函数几乎必须以相反的顺序推送它们并让调用者清理堆栈。 被调用的函数可能甚至不知道堆栈中有多少参数! 也就是说,如果你调用printf("%d\n", x); 编译器将x ,然后是格式字符串,然后返回地址,压入堆栈。 这保证了第一个参数与堆栈指针的已知偏移量,并且具有它需要工作的信息。
  • 大多数其他语言,以及C编译器支持的一些操作系统,反过来做:参数从左向右推。 被调用的函数通常会清理自己的堆栈帧。 这曾经被称为MS-DOS上的PASCAL约定,并且作为Windows上的STDCALL约定而存在。 它不能支持可变参数function。 ( https://en.wikibooks.org/wiki/X86_Disassembly/Calling_Conventions
  • Fortran和其他一些语言历史上通过引用传递所有参数,这转换为C作为指针参数。 可能需要与这些其他语言交互的编译器通常支持这些外部调用约定。
  • 因为错误的主要来源是“粉碎堆栈”,许多编译器现在都有办法添加金丝雀价值(就像煤矿中的金丝雀一样,警告你,如果发生任何事情会发生危险)和其他检测何时代码篡改堆栈帧的方法。
  • 不同平台的另一种变体forms是堆栈帧是否包含调试器或exception处理程序回溯所需的所有信息,或者该信息是否将在单独的元数据中(或根本不存在),从而简化了函数序言/ epilogue( -fomit-frame-pointer )。

您可以使用不同的调用约定来使用交叉编译器发出代码,并使用-S -target (on clang )等开关对它们进行比较。

基本上,C通过在堆栈上推送它们来传递参数。 对于指针类型,指针被推入堆栈。

关于C的一件事是调用者恢复堆栈而不是被调用的函数。 这样,参数的数量可以变化,并且被调用的函数不需要提前知道将传递多少个参数。

返回值在AX寄存器中返回,或其变体。