堆栈是构造程序的唯一合理方法吗?


74

我见过的大多数体系结构都依赖调用堆栈来保存/恢复函数调用之前的上下文。这种常见的范例使大多数处理器都内置了推入和弹出操作。是否有没有堆栈的系统?如果是这样,它们如何工作?它们的作用是什么?


5
由于功能是如何预计在类似C语言的行为(即你可以嵌套调用像你一样深,并且可以返回以相反的顺序背出),这不是很清楚,我一个怎么回事实现的功能,没有它是令人难以置信的呼吁效率低下。例如,您可以强迫程序员使用延续传递样式或其他怪异的编程形式,但是由于某种原因,似乎没有人在底层使用CPS。
凯文

5
GLSL无需堆栈即可工作(与该特定括号中的其他语言一样)。它只是不允许递归调用。
Leushenko '16

3
您可能还希望查看某些RISC体系结构使用的Register窗口
Mark Booth

2
@Kevin:“早期的FORTRAN编译器不支持子例程的递归。早期的计算机体系结构不支持堆栈的概念,当它们直接支持子例程调用时,返回位置通常存储在与子例程代码相邻的一个固定位置,不允许子例程在子例程的先前调用返回之前再次被调用。尽管在Fortran 77中未指定,但是许多F77编译器都支持将递归作为选项,而在Fortran 90中已成为标准。en.wikipedia.org/wiki/Fortran#FORTRAN_II
鸣叫鸭

3
P8X32A(“螺旋桨”)微控制器的标准汇编语言(PASM)没有堆栈的概念。负责跳转的指令还可以自行修改RAM中的返回指令,以确定返回到的位置-可以任意选择。有趣的是,“旋转”语言(在同一芯片上运行的一种解释性高级语言)确实具有传统的堆栈语义。
Wossname

Answers:


50

调用栈的(某种程度上)流行的替代方法是延续

例如,Parrot VM是基于连续的。它是完全没有堆栈的:数据保存在寄存器中(如Dalvik或LuaVM,Parrot是基于寄存器的),并且控制流用延续表示(与Dalvik或LuaVM不同,后者具有调用堆栈)。

通常由Smalltalk和Lisp VM使用的另一种流行的数据结构是意大利面条堆栈,类似于堆栈网络。

正如@rwong指出的那样,连续传递样式是调用堆栈的替代方法。以连续传递样式编写(或转换为连续传递样式)的程序永远不会返回,因此不需要堆栈。

从不同的角度回答您的问题:通过在堆上分配堆栈帧,可以在没有单独堆栈的情况下拥有调用堆栈。一些Lisp和Scheme的实现可以做到这一点。


4
这取决于您对堆栈的定义。我不确定堆栈框架的链表(或指向或...的指针数组)到底是不是“堆栈”而不是“堆栈的不同表示”?CPS语言和程序(在实践中)往往会构建与栈非常相似的有效的连续链接列表(如果没有,您可能会签出GHC,这会将所谓的“连续”推入线性栈)以提高效率)。
乔纳森·

6
以连续传递样式编写(或转换为连续传递样式的程序永不返回 ”)……听起来不祥。
罗伯·彭里奇

5
@RobPenridge:我同意,这有点神秘。CPS意味着函数返回工作后,将返回另一个函数作为附加参数,而不是返回。因此,当您调用一个函数并且在调用该函数之后还需要执行其他一些工作时,您不必等待函数返回然后继续进行您的工作,而是包装剩余的工作(“延续” )转换为函数,然后将该函数作为附加参数传递。然后,您调用的函数将调用该函数,而不是返回该函数,依此类推。无功能不断返回,它只是
约尔格W¯¯米塔格

3
…调用下一个函数。因此,您不需要调用堆栈,因为您不需要返回并恢复先前调用的函数的绑定状态。如果愿意,您可以携带将来的状态,而不是携带过去的状态使您可以回到过去
约尔格W¯¯米塔格

1
@jcast:堆栈的定义功能是IMO,您只能访问最顶层的元素。连续列表OTOH将使您能够访问所有连续而不仅仅是最顶层的堆栈帧。例如,如果您具有Smalltalk风格的可恢复异常,则需要能够遍历堆栈而不会弹出堆栈。在具有语言连续性的同时仍要保持熟悉的调用堆栈概念会导致意大利面条堆栈,这基本上是一棵堆栈树,其中,延续语句“分叉”了堆栈。
约尔格W¯¯米塔格

36

在过去,处理器没有堆栈指令,并且编程语言不支持递归。随着时间的流逝,越来越多的语言选择支持递归,并且具有堆栈框架分配功能的硬件紧随其后。这些年来,使用不同的处理器的支持变化很大。一些处理器采用了堆栈帧和/或堆栈指针寄存器。一些采用的指令将在单个指令中完成堆栈帧的分配。

随着处理器先使用单级缓存再使用多级缓存,堆栈的一项关键优势是缓存局部性。堆栈的顶部几乎总是在高速缓存中。只要您可以执行具有较高高速缓存命中率的操作,那么使用现代处理器便会走上正确的道路。应用于堆栈的缓存意味着本地变量,参数等几乎始终位于缓存中,并具有最高的性能水平。

简而言之,堆栈的用法在硬件和软件上都有所发展。还有其他模型(例如,尝试了较长时间的数据流计算),但是,堆栈的局部性使其工作得非常好。此外,过程代码正是处理器想要的性能:一条指令告诉它下一步要做什么。当指令不按线性顺序排列时,处理器至少在目前为止已经大大减慢了速度,因为我们还没有弄清楚如何使随机存取和顺序存取一样快。(顺便说一句,从高速缓存到主内存再到光盘,每个内存级别都有类似的问题。)

在已证明的顺序访问指令性能和调用堆栈的有益缓存行为之间,至少目前,我们有一个成功的性能模型。

(我们也可能将数据结构的可变性投入工作中……)

这并不意味着其他编程模型无法工作,特别是当它们可以转换为当今硬件的顺序指令和调用堆栈模型时。但是,支持硬件所在位置的模型有明显的优势。但是,情况并非总是保持不变,因此随着不同的存储器和晶体管技术允许更多的并行性,我们可以看到未来的变化。它总是在编程语言和硬件功能之间开玩笑,因此,我们将看到!


9
实际上,GPU仍然根本没有堆栈。禁止您在GLSL / SPIR-V / OpenCL中递归使用(不确定HLSL,但也许我看不出为什么会有所不同)。他们实际处理函数调用“堆栈”的方式是使用大量的寄存器。
LinearZoetrope16年

@Jsor:从SPARC体系结构可以看出,这在很大程度上是实现细节。像您的GPU一样,SPARC具有庞大的寄存器集,但它的独特之处在于它具有一个滑动窗口,该窗口在回绕时会将很旧的寄存器溢出到RAM中的堆栈中。因此,这实际上是两种模型之间的混合体。SPARC并没有确切指定有多少个物理寄存器,而是寄存器窗口有多大,因此不同的实现可能会处于“大量寄存器”规模的任何位置,以至于“每个函数调用只适合一个窗口”直接溢出到堆栈中”
MSalters

调用堆栈模型的缺点是必须非常小心地监视数组和/或地址溢出,因为如果可以执行堆的任意位,则可以将自修改程序作为漏洞利用程序。

14

TL; DR

  • 调用堆栈作为函数调用机制:
    1. 通常由硬件模拟,但不是硬件构造的基础
    2. 是命令式编程的基础
    3. 不是函数式编程的基础
  • 堆栈作为“后进先出”(LIFO)的抽象,对于计算机科学,算法甚至某些非技术领域都是至关重要的。
  • 不使用调用堆栈的程序组织的一些示例:
    • 连续通过样式(CPS)
    • 状态机-一个巨大的循环,所有内容都内联。(据称受Saab Gripen固件体系结构的启发,并归因于Henry Spencer的通信,并由John Carmack复制。) (注1)
    • 数据流体系结构-通过队列(FIFO)连接的参与者网络。队列有时称为通道。

这个答案的其余部分是思想和轶事的随机集合,因此有些杂乱无章。


您所描述的堆栈(作为函数调用机制)特定于命令式编程。

在命令式编程下面,您将找到机器代码。机器代码可以通过执行一小段指令来模拟调用堆栈。

在机器代码下,您会找到负责执行软件的硬件。虽然现代微处理器过于复杂,无法在此进行描述,但可以想象存在一种非常简单的设计,该设计虽然速度慢,但仍然能够执行相同的机器代码。这样简单的设计将利用数字逻辑的基本元素:

  1. 组合逻辑,即逻辑门的连接(和/或不,...)请注意,“组合逻辑”不包括反馈。
  2. 存储器,即触发器,锁存器,寄存器,SRAM,DRAM等。
  3. 一个状态机,它由一些组合逻辑和一些内存组成,刚好足以使它可以实现管理其余硬件的“控制器”。

以下讨论包含构造命令式程序的替代方法的大量示例。

这样的程序的结构如下所示:

void main(void)
{
    do
    {
        // validate inputs for task 1
        // execute task 1, inlined, 
        // must complete in a deterministically short amount of time
        // and limited to a statically allocated amount of memory
        // ...
        // validate inputs for task 2
        // execute task 2, inlined
        // ...
        // validate inputs for task N
        // execute task N, inlined
    }
    while (true);
    // if this line is reached, tell the programmers to prepare
    // themselves to appear before an accident investigation board.
    return 0; 
}

这种风格适用于微控制器,即适合那些将软件视为硬件功能伴侣的人。



@Peteris:堆栈是LIFO数据结构。
Christopher Creutzig '16

1
有趣。我本来会以为相反。例如,FORTRAN是命令式编程语言,早期版本未使用调用堆栈。但是,递归是函数式编程的基础,并且我认为一般情况下不使用堆栈就不可能实现递归。
TED

@TED-在函数式语言实现中,存在一个堆栈(或通常为树)数据结构,该结构表示待处理的计算,但您不一定要使用机器面向堆栈的寻址模式甚至调用/返回指令的指令来执行它(以嵌套/递归的方式-可能只是状态机循环的一部分)。
davidbak

@davidbak-IIRC,为了能够摆脱堆栈,递归算法几乎必须是尾递归的。可能还有其他一些情况可以对其进行优化,但是在一般情况下,您必须具有stack。实际上,有人告诉我,有数学证明可以在某处漂浮。这个答案声称这是Church-Turing定理(我认为是基于Turing机器使用堆栈的事实?)
TED

1
@TED-我同意你的看法。我认为这里的沟通不畅是因为我阅读了OP的帖子以谈论对我来说意味着机器体系结构的系统体系结构。我认为在这里回答的其他人也有相同的理解。所以,我们这些谁理解要在上下文已通过响应您不要在机器指令需要堆叠/寻址模式级别回答。但是我可以看到问题也可以解释为仅语言系统通常需要调用堆栈。这个问题的答案是没有,但出于不同的原因。
davidbak

11

不,不一定。

阅读Appel的旧论文《垃圾收集》可能比Stack Allocation更快。它使用延续传递样式,并显示了无堆栈的实现。

还要注意,旧的计算机体系结构(例如IBM / 360)没有任何硬件堆栈寄存器。但是OS和编译器按照约定(与调用约定有关)为堆栈指针保留了一个寄存器,以便他们可以有一个软件调用堆栈

原则上,整个程序C编译器和优化器都可以检测到调用图是静态已知的并且没有任何递归(或函数指针)的情况(对于嵌入式系统来说这是常见的)。在这样的系统中,每个函数都可以将其返回地址保持在固定的静态位置(这就是Fortran77在1970年代计算机中的工作方式)。

如今,处理器还具有了解CPU缓存的调用堆栈(以及调用和返回机器指令)。


1
很肯定FORTRAN使用静态返回地点时,FORTRAN-66出来,并且在需要支持停止SUBROUTINEFUNCTION。不过,您对较早的版本是正确的(FORTRAN-IV以及WATFIV)。
TMN

当然还有COBOL。关于IBM / 360的一个优点是-即使缺少硬件堆栈寻址模式,它也得到了很多使用。(R14,我相信是吗?)它具有用于基于堆栈的语言的编译器,例如PL / I,Ada,Algol,
C。– davidbak

的确,我在大学里学习过360并一开始就感到困惑。
JDługosz

1
@JDługosz对于计算机体系结构现代学生来说,考虑360的最佳方法是作为一台非常简单的RISC机器……尽管具有多种指令格式……还有一些异常,例如TRTRT
davidbak

如何“零加打包”移动寄存器?但是“分支和链接”而不是返回地址的堆栈已经卷土重来。
JDługosz

10

到目前为止,您已经得到了一些好的答案;让我给您一个不切实际但极富教育意义的示例,说明您如何设计语言时完全不需要堆栈或“控制流”的概念。这是确定阶乘的程序:

function f(i) => if i == 0 then 1 else i * f(i - 1)
let x = f(3)

我们将此程序放在字符串中,然后通过文本替换对程序进行评估。因此,当我们评估时f(3),我们进行搜索并用3代替i,如下所示:

function f(i) => if i == 0 then 1 else i * f(i - 1)
let x = if 3 == 0 then 1 else 3 * f(3 - 1)

大。现在,我们执行另一个文本替换:我们看到“ if”的条件为假,并执行另一个字符串替换,从而生成程序:

function f(i) => if i == 0 then 1 else i * f(i - 1)
let x = 3 * f(3 - 1)

现在我们对涉及常量的所有子表达式进行另一个字符串替换:

function f(i) => if i == 0 then 1 else i * f(i - 1)
let x = 3 * f(2)

您会看到情况如何;我不会再努力了。我们可以继续进行一系列的字符串替换,直到我们let x = 6完成为止。

传统上,我们使用堆栈来获取局部变量和延续信息。记住,堆栈不会告诉您您来自何处,它会告诉您下一步将要返回的值。

在编程的字符串替换模型中,堆栈上没有“局部变量”。将该函数应用于其形参时,形式参数会替换为其值,而不是放入堆栈的查找表中。而且没有“下一步”,因为程序评估只是将简单的规则应用到字符串替换中来生成一个不同但等效的程序。

现在,当然,实际上不可能进行字符串替换。但是逻辑上支持这种“等式推理”的编程语言(例如Haskell)正在使用这种技术。


3
Retina是使用字符串运算进行计算的基于Regex的编程语言的示例。
Andrew Piliser

2
@AndrewPiliser由这个帅哥设计和实现。

3

自Parnas于1972 年发布“将系统分解为模块所用的标准”以来,人们已经合理地接受了将信息隐藏在软件中是一件好事。这是整个上世纪60年代关于结构分解和模块化编程的长期辩论之后的结果。

模块化

在任何多线程系统中,由不同组实现的模块之间的黑匣子关系的必要结果,需要一种允许重入的机制和一种跟踪系统动态调用图的手段。受控的执行流必须同时传入和传出多个模块。

动态范围

一旦词法作用域不足以跟踪动态行为,则需要一些运行时簿记来跟踪差异。

给定任何线程(根据定义)只有一个当前指令指针,则LIFO堆栈适合跟踪每次调用。

例外情况

因此,尽管延续模型并没有为堆栈显式维护数据结构,但仍然有模块的嵌套调用必须在某个地方维护!

甚至声明性语言也可以保留评估历史记录,或者出于性能原因反而使执行计划变扁平并以其他方式保持进度。

rwong标识的无限循环结构在具有静态调度的高可靠性应用程序中很常见,这种静态调度不允许许多常见的编程结构,但要求将整个应用程序视为白盒,而没有大量的信息隐藏。

多个并发的无穷循环不需要任何结构来保存返回地址,因为它们不调用函数,这使问题无济于事。如果它们使用共享变量进行通信,则它们可以轻松地退化为传统的Fortran风格的返回地址类似物。


1
您可以通过假设“ 任何多线程系统”将自己描绘在一个角落。耦合的有限状态机可能在其实现中具有多个线程,但不需要LIFO堆栈。FSM不受限制,您可以返回到以前的任何状态,更不用说按LIFO顺序了。因此,这是一个不适合的真正的多线程系统。而且,如果将自己限制为“并行独立函数调用堆栈”的多线程定义,则最终会得到循环定义。
MSalters

我没有那样看问题。OP熟悉函数调用,但是询问其他系统。
MSalters

@MSalters更新为合并并发无限循环。该模型有效,但限制了可伸缩性。我建议即使是中等状态机也要包含函数调用以允许代码的重用。
Pekka

2

所有旧的大型机(IBM System / 360)根本没有堆栈的概念。例如,在260上,参数是在内存中的固定位置构造的,当调用子例程时,将通过R1指向参数块并R14包含返回地址的方式来调用该子例程。如果要调用另一个子例程,则被调用例程必须R14在进行该调用之前存储在已知位置。

这比堆栈可靠得多,因为所有内容都可以存储在编译时建立的固定内存位置中,并且可以100%保证进程永远不会耗尽堆栈。如今,我们无需执行任何“分配1MB的资源”的工作。

通过指定关键字,PL / I中允许进行递归子例程调用RECURSIVE。它们意味着子例程使用的内存是动态分配的,而不是静态分配的。但是递归调用比现在稀少了。

无堆栈操作也使大规模多线程操作变得更加容易,这就是为什么经常尝试使现代语言变得无柄。根本没有理由,例如,为什么不能对C ++编译器进行后端修改以使用动态分配的内存而不是堆栈。

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.