参数传递如何工作?

发布于 2024-10-06 10:48:39 字数 139 浏览 5 评论 0原文

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

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

I want to know how passing arguments to functions in C works. Where are the values being stored and how and they retrieved? How does variadic argument passing work? Also since it's related: what about return values?

I have a basic understanding of CPU registers and assembler, but not enough that I thoroughly understand the ASM that GCC spits back at me. Some simple annotated examples would be much appreciated.

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。

评论(5

绅刃 2024-10-13 10:48:39

考虑这段代码:

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 寄存器中,以便调用者可以使用它。

Considering this code:

int foo (int a, int b) {
  return a + b;
}

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

Compiling it with gcc foo.c -S gives the assembly output:

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

So basically the caller (in this case main) first allocates 8 bytes on the stack to accomodate the two arguments, then puts the two arguments on the stack at the corresponding offsets (4 and 0), and then the call instruction is issued which transfers the control to the foo routine. The foo routine reads its arguments from the corresponding offsets at the stack, restores it, and puts its return value in the eax register so it's available to the caller.

ぃ双果 2024-10-13 10:48:39

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

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

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

返回值同样以同样的方式工作。

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

That is platform specific and part of the "ABI". In fact, some compilers even allow you to choose between different conventions.

Microsoft's Visual Studio, for example, offers the __fastcall calling convention, which uses registers. Other platforms or calling conventions use the stack exclusively.

Variadic arguments work in a very similar way - they are passed via registers or stack. In case of registers, they are usually in ascending order, based on type. If you have something like (int a, int b, float c, int d), a PowerPC ABI might put a in r3, b in r4, d in r5, and c in fp1 (I forgot where float registers start, but you get the idea).

Return values, again, work the same way.

Unfortunately, I don't have many examples, most of my assembly is in PowerPC, and all you see in the assembly is the code going straight for r3, r4, r5, and placing the return value in r3 as well.

傻比既视感 2024-10-13 10:48:39

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

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

Your questions are more than anybody could reasonably try to answer in a SO post, not to mention that it's implementation defined as well.

However, if you're interested in the x86 answer might I suggest you watch this Stanford CS107 Lecture titled Programming Paradigms where all the answers to the questions you posed will be explained in great detail (and quite eloquently) in the first 6-8 lectures.

☆獨立☆ 2024-10-13 10:48:39

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

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

您可以让交叉编译器使用不同的调用约定生成代码,并使用 -S -target(在 clang 上)等开关进行比较。

It depends on your compiler, the target architecture and OS you’re compiling for, and whether your compiler supports non-standard extensions that change the calling convention. But there are some commonalities.

The C calling convention is usually established by the vendor of the operating system, because they need to decide what convention the system libraries use.

More recent CPUs (such as ARM or PowerPC) tend to have their calling conventions defined by the CPU vendor and compatible across different operating systems. x86 is an exception to this: different systems use different calling conventions. There used to be a lot more calling conventions for the 16-bit 8086 and 32-bit 80386 than there are for x86_64 (although even that is not down to one). 32-bit x86 Windows programs sometimes use multiple calling conventions within the same program.

Some observations:

  • An example of an operating system that supports several different ABIs with different calling conventions simultaneously, some of which follow the same conventions as other OSes for the same architecture, is Linux for x86_64. This can host three different major ABIs (i386, x32 and x86_64), two of which are the same as other operating systems for the same CPU, and several variants.
  • An exception to the rule that there's one system calling convention used for everything is 16- and 32-bit versions of MS Windows, which inherited some of the proliferation of calling conventions from MS-DOS. The Windows C API uses a different calling convention (STDCALL, originally FAR PASCAL) than the “C” calling convention for the same platform, and also supports FORTRAN and FASTCALL conventions. All four come in NEAR and FAR variants on 16-bit OSes. Nearly all Windows programs therefore use at least two different conventions in the same program.
  • Architectures with a lot of registers, including classic RISC and nearly all modern ISAs, use several of those registers to pass and return function arguments.
  • Architectures with few or no general-purpose registers often pass arguments on the stack, pointed to by a stack pointer. CISC architectures often have instructions to call and return which store the return address on the stack. (RISC architectures typically store the return address in a "link register", which the callee can save/restore manually if it's not a leaf function.)
  • A common variant is for tail calls, functions whose return value is also the return value of the caller, to jump to the next function (so it returns to our parent function) instead of calling it and then returning after it returns. Placing args in the right places has to account for the return address already being on the stack, where a call instruction would place it.
    This is especially true of tail-recursive calls, which have exactly the same stack frame on each invocation. A tail-recursive call is typically equivalent to a loop: update a few registers that changed, then jump back to the entry point. They do not need to create a new stack frame, or have their own return address: you can simply update the caller’s stack frame and use its return address as the tail call’s. i.e. tail-recursion easily optimizes into a loop.
  • Some architectures with only a few registers nevertheless defined an alternative calling convention that could pass one or two arguments in registers. This was FASTCALL on MS-DOS and Windows.
  • A few older ISAs, such as SPARC, had a special bank of “windowed” registers, so that every function has its own bank of input and output registers, and when it made a function call, the caller’s outputs became the callee’s inputs, and the reverse when it came time to return a value. Modern superscalar designs consider this more trouble than it’s worth.
  • A few very old architectures used self-modifying code in their calling convention, and the first edition of The Art of Computer Programming followed this model for its abstract language. It no longer works on most modern CPUs, which have instruction caches.
  • A few other very old architectures had no stack and generally could not call the same function again, re-entering it, until it returned.
  • A function with a lot of arguments almost always puts most of them onto the stack.
  • C functions that put arguments on the stack almost have to push them in reverse order and have the caller clean up the stack. The called function might not even know exactly how many arguments are on the stack! That is, if you call printf("%d\n", x); the compiler will push x, then the format string, then the return address, onto the stack. This guarantees that the first argument is at a known offset from the stack pointer and <varargs.h> has the information it needs to work.
  • Most other languages, and therefore some operating systems that C compilers support, do it the other way around: arguments are pushed from left to right. The function being called usually cleans up its own stack frame. This used to be called the PASCAL convention on MS-DOS, and survives as the STDCALL convention on Windows. It cannot support variadic functions. (https://en.wikibooks.org/wiki/X86_Disassembly/Calling_Conventions)
  • Fortran and a few other language historically passed all arguments by reference, which translates to C as pointer arguments. Compilers that might need to interface with these other languages often support these foreign calling conventions.
  • Because a major source of bugs was “smashing the stack,” many compilers now have a way to add canary values (which, like a canary in a coal mine, warn you that something dangerous is going on if anything happens to them) and other means of detecting when code tampers with the stack frame.
  • Another form of variation across different platforms is whether the stack frame will contain all the information it needs for a debugger or exception-handler to backtrace, or whether that info will be in separate metadata (or not present at all) allowing simplification of function prologue/epilogue (-fomit-frame-pointer).

You can get cross-compilers to emit code using different calling conventions, and compare them, with switches such as -S -target (on clang).

绻影浮沉 2024-10-13 10:48:39

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

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

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

Basically, C passes arguments by pushing them on the stack. For pointer types, the pointer is pushed on the stack.

One things about C is that the caller restores the stack rather the function being called. This way, the number of arguments can vary and the called function doesn't need to know ahead of time how many arguments will be passed.

Return values are returned in the AX register, or variations thereof.

~没有更多了~
我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
原文