堆栈的目的是什么?我们为什么需要它?


320

因此,我现在正在学习MSIL,以学习调试C#.NET应用程序。

我一直想知道:堆栈的目的是什么?

只是为了回答我的问题:
为什么要从内存转移到堆栈或“加载”?另一方面,为什么要从堆栈转移到内存或“存储”呢? 为什么不将它们全部放置在内存中?

  • 是因为速度更快吗?
  • 是因为它基于RAM吗?
  • 为了效率?

我试图抓住这一点,以帮助我更深入地了解CIL代码。


28
堆栈是内存的一部分,就像堆是内存的另一部分一样。
CodesInChaos 2011年

@CodeInChaos您是在谈论值类型还是引用类型?还是在IL代码方面相同?...我知道堆栈比堆更快,更高效(但这在value / ref类型世界中。我不知道这里是否一样吗?)
Jan Carlo Viray 2011年

15
@CodeInChaos-我认为Jan引用的堆栈是IL写入的堆栈计算机,而不是在函数调用期间接受堆栈帧的内存区域。它们是两个不同的堆栈,在JIT之后,IL堆栈不存在(无论如何在x86上)
Damien_The_Unbeliever 2011年

4
MSIL知识如何帮助您调试.NET应用程序?
霹雳霹雳州

1
在现代机器上,代码的缓存行为是性能的决定者。记忆无处不在。通常,堆栈就在这里。假定堆栈是真实的东西,而不仅仅是表示某些代码的操作所使用的概念。在实现运行MSIL的平台时,不要求堆栈概念使之成为实际推动位的硬件。
恢复莫妮卡

Answers:


441

更新:我非常喜欢这个问题,因此在2011年11月18日将其作为博客主题。感谢您提出的好问题!

我一直想知道:堆栈的目的是什么?

我假设您的意思是MSIL语言的评估堆栈,而不是运行时的实际每线程堆栈。

为什么要从内存转移到堆栈或“加载”?另一方面,为什么要从堆栈转移到内存或“存储”呢?为什么不将它们全部放置在内存中?

MSIL是一种“虚拟机”语言。像C#编译器这样的编译器会生成CIL,然后在运行时另一个称为JIT(即时)的编译器会将IL转换为可以执行的实际机器代码。

因此,首先让我们回答“为什么要拥有MSIL”这个问题。为什么不让C#编译器写出机器代码?

因为这样做比较便宜。假设我们没有那样做;假设每种语言都必须具有自己的机器代码生成器。您有二十种不同的语言:C#,JScript .NET,Visual Basic,IronPythonF# ...并且假设您有十种不同的处理器。您必须编写多少个代码生成器?20 x 10 = 200个代码生成器。这是很多工作。现在假设您要添加一个新处理器。您必须为此编写代码生成器20次,每种语言编写一次。

此外,这是困难而危险的工作。为您不是专家的芯片编写高效的代码生成器是一项艰巨的任务!编译器设计人员是他们语言的语义分析专家,而不是新芯片组的有效寄存器分配专家。

现在假设我们以CIL方式进行操作。您必须编写多少个CIL生成器?一种语言。您必须编写多少个JIT编译器?每个处理器一个。总计:20 + 10 = 30个代码生成器。此外,由于CIL是一种简单的语言,所以语言到CIL生成器很容易编写,而由于CIL是一种简单的语言,CIL到机器代码生成器也很容易编写。我们摆脱了C#和VB以及所有复杂内容的所有复杂性,并将所有内容“降低”为一种易于编写抖动的简单语言。

使用中间语言可以大大降低生产新语言编译器的成本。它还大大降低了支持新芯片的成本。您想要支持新芯片,找到该芯片上的一些专家,并让他们写出CIL抖动,您就完成了;然后,您就可以在芯片上支持所有这些语言。

好的,因此我们已经确定了拥有MSIL的原因;因为使用中间语言可以降低成本。那么为什么该语言是“堆栈机”?

因为从概念上讲,堆栈计算机对于语言编译器编写者来说非常简单。堆栈是一种用于描述计算的简单易懂的机制。对于JIT编译器作者来说,堆栈计算机在概念上也非常容易。使用堆栈是一种简化的抽象,因此,它又降低了我们的成本

您问:“为什么要一堆呢?” 为什么不直接将所有内容都耗尽内存?好吧,让我们考虑一下。假设您要生成以下内容的CIL代码:

int x = A() + B() + C() + 10;

假设我们有一个约定,即“ add”,“ call”,“ store”等始终将其参数移出堆栈,并将其结果(如果有)放在堆栈上。要为此C#生成CIL代码,我们只需要说些类似的话:

load the address of x // The stack now contains address of x
call A()              // The stack contains address of x and result of A()
call B()              // Address of x, result of A(), result of B()
add                   // Address of x, result of A() + B()
call C()              // Address of x, result of A() + B(), result of C()
add                   // Address of x, result of A() + B() + C()
load 10               // Address of x, result of A() + B() + C(), 10
add                   // Address of x, result of A() + B() + C() + 10
store in address      // The result is now stored in x, and the stack is empty.

现在假设我们没有堆栈就做到了。我们将按照您的方式进行操作,其中每个操作码都将获取其操作数的地址以及将其结果存储到的地址

Allocate temporary store T1 for result of A()
Call A() with the address of T1
Allocate temporary store T2 for result of B()
Call B() with the address of T2
Allocate temporary store T3 for the result of the first addition
Add contents of T1 to T2, then store the result into the address of T3
Allocate temporary store T4 for the result of C()
Call C() with the address of T4
Allocate temporary store T5 for result of the second addition
...

你明白了吗?我们的代码越来越庞大,因为我们必须显式分配通常按照约定会放在堆栈上的所有临时存储。更糟糕的是,我们的操作码本身变得越来越庞大,因为它们现在都必须将要写入结果的地址以及每个操作数的地址作为参数。一条“ add”指令知道它将要从堆栈中取出两件事并放在一件事上,可以是一个字节。一个带有两个操作数地址和一个结果地址的加法指令将非常庞大。

我们使用基于堆栈的操作码,因为堆栈可以解决常见的问题。即:我想分配一些临时存储,请尽快使用它,然后在完成后迅速删除它。通过假设我们拥有可用的堆栈,我们可以使操作码非常小,并使代码非常简洁。

更新:一些其他想法

顺便提一句,通过(1)指定虚拟机,(2)编写针对VM语言的编译器以及(3)在各种硬件上编写VM的实现来大幅降低成本的想法根本不是一个新主意。 。它不是起源于MSIL,LLVM,Java字节码或任何其他现代基础结构。我知道的这种策略的最早实现是1966年的pcode机器

我第一次听到这个概念是在我了解到Infocom实现者如何使Zork如此出色地在许多不同的机器上运行时。他们指定了一个名为Z-machine的虚拟机,然后为其要运行游戏的所有硬件制作了Z-machine模拟器。这具有更大的好处,即他们可以在原始的8位系统上实现虚拟内存管理。一个游戏可能比适合内存的游戏大,因为他们可以在需要时从磁盘分页代码,而在需要加载新代码时将其丢弃。


63
哇。正是我所要的。获得答案的最佳方法是从主要开发人员自己那里获得答案。感谢您的宝贵时间,我相信这将对每个想知道编译器和MSIL的复杂性的人有所帮助。谢谢埃里克。
Jan Carlo Viray 2011年

18
那是一个很好的答案。提醒我即使我是Java的人,我也为什么阅读您的博客。;-)
jprete 2011年

34
@JanCarloViray:非常欢迎!我注意到,我是一个主要开发者,而不是主要开发。这个团队中有几个人拥有这个职位,但我什至都不是最高级的。
埃里克·利珀特

17
@Eric:如果/当你停止爱编码时,应该考虑去教程序员。除了娱乐,您还可以在没有业务压力的情况下谋杀。您在该地区拥有的才能真棒(我可能会补充,非常耐心)。我说这是前大学的讲师。
艾伦(Alan)

19
我大约对4个段落自言自语:“这听起来像埃里克(Eric)”,到了第5或第6天,我毕业于“是的,绝对是埃里克(Eric)” :)另一个真正意义上的综合答案。
Binary Worrier

86

请记住,当您谈论MSIL时,您是在谈论虚拟机的指令。.NET中使用的VM是基于堆栈的虚拟机。与基于寄存器的VM相对,Android操作系统中使用的Dalvik VM是一个例子。

VM中的堆栈是虚拟的,这取决于解释器或即时编译器将VM指令转换为在处理器上运行的实际代码。在.NET的情况下,这几乎总是抖动,MSIL指令集被设计为一开始就被抖动。例如,与Java字节码相反,它具有针对特定数据类型进行操作的不同指令。这使得它经过优化可以解释。尽管实际上存在一个MSIL解释器,但它已在.NET Micro Framework中使用。它在资源非常有限的处理器上运行,无法承受存储机器代码所需的RAM。

实际的机器代码模型是混合的,具有堆栈和寄存器。JIT代码优化器的一项重要工作是想出一种方法来将保留在堆栈中的变量存储在寄存器中,从而大大提高了执行速度。Dalvik抖动有相反的问题。

否则,机器堆栈是非常基本的存储设备,已经在处理器设计中使用了很长时间。它具有很好的参考位置,这是现代CPU上的一个非常重要的功能,它可以以比RAM提供数据更快的速度读取数据并支持递归。语言设计在很大程度上受堆栈的影响,堆栈对于支持局部变量和范围限于方法主体可见。堆栈的一个重要问题是该站点的名称。


2
+1是非常详细的说明,而+100(如果可以的话)是与其他系统和语言的详细比较:)
Jan Carlo Viray 2011年

4
Dalvik为什么是套准机?Sicne主要针对ARM处理器。现在,x86具有相同数量的寄存器,但它是一个CISC,只有4个真正可用于存储本地,因为其余的都隐式地用在通用指令中。另一方面,ARM体系结构具有更多可用于存储本地变量的寄存器,因此它们促进了基于寄存器的执行模型。
约翰内斯·鲁道夫

1
@JohannesRudolph这已经过去了将近二十年了。仅仅因为大多数C ++编译器仍然针对90年代的x86指令集,并不意味着x86本身是低效率的。例如,Haswell有168个通用整数寄存器和168个GP AVX寄存器-远远超过我所知道的任何ARM CPU。您可以根据需要使用(现代)x86程序集中的所有功能。指责编译器作者,而不是体系结构/ CPU。实际上,这是中间编译如此吸引人的原因之一-一个给定CPU的二进制最佳代码。90年代的建筑风格无可厚非。
a安

2
@JohannesRudolph .NET JIT编译器实际上大量使用寄存器。堆栈主要是IL虚拟机的抽象,实际上在CPU上运行的代码是非常不同的。方法调用可能是传递寄存器,本地调用可能被提升到寄存器...机器代码中堆栈的主要好处是它与子例程调用具有隔离性-如果将本地变量放在寄存器中,则函数调用可以您失去了价值,却无法真正分辨。
a安

1
@RahulAgarwal生成的机器代码可以或可以不将堆栈用于任何给定的本地或中间值。在IL中,每个参数和局部变量都在堆栈上-但是在机器代码中,这是正确的(允许,但不是必需的)。有些东西在堆栈上很有用,它们被放在堆栈上。有些东西在堆上很有用,它们被放在堆上。有些事情根本不是必需的,或者只需要一会儿即可。调用可以完全消除(内联),也可以在寄存器中传递其参数。JIT有很多自由。
a安

20

对此,有一篇非常有趣/详细的Wikipedia文章:堆栈机指令集的优点。我需要全部引用它,因此简单地放置一个链接会更容易。我只引用字幕

  • 非常紧凑的目标代码
  • 简单的编译器/简单的解释器
  • 最小处理器状态

-1 @xanatos您可以尝试总结一下您的标题吗?
蒂姆·劳埃德

@chibacity如果我想总结一下,我会做一个答案。我试图挽救一个很好的联系。
xanatos 2011年

@xanatos我理解您的目标,但是共享指向如此大的Wikipedia文章的链接不是一个很好的答案。仅仅通过谷歌搜索就不难发现。另一方面,汉斯有一个很好的答案。
蒂姆·劳埃德

@chibacity OP可能没有先搜索就很懒。这里的回答者给出了一个很好的链接(没有描述)。两种弊端有好处:-)我将投票给汉斯。
xanatos 2011年

接听者和@xanatos +1以获得很棒的链接。我正在等待某人进行全面总结并获得一个知识包的答案..如果汉斯没有给出答案,我会以您的答案为被接受的答案..这仅仅是一个链接,所以不是汉斯(Hans)付出了很大的努力来解决这个问题.. :)
Jan Carlo Viray 2011年

8

向堆栈问题添加更多内容。堆栈概念源自CPU设计,其中算术逻辑单元(ALU)中的机器代码对位于堆栈上的操作数进行操作。例如,一个乘法运算可以从堆栈中取出两个顶部操作数,将它们乘以多个,然后将结果放回堆栈中。机器语言通常具有两个基本功能,可从堆栈中添加和删除操作数。推和POP。在许多cpu的dsp(数字信号处理器)和机器控制器(例如控制洗衣机的机器)中,堆栈位于芯片本身上。这样可以更快地访问ALU,并将所需的功能整合到单个芯片中。


5

如果不遵循堆栈/堆的概念,并且将数据加载到随机存储器位置,或者从随机存储器位置存储数据,那么它将非常无组织和不受管理。

这些概念用于将数据存储在预定义的结构中,以提高性能,内存使用量,因此也称为数据结构。



0

我在寻找“中断”,没有人将其作为优势。对于每个中断微控制器或其他处理器的设备,通常会有一些寄存器被压入堆栈,调用中断服务程序,完成后,将这些寄存器从堆栈中弹出,并放回它们的位置。是。然后恢复指令指针,正常活动从中断处恢复,几乎就像中断从未发生过一样。使用堆栈,实际上(理论上)您可以让多个设备互相中断,并且由于堆栈的缘故,它们都可以正常工作。

还有一系列基于堆栈的语言,称为连接语言。它们都是(我相信)功能语言,因为堆栈是传入的隐式参数,而且更改后的堆栈是每个函数的隐式返回。两个第四因子(其是优异的)是一个例子,与他人一起。Factor已与Lua相似地用于脚本游戏,并且由目前在Apple工作的天才Slava Pestov编写。我看过他在youtube上的Google TechTalk。他谈论Boa构造函数,但我不确定他是什么意思;-)。

我真的认为,当前的某些VM,例如JVM,Microsoft的CIL,甚至我所见的VM都是为Lua编写的,都应该使用某些基于堆栈的语言编写,以使其可移植到更多平台上。我认为这些连接语言在某种程度上缺少了作为VM创建工具包和可移植性平台的调用。甚至还有pForth,它是用ANSI C语言编写的“便携式” Forth,可以用于更通用的可移植性。有人尝试使用Emscripten或WebAssembly对其进行编译吗?

在基于堆栈的语言中,存在一种称为零点的代码样式,因为您可以仅列出要调用的函数,而不必(有时)不传递任何参数。如果这些函数完美地结合在一起,那么您将只有所有零点函数的列表,并且从理论上讲就是您的应用程序。如果您深入研究Forth或Factor,您将了解我在说什么。

Easy Forth上,这是一个用JavaScript编写的不错的在线教程,下面是一个小示例(请注意“ sq sq sq sq”作为零点调用样式的示例):

: sq dup * ;  ok
2 sq . 4  ok
: ^4 sq sq ;  ok
2 ^4 . 16  ok
: ^8 sq sq sq sq ;  ok
2 ^8 . 65536  ok

另外,如果您查看Easy Forth网页源代码,则会在底部看到它是非常模块化的,用大约8个JavaScript文件编写。

我几乎花了很多钱在每本Forth书上,我可以尝试吸收它,但是现在我才开始更好地理解它。我想跟进来的人,如果您真的想要得到它(我发现这太晚了),请下载关于FigForth的书并实现它。商业化的Forth太复杂了,而Forth的最大优点是可以从上到下理解整个系统。尽管需要,Forth可以以某种方式在新处理器上实现整个开发环境。因为C似乎在所有方面都已通过,所以从头开始编写Forth仍然是一种通过仪式。因此,如果您选择执行此操作,请尝试一下FigForth书-这是在各种处理器上同时实现的几个Forth。一种罗塞塔石碑。

我们为什么需要堆栈-效率,优化,零点,在中断时保存寄存器,对于递归算法来说,它是“正确的形状”。

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.