堆栈指针指向堆栈的顶部,该堆栈以我们所谓的“ LIFO”为基础存储数据。偷别人的比喻,就像是一堆盘子放在顶部放在盘子里。堆栈指针OTOH指向堆栈的顶部“盘”。至少对于x86如此。
但是,为什么计算机/程序“关心”堆栈指针指向的内容呢?换句话说,拥有堆栈指针并知道其指向服务的目的是什么?
C程序员可以理解的解释将不胜感激。
堆栈指针指向堆栈的顶部,该堆栈以我们所谓的“ LIFO”为基础存储数据。偷别人的比喻,就像是一堆盘子放在顶部放在盘子里。堆栈指针OTOH指向堆栈的顶部“盘”。至少对于x86如此。
但是,为什么计算机/程序“关心”堆栈指针指向的内容呢?换句话说,拥有堆栈指针并知道其指向服务的目的是什么?
C程序员可以理解的解释将不胜感激。
Answers:
与解释其结构相反,此堆栈实际上有什么作用?
您有许多答案可以准确地描述存储在堆栈上的数据的结构,我注意到这与您提出的问题相反。
栈所服务的目的是:栈是没有协程的语言中延续性验证的一部分。
让我们打开包装。
延续是简单地说,回答这个问题:“到底是怎么回事,我计划接下来会发生什么?” 接下来,每个程序中的每个点都会发生一些事情。将要计算两个操作数,然后程序通过计算它们的和继续执行程序,然后程序通过将和分配给变量来继续程序,然后...等等。
修复只是将highfalutin一词用于使抽象概念的具体实现。“接下来发生什么?” 是一个抽象的概念;堆栈的布局方式是将该抽象概念转换为真正可计算事物的真实机器的一部分。
协同程序是可以记得他们在哪里,收益率控制到另一个协程一会儿,然后继续他们离开的地方后,但功能并不刚刚叫做协程的产量后一定马上。想想C#中的“收益回报”或“等待”,它们必须记住当请求下一项或异步操作完成时它们在哪里。具有协程或类似语言功能的语言需要比堆栈更高级的数据结构才能实现延续。
堆栈如何实现连续性?其他答案说如何。堆栈存储(1)变量和临时变量的值,这些变量和临时变量的生存期不超过当前方法的激活,以及(2)与最近方法激活关联的继续代码的地址。在具有异常处理的语言中,堆栈还可以存储有关“错误继续”的信息,即异常情况发生时程序下一步将做什么。
让我借此机会注意到,堆栈并没有告诉您“我来自哪里?” -尽管它经常在调试中使用。堆栈告诉您下一步要去哪里,以及到达那里时激活变量的值是什么。在没有协程的语言中,下一步通常是您来自哪里,这一事实使得这种调试更加容易。但是并没有要求编译器存储有关控制权来源的信息,如果它不这样做就可以摆脱。例如,尾调用优化会破坏有关程序控件来源的信息。
为什么我们使用堆栈在没有协程的语言中实现延续?因为方法的同步激活的特征是当其自身逻辑上组成一系列激活时,“挂起当前方法,激活另一种方法,恢复当前方法,知道已知方法的结果”的模式。制作实现这种类似堆栈行为的数据结构非常便宜且容易。为什么这么便宜又容易?因为芯片组经过数十年的专门设计,以使编译器编写人员可以轻松进行这种编程。
堆栈最基本的用途是存储函数的返回地址:
void a(){
sub();
}
void b(){
sub();
}
void sub() {
//should i got back to a() or to b()?
}
从C的角度来看,这就是全部。从编译器的角度来看:
从OS的角度来看:程序可以随时中断,因此在完成系统任务后,我们必须恢复CPU状态,因此可以将所有内容存储在堆栈中
所有这些都能正常工作,因为我们不在乎堆栈中已经有多少项目,或者将来会有其他人添加多少项目,我们只需要知道我们移动了多少堆栈指针并在完成后将其还原即可。
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
,然后移回并返回了之前的随机垃圾。
修复起来很容易,我们只需要添加操作Push
并Pop
确保始终将栈顶更新为0
,并用终止符初始化栈即可。当然,这也意味着我们不能0
在堆栈中有一个(或任何我们选择为终止符的值)作为实际值。
最重要的是,我们还将O(1)运算更改为O(n)运算。
堆栈指针跟踪发生所有动作的堆栈顶部。有很多方法可以摆脱它(bp[count]
并且top
本质上仍然是堆栈指针),但是与简单地拥有堆栈指针相比,它们最终都变得更加复杂和缓慢。而且,不知道堆栈顶部在哪里意味着您无法使用堆栈。
注意:指向x86中运行时堆栈“底部”的堆栈指针可能是一个误解,与整个运行时堆栈倒置有关。换句话说,堆栈的底部位于高位存储器地址,而堆栈的顶端向下生长到较低的存储器地址。堆栈指针确实指向发生所有操作的堆栈尖端,只是该尖端的内存地址比堆栈基数低。
堆栈指针(与框架指针一起)用于调用堆栈(遵循指向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个字节,即两个字或指针)。
简而言之,该程序很在乎,因为它正在使用该数据,并且需要跟踪在哪里找到它。
如果在函数中声明局部变量,则在堆栈中存储它们。另外,如果调用另一个函数,则堆栈是在其中存储返回地址的位置,以便在调用完该函数后可以返回到您所在的函数,并在中断的地方取回该函数。
如果没有SP,众所周知,结构化编程将是根本不可能的。(您可以解决没有它的问题,但是这几乎需要实现您自己的版本,因此没有太大的区别。)
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]版本”不同。
对于x86处理器中的处理器堆栈,碟式堆栈的类比确实是不准确的。
由于各种原因(大多数是历史原因),处理器堆栈从内存的顶部向内存的底部增长,因此更好的类比是悬挂在天花板上的链条链。将某些东西推入堆栈时,链链接会添加到最低的链接中。
堆栈指针指向链的最低链接,处理器使用它来“查看”该最低链接的位置,以便可以添加或删除链接,而不必从天花板向下移动整个链。
从某种意义上说,在x86处理器内部,堆栈是上下颠倒的,但是使用了正常的堆栈术语,因此,最低的链接被称为堆栈的顶部。
我上面提到的链链接实际上是计算机中的存储单元,它们被用来存储局部变量和一些中间计算结果。计算机程序关心堆栈顶部的位置(即最低的链接挂起的位置),因为函数需要访问的绝大多数变量都位于堆栈指针所指的位置附近,因此需要对其进行快速访问。
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.
我不确定这是一个很好的类比。实际上,永远不会添加或删除链接。堆栈指针更像是用来标记链接之一的磁带。如果你失去了那盒磁带,你就没有办法知道哪些是你所使用的最底层链路在所有 ; 从天花板向下移动链条不会对您有帮助。
该答案具体指的是当前线程(执行中)的堆栈指针。
在过程编程语言中,出于以下目的,线程通常可以访问堆栈1:
注意1:专用于线程的使用,尽管其内容可以被其他线程完全读取(并且可以砸碎)。
在汇编编程,C和C ++中,这三个目的都可以在同一堆栈中实现。在某些其他语言中,某些目的可以通过单独的堆栈或动态分配的内存来实现。
这是堆栈用途的故意过分简化的版本。
可以将堆栈想象成一堆索引卡。堆栈指针指向顶部卡。
调用函数时:
至此,函数中的代码开始运行。编译代码以了解每个卡相对于顶部的位置。因此,它知道变量x
是从顶部算起的第三张牌(即堆栈指针-3),而参数y
是从顶部算起的第六张牌(即堆栈指针-6)。
此方法意味着不需要将每个局部变量或参数的地址烘焙到代码中。相反,所有这些数据项都是相对于堆栈指针寻址的。
当函数返回时,反向操作很简单:
现在,堆栈返回到调用该函数之前的状态。
考虑这一点时,请注意两件事:分配和释放局部变量是一个非常快的操作,因为它只是在堆栈指针中增加一个数字或从中减去一个数字。还要注意,这与递归的工作方式很自然。
为了说明的目的,这被过度简化。实际上,可以将参数和局部变量作为优化放置在寄存器中,并且堆栈指针通常按机器的字长而不是一个字长递增和递减。(仅举几例。)
众所周知,现代编程语言支持子例程调用(通常称为“函数调用”)的概念。这意味着:
return
,控制权将返回到启动调用的确切点,所有本地变量值都将在启动调用时生效。电脑如何跟踪呢?它维护着哪些功能正在等待哪些调用返回的持续记录。该记录是一个堆栈-由于它是如此重要,因此我们通常将其称为堆栈。
而且,由于这种调用/返回模式非常重要,因此长期以来,CPU一直被设计为为其提供特殊的硬件支持。堆栈指针是CPU中的一项硬件功能,是专门用于跟踪堆栈顶部的寄存器,CPU的指令将其用于分支到子例程并从中返回。