论证如何传递?

时间:2010-12-09 07:09:07

标签: c assembly x86 calling-convention

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

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

5 个答案:

答案 0 :(得分:18)

考虑此代码:

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个字节以容纳两个参数,然后将两个参数放在堆栈上相应的偏移量(4和{ {1}}),然后发出0指令,将控制转移到call例程。 foo例程从堆栈中的相应偏移量中读取其参数,恢复它,并将其返回值放在foo寄存器中,以便调用者可以使用它。

答案 1 :(得分:5)

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

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

Variadic参数的工作方式非常相似 - 它们通过寄存器或堆栈传递。在寄存器的情况下,它们通常按照类型的升序排列。如果你有(int a,int b,float c,int d),PowerPC ABI可能会将a放在r3中,b放在r4中,d放在r5中,{ {1}}在fp1中(我忘记了浮动寄存器的起始位置,但你明白了。)

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

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

答案 2 :(得分:3)

你的问题比任何人都可以合理地在SO帖子中回答的问题更多,更不用说它的定义也是如此。

但是,如果您对x86答案感兴趣,我建议您观看这个标题为Programming Paradigms的斯坦福CS107讲座,其中您提出的问题的所有答案都将得到非常详细的解释(并且非常有说服力)在前6-8个讲座中。

答案 3 :(得分:2)

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

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

最近的CPU(例如ARM或PowerPC)往往具有其调用约定,这些约定由CPU供应商定义并且在不同的操作系统之间兼容。 x86是一个例外:不同的系统使用不同的调用约定。 16位8086和32位80386的调用约定比x86_64的调用约定要多得多(尽管即使不是这样)。 32位x86 Windows程序有时会在同一程序中使用多个调用约定。

一些观察:

  • Linux for x86_64就是一个示例,该操作系统同时支持具有不同调用约定的多个不同ABI,其中一些遵循与其他OS相同的约定。它可以承载三个不同的主要ABI(i386,x32和x86_64),其中两个与同一CPU的其他操作系统相同,并且具有多个变体。
  • 对于所有事物都使用一个系统调用约定的规则的一个例外是MS Windows的16位和32位版本,它继承了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。
  • 其他一些非常老的体系结构没有堆栈,通常无法再次调用相同的函数,重新输入它,直到返回为止。
  • 具有很多参数的函数几乎总是将大多数参数放入堆栈中。
  • 将参数放在堆栈上的
  • C函数几乎必须以相反的顺序推送它们,并使调用者清理堆栈。被调用的函数甚至可能不知道堆栈中到底有多少个参数!也就是说,如果调用printf("%d\n", x);,编译器将把x,然后是格式字符串,然后是返回地址,压入堆栈。这样可以保证第一个参数与堆栈指针的偏移量是已知的,并且<varargs.h>具有它需要工作的信息。
  • 大多数其他语言(因此是C编译器支持的某些操作系统)采用相反的方式:参数从左向右推。通常,被调用的函数会清理自己的堆栈框架。这以前在MS-DOS上称为PASCAL约定,而在Windows上则作为STDCALL约定保留下来。它不支持可变参数功能。 (https://en.wikibooks.org/wiki/X86_Disassembly/Calling_Conventions
  • Fortran和其他一些语言在历史上都是通过引用传递所有参数的,这些参数转换为C作为指针参数。可能需要与其他语言交互的编译器通常支持这些外来调用约定。
  • 由于错误的主要来源是“破坏堆栈”,因此许多编译器现在都可以添加金丝雀值(就像煤矿中的金丝雀一样,警告您如果发生任何危险,则可能会发生危险) )和其他检测代码何时篡改堆栈帧的方法。
  • 跨不同平台的另一种形式的变化是,堆栈框架是否将包含调试器或异常处理程序回溯所需的所有信息,或者该信息是否位于单独的元数据中(或根本不存在)以简化操作功能序言/结尾(-fomit-frame-pointer)。

您可以让交叉编译器使用不同的调用约定来发出代码,并与-S -target(在clang上)进行比较。

答案 4 :(得分:0)

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

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

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