10

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

我对 CPU 寄存器和汇编器有基本的了解,但还不足以让我彻底了解 GCC 向我吐口水的 ASM。一些简单的带注释的例子将不胜感激。

4

5 回答 5

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

于 2010-12-09T07:28:15.540 回答
5

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

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

可变参数以非常相似的方式工作 - 它们通过寄存器或堆栈传递。对于寄存器,它们通常根据类型按升序排列。如果你有类似 (int a, int b, float c, int d) 的东西,PowerPC ABI 可能会放在ar3、br4、dr5 和cfp1 中(我忘记了浮点寄存器从哪里开始,但你明白了) .

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

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

于 2010-12-09T07:17:32.683 回答
2

您的问题比任何人都可以在 SO 帖子中合理地尝试回答更多,更不用说它的实现也定义了。

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

于 2010-12-09T07:15:32.170 回答
2

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

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

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

一些观察:

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

您可以让交叉编译器使用不同的调用约定发出代码,并将它们与诸如-S -target(on clang) 之类的开关进行比较。

于 2018-10-06T02:56:27.563 回答
0

基本上,C 通过将参数压入堆栈来传递参数。对于指针类型,指针被压入堆栈。

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

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

于 2010-12-09T07:17:38.040 回答