调用栈是如何工作的?

128

我正在努力加深对编程语言低级操作的理解,特别是它们如何与操作系统/CPU交互。我可能已经阅读了Stack Overflow上所有堆栈/堆相关线程中的每个答案,它们都很出色。但仍有一件事情我还没有完全明白。

考虑下面这个伪代码函数,它往往是有效的Rust代码 ;-)

fn foo() {
    let a = 1;
    let b = 2;
    let c = 3;
    let d = 4;

    // line X

    doSomething(a, b);
    doAnotherThing(c, d);
}

我假设在X行的情况下堆栈的样子如下:

Stack

a +-------------+
  | 1           | 
b +-------------+     
  | 2           |  
c +-------------+
  | 3           | 
d +-------------+     
  | 4           | 
  +-------------+ 

现在,我读到的关于栈如何工作的所有内容都指出它严格遵循LIFO规则(后进先出)。就像.NET、Java或任何其他编程语言中的堆栈数据类型一样。

但如果是这样的话,那么在X行之后会发生什么呢?因为显然,我们需要下一步处理ab,但这将意味着操作系统/CPU(?)必须先弹出dc才能回到ab。但接下来它需要cd,这将是自己给自己找麻烦。

所以,我想知道背后到底发生了什么?

另外一个相关的问题。假设我们像这样向另一个函数传递引用:

fn foo() {
    let a = 1;
    let b = 2;
    let c = 3;
    let d = 4;

    // line X

    doSomething(&a, &b);
    doAnotherThing(c, d);
}

根据我的理解,这意味着doSomething中的参数基本上指向与foo中的ab相同的内存地址。但另一方面,这意味着没有弹出堆栈,直到我们到达ab的情况。

这两种情况让我觉得我还没有完全掌握堆栈的工作原理以及它如何严格遵循LIFO规则。


16
LIFO 只对保留堆栈空间有影响。即使变量被其他很多变量覆盖,只要它在您的堆栈帧上(在函数内声明),您仍然可以访问它。 - VoidStar
3
换句话说,LIFO 意味着只能在栈的末尾添加或删除元素,但你始终可以读取/更改任何元素。 - HolyBlackCat
17
在使用-O0编译后,为什么不拆解一个简单函数并查看生成的指令呢?这很有启示作用;-)你会发现代码很好地利用了RAM的R部分;它可以随意直接访问地址。您可以将变量名视为地址寄存器(堆栈指针)的偏移量。正如其他人所说,堆栈只是LIFO与堆叠有关(递归等很好),但不是LIFO与访问有关。访问是完全随机的。 - Peter - Reinstate Monica
7
你可以使用一个数组自己创建堆栈数据结构,只需存储顶部元素的索引,在 push 时增加它,在 pop 时减少它。如果你这样做,你仍然可以在任何时候访问数组中的任何单个元素,而不需要像一般数组一样进行 push 或 pop 操作。这里大致发生了同样的事情。 - Crowman
3
基本上,栈和堆的命名不太妥当。它们与数据结构术语中的栈和堆几乎没有相似之处,因此将它们称为相同的名称会非常令人困惑。 - Siyuan Ren
显示剩余13条评论
7个回答

136
调用栈也可以称为帧栈。
按照后进先出原则堆叠的事物并不是局部变量,而是被调用函数的整个堆栈帧("调用")。这些局部变量与帧一起在所谓的函数序言尾声中一起被推入并弹出。
在函数框架内,变量的顺序完全没有规定;编译器会适当地“重新排列”框架内局部变量的位置以优化它们的对齐方式,以便处理器能够尽快获取它们。关键的事实是,在整个框架的生命周期中,变量相对于某个固定地址的偏移量是恒定的——因此,只需取一个锚点地址(比如框架本身的地址),并使用该地址到变量的偏移量即可。这样的锚点地址实际上包含在所谓的基址帧指针中,该指针存储在EBP寄存器中。另一方面,偏移量在编译时是明确知道的,因此已经硬编码进入机器代码中。
这张来自Wikipedia的图表显示了典型的调用栈结构1

Picture of a stack

将我们想要访问的变量的偏移量加到帧指针中包含的地址上,就可以得到我们变量的地址。简而言之,该代码只是通过基指针的常数编译时偏移直接访问它们;这是简单的指针算术。

示例

#include <iostream>

int main()
{
    char c = std::cin.get();
    std::cout << c;
}

gcc.godbolt.org 给我们提供了

main:
    pushq   %rbp
    movq    %rsp, %rbp
    subq    $16, %rsp

    movl    std::cin, %edi
    call    std::basic_istream<char, std::char_traits<char> >::get()
    movb    %al, -1(%rbp)
    movsbl  -1(%rbp), %eax
    movl    %eax, %esi
    movl    std::cout, %edi
    call    [... the insertion operator for char, long thing... ]

    movl    $0, %eax
    leave
    ret

对于main函数,我将代码分成了三个子部分。函数前奏包括以下三个操作:

  • 基指针被压入堆栈。
  • 堆栈指针被保存在基指针中。
  • 堆栈指针被减去以为本地变量腾出空间。

然后将cin移动到EDI寄存器2并调用get; 返回值在EAX中。

到目前为止一切都好。现在有趣的事情发生了:

EAX的低位字节,由8位寄存器AL指定,被取出并存储在基指针右侧的字节中:即-1(%rbp),基指针的偏移量是-1这个字节是我们的变量c。 偏移量是负数,因为x86上的堆栈向下增长。 接下来的操作将c存储在EAX中:EAX被移动到ESI,cout被移动到EDI,然后插入运算符与coutc作为参数被调用。

最后,

  • main的返回值存储在EAX中:0。这是由于隐式的return语句造成的。您也可能会看到xorl rax rax代替movl
  • leave指令缩写了这个结尾部分并隐含地
    • 用基址寄存器替换堆栈指针,并且
    • 弹出基址指针。

执行这个操作和ret指令后,帧已被有效弹出,尽管调用者仍需清理参数,因为我们使用的是cdecl调用约定。其他约定,例如stdcall,要求被调用者进行整理,例如通过传递字节数给ret指令。

省略帧指针

也可以不使用基址/帧指针的偏移量,而是使用堆栈指针(ESB)的偏移量。这使得原本会包含帧指针值的EBP寄存器可用于任意用途 - 但这可能会导致某些机器上的调试不可能, 并且对某些函数隐式关闭。它在编译只有少数寄存器的处理器(包括x86)时特别有用。

这种优化称为FPO(省略帧指针),在GCC中通过-fomit-frame-pointer设置,在Clang中通过-Oy设置;请注意,只有在仍然可以进行调试的情况下,如果优化级别> 0,则每个级别都会自动触发它,因为除此之外它没有任何成本。 有关更多信息,请参见此处此处


1 正如评论中所指出的那样,帧指针可能指向返回地址之后的地址。

2 注意以 R 开头的寄存器是与以 E 开头的寄存器对应的 64 位寄存器。EAX 指定了 RAX 的四个低位字节。我使用了 32 位寄存器的名称以便更清晰明了。


1
很棒的答案。通过偏移量来寻址数据是我缺失的部分 :) - Christoph
1
我认为图纸上有一个小错误。帧指针应该在返回地址的另一侧。离开函数通常是这样完成的:将堆栈指针移动到帧指针,从堆栈中弹出调用者的帧指针,返回(即从堆栈中弹出调用者的程序计数器/指令指针)。 - kasperd
3
我认为你从概念角度在处理这个问题。以下的评论希望能够澄清一下:运行时堆栈(RTS)与其他堆栈有点不同,因为它是一个“脏堆栈”——实际上没有任何东西阻止您查看不在顶部的值。请注意,在图表中,绿色方法的“返回地址”——蓝色方法需要的返回地址!在参数之后。在弹出前一个框架后,蓝色方法如何获得返回值?好吧,它是一个脏堆栈,所以它可以伸手进去并抓取它。 - Riking
1
帧指针实际上并不需要,因为可以始终使用从堆栈指针的偏移量。GCC默认针对x64架构使用堆栈指针,并释放rbp以执行其他工作。 - Siyuan Ren
@Columbo:在大多数调用约定中,调用者将指针作为隐藏的第一个参数传递。被调用者将返回值存储在指向的空间中。有关详细信息,请参阅特定的ABI文档(该体系结构的x86标签wiki上的链接)。例如:在i386 SysV中,即使是结构也是以这种方式返回的,但是amd64 SysV将结构打包到rdx:rax中,最多可达128b。(只要成员都是整数)。还有其他选择,比如将其留在堆栈上的临时变量中。隐藏指针可以在调用者可以将指针传递给最终目标的情况下节省副本。 - Peter Cordes
显示剩余8条评论

31
因为显然,下一步我们需要与a和b一起工作,但这意味着操作系统/CPU(?)必须先弹出d和c才能回到a和b。但它会自食其果,因为它在下一行需要c和d。
简而言之:
没有必要弹出参数。调用者foo传递给函数doSomething的参数和doSomething中的局部变量都可以作为偏移量从基指针引用。
所以,
当函数调用被执行时,函数的参数被PUSH到堆栈上。这些参数可以通过基指针进一步引用。 当函数返回到其调用者时,使用LIFO方法从堆栈中POP返回函数的参数。

详细解释:

规则是每次函数调用都会创建一个堆栈帧(最小的是返回地址)。因此,如果funcA调用funcB并且funcB调用funcC,则三个堆栈帧将依次设置在另一个堆栈帧之上。当函数返回时,它的帧变为无效。一种表现良好的函数只对自己的堆栈帧进行操作,不侵犯其他人的堆栈帧。换句话说,在从函数返回时,POP操作是针对顶部的堆栈帧执行的。

enter image description here

您的问题中提到的堆栈是由调用者foo设置的。当调用doSomethingdoAnotherThing时,它们会设置自己的堆栈。下图可能有助于您理解这一点:

enter image description here

请注意,要访问参数,函数体必须从存储返回地址的位置向下遍历(更高的地址),要访问局部变量,函数体必须相对于存储返回地址的位置向上遍历堆栈(较低的地址)。实际上,函数的典型编译器生成代码就是这样做的。编译器为此专门分配了一个名为EBP(基指针)的寄存器。另一个名称是帧指针。编译器通常会将当前的EBP值作为函数体的第一件事推送到堆栈上,并将EBP设置为当前的ESP。这意味着,一旦完成这个步骤,在函数代码的任何部分,参数1与EBP+8相距(调用者的EBP和返回地址每个4个字节),参数2与EBP+12(十进制)相距,局部变量与EBP-4n相距。
.
.
.
[ebp - 4]  (1st local variable)
[ebp]      (old ebp value)
[ebp + 4]  (return address)
[ebp + 8]  (1st argument)
[ebp + 12] (2nd argument)
[ebp + 16] (3rd function argument) 

请看下面的C代码,用于形成函数的堆栈帧:
void MyFunction(int x, int y, int z)
{
     int a, int b, int c;
     ...
}

当调用者调用它时
MyFunction(10, 5, 2);  

以下代码将被生成。
^
| call _MyFunction  ; Equivalent to: 
|                   ; push eip + 2
|                   ; jmp _MyFunction
| push 2            ; Push first argument  
| push 5            ; Push second argument  
| push 10           ; Push third argument  

这个函数的汇编代码将由被调用者在返回之前进行设置。

^
| _MyFunction:
|  sub esp, 12 ; sizeof(a) + sizeof(b) + sizeof(c)
|  ;x = [ebp + 8], y = [ebp + 12], z = [ebp + 16]
|  ;a = [ebp - 4] = [esp + 8], b = [ebp - 8] = [esp + 4], c = [ebp - 12] =   [esp]
|  mov ebp, esp
|  push ebp
 

参考资料:


1
感谢您的回答。此外,这些链接真的很酷,帮助我更深入地了解关于计算机是如何工作的这个永无止境的问题 :) - Christoph
“将当前 EBP 值推入堆栈”是什么意思?堆栈指针存储在寄存器中还是也占据堆栈中的位置?我有点困惑。 - Suraj Jain
这不应该是*[ebp + 8]而不是[ebp + 8]吗? - Suraj Jain
@Suraj Jain; 你知道EBPESP是什么吗? - haccks
esp 是栈指针,ebp 是基址指针。如果我有任何错误的认识,请您指正。 - Suraj Jain
显示剩余3条评论

19

像其他人指出的那样,在参数超出作用域之前,没有必要弹出它们。

我将从Nick Parlante的“指针和内存”中粘贴一些示例。我认为情况比你想象的要简单一些。

这是代码:

void X() 
{
  int a = 1;
  int b = 2;

  // T1
  Y(a);

  // T3
  Y(b);

  // T5
}

void Y(int p) 
{
  int q;
  q = p + 2;
  // T2 (first time through), T4 (second time through)
}

代码中标记了时间点T1,T2等,并在图示中显示了该时间的内存状态:

enter image description here


2
很棒的视觉解释。我谷歌搜索并在这里找到了这篇论文:http://cslibrary.stanford.edu/102/PointersAndMemory.pdf真的很有帮助! - Christoph

7
不同的处理器和编程语言使用不同的栈设计。在8x86和68000上,两种传统模式称为Pascal调用约定和C调用约定;每个约定在两个处理器中都以相同的方式处理,除了寄存器的名称。每个约定使用两个寄存器来管理堆栈和相关变量,称为堆栈指针(SP或A7)和帧指针(BP或A6)。
使用任一约定调用子例程时,在调用例程之前,任何参数都将被推送到堆栈上。然后,例程的代码将当前帧指针的值推送到堆栈上,将当前堆栈指针的值复制到帧指针,并从堆栈指针中减去局部变量使用的字节数[如果有]。完成后,即使将其他数据推送到堆栈上,所有局部变量也将存储在与堆栈指针具有恒定负位移的变量中,调用者推送到堆栈上的所有参数都可以在帧指针具有恒定正位移的位置访问。
这两种约定之间的区别在于它们如何处理从子例程退出。在C约定中,返回函数将帧指针复制到堆栈指针[将其恢复为刚推送旧帧指针时的值],弹出旧帧指针的值,并执行返回。调用者在调用之前推送到堆栈上的任何参数都将保留在那里。在Pascal约定中,在弹出旧帧指针之后,处理器会弹出函数返回地址,将调用者推送的参数字节数添加到堆栈指针中,然后转到弹出的返回地址。在原始的68000上,需要使用一个3指令序列来删除调用者的参数;8x86和所有原始680x0处理器都包括一个“ret N”[或680x0等效]指令,当执行返回时会将N添加到堆栈指针上。
Pascal约定的优点在于在调用方面节省了一些代码,因为调用者在函数调用后不必更新堆栈指针。但是,它要求被调用的函数准确知道调用者将要放置在堆栈上的参数的字节数。在调用使用Pascal约定的函数之前未将正确数量的参数推送到堆栈上几乎肯定会导致崩溃。然而,这可以通过每个被调用方法内的一些额外代码来抵消,以在调用该方法的地方节省代码。因此,大多数原始Macintosh工具箱例程使用Pascal调用约定。
C调用约定的优点在于允许例程接受可变数量的参数,并且即使例程没有使用所有传递的参数(调用者将知道它推送了多少字节的参数,因此能够清理它们),也会很稳健。此外,每个函数调用后都不需要执行堆栈清除。如果一个例程连续调用了四个函数,每个函数都使用了四个字节的参数,则可以在最后一次调用后使用一个“ADD SP,16”来清理所有四次调用的参数,而不是在每次调用后使用一个“ADD SP,4”。
现在这种调用约定被认为有些过时。由于编译器在寄存器使用方面效率更高,常常会让方法在寄存器中接受少量的参数,而不是要求将所有参数推送到堆栈上;如果一个方法可以使用寄存器来保存所有参数和局部变量,那么就不需要使用帧指针,也就不需要保存和恢复旧的帧指针。尽管如此,在调用链接到使用老的调用约定的库时,有时仍需要使用旧的调用约定。

1
哇!我能借用你的大脑一周左右吗?需要提取一些细枝末节的东西!好答案! - Christoph
帧指针和栈指针存储在堆栈本身还是其他地方? - Suraj Jain
@SurajJain:通常,每个保存的帧指针副本将存储在相对于新帧指针值的固定位移处。 - supercat
先生,我有一个长期以来的疑问。如果在我的函数中我写了 if (g==4),然后 int d = 3g 我使用 scanf 输入,之后我定义另一个变量 int h = 5。现在,编译器如何知道在堆栈中给 d=3 分配空间。偏移量是如何完成的,因为如果 g 不等于 4,那么在堆栈中就没有内存可以给 d,并且只会给 h 分配偏移量;如果 g == 4,则首先为 g 分配偏移量,然后为 h 分配偏移量。编译器如何在编译时完成这个过程,它不知道我们输入的 g 是什么。 - Suraj Jain
@SurajJain: C语言的早期版本要求函数内的所有自动变量必须出现在任何可执行语句之前。稍微放松一下这个复杂的编译过程,但其中一种方法是在函数开始处生成代码,将FP减去前向声明标签的值。在函数内部,编译器可以在代码的每个点上跟踪仍在作用域内的本地字节数,并跟踪曾经在作用域内的最大字节数。在函数结束时,它可以提供先前值的值... - supercat
显示剩余4条评论

5
这里已经有一些非常好的答案了。但是,如果您仍然担心栈的LIFO行为,请将其视为帧的堆栈,而不是变量的堆栈。我的意思是建议,尽管函数可能访问不在堆栈顶部的变量,但它仍然只操作堆栈顶部的:一个单独的堆栈帧。
当然,也有例外情况。整个调用链的局部变量仍然被分配并可用。但是它们不会直接访问。相反,它们通过引用传递(或通过指针,从语义上来说实际上并没有区别)。在这种情况下,可以访问远远下方的堆栈帧的局部变量。但即使在这种情况下,当前执行的函数仍然只操作自己的本地数据。它正在访问存储在其自己的堆栈帧中的引用,这可能是对堆、静态内存或更深的堆栈中的某些内容的引用。
这是堆栈抽象的一部分,使函数可以以任何顺序调用,并允许递归。顶部堆栈帧是代码直接访问的唯一对象。其他任何东西都是间接访问的(通过存储在顶部堆栈帧中的指针)。
如果您编译时不进行优化,查看您的小程序的汇编代码可能会很有启发性。我认为您会发现,函数中的所有内存访问都是通过堆栈帧指针的偏移量进行的,这是编译器编写函数代码的方式。在引用传递的情况下,您将看到通过存储在距堆栈帧指针某个偏移量处的指针进行的间接内存访问指令。

4
调用栈实际上不是一个堆栈数据结构。在幕后,我们使用的计算机是随机访问机器体系结构的实现。因此,a和b可以直接访问。
在幕后,机器执行以下操作:
- 获取"a"等于读取堆栈顶部下方的第四个元素的值。 - 获取"b"等于读取堆栈顶部下方的第三个元素的值。

http://en.wikipedia.org/wiki/Random-access_machine


2
这是我为一个使用Windows x64调用约定的C++程序创建的调用堆栈图。它比Google图片版本更准确和现代化。

enter image description here

与上图的确切结构相对应,下面是在Windows 7上对notepad.exe x64进行调试的结果,其中一个函数的第一条指令“当前函数”(因为我忘记了它是哪个函数)即将执行。

enter image description here


低地址和高地址被交换,因此在此图中堆栈向上爬升(它是第一个图的垂直翻转,还要注意数据格式化为显示四字节而不是字节,因此无法看到小端性)。黑色是主空间;蓝色是返回地址,它是调用函数或标签中指向调用后指令的偏移量;橙色是对齐;粉色是在函数序言之后,或者使用alloca时,在调用之前rsp指向的位置。 homespace_for_the_next_function+return_address值是Windows上允许的最小帧大小,因为必须在调用的函数开始处维护16字节的rsp对齐,所以它还包括8字节的对齐,使得rsp指向返回地址后的第一个字节将被对齐到16字节(因为当函数被调用时,rsp保证对齐到16字节,而homespace+return_address = 40,不可被16整除,因此需要额外的8字节来确保函数调用后rsp将被对齐)。由于这些函数不需要任何堆栈本地变量(因为它们可以优化为寄存器)或堆栈参数/返回值(因为它们适合寄存器)并且不使用任何其他字段,因此绿色的堆栈帧大小均为alignment+homespace+return_address
红色的函数线条勾画出调用约定中被调用函数逻辑上“拥有”+按值读取/修改而不需要引用的内容(它可以修改在堆栈上传递的参数,这些参数太大以至于无法在-OFAST上通过寄存器传递),这是经典的堆栈帧概念。绿色帧标记出调用结果和被调用函数所做的分配:第一个绿色帧显示了实际上在函数调用期间分配的内容(从调用之前立即到执行下一个调用指令),并且从返回地址之前的第一个字节到函数序言分配的最后一个字节(如果使用alloca,则更多)。将返回地址本身分配为null,因此您在序言中看到而不是,因为没有调用,它只是从堆栈底部的rip开始执行。

函数需要的堆栈空间在函数序言中通过减少堆栈指针来分配。

例如,考虑以下C++代码及其编译成的MASM(-O0)。

typedef struct _struc {int a;} struc, pstruc;
int func(){return 1;}
int square(_struc num) {
    int a=1;
    int b=2;
    int c=3;
    return func();
}

_DATA SEGMENT
_DATA ENDS

int func(void) PROC ; func
  mov eax, 1
  ret 0
int func(void) ENDP ; func

a$ = 32  //4 bytes from rsp+32 to rsp+35
b$ = 36
c$ = 40 
num$ = 64 

//masm shows stack locals and params relative to the address of rsp; the rsp address
//is the rsp in the main body of the function after the prolog and before the epilog

int square(_struc) PROC ; square
$LN3:
  mov DWORD PTR [rsp+8], ecx
  sub rsp, 56 ; 00000038H
  mov DWORD PTR a$[rsp], 1
  mov DWORD PTR b$[rsp], 2
  mov DWORD PTR c$[rsp], 3
  call int func(void) ; func
  add rsp, 56 ; 00000038H
  ret 0
int square(_struc) ENDP ; square

可以看到,有56个字节被保留下来,当call指令分配8个字节的返回地址时,绿色的堆栈帧将会变成64个字节大小。
这56个字节包括12个字节的本地变量、32个字节的主存空间和12个字节的对齐。
所有被调用者寄存器保存和在主存中存储寄存器参数都发生在函数序言中,在序言保留(使用sub rsp, x指令)主体需要的堆栈空间之前。对齐位于sub rsp, x指令保留的空间的最高地址处,并且函数中的最后一个局部变量分配在该地址的下一个较低地址上(在原始数据类型的赋值内部,它从该赋值的最低地址开始向更高地址按字节递增,因为它是小端),使得函数中的第一个原始类型(数组单元、变量等)位于堆栈顶部,尽管局部变量可以以任何顺序分配。以下是一个不同于上面的随机示例代码的示例图,该代码不调用任何函数(仍然使用x64 Windows cc):

enter image description here

如果你删除对func()的调用,那么它只会保留24个字节,即12个字节的本地变量和12个字节的对齐空间。对齐空间位于帧的开始处。当一个函数将某些内容推入堆栈或通过减少rsp来保留堆栈空间时,无论是否要调用另一个函数,rsp都需要对齐。如果可以优化掉堆栈空间的分配并且不需要homespace+return_addreess,因为函数不进行调用,则不需要对齐要求,因为rsp不会改变。如果堆栈仅通过它需要分配的本地变量(+ homespace+return_address如果它进行了调用)来对齐16,则也不需要对齐,本质上它将需要分配的空间向上舍入到16字节边界。
在x64 Windows调用约定中,除非使用alloca,否则不使用rbp
在gcc 32位cdecl和64位system V调用约定中,使用rbp,新的rbp指向旧rbp后的第一个字节(仅在使用-O0编译时,因为它保存到堆栈上-O0,否则rbp将指向返回地址后的第一个字节)。在这些调用约定中,如果使用-O0进行编译,则在保存被调用者保存的寄存器之后,将寄存器参数存储到堆栈中,这将与rbp相关,并且是由rsp减量完成的堆栈保留的一部分。在由rsp减量完成的堆栈保留中访问数据相对于rbp而不是rsp,与Windows x64 cc不同。在Windows x64调用约定中,它将传递给它的参数存储在为其分配的homespace中,如果它是一个varargs函数或者使用-O0进行编译。如果它不是一个varargs函数,那么在-O1上,它将不会将它们写入homespace,但是调用函数仍然会为它提供homespace,这意味着它实际上从寄存器而不是homespace位置的堆栈访问这些变量,在将其存储在那里之后,不像O0(它将它们保存到homespace中,然后通过堆栈而不是寄存器访问它们)。
如果在前面图表所代表的函数中放置一个函数调用,则在被调用函数的序言开始之前,堆栈现在将如下所示(Windows x64 cc):

enter image description here


橙色部分表示被调用者可以自由安排的部分(数组和结构体当然仍然是连续的,朝着更高的地址工作,每个元素都是小端),因此它可以以任何顺序放置变量和返回值分配,并在调用无法通过rax传递函数的返回类型时,在rcx中传递返回值分配的指针供被调用者写入。在-O0下,如果返回值无法通过rax传递,则还会创建一个匿名变量(以及返回值空间以及任何分配给它的变量,因此结构体可能有3个副本)。-Ofast不能优化掉返回值空间,因为它是按值返回的,但是如果不使用返回值,它将优化掉匿名返回变量,或者将其直接分配给分配返回值的变量,而无需创建匿名变量,因此-Ofast具有2/1个副本,而-O0具有3/2个副本(返回值分配给变量/返回值未分配给变量)。蓝色部分表示被调用者必须按照规定顺序提供的部分,以符合被调用者的调用约定(参数必须按照该顺序排列,使得函数签名从左到右的第一个栈参数位于堆栈顶部,这与cdecl(它是32位cc)如何排序其堆栈参数相同。然而,被调用者的对齐方式可以位于任何位置,尽管我只见过它在局部变量和被调用者推入的寄存器之间。
如果函数调用多个函数,则对于函数中的所有不同可能调用点,调用都在栈上相同的位置,这是因为预处理程序为整个函数提供服务,包括它所做的所有调用以及为任何被调用的函数分配的参数和空间总是在预处理程序中分配的末尾。
事实证明,C/C++ Microsoft 调用约定仅在结构适合一个寄存器时才通过寄存器传递结构,否则它会复制本地/匿名变量并将其指针传递给第一个可用寄存器。在 gcc C/C++ 中,如果结构不适合前两个参数寄存器,则它会在堆栈上传递,并且不会传递指向它的指针,因为被调用方知道由于调用约定结构的位置。
数组无论大小如何都是通过引用传递的。因此,如果您需要将 rcx 用作返回值分配的指针,则如果第一个参数是数组,则指针将在 rdx 中传递,这将是传递的本地变量的指针。在这种情况下,它不需要将其作为参数复制到堆栈中,因为它不是按值传递的。但是,如果没有可用的寄存器来传递指针,则通过引用传递指针时会在堆栈上传递指针。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接