变量如何存储在程序堆栈中以及如何从程序堆栈中检索?


47

提前为这个问题的幼稚表示歉意。我是位50岁的艺术家,这是我第一次真正地正确理解计算机。所以去了。

我一直在尝试了解编译器如何处理数据类型和变量(从非常普遍的意义上讲,我知道有很多事情要做)。我对“堆栈”中的存储与值类型之间的关系以及“堆”中的存储与引用类型之间的关系的理解中缺少一些东西(引号旨在表示我理解这些术语是抽象的,而不是抽象的在像我构想此问题的方式这样的简化上下文中太夸张了。无论如何,我的简单想法是布尔值和整数之类的类型都放在“堆栈”上,因为它们可以存储,因为它们在存储空间方面是已知的实体,并且可以轻松地控制它们的范围。

但是我没有得到的是应用程序随后如何读取堆栈上的变量-如果我声明并分配x为整数,例如x = 3,并且存储在堆栈上保留,然后将其值3存储在堆栈中,然后在我声明和分配的功能与相同y,例如4,然后x在另一个表达式中使用(例如z = 5 + x),该程序如何读取x以便评估z何时在下面y在堆栈上?我显然缺少了一些东西。是不是堆栈上的位置仅与变量的生存期/范围有关,并且整个堆栈实际上始终可以被程序访问?如果是这样,是否意味着还有其他索引仅保留堆栈上变量的地址以允许检索值?但是后来我认为堆栈的全部目的是将值与变量地址存储在同一位置?在我微弱的头脑中,似乎还有其他索引,那么我们在谈论的更像是堆吗?我显然很困惑,我只是希望对我的简单问题有一个简单的答案。

感谢您阅读本文。


7
@ fade2black我不同意-应该有可能给出合理长度的答案,以总结要点。
David Richerby

9
您正在犯一个极其常见的错误,那就是将种类它的存储位置混淆在一起。说布尔值一直在堆栈上是完全错误的。布尔进入变量如果已知变量的生存期较短则将其放入堆栈中;如果未知变量生存期较短则将其放入堆中。有关如何这涉及到C#的一些想法,见 blogs.msdn.microsoft.com/ericlippert/2010/09/30/...
埃里克利珀

7
另外,不要将堆栈视为变量中堆栈。可以将其视为方法激活框架的堆栈。在方法内,您可以访问该方法激活的任何变量,但不能访问调用方的变量,因为它们不在堆栈顶部的框架中
埃里克·利珀特

5
另外:我为您主动学习新知识并深入研究某种语言的实现细节而鼓掌。您在这里遇到了一个有趣的绊脚石:您了解堆栈作为抽象数据类型是什么,但不了解用于简化激活和延续性的实现细节。后者不会遵循堆栈的抽象数据类型的规则; 它更多地将它们视为准则而不是规则。编程语言的全部重点是确保您不必了解这些抽象的细节即可解决编程问题。
埃里克·利珀特

4
谢谢Eric,Sava,Thumbnail,这些评论和参考都非常有用。我总是觉得像您这样的人在看到像我这样的问题时必须内向地吟,但是请知道获得答案的巨大兴奋和满足感!
Celine Atwood

Answers:


24

将局部变量存储在堆栈上是实现细节-本质上是一种优化。您可以这样想。输入函数时,所有局部变量的空间都分配在某处。然后,您可以访问所有变量,因为您以某种方式知道它们的位置(这是分配过程的一部分)。离开函数时,空间将被释放(释放)。

堆栈是实现此过程的一种方法–您可以将其视为大小有限的“快速堆”,因此仅适用于小变量。作为一项额外的优化,所有局部变量都存储在一个块中。由于每个局部变量都有已知的大小,因此您可以知道块中每个变量的偏移量,这就是访问变量的方式。这与在堆上分配的变量相反,堆的变量本身的地址存储在其他变量中。

您可以将堆栈视为与经典堆栈数据结构非常相似,但有一个重要区别:您可以访问堆栈顶部以下的项目。实际上,您可以从顶部访问第个项目。这样可以通过推入和弹出访问所有局部变量。唯一要做的推动是在进入功能时,而唯一弹出是在退出功能时。k

最后,让我说一下,实际上,一些局部变量存储在寄存器中。这是因为对寄存器的访问比对堆栈的访问快。这是为局部变量实现空间的另一种方法。再一次,我们确切地知道了变量的存储位置(这次不是通过偏移量,而是通过寄存器的名称),并且这种存储仅适用于小数据。


1
“分配在一个块中”是另一种实现细节。不过没关系。编译器知道局部变量如何需要内存,然后将其分配给一个或多个块,然后在该内存中创建局部变量。
MSalters

谢谢,改正。确实,其中一些“块”只是寄存器。
Yuval Filmus

1
如果确实如此,您只需要堆栈即可存储返回地址。通过传递指向堆上返回地址的指针,您可以很容易地在没有堆栈的情况下实现递归。
Yuval Filmus

1
@MikeCaron Stacks与递归几乎无关。为什么要在其他实施策略中“淘汰变量”?
gardenhead

1
@gardenhead最明显的替代方法(实际上已经使用过)是静态分配每个过程的变量。快速,简单,可预测...但是不允许递归或重入。这与传统的堆栈不是唯一的,当然(动态分配的一切是另一个),但他们通常是那些证明堆栈时,讨论:)替代品
霍布斯

23

如您所指出的那样,拥有y堆栈并不会物理阻止x访问,这使计算机堆栈与其他堆栈有所不同。

编译程序时,变量在堆栈中的位置也是预先确定的(在函数上下文内)。在你的榜样,如果堆栈包含xy“之上”,然后程序知道事先x将是1项下的堆栈的顶部,而在函数内部。由于计算机硬件可以在堆栈顶部下方明确要求1个项目,因此x即使y存在也可以获得计算机。

是不是堆栈上的位置仅与变量的生存期/范围有关,并且整个堆栈实际上始终可以被程序访问?

是。退出函数时,堆栈指针将移回其先前位置,有效擦除xy,但是从技术上讲,它们将一直存在,直到将内存用于其他用途为止。此外,如果您的函数调用了另一个函数,x并且y仍然存在,并且可以通过故意在堆栈中移得太深来进行访问。


1
到目前为止,这似乎是最干净的答案,因为它不会超出OP带来的背景知识。+1可真正定位OP!
Ben I.

1
我也同意!尽管所有答案都非常有帮助,并且我非常感谢,但我的原始帖子很有动力,因为我感觉到(d)整个堆栈/堆东西对于理解值/引用类型的区别是绝对必不可少的,但是我不能看不到如何只能查看“堆栈”的顶部。因此,您的回答使我摆脱了困境。(当我第一次意识到物理学中的所有各种平方反比定律时,我的感觉就跟从球体发出的辐射的几何形状完全一样,您可以画一个简单的图来查看它。)
Celine Atwood

我喜欢它,因为当您看到更高层次上的某种现象(例如在语言中)实际上是由于某种基本现象在抽象树的下方而产生的原因时,它总是非常有用的。即使保持非常简单。
Celine Atwood

1
@CelineAtwood请注意,在将变量从堆栈中删除后,尝试“强制”访问变量将给您带来不可预知/不确定的行为,因此不应这样做。请注意,我没有说“不能” b / c,某些语言允许您尝试。不过,这仍然是编程错误,应该避免。
code_dredd

12

为了提供一个具体的示例,说明编译器如何管理堆栈以及如何访问堆栈上的值,我们可以看一下视觉描述,以及GCC在i386作为目标体系结构的Linux环境中由Linux 生成的代码。

1.堆叠框架

如您所知,栈是函数过程使用的正在运行的进程的地址空间中的位置,在某种意义上来说,栈是在栈上为本地声明的变量以及传递给函数的参数分配的空间(在函数之外声明的变量(即全局变量)的空间分配在虚拟内存的不同区域中。为函数的所有数据分配的空间称为堆栈帧。这是多个堆栈框架的可视化描述(来自“ 计算机系统:程序员的视角”):

CSAPP堆栈框架

2.堆栈框架管理和可变位置

为了使在特定堆栈帧中写入堆栈的值由编译器管理并由程序读取,必须有某种方法来计算这些值的位置并检索其内存地址。CPU中称为堆栈指针和基本指针的寄存器可以帮助实现这一点。

按照ebp惯例,基本指针包含堆栈底部或底部的内存地址。可以使用基本指针中的地址作为参考来计算堆栈帧中所有值的位置。如上图所示:例如,它%ebp + 4是存储在基本指针加4中的内存地址。

3.编译器生成的代码

但是我没有得到的是应用程序如何读取堆栈上的变量-如果我声明x并将其分配为整数,例如x = 3,并在堆栈上保留存储,然后将其值3存储在那里,然后在同一函数中我声明并分配y,例如4,然后在另一个表达式中使用x(例如z = 5 + x),程序如何读取x以便在以下情况下计算z它在堆栈上的y以下吗?

让我们使用一个用C编写的简单示例程序来查看其工作原理:

int main(void)
{
        int x = 3;
        int y = 4;
        int z = 5 + x;

        return 0;
}

让我们检查一下GCC为该C源代码生成的汇编文本(为清晰起见,我对其进行了一些清理):

main:
    pushl   %ebp              # save previous frame's base address on stack
    movl    %esp, %ebp        # use current address of stack pointer as new frame base address
    subl    $16, %esp         # allocate 16 bytes of space on stack for function data
    movl    $3, -12(%ebp)     # variable x at address %ebp - 12
    movl    $4, -8(%ebp)      # variable y at address %ebp - 8
    movl    -12(%ebp), %eax   # write x to register %eax
    addl    $5, %eax          # x + 5 = 9
    movl    %eax, -4(%ebp)    # write 9 to address %ebp - 4 - this is z
    movl    $0, %eax
    leave

我们观察到的是,变量x,y和z位于地址%ebp - 12%ebp -8%ebp - 4分别。换句话说,使用保存在CPU寄存器中的存储器地址来计算变量在堆栈帧中的位置。main()%ebp

4.超出堆栈指针的内存中的数据超出范围

我显然缺少了一些东西。是不是堆栈上的位置仅与变量的生存期/范围有关,并且整个堆栈实际上始终可以被程序访问?如果是这样,是否意味着还有其他索引仅保留堆栈上变量的地址以允许检索值?但是后来我认为堆栈的全部目的是将值与变量地址存储在同一位置?

堆栈是虚拟内存中的一个区域,其使用由编译器管理。编译器以这样的方式生成代码:绝不会引用超出堆栈指针的值(超出堆栈顶部的值)。可以说,当调用一个函数时,堆栈指针的位置会发生变化,从而在堆栈上创建被认为不是“超出范围”的空间。

随着函数的调用和返回,堆栈指针递减并递增。超出范围后,写入堆栈的数据不会消失,但是编译器不会生成引用该数据的指令,因为编译器无法使用%ebp或来计算这些数据的地址%esp

5.总结

可以由CPU直接执行的代码由编译器生成。编译器管理堆栈,功能和CPU寄存器的堆栈帧。GCC用来跟踪打算在i386架构上执行的代码中堆栈帧中变量位置的一种策略是使用堆栈帧基址指针中的内存地址%ebp作为参考,并将变量值写入堆栈帧中的位置相对于中地址的偏移量%ebp


我想问问那个图像是从哪里来的吗?它看起来好像很可疑... :-)可能是在过去的教科书中。
大鸭

1
nvmd。我刚刚看到了链接。这就是我的想法。+1分享这本书。
大鸭图片

1
+1进行gcc组装演示:)
flow2k '18

9

有两个特殊的寄存器:ESP(堆栈指针)和EBP(基本指针)。调用过程时,通常前两个操作是

push        ebp  
mov         ebp,esp 

第一个操作将EBP的值保存在堆栈上,第二个操作将堆栈指针的值加载到基本指针中(以访问局部变量)。因此,EBP指向与ESP相同的位置。

汇编程序将变量名称转换为EBP偏移量。例如,如果您有两个局部变量x,y,并且您有类似

  x = 1;
  y = 2;
  return x + y;

那么它可能会被翻译成类似

   push        ebp  
   mov         ebp,esp
   mov  DWORD PTR [ ebp + 6],  1   ;x = 1
   mov  DWORD PTR [ ebp + 14], 2   ;y = 2
   mov  eax, [ ebp + 6 ]
   add  [ ebp + 14 ], eax          ; x + y 
   mov  eax, [ ebp + 14 ] 
   ...  

偏移值6和14在编译时计算。

大致就是这样。有关详细信息,请参阅编译器手册。


14
这特定于Intel x86。在ARM上,使用寄存器SP(R13)和FP(R11)。在x86上,缺少寄存器意味着积极的编译器将不会使用EBP,因为它可以从ESP派生。这在最后一个示例中很明显,其中所有与EBP相关的寻址都可以转换为与ESP相关的寻址,而无需其他更改。
MSalters

您不是在ESP上缺少SUB来为x,y腾出空间吗?
哈根·冯·埃森

@HagenvonEitzen,大概。我只是想表达一下如何使用硬件寄存器访问堆栈上分配的变量的想法。
fade2black

投票人,请发表评论!!!
fade2black

8

您很困惑,因为无法使用堆栈的访问规则来访问存储在堆栈中的局部变量:先进先出或仅FILO

问题是FILO规则适用于函数调用序列和堆栈帧,而不适用于局部变量。

什么是堆栈框架?

当您输入一个函数时,它会在堆栈上被分配一些内存,称为堆栈框架。函数的局部变量存储在堆栈框架中。您可以想象堆栈框架的大小因函数而异,因为每个函数具有不同数量和大小的局部变量。

局部变量如何在堆栈框架中存储与FILO无关。(即使局部变量在源代码中的出现顺序也不能确保局部变量将以该顺序存储。)正如您在问题中正确推论的那样,“还有其他一些索引仅保留变量的地址在堆栈上以允许检索值”。通常使用基地址(例如堆栈帧的边界地址)和特定于每个局部变量的偏移值来计算局部变量的地址。

那么,这种FILO行为何时出现?

现在,如果调用另一个函数会怎样?被调用方函数必须具有其自己的堆栈框架,并且正是该堆栈框架被入了堆栈。也就是说,被调用方函数的堆栈框架位于调用方函数的堆栈框架之上。如果此被调用函数调用了另一个函数,则其堆栈框架将再次被推入堆栈的顶部。

如果函数返回会怎样?当被调用方函数返回到调用方函数时,被调用方函数的堆栈框架将从堆栈中弹出,从而释放空间供将来使用。

因此,根据您的问题:

是不是堆栈上的位置仅与变量的生存期/作用域有关,并且整个堆栈实际上始终可以被程序访问?

您在这里非常正确,因为函数返回时并不会真正擦除堆栈帧上的局部变量值。尽管存储该值的内存位置不属于任何函数的堆栈框架,但该值仅停留在该位置。当某些其他函数获得其包含该位置的堆栈帧并将其上的其他值写入该存储位置时,该值将被擦除。

那有什么区别堆和堆呢?

从意义上来说,堆栈和堆是相同的,它们都是引用内存中某些空间的名称。由于我们可以使用其地址访问内存中的任何位置,因此您可以访问堆栈或堆中的任何位置。

区别来自对计算机系统如何使用它们的承诺。如您所说,堆是供参考类型。由于堆中的值与任何特定的堆栈框架都没有关系,因此该值的范围不受任何函数的约束。但是,局部变量的作用范围是一个函数,尽管您可以访问位于当前函数堆栈框架之外的任何局部变量值,但系统将尝试通过使用以下方法来确保这种行为不会发生:堆叠框架。这给我们一种错觉,认为局部变量的作用域是特定的函数。


4

语言运行时系统有许多方法可以实现局部变量。使用堆栈是一种常见的有效解决方案,在许多实际情况下都可以使用。

直观地讲,堆栈指针sp在运行时保持不变(在固定地址或寄存器中,这确实很重要)。假定每个“推”都会递增堆栈指针。

在编译时,编译器确定每个变量的地址作为sp - K其中K是一个常数,其仅依赖于变量(因此可以在编译时来计算)的范围。

请注意,此处我们在广义上使用“堆栈”一词。不仅可以通过push / pop / top操作访问此堆栈,还可以使用进行访问sp - K

例如,考虑以下伪代码:

procedure f(int x, int y) {
  print(x,y);    // (1)
  if (...) {
    int z=x+y; // (2)
    print(x,y,z);  // (3)
  }
  print(x,y); // (4)
  return;
}

调用该过程时,x,y可以在堆栈上传递参数。为简单起见,假设约定是调用者先按x,然后按y

然后,编译器在点(1)可以xsp - 2y处找到sp - 1

在点(2),将新变量引入作用域。编译器生成的代码,金额x+y,即通过什么指向真实sp - 2sp - 1,并推动在堆栈中和的结果。

在点(3)z上打印。编译器知道它是作用域中的最后一个变量,因此由指向sp - 1y由于已sp更改,因此不再是。不过,要打印出来y,编译器知道可以在此范围内的处找到它sp - 2。同样,x现在位于sp - 3

在点(4),我们退出范围。z弹出,并y再次在address sp - 1xat找到sp - 2

当我们返回时,要么f调用者x,y从堆栈中弹出。

因此,K为编译器进行计算只是大致计算范围内有多少个变量。在现实世界中,这实际上更为复杂,因为并非所有变量的大小都相同,因此的计算K稍微复杂一些。有时,堆栈还包含的返回地址f,因此也K必须“跳过”该地址。但是这些都是技术性的。

请注意,在某些编程语言中,如果必须处理更复杂的功能,事情可能会变得更加复杂。例如,嵌套过程需要非常仔细的分析,因为K现在必须“跳过”许多返回地址,尤其是在嵌套过程是递归的情况下。闭包/ lambdas /匿名函数还需要谨慎处理“捕获的”变量。上面的示例仍然说明了基本思想。


3

最简单的想法是将变量视为内存中地址的固定名称。实际上,某些汇编程序以这种方式显示机器代码(“将值5存储在地址中i,其中i是变量名”)。

这些地址中的一些是“绝对”的,例如全局变量,一些是“相对的”,例如局部变量。函数中的变量(即地址)是相对于“堆栈”上某个位置的,这对于每个函数调用而言都是不同的。这样,相同的名称可以引用不同的实际对象,并且对同一函数的循环调用是在独立内存上进行的独立调用。


2

可以放在堆栈上的数据项被放在堆栈上-是的!这是一个高级空间。同样,一旦我们推x入堆栈然后又推y入堆栈,理想情况下,x直到y在那里我们才可以访问。我们需要弹出y才能访问x。你说对了。

堆栈不是变量,而是 frames

你弄错的地方是关于堆栈本身。在堆栈上,不是直接压入数据项。而是在堆栈stack-frame中压入一个称为的东西。该堆栈框架包含数据项。虽然您无法访问堆栈内部深处的框架,但可以访问顶部框架及其中包含的所有数据项。

可以说,我们的数据项捆绑在两个堆栈帧frame-x和中frame-y。我们一个接一个地推他们。现在,只要frame-y位于顶部frame-x,您就无法理想地访问其中的任何数据项frame-x。仅frame-y可见。但只要frame-y可见,您就可以访问其中捆绑的所有数据项。整个框架可见,暴露其中包含的所有数据项。

答案结束。这些框架上有更多(喧闹声)

在编译期间,将列出程序中所有功能的列表。然后,针对每个功能,列出可堆叠数据项。然后为每个功能创建一个stack-frame-template。该模板是一个数据结构,其中包含所有这些选择的变量,函数输入数据的空间,输出数据等。现在,在运行时,每当调用函数时,该函数的副本template以及所有输入变量和中间变量都将被放入堆栈中。当此函数调用其他函数时,函数的新副本stack-frame将放在堆栈中。现在只要函数正在运行,函数的数据项就会保留下来。一旦功能的目的,它的堆栈帧被弹出。现在堆栈帧处于活动状态,并且该函数可以访问其所有变量。

请注意,堆栈框架的结构和组成因编程语言而异。即使使用一种语言,在不同的实现中也可能存在细微的差异。


感谢您考虑CS。我现在是程序员,每天上钢琴课:)

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.