callstack究竟是如何工作的?

时间:2014-06-01 15:27:06

标签: assembly cpu callstack low-level calling-convention

我正在努力深入了解编程语言的低级操作如何工作,尤其是它们如何与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,但这意味着操作系统/ CPU(?)必须弹出d和{{1}首先回到ca。但是它会在脚下射击,因为它在下一行需要bc

所以,我想知道完全在幕后发生了什么?

另一个相关问题。考虑我们传递对这样的其他函数之一的引用:

d

根据我的理解,这意味着fn foo() { let a = 1; let b = 2; let c = 3; let d = 4; // line X doSomething(&a, &b); doAnotherThing(c, d); } 中的参数实际上指向doSomething中的ab相同的内存地址。但是,这意味着在我们到达fooa 之前,没有弹出堆栈。

这两个案例让我觉得我还没有完全理解堆栈的完全是如何运​​作的,以及它如何严格遵循 LIFO 规则。

7 个答案:

答案 0 :(得分:112)

调用堆栈也可以称为帧堆栈 在LIFO原则之后堆叠的东西不是局部变量,而是被调用函数的整个堆栈帧(“调用”)。局部变量分别与所谓的function prologueepilogue中的那些帧一起推送和弹出。

在框架内部,变量的顺序是完全未指定的;编译器"reorder" the positions of local variables inside a frame适当地优化其对齐,以便处理器可以尽快获取它们。关键的事实是,变量相对于某个固定地址的偏移在帧的整个生命周期内是恒定的 - 所以只需要一个锚地址,比如帧本身的地址,使用该地址的偏移量来处理变量。这样的锚地址实际上包含在所谓的 base 帧指针中,它存储在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中。

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

由8位寄存器AL指定的EAX的低位字节被取并存储在基指针之后的字节中:即-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寄存器本来可以包含任意使用的帧指针值 - 但它可以使debugging impossible on some machines成为implicitly turned off for some functions。在为只有很少寄存器的处理器进行编译时尤其有用,包括x86。

此优化称为FPO(帧指针省略),由GCC中的-fomit-frame-pointer和Clang中的-Oy设置;注意,它是由每个优化级别隐式触发的> 0当且仅当调试仍然可行时,因为它除此之外没有任何费用。 有关详细信息,请参阅herehere


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

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

答案 1 :(得分:25)

  

因为很明显,接下来我们需要的是使用a和b,但这意味着OS / CPU(?)必须首先弹出d和c才能返回a和b。但是它会在脚下射击,因为它需要下一行中的c和d。

简而言之:

无需弹出参数。调用者foo传递给函数doSomething的参数和doSomething 中的局部变量都可以作为base pointer 的偏移量引用。<登记/> 所以,

  
      
  • 进行函数调用时,函数的参数在堆栈上被推送。基指针进一步引用这些参数。
  •   
  • 当函数返回其调用者时,返回函数的参数使用LIFO方法从堆栈中弹出。
  •   

详细说明:

规则是每个函数调用都会导致创建堆栈帧(最小值是要返回的地址)。因此,如果funcA调用funcBfuncB调用funcC,则会在另一个上面建立三个堆栈帧。 当函数返回时,其框架变为无效。一个表现良好的函数仅在其自己的堆栈帧上起作用,并且不会侵入另一个堆栈帧。换句话说,POPing被执行到顶部的堆栈帧(当从函数返回时)。

enter image description here

问题中的堆栈由来电者foo设置。调用doSomethingdoAnotherThing时,他们会设置自己的堆栈。该图可能有助于您理解这一点:

enter image description here

注意,要访问参数,函数体必须从存储返回地址的位置向下遍历(更高的地址),并且要访问局部变量,函数体必须遍历堆栈(较低地址)相对于存储返回地址的位置。事实上,典型的编译器生成的函数代码就是这样做的。编译器为此指定了一个名为EBP的寄存器(Base Pointer)。另一个名称是帧指针。作为函数体的第一件事,编译器通常将当前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

参考文献:

答案 2 :(得分:18)

与其他人一样,没有必要弹出参数,直到它们超出范围。

我将贴上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, etc。标有 当时的代码和内存状态如图所示:

enter image description here

答案 3 :(得分:7)

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

使用任一约定调用子例程时,在调用例程之前,任何参数都将被压入堆栈。例程代码然后将帧指针的当前值压入堆栈,将堆栈指针的当前值复制到帧指针,并从堆栈指针中减去局部变量使用的字节数[如果有的话] ]。完成后,即使将其他数据压入堆栈,所有局部变量也将存储在具有来自堆栈指针的恒定负位移的变量中,并且调用者在堆栈上推送的所有参数都可以在帧指针的恒定正位移。

两种约定之间的区别在于它们处理从子程序退出的方式。在C约定中,返回函数将帧指针复制到堆栈指针[将其恢复到刚按下旧帧指针后的值],弹出旧帧指针值,然后执行返回。调用之前调用者在堆栈上推送的任何参数都将保留在那里。在Pascal约定中,在弹出旧的帧指针之后,处理器弹出函数返回地址,向栈指针添加调用者推送的参数的字节数,然后转到弹出的返回地址。在原始68000上,必须使用3指令序列来删除呼叫者的参数; 8x86和所有680x0处理器之后的原版包含了一个&#34; ret N&#34; [或680x0等效]指令,在执行返回时将N添加到堆栈指针。

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

C调用约定的优点是允许例程接受可变数量的参数,并且即使例程没有使用所有传递的参数也是健壮的(调用者将知道多少字节值它推动的参数,因此能够清理它们。此外,在每次函数调用之后执行堆栈清理都是不必要的。如果一个例程按顺序调用四个函数,每个函数使用四个字节的参数,它可以 - 而不是在每次调用后使用ADD SP,4,在最后一次调用之后使用一个ADD SP,16来清理来自所有四个电话的参数。

如今所描述的调用约定被认为有些陈旧。由于编译器在寄存器使用方面的效率更高,因此通常让方法接受寄存器中的一些参数,而不是要求所有参数都被压入堆栈;如果一个方法可以使用寄存器来保存所有参数和局部变量,则不需要使用帧指针,因此不需要保存和恢复旧的指针。但是,在调用链接使用它们的库时,有时需要使用较旧的调用约定。

答案 4 :(得分:4)

这里已经有了一些非常好的答案。但是,如果您仍然关注堆栈的LIFO行为,请将其视为一堆帧,而不是一堆变量。我的意思是,虽然函数可以访问不在堆栈顶部的变量,但它仍然只在堆栈顶部的上运行:单个堆栈框架

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

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

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

答案 5 :(得分:4)

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

在幕后,机器会:

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

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

答案 6 :(得分:1)

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

enter image description here

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

enter image description here

低地址和高地址被交换,因此堆栈在该图中向上爬。红色表示与第一张图完全相同的帧(使用红色和黑色,但现在黑色已被重新使用);黑色是家庭空间;蓝色是返回地址,它是调用后指令的调用函数的偏移量;橙色是对齐方式,粉红色是指令指针在调用之后和第一个指令之前指向的位置。 homespace + return值是Windows上允许的最小帧,并且由于必须保持被调用函数开始处的16字节rsp对齐,因此也始终包括8字节的对齐。因为这些函数不需要任何堆栈本地变量(因为它们可以优化到寄存器中)或堆栈参数/返回值(因为它们适合寄存器)并且不使用任何其他字段,所以堆栈帧都是homespace + return_value +尺寸对齐。第一帧为BaseThreadInitThunk,依此类推。

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