我见过的大多数体系结构都依赖调用堆栈来保存/恢复函数调用之前的上下文。这种常见的范例使大多数处理器都内置了推入和弹出操作。是否有没有堆栈的系统?如果是这样,它们如何工作?它们的作用是什么?
我见过的大多数体系结构都依赖调用堆栈来保存/恢复函数调用之前的上下文。这种常见的范例使大多数处理器都内置了推入和弹出操作。是否有没有堆栈的系统?如果是这样,它们如何工作?它们的作用是什么?
Answers:
调用栈的(某种程度上)流行的替代方法是延续。
例如,Parrot VM是基于连续的。它是完全没有堆栈的:数据保存在寄存器中(如Dalvik或LuaVM,Parrot是基于寄存器的),并且控制流用延续表示(与Dalvik或LuaVM不同,后者具有调用堆栈)。
通常由Smalltalk和Lisp VM使用的另一种流行的数据结构是意大利面条堆栈,类似于堆栈网络。
正如@rwong指出的那样,连续传递样式是调用堆栈的替代方法。以连续传递样式编写(或转换为连续传递样式)的程序永远不会返回,因此不需要堆栈。
从不同的角度回答您的问题:通过在堆上分配堆栈帧,可以在没有单独堆栈的情况下拥有调用堆栈。一些Lisp和Scheme的实现可以做到这一点。
在过去,处理器没有堆栈指令,并且编程语言不支持递归。随着时间的流逝,越来越多的语言选择支持递归,并且具有堆栈框架分配功能的硬件紧随其后。这些年来,使用不同的处理器的支持变化很大。一些处理器采用了堆栈帧和/或堆栈指针寄存器。一些采用的指令将在单个指令中完成堆栈帧的分配。
随着处理器先使用单级缓存再使用多级缓存,堆栈的一项关键优势是缓存局部性。堆栈的顶部几乎总是在高速缓存中。只要您可以执行具有较高高速缓存命中率的操作,那么使用现代处理器便会走上正确的道路。应用于堆栈的缓存意味着本地变量,参数等几乎始终位于缓存中,并具有最高的性能水平。
简而言之,堆栈的用法在硬件和软件上都有所发展。还有其他模型(例如,尝试了较长时间的数据流计算),但是,堆栈的局部性使其工作得非常好。此外,过程代码正是处理器想要的性能:一条指令告诉它下一步要做什么。当指令不按线性顺序排列时,处理器至少在目前为止已经大大减慢了速度,因为我们还没有弄清楚如何使随机存取和顺序存取一样快。(顺便说一句,从高速缓存到主内存再到光盘,每个内存级别都有类似的问题。)
在已证明的顺序访问指令性能和调用堆栈的有益缓存行为之间,至少目前,我们有一个成功的性能模型。
(我们也可能将数据结构的可变性投入工作中……)
这并不意味着其他编程模型无法工作,特别是当它们可以转换为当今硬件的顺序指令和调用堆栈模型时。但是,支持硬件所在位置的模型有明显的优势。但是,情况并非总是保持不变,因此随着不同的存储器和晶体管技术允许更多的并行性,我们可以看到未来的变化。它总是在编程语言和硬件功能之间开玩笑,因此,我们将看到!
TL; DR
这个答案的其余部分是思想和轶事的随机集合,因此有些杂乱无章。
您所描述的堆栈(作为函数调用机制)特定于命令式编程。
在命令式编程下面,您将找到机器代码。机器代码可以通过执行一小段指令来模拟调用堆栈。
在机器代码下,您会找到负责执行软件的硬件。虽然现代微处理器过于复杂,无法在此进行描述,但可以想象存在一种非常简单的设计,该设计虽然速度慢,但仍然能够执行相同的机器代码。这样简单的设计将利用数字逻辑的基本元素:
以下讨论包含构造命令式程序的替代方法的大量示例。
这样的程序的结构如下所示:
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;
}
这种风格适用于微控制器,即适合那些将软件视为硬件功能伴侣的人。
不,不一定。
阅读Appel的旧论文《垃圾收集》可能比Stack Allocation更快。它使用延续传递样式,并显示了无堆栈的实现。
还要注意,旧的计算机体系结构(例如IBM / 360)没有任何硬件堆栈寄存器。但是OS和编译器按照约定(与调用约定有关)为堆栈指针保留了一个寄存器,以便他们可以有一个软件调用堆栈。
原则上,整个程序C编译器和优化器都可以检测到调用图是静态已知的并且没有任何递归(或函数指针)的情况(对于嵌入式系统来说这是常见的)。在这样的系统中,每个函数都可以将其返回地址保持在固定的静态位置(这就是Fortran77在1970年代计算机中的工作方式)。
如今,处理器还具有了解CPU缓存的调用堆栈(以及调用和返回机器指令)。
SUBROUTINE
和FUNCTION
。不过,您对较早的版本是正确的(FORTRAN-IV以及WATFIV)。
TR
和TRT
。
到目前为止,您已经得到了一些好的答案;让我给您一个不切实际但极富教育意义的示例,说明您如何设计语言时完全不需要堆栈或“控制流”的概念。这是确定阶乘的程序:
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)正在使用这种技术。
自Parnas于1972 年发布“将系统分解为模块所用的标准”以来,人们已经合理地接受了将信息隐藏在软件中是一件好事。这是整个上世纪60年代关于结构分解和模块化编程的长期辩论之后的结果。
在任何多线程系统中,由不同组实现的模块之间的黑匣子关系的必要结果,需要一种允许重入的机制和一种跟踪系统动态调用图的手段。受控的执行流必须同时传入和传出多个模块。
一旦词法作用域不足以跟踪动态行为,则需要一些运行时簿记来跟踪差异。
给定任何线程(根据定义)只有一个当前指令指针,则LIFO堆栈适合跟踪每次调用。
因此,尽管延续模型并没有为堆栈显式维护数据结构,但仍然有模块的嵌套调用必须在某个地方维护!
甚至声明性语言也可以保留评估历史记录,或者出于性能原因反而使执行计划变扁平并以其他方式保持进度。
由rwong标识的无限循环结构在具有静态调度的高可靠性应用程序中很常见,这种静态调度不允许许多常见的编程结构,但要求将整个应用程序视为白盒,而没有大量的信息隐藏。
多个并发的无穷循环不需要任何结构来保存返回地址,因为它们不调用函数,这使问题无济于事。如果它们使用共享变量进行通信,则它们可以轻松地退化为传统的Fortran风格的返回地址类似物。
所有旧的大型机(IBM System / 360)根本没有堆栈的概念。例如,在260上,参数是在内存中的固定位置构造的,当调用子例程时,将通过R1
指向参数块并R14
包含返回地址的方式来调用该子例程。如果要调用另一个子例程,则被调用例程必须R14
在进行该调用之前存储在已知位置。
这比堆栈可靠得多,因为所有内容都可以存储在编译时建立的固定内存位置中,并且可以100%保证进程永远不会耗尽堆栈。如今,我们无需执行任何“分配1MB的资源”的工作。
通过指定关键字,PL / I中允许进行递归子例程调用RECURSIVE
。它们意味着子例程使用的内存是动态分配的,而不是静态分配的。但是递归调用比现在稀少了。
无堆栈操作也使大规模多线程操作变得更加容易,这就是为什么经常尝试使现代语言变得无柄。根本没有理由,例如,为什么不能对C ++编译器进行后端修改以使用动态分配的内存而不是堆栈。