我了解堆栈指针是什么-但是它的作用是什么?


11

堆栈指针指向堆栈的顶部,该堆栈以我们所谓的“ LIFO”为基础存储数据。偷别人的比喻,就像是一堆盘子放在顶部放在盘子里。堆栈指针OTOH指向堆栈的顶部“盘”。至少对于x86如此。

但是,为什么计算机/程序“关心”堆栈指针指向的内容呢?换句话说,拥有堆栈指针并知道其指向服务的目的是什么?

C程序员可以理解的解释将不胜感激。


因为您看不到 ram中的堆栈顶部,所以您可以看到一堆碗碟的顶部。
tkausl 2015年


8
采取从堆栈底部的菜。您在顶部添加一个,其他人则从顶部添加。您正在考虑在这里排队。
Kilian Foth,2015年

@Snowman您的编辑似乎改变了问题的含义。moonman239,您可以验证Snowman的更改是否准确,特别是添加“此堆栈实际上有什么作用,而不是解释其结构?”
8bittree

1
@ 8bittree请参阅编辑说明:我将主题行中所述的问题复制到了问题的主体中。当然,我总是愿意更改某些内容,并且原始作者始终可以自由回滚或以其他方式编辑帖子。

Answers:


23

与解释其结构相反,此堆栈实际上有什么作用?

您有许多答案可以准确地描述存储在堆栈上的数据的结构,我注意到这与您提出的问题相反。

栈所服务的目的是:栈是没有协程的语言中延续性验证的一部分

让我们打开包装。

延续是简单地说,回答这个问题:“到底是怎么回事,我计划接下来会发生什么?” 接下来,每个程序中的每个点都会发生一些事情。将要计算两个操作数,然后程序通过计算它们的和继续执行程序,然后程序通过将和分配给变量来继续程序,然后...等等。

修复只是将highfalutin一词用于使抽象概念的具体实现。“接下来发生什么?” 是一个抽象的概念;堆栈的布局方式是将该抽象概念转换为真正可计算事物的真实机器的一部分。

协同程序是可以记得他们在哪里,收益率控制到另一个协程一会儿,然后继续他们离开的地方后,但功能并不刚刚叫做协程的产量后一定马上。想想C#中的“收益回报”或“等待”,它们必须记住当请求下一项或异步操作完成时它们在哪里。具有协程或类似语言功能的语言需要比堆栈更高级的数据结构才能实现延续。

堆栈如何实现连续性?其他答案说如何。堆栈存储(1)变量和临时变量的值,这些变量和临时变量的生存期不超过当前方法的激活,以及(2)与最近方法激活关联的继续代码的地址。在具有异常处理的语言中,堆栈还可以存储有关“错误继续”的信息,即异常情况发生时程序下一步将做什么。

让我借此机会注意到,堆栈并没有告诉您“我来自哪里?” -尽管它经常在调试中使用。堆栈告诉您下一步要去哪里,以及到达那里时激活变量的值是什么。在没有协程的语言中,下一步通常是您来自哪里,这一事实使得这种调试更加容易。但是并没有要求编译器存储有关控制权来源的信息,如果它不这样做就可以摆脱。例如,尾调用优化会破坏有关程序控件来源的信息。

为什么我们使用堆栈在没有协程的语言中实现延续?因为方法的同步激活的特征是当其自身逻辑上组成一系列激活时,“挂起当前方法,激活另一种方法,恢复当前方法,知道已知方法的结果”的模式。制作实现这种类似堆栈行为的数据结构非常便宜且容易。为什么这么便宜又容易?因为芯片组经过数十年的专门设计,以使编译器编写人员可以轻松进行这种编程。


请注意,您引用的引用是另一位用户在编辑中错误添加的,此后已得到纠正,这使得此答案无法完全解决问题。
8bittree

2
我可以肯定地解释是应该增加清晰度。我并不完全相信“堆栈是在没有协程的语言中实现延续性的一部分”甚至接近:-)

4

堆栈最基本的用途是存储函数的返回地址:

void a(){
    sub();
}
void b(){
    sub();
}
void sub() {
    //should i got back to a() or to b()?
}

从C的角度来看,这就是全部。从编译器的角度来看:

  • 所有函数参数都由CPU寄存器传递-如果没有足够的寄存器,则参数将被放入堆栈
  • 函数结束后(大多数)寄存器应具有与输入之前相同的值-因此使用过的寄存器将被备份到堆栈中

从OS的角度来看:程序可以随时中断,因此在完成系统任务后,我们必须恢复CPU状态,因此可以将所有内容存储在堆栈中

所有这些都能正常工作,因为我们不在乎堆栈中已经有多少项目,或者将来会有其他人添加多少项目,我们只需要知道我们移动了多少堆栈指针并在完成后将其还原即可。


1
我认为说参数被压入堆栈更为准确,尽管通常是在具有足够空闲寄存器来执行任务的处理器上使用优化寄存器来代替。这是一个固有的问题,但我认为这与语言的历史发展方式更加匹配。最早的C / C ++编译器根本不使用寄存器。

4

LIFO与FIFO

LIFO代表后进先出。如图所示,放入堆栈中的最后一个项目是从堆栈中取出的第一个项目。

用菜类比(在第一个修订版中)描述的是队列或FIFO,先进先出。

两者之间的主要区别在于,LIFO /堆栈从同一端压入(插入)和弹出(取出),而FIFO /队列从相对端压入(插入)。

// Both:

Push(a)
-> [a]
Push(b)
-> [a, b]
Push(c)
-> [a, b, c]

// Stack            // Queue
Pop()               Pop()
-> [a, b]           -> [b, c]

堆栈指针

让我们看一下堆栈底层的情况。这是一些内存,每个盒子是一个地址:

...[ ][ ][ ][ ]...                       char* sp;
    ^- Stack Pointer (SP)

并且有一个指向当前空堆栈底部的堆栈指针(堆栈是向上增长还是向下增长与此处无关紧要,因此我们将忽略它,但是当然,在现实世界中,确实会确定要添加哪个操作) ,并从SP中减去)。

因此,让我们a, b, and c再次推动。左侧的图形,中间的“高级”操作,右侧的C-ish伪代码:

...[a][ ][ ][ ]...        Push('a')      *sp = 'a';
    ^- SP
...[a][ ][ ][ ]...                       ++sp;
       ^- SP

...[a][b][ ][ ]...        Push('b')      *sp = 'b';
       ^- SP
...[a][b][ ][ ]...                       ++sp;
          ^- SP

...[a][b][c][ ]...        Push('c')      *sp = 'c';
          ^- SP
...[a][b][c][ ]...                       ++sp;
             ^- SP

如您所见,每次我们push将参数插入到堆栈指针当前指向的位置,并调整堆栈指针以指向下一个位置。

现在弹出:

...[a][b][c][ ]...        Pop()          --sp;
          ^- SP
...[a][b][c][ ]...                       return *sp; // returns 'c'
          ^- SP
...[a][b][c][ ]...        Pop()          --sp;
       ^- SP
...[a][b][c][ ]...                       return *sp; // returns 'b'
       ^- SP

Pop与的相反push,它调整堆栈指针以指向先前的位置,并删除那里的项目(通常将其返回给任何人pop)。

您可能已经注意到了,b并且c仍在内存中。我只想向您保证这些不是错别字。我们将很快返回。

没有堆栈指针的生活

让我们看看如果没有堆栈指针会发生什么。从再次推送开始:

...[ ][ ][ ][ ]...
...[ ][ ][ ][ ]...        Push(a)        ? = 'a';

嗯,嗯……如果我们没有堆栈指针,那么我们就无法将某些内容移到它所指向的地址。也许我们可以使用一个指向底部而不是顶部的指针。

...[ ][ ][ ][ ]...                       char* bp; // "base pointer"
    ^- bp                                bp = malloc(...);

...[a][ ][ ][ ]...        Push(a)        *bp = 'a';
    ^- bp
// No stack pointer, so no need to update it.
...[b][ ][ ][ ]...        Push(b)        *bp = 'b';
    ^- bp

哦哦 由于我们无法更改堆栈基数的固定值,因此我们只需要a通过压b入相同的位置来覆盖堆栈的固定值即可。

好吧,为什么我们不跟踪自己被推了多少次。而且,我们还需要跟踪弹出的时间。

...[ ][ ][ ][ ]...                       char* bp; // "base pointer"
    ^- bp                                bp = malloc(...);
                                         int count = 0;

...[a][ ][ ][ ]...        Push(a)        bp[count] = 'a';
    ^- bp
...[a][ ][ ][ ]...                       ++count;
    ^- bp
...[a][b][ ][ ]...        Push(a)        bp[count] = 'b';
    ^- bp
...[a][b][ ][ ]...                       ++count;
    ^- bp
...[a][b][ ][ ]...        Pop()          --count;
    ^- bp
...[a][b][ ][ ]...                       return bp[count]; //returns b
    ^- bp

很好,它可以工作,但是它实际上与以前非常相似,只是*pointer它比pointer[offset](不需要额外的算术)便宜,更不用说键入更少了。这对我来说似乎是一种损失。

让我们再试一次。代替使用Pascal字符串样式查找基于数组的集合的末尾(跟踪集合中有多少项),让我们尝试C字符串样式(从头到尾扫描):

...[ ][ ][ ][ ]...                       char* bp; // "base pointer"
    ^- bp                                bp = malloc(...);

...[ ][ ][ ][ ]...        Push(a)        char* top = bp;
    ^- bp, top
                                         while(*top != 0) { ++top; }
...[ ][ ][ ][a]...                       *top = 'a';
    ^- bp    ^- top

...[ ][ ][ ][ ]...        Pop()          char* top = bp;
    ^- bp, top
                                         while(*top != 0) { ++top; }
...[ ][ ][ ][a]...                       --top;
    ^- bp       ^- top                   return *top; // returns '('

您可能已经在这里猜到了问题。未初始化的内存不能保证为0。因此,当我们寻找要放置的顶部时a,最终将跳过一堆未使用的内存位置,这些位置中有随机垃圾。同样,当我们扫描到顶部时,最终跳过的a距离远远超过了刚刚推入的位置,直到我们最终找到恰好是的另一个内存位置0,然后移回并返回了之前的随机垃圾。

修复起来很容易,我们只需要添加操作PushPop确保始终将栈顶更新为0,并用终止符初始化栈即可。当然,这也意味着我们不能0在堆栈中有一个(或任何我们选择为终止符的值)作为实际值。

最重要的是,我们还将O(1)运算更改为O(n)运算。

TL; DR

堆栈指针跟踪发生所有动作的堆栈顶部。有很多方法可以摆脱它(bp[count]并且top本质上仍然是堆栈指针),但是与简单地拥有堆栈指针相比,它们最终都变得更加复杂和缓慢。而且,不知道堆栈顶部在哪里意味着您无法使用堆栈。

注意:指向x86中运行时堆栈“底部”的堆栈指针可能是一个误解,与整个运行时堆栈倒置有关。换句话说,堆栈的底部位于高位存储器地址,而堆栈的顶端向下生长到较低的存储器地址。堆栈指针确实指向发生所有操作的堆栈尖端,只是该尖端的内存地址比堆栈基数低。


2

堆栈指针(与框架指针一起)用于调用堆栈(遵循指向Wikipedia的链接,那里有很好的图片)。

调用堆栈包含调用框架,其中包含返回地址,局部变量和其他局部数据(尤其是寄存器的溢出内容;形式)。

另请阅读有关尾部调用(某些尾部递归调用不需要任何调用框架),异常处理(如setjmp和longjmp,它们可能涉及一次弹出许多堆栈框架),信号中断以及延续。另请参阅调用约定应用程序二进制接口(ABI),尤其是x86-64 ABI(定义一些正式参数由寄存器传递)。

另外,用C语言编写一些简单的函数,然后使用对其gcc -Wall -O -S -fverbose-asm 进行编译,并查看生成的.s 汇编文件。

Appel在1986年发表的一篇旧论文中声称,垃圾回收可以比堆栈分配更快(在编译器中使用Continuation-Passing Style),但这在当今的x86处理器上可能是错误的(特别是由于缓存影响)。

请注意,在32位i686和64位x86-64上,调用约定,ABI和堆栈布局是不同的。同样,使用不同的语言(例如C,Pascal,Ocaml,SBCL Common Lisp具有不同的调用约定)的调用约定(负责分配或弹出调用帧的人员)可能会有所不同。

顺便说一句,最近的x86扩展(如AVX)在堆栈指针上施加了越来越大的对齐约束(IIRC,x86-64上的调用帧希望对齐到16个字节,即两个字或指针)。


1
您可能要提到的是,在x86-64上对齐16个字节意味着指针的大小/对齐方式是原来的两倍,这实际上比字节数更有趣。
Deduplicator 2015年

1

简而言之,该程序很在乎,因为它正在使用该数据,并且需要跟踪在哪里找到它。

如果在函数中声明局部变量,则在堆栈中存储它们。另外,如果调用另一个函数,则堆栈是在其中存储返回地址的位置,以便在调用完该函数后可以返回到您所在的函数,并在中断的地方取回该函数。

如果没有SP,众所周知,结构化编程将是根本不可能的。(您可以解决没有它的问题,但是这几乎需要实现您自己的版本,因此没有太大的区别。)


1
您断言没有堆栈的结构化编程是不可能的,这是错误的。编译为连续传递样式的程序不占用堆栈,但是它们是完全明智的程序。
埃里克·利珀特

@EricLippert:对于“完全明智”的值是完全荒谬的,以至于它们可能包括站着头并把自己从里到外翻出来。;-)
Mason Wheeler

1
通过继续传递,可能根本不需要调用堆栈。实际上,每个调用都是尾调用和转到而不是返回。“随着CPS和TCO消除了隐式函数返回的概念,它们的结合使用可以消除对运行时堆栈的需求。”

@MichaelT:我说“基本上”不可能是有原因的。从理论上讲,CPS可以做到这一点,但实际上,正如Eric 在一系列有关该主题的博客文章中所指出的那样在CPS中编写任何复杂的现实世界代码变得非常困难。
梅森惠勒2015年

1
@MasonWheeler Eric在谈论编译成 CPS的程序。例如,引用Jon Harrop的博客In fact, some compilers don’t even use stack frames [...], and other compilers like SML/NJ convert every call into continuation style and put stack frames on the heap, splitting every segment of code between a pair of function calls in the source into its own separate function in the compiled form.与“实现自己的[stack]版本”不同。
2015年

1

对于x86处理器中的处理器堆栈,碟式堆栈的类比确实是不准确的。
由于各种原因(大多数是历史原因),处理器堆栈从内存的顶部向内存的底部增长,因此更好的类比是悬挂在天花板上的链条链。将某些东西推入堆栈时,链链接会添加到最低的链接中。

堆栈指针指向链的最低链接,处理器使用它来“查看”该最低链接的位置,以便可以添加或删除链接,而不必从天花板向下移动整个链。

从某种意义上说,在x86处理器内部,堆栈是上下颠倒的,但是使用了正常的堆栈术语,因此,最低的链接被称为堆栈的顶部


我上面提到的链链接实际上是计算机中的存储单元,它们被用来存储局部变量和一些中间计算结果。计算机程序关心堆栈顶部的位置(即最低的链接挂起的位置),因为函数需要访问的绝大多数变量都位于堆栈指针所指的位置附近,因此需要对其进行快速访问。


1
The stack pointer refers to the lowest link of the chain and is used by the processor to "see" where that lowest link is, so that links can be added or removed without having to travel the entire chain from the ceiling down.我不确定这是一个很好的类比。实际上,永远不会添加或删除链接。堆栈指针更像是用来标记链接之一的磁带。如果你失去了那盒磁带,你就没有办法知道哪些是你所使用的最底层链路在所有 ; 从天花板向下移动链条不会对您有帮助。
2015年

因此,堆栈指针提供了程序/计算机可用来查找函数局部变量的参考点吗?
moonman239

如果是这样,那么计算机如何找到局部变量?它只是从头开始搜索每个内存地址吗?
moonman239

@ moonman239:否,编译时,编译器会跟踪每个变量相对于堆栈指针的存储位置。处理器了解这种相对寻址,可以直接访问变量。
Bart van Ingen Schenau 2015年

1
@BartvanIngenSchenau啊,好的。就像您在茫茫荒野中时需要帮助一样,因此您给911一个有关地标的位置的想法。在这种情况下,堆栈指针通常是最接近的“地标”,因此可能是最佳参考点。
moonman239

1

该答案具体指的是当前线程(执行中)堆栈指针。

在过程编程语言中,出于以下目的,线程通常可以访问堆栈1

  • 控制流程,即“调用栈”。
    • 当一个函数调用另一个函数时,调用堆栈会记住要返回的位置。
    • 调用堆栈是必需的,因为这就是我们希望“函数调用”的行为- “从上次中断的地方接管”
    • 还有其他编程样式在执行过程中没有函数调用(例如,仅在到达当前函数的末尾时才允许指定下一个函数),或者根本没有函数调用(仅使用goto和条件跳转) )。这些编程样式可能不需要调用堆栈。
  • 函数调用参数。
    • 当一个函数调用另一个函数时,可以将参数压入堆栈。
    • 调用结束时,调用方和被调用方必须遵循与谁负责从堆栈中清除参数相同的约定。
  • 存在于函数调用中的局部变量。
    • 注意,可以通过将指向该调用者的局部变量的指针传递被调用者,来使该调用者可以访问属于该调用者的局部变量

注意1:专用于线程的使用,尽管其内容可以被其他线程完全读取(并且可以砸碎)

在汇编编程,C和C ++中,这三个目的都可以在同一堆栈中实现。在某些其他语言中,某些目的可以通过单独的堆栈或动态分配的内存来实现。


1

这是堆栈用途的故意过分简化的版本。

可以将堆栈想象成一堆索引卡。堆栈指针指向顶部卡。

调用函数时:

  • 您可以在卡上调用该函数的行之后立即编写代码地址,然后将其放在堆栈上。(即,将堆栈指针加1,然后将地址写到它指向的位置)
  • 然后,将寄存器中包含的值写到一些卡上,然后将它们放在堆中。(即,将堆栈指针增加寄存器的数量,然后将寄存器的内容复制到其指向的位置)
  • 然后将标记卡放在桩上。(即,保存当前的堆栈指针。)
  • 然后,将调用该函数的每个参数的值写到卡上,然后将其放在堆上。(即,将堆栈指针增加参数数量,然后将参数写入堆栈指针指向的位置。)
  • 然后,为每个局部变量添加一个卡,可能在其上写入初始值。(即,您将堆栈指针增加局部变量的数量。)

至此,函数中的代码开始运行。编译代码以了解每个卡相对于顶部的位置。因此,它知道变量x是从顶部算起的第三张牌(即堆栈指针-3),而参数y是从顶部算起的第六张牌(即堆栈指针-6)。

此方法意味着不需要将每个局部变量或参数的地址烘焙到代码中。相反,所有这些数据项都是相对于堆栈指针寻址的。

当函数返回时,反向操作很简单:

  • 寻找标记卡,并将其上方的所有卡扔掉。(即,将堆栈指针设置为保存的地址。)
  • 从之前保存的卡中恢复寄存器并丢弃。(即从堆栈指针中减去一个固定值)
  • 从顶部卡上的地址开始运行代码,然后丢弃它。(即从堆栈指针中减去1。)

现在,堆栈返回到调用该函数之前的状态。

考虑这一点时,请注意两件事:分配和释放局部变量是一个非常快的操作,因为它只是在堆栈指针中增加一个数字或从中减去一个数字。还要注意,这与递归的工作方式很自然。

为了说明的目的,这被过度简化。实际上,可以将参数和局部变量作为优化放置在寄存器中,并且堆栈指针通常按机器的字长而不是一个字长递增和递减。(仅举几例。)


1

众所周知,现代编程语言支持子例程调用(通常称为“函数调用”)的概念。这意味着:

  1. 在某些代码中间,您可以在程序中调用其他函数;
  2. 该函数不明确知道从何处调用该函数。
  3. 但是,当其工作完成并结束时return,控制权将返回到启动调用的确切点,所有本地变量值都将在启动调用时生效。

电脑如何跟踪呢?它维护着哪些功能正在等待哪些调用返回的持续记录。该记录是一个堆栈-由于它是如此重要,因此我们通常将其称为堆栈。

而且,由于这种调用/返回模式非常重要,因此长期以来,CPU一直被设计为为其提供特殊的硬件支持。堆栈指针是CPU中的一项硬件功能,是专门用于跟踪堆栈顶部的寄存器,CPU的指令将其用于分支到子例程并从中返回。

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.