调用堆栈如何工作?


103

我试图更深入地了解编程语言的低级操作是如何工作的,尤其是它们如何与OS / 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,但这意味着OS / 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);
}

根据我的理解,这意味着in doSomething中的参数实际上指向abin中相同的内存地址foo。但是再一次,这意味着直到我们进入ab发生之前,不会弹出堆栈

这些2案件让我觉得我还没有完全掌握如何准确栈工作,以及如何严格遵循LIFO规则。


14
LIFO仅对于保留堆栈上的空间很重要。您始终可以访问至少在堆栈框架上的任何变量(在函数内部声明),即使该变量在许多其他变量之下
VoidStar 2014年

2
换句话说,LIFO意味着您只能在堆栈末尾添加或删除元素,并且您始终可以读取/更改任何元素。
HolyBlackCat 2014年

12
为什么用-O0编译后不反汇编一个简单的函数,并查看生成的指令?这很漂亮,很有启发性;-)。您会发现代码充分利用了RAM的R部分。它可以直接直接访问地址。您可以将变量名视为地址寄存器(堆栈指针)的偏移量。正如其他人所说,就堆栈而言,堆栈只是LIFO(适用于递归等)。关于访问它不是LIFO。访问是完全随机的。
彼得-恢复莫妮卡

6
您可以使用数组创建自己的堆栈数据结构,并仅存储顶部元素的索引,在按下时将其递增,在弹出时将其递减。如果这样做,您仍然可以随时访问数组中的任何单个元素,而无需压入或弹出它,就像您始终可以使用数组一样。大约同一件事正在发生。
Crowman 2014年

3
基本上,堆栈/堆的命名是不幸的。它们与数据结构术语的堆叠和堆放几乎没有相似之处,因此将它们称为相同是非常混乱的。
思远人

Answers:


117

调用堆栈也可以称为框架堆栈。遵循LIFO原理堆叠
的事物不是局部变量,而是被调用函数的整个堆叠框架(“调用”)。局部变量与那些框架一起被推送和弹出,分别在所谓的函数序言结尾句中

在框架内部,变量的顺序是完全不确定的。编译器会适当地“重新排序”框架内局部变量的位置,以优化它们的对齐方式,以便处理器能够尽快获取它们。至关重要的事实是,变量相对于某个固定地址的偏移在帧的整个生命周期中都是恒定的 -因此,只要取一个锚地址(例如帧本身的地址)并对该地址的偏移进行处理即可。变量。这样的锚地址实际上包含在所谓的基本帧指针中它存储在EBP寄存器中。另一方面,偏移量在编译时就很清楚,因此被硬编码到机器代码中。

维基百科的这张图显示了典型的调用堆栈的结构,如1所示

堆栈的图片

将我们要访问的变量的偏移量添加到帧指针中包含的地址,即可获得变量的地址。简而言之,代码只是通过基本指针的恒定编译时偏移量直接访问它们;这是简单的指针算法。

#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(帧指针省略),由-fomit-frame-pointerGCC和-OyClang设置。请注意,当且仅当仍然有可能进行调试时,每个优化级别> 0都会隐式地触发它,因为除此之外它没有任何成本。有关更多信息,请参见此处此处


1如注释中所指出的那样,帧指针大概是指向返回地址之后的地址。

2请注意,以R开头的寄存器是以E开头的寄存器的64位对应物。EAX指定RAX的四个低位字节。为了清楚起见,我使用了32位寄存器的名称。


1
好答案。用偏移量寻址数据的事情对我来说是一个缺失的地方:)
Christoph

1
我认为图中有个小错误。帧指针必须位于返回地址的另一侧。离开函数通常如下:将堆栈指针移至框架指针,从堆栈中弹出调用者框架指针,然后返回(即从堆栈中弹出调用者程序计数器/指令指针。)
kasperd 2014年

卡巴斯德是绝对正确的。您要么根本不使用帧指针(有效的优化,尤其是对于x86之类的寄存器匮乏的体系结构非常有用),要么使用它并将前一个指针存储在堆栈中-通常就在返回地址之后。如何设置和删除框架在很大程度上取决于体系结构和ABI。有很多体系结构(hello Itanium),整个过程都比较有趣。(还有可变大小的参数列表之类的东西!)
Voo

3
@Christoph我认为您正在从概念的角度来解决这个问题。这是一条有望有望解决这一问题的评论-RTS或运行时堆栈与其他堆栈有些不同,因为它是“脏堆栈”-实际上并没有什么阻止您查看不等于该值的值。 t在顶部。注意,在图中,绿色方法的“返回地址”-蓝色方法需要的!在参数之后。弹出前一帧后,blue方法如何获得返回值?好吧,这是一个肮脏的堆栈,因此它可以伸手抓住它。
2014年

1
实际上并不需要帧指针,因为可以总是使用堆栈指针的偏移量。默认情况下,面向x64体系结构的GCC使用堆栈指针,并释放了rbp执行其他工作的空间。
思远人

27

因为显然,接下来我们需要处理a和b,但这意味着OS / CPU(?)必须首先弹出d和c才能返回到a和b。但随后它会自行射击,因为在下一行中需要c和d。

简而言之:

无需弹出参数。调用者传递foo给函数的参数doSomething和in中的局部变量doSomething 都可以引用为距基本指针的偏移量
所以,

  • 进行函数调用时,将函数的参数推入堆栈。这些参数由基本指针进一步引用。
  • 当函数返回到其调用方时,使用LIFO方法从堆栈中弹出返回函数的参数。

详细地:

规则是,每个函数调用都会导致堆栈帧的创建(最小的是返回的地址)。因此,如果funcA调用funcBfuncBcall funcC,则在三个堆栈框架之间建立一个堆栈框架。当函数返回时,其框架无效。行为良好的函数仅作用于其自身的堆栈框架,而不会侵入其他函数。换句话说,对顶部的堆栈帧执行POPing(从函数返回时)。

在此处输入图片说明

您问题中的堆栈由调用方设置foo。当doSomethingdoAnotherThing被调用时,它们将建立自己的堆栈。该图可以帮助您理解这一点:

在此处输入图片说明

请注意,要访问参数,函数体必须从存储返回地址的位置向下遍历(较高的地址),并且要访问局部变量,函数体将必须向上遍历堆栈(较低的地址) )相对于返回地址的存储位置。实际上,典型的编译器为该函数生成的代码将完全做到这一点。编译器为此(基本指针)专用一个称为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
鹰头鹰嘴

esp是堆栈指针,而ebp是基本指针。如果我有一些小姐的知识,请更正。
Suraj Jain

19

就像其他人指出的那样,无需弹出参数,直到它们超出范围。

我将粘贴Nick Parlante的“ Pointers and Memory”中的一些示例。我认为情况比您想象的要简单一些。

这是代码:

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, etc。在代码中标记,并且当时的内存状态在图中显示:

在此处输入图片说明


2
很棒的视觉解释。我在Google上搜索并找到了该文件:cslibrary.stanford.edu/102/PointersAndMemory.pdf 真正有用的文件!
Christoph

7

不同的处理器和语言使用一些不同的堆栈设计。8x86和68000上的两种传统模式分别称为Pascal调用约定和C调用约定。除寄存器名称外,两个约定在两个处理器中的处理方式相同。每个寄存器都使用两个寄存器来管理堆栈和相关变量,称为堆栈指针(SP或A7)和帧指针(BP或A6)。

使用任何一种约定调用子例程时,在调用例程之前,所有参数都会被压入堆栈。然后,例程的代码将帧指针的当前值压入堆栈,将堆栈指针的当前值复制到帧指针,并从堆栈指针中减去局部变量(如果有)使用的字节数。完成此操作后,即使将其他数据压入堆栈,所有局部变量也将存储在与堆栈指针保持恒定负位移的变量处,并且调用方压入堆栈的所有参数都可以在以下位置访问:与帧指针的恒定正位移。

两种约定之间的区别在于它们处理从子例程退出的方式。在C约定中,返回函数将帧指针复制到堆栈指针(将其恢复为刚按下旧帧指针后的值),弹出旧帧指针的值,然后执行返回。调用者在调用之前将其压入堆栈的任何参数都将保留在那里。在Pascal约定中,在弹出旧的帧指针之后,处理器将弹出函数返回地址,将调用者推送的参数字节数添加到堆栈指针,然后转到弹出的返回地址。在最初的68000上,有必要使用3条指令序列来删除调用者的参数。原始版本之后的8x86和所有680x0处理器都包含“ ret N”

Pascal约定的优点是可以在调用者端节省一些代码,因为在函数调用之后,调用者不必更新堆栈指针。但是,它要求被调用的函数确切知道调用者要放置在堆栈中的参数字节数。在调用使用Pascal约定的函数之前,没有将适当数量的参数压入堆栈几乎可以保证会导致崩溃。但是,这可以通过以下事实来抵消:每个被调用方法中的一些额外代码会将代码保存在该方法被调用的位置。因此,大多数原始Macintosh工具箱例程都使用Pascal调用约定。

C调用约定的优点是允许例程接受可变数量的参数,并且即使例程不使用传递的所有参数也很健壮(调用者将知道它推送了多少字节的参数,并且这样就可以清理它们)。此外,没有必要在每个函数调用之后执行堆栈清理。如果例程按顺序调用四个函数,每个函数使用了价值四个字节的参数,则它可能会(而不是ADD SP,4在每个调用之后使用),而是ADD SP,16在最后一个调用之后使用一个,以清除所有四个调用中的参数。

如今,所描述的调用约定被认为是过时的。由于编译器在寄存器使用方面变得更加高效,因此通常使方法在寄存器中接受一些参数,而不是要求将所有参数都压入堆栈。如果一种方法可以使用寄存器保存所有参数和局部变量,则无需使用框架指针,因此无需保存和恢复旧的指针。尽管如此,有时在调用链接使用它们的库时仍需要使用旧的调用约定。


1
哇!我可以借你一个星期左右的时间吗?需要提取一些具体的东西!好答案!
Christoph

框架和堆栈指针存储在堆栈本身或其他任何位置中?
Suraj Jain

@SurajJain:通常,每个保存的帧指针副本将相对于新的帧指针值以固定的位移存储。
超级猫

主席先生,我对此表示怀疑已有很长时间了。如果在我的功能我写的,如果(g==4)int d = 3g我拿的输入使用scanf后,我定义另一个变量int h = 5。现在,编译器现在如何d = 3在堆栈中提供空间。偏移量是如何完成的,因为如果g不是4,那么堆栈中就不会有d的内存,而只会给偏移量h,如果g == 4偏移量首先是g,然后是for h。编译器如何在编译时执行此操作,它不知道我们的输入g
Suraj Jain

@SurajJain:早期的C版本要求函数中的所有自动变量必须出现在任何可执行语句之前。稍微放松一下复杂的编译,但是一种方法是在函数的开头生成代码,该函数从SP中减去前向声明的标签的值。在该函数中,编译器可以在代码的每个点上跟踪仍在作用域中的本地字节数,还可以跟踪在作用域中的最大字节数本地值。在功能结束时,它可以提供较早的值...
supercat

5

这里已经有一些非常好的答案。但是,如果您仍然担心堆栈的LIFO行为,可以将其视为框架堆栈,而不是变量堆栈。我的意思是,尽管一个函数可以访问不在堆栈顶部的变量,但它仍然只对堆栈顶部的项目起作用:单个堆栈框架。

当然,这也有例外。整个调用链的局部变量仍然分配并可用。但是不会直接访问它们。相反,它们是通过引用(或通过指针,实际上只是语义上的不同)传递的。在这种情况下,可以访问堆栈框架的局部变量。但是即使在这种情况下,当前正在执行的功能仍仅在其自身的本地数据上运行。它正在访问存储在其自己的堆栈帧中的引用,该引用可能是对堆中,静态内存中或堆栈下方的某些内容的引用。

这是堆栈抽象的一部分,它使函数可以按任何顺序调用,并允许递归。顶部堆栈框架是代码直接访问的唯一对象。可以间接访问其他任何内容(通过驻留在顶部堆栈框架中的指针)。

查看小程序的汇编可能会很有帮助,特别是如果您在没有优化的情况下进行编译。我认为您会看到函数中的所有内存访问都是通过与堆栈帧指针的偏移量发生的,这就是编译器将如何编写函数代码的方式。在通过引用传递的情况下,您将看到通过与堆栈帧指针有一定偏移量存储的指针的间接内存访问指令。


4

调用堆栈实际上不是堆栈数据结构。在幕后,我们使用的计算机是随机访问计算机体系结构的实现。因此,a和b可以直接访问。

在后台,机器执行以下操作:

  • 得到“ a”等于读取堆栈顶部下方第四个元素的值。
  • 得到“ b”等于读取堆栈顶部下面的第三个元素的值。

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


1

这是我为C的调用堆栈创建的图。比Google图片版本更准确,更现代

在此处输入图片说明

与上面图的确切结构相对应,这是在Windows 7上对notepad.exe x64的调试。

在此处输入图片说明

低地址和高地址被交换,因此堆栈在该图中向上爬。红色表示与第一张图完全相同的帧(使用红色和黑色,但现在黑色已被重新使用);黑色是家庭空间;蓝色是返回地址,是调用后指令的调用函数的偏移量;橙色是对齐方式,粉红色是指令指针在调用之后和第一个指令之前指向的位置。homespace + return值是Windows上允许的最小帧,并且由于必须保持被调用函数开始处的16字节rsp对齐,因此也始终包括8字节的对齐。BaseThreadInitThunk 等等。

红色功能框概述了被调用方函数在逻辑上“拥有” +读取/修改的内容(它可以修改在堆栈上传递的参数,该参数太大而无法在-Ofast上传递寄存器)。绿线划定了功能从功能开始到结束分配的空间。


如果在调试模式下进行编译,则RDI和其他寄存器args根本不会溢出到堆栈中,并且不能保证编译会选择该顺序。另外,对于最旧的函数调用,为什么在图的顶部未显示堆栈args?您的图表中没有明确划分哪个“拥有”哪些数据的框。(被调用方拥有其堆栈args)。从图的顶部省略堆栈args使得更难于看到“无法在寄存器中传递的参数”始终在每个函数的返回地址的正上方。
Peter Cordes

@PeterCordes goldbolt asm输出显示clang和gcc被调用者将在寄存器中传递的参数作为默认行为推送到堆栈,因此它具有地址。在gcc上,使用register参数后面的参数可以优化此效果,但是您认为无论如何都可以优化该参数,因为该地址从未在函数中使用。我将固定顶部框架;当然,我应该将省略号放在一个单独的空白框中。“被调用方拥有其堆栈args”,如果不能在寄存器中传递它们,调用者将推入的堆栈参数包括哪些?
Lewis Kelsey

是的,如果禁用优化功能进行编译,则被调用方会将其溢出到某个地方。但是与堆栈args的位置(以及可以保存的RBP)不同,没有关于位置的标准化。回复:被调用者拥有其堆栈args:是的,允许函数修改其传入的args。它本身溢出的reg args不是堆栈args。编译器有时会这样做,但是IIRC经常通过使用返回地址下方的空间来浪费堆栈空间,即使它们从未重新读取arg也不例外。如果呼叫者想使用相同的参数进行另一个呼叫,为安全起见,他们必须存储另一份副本,然后再重复call
Peter Cordes

@PeterCordes好吧,我将参数作为调用者堆栈的一部分,因为我是根据rbp指向的位置来划分堆栈帧的。一些图将其显示为被调用方堆栈的一部分(如该问题的第一个图所示),而另一些图则将其显示为调用方堆栈的一部分,但将它们视为参数范围的一部分使其成为被调用方堆栈的一部分确实有意义。调用者无法使用更高级别的代码访问。是的,看来register并且const优化仅对-O0有所不同。
Lewis Kelsey

@PeterCordes我更改了它。我可能会再次更改
Lewis Kelsey,
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.