调用栈并不是说“你来自哪里”,而是说“你接下来要去哪里”?

16
在之前的一个问题中(Get object call hierarchy),我得到了这个有趣的回答

调用堆栈并不是用来告诉你你来自哪里的,而是告诉你接下来要去哪里。

据我所知,当程序到达函数调用时,通常会执行以下操作:
  1. 调用代码中:

    • 存储返回地址(在调用堆栈上)
    • 保存寄存器状态(在调用堆栈上)
    • 写入将传递给函数的参数(在调用堆栈或寄存器中)
    • 跳转到目标函数

  2. 被调用目标代码中:

    • 检索存储的变量(如果需要)

  3. 返回过程:撤消我们调用函数时所做的操作,即展开/弹出调用堆栈:

    • 从调用堆栈中删除本地变量
    • 从调用堆栈中删除函数变量
    • 恢复寄存器状态(我们之前存储的状态)
    • 跳转到返回地址(我们之前存储的地址)

问题: 这如何被视为告诉你“下一步要去哪里”的东西,而不是“告诉你你来自哪里”的东西?
C#的JIT或C#的运行时环境中是否有使调用堆栈工作不同的内容?
感谢任何关于调用堆栈描述的文档指针 - 有关传统调用堆栈如何工作的文档有很多。

两者都是谎言。在尾调用存在的情况下,你不知道自己来自哪里或要去哪里。 - leppie
我认为你正在过度使用调用堆栈。你在调试器窗口中查看的调用堆栈是“the”调用堆栈,还是调用堆栈的隐喻,或者只是一个有用的调试辅助工具? - Jodrell
1
嗯,Eric倾向于戴着他的语言实现者眼镜回答问题。在任何实际场景中,你实际上都会“查看”调用堆栈(调试器的调用堆栈窗口,异常的StackTrace属性),你最感兴趣的是“我是如何到达这里的”这个问题。特别是在异常的情况下,那个堆栈跟踪并不告诉你接下来要去哪里。 - Hans Passant
1
@Hans:我不确定我同意你的观点。当然,你查看堆栈跟踪窗口的原因是要看“我是怎么到这里来的?”但是,从堆栈上的连续信息中推断出该信息是一种便利的偶然事件,使得实现该功能变得容易。没有要求堆栈告诉你你来自哪里。关于异常的问题:同样的事情!堆栈是一个数据结构,告诉你(1)如果没有异常,我下一步去哪里,(2)如果有异常,我去哪里? - Eric Lippert
1
后面的信息当然不包括在堆栈跟踪中,但它肯定在堆栈上。我们也可以构建一个调试器工具,检查堆栈并告诉您在异常情况下执行将分支到哪里,但是很少有客户真正关心那些信息,因此没有人实现该功能。 - Eric Lippert
5个回答

34
你已经解释过了。按照定义,“返回地址”告诉你下一步要去哪里。在堆栈中放置的返回地址并没有任何要求必须是调用当前方法的方法内部地址。通常情况下,它确实是,这使得调试更加容易。但是,并不要求返回地址是调用者内部地址。如果优化器可以通过改变返回地址来使程序更快(或更小,或者其他优化目标)而不改变其含义,则允许这样做。
堆栈的目的是确保当此子例程完成时,其“续接”——下一步要发生什么——是正确的。堆栈的目的不是告诉你从哪里来。通常情况下,它确实这样做,这是一个幸运的巧合。
此外:堆栈只是“续接”和“激活”概念的实现细节。没有要求两个概念由同一个堆栈实现;可能有两个堆栈,一个用于激活(局部变量),另一个用于续接(返回地址)。这种架构显然更能抵御恶意软件的堆栈攻击,因为返回地址与数据毫无关系。
更有趣的是,并不需要存在任何堆栈!我们使用调用堆栈来实现 continuation,因为它们对于我们通常进行的基于子例程的同步调用编程非常方便。我们可以选择将 C# 实现为“Continuation Passing Style”语言,其中 continuation 实际上作为堆上的对象 reified ,而不是作为推送到百万字节系统堆栈上的一堆字节。然后将该对象从方法传递到方法,其中没有任何使用堆栈的方法。(然后通过将每个方法分解为可能有多个委托来重新实现激活,每个委托与一个激活对象相关联。)
在 continuation passing style 中,根本不存在堆栈,也没有任何方法可以告诉您来自哪里;continuation 对象没有那些信息。它只知道您接下来要去哪里。
这似乎是一种高深的理论胡言,但在下一个版本中,我们实际上正在将 C# 和 VB 转换为 continuation passing style 语言;即将到来的“async”功能只是以薄装掩饰的 continuation passing style。在下一个版本中,如果您使用 async 功能,您将基本上放弃基于堆栈的编程;因为堆栈经常为空,所以没有办法查看调用堆栈并知道您是如何到达这里的。

将Continuations重新定义为不同于调用栈的东西对很多人来说是一个难以理解的概念,当然对我来说也是如此。但是一旦你理解了它,它就会恍然大悟并变得非常合理。为了温和地介绍这个概念,以下是我写的一些相关文章:

使用JScript示例介绍CPS:

http://blogs.msdn.com/b/ericlippert/archive/2005/08/08/recursion-part-four-continuation-passing-style.aspx

http://blogs.msdn.com/b/ericlippert/archive/2005/08/11/recursion-part-five-more-on-cps.aspx

http://blogs.msdn.com/b/ericlippert/archive/2005/08/15/recursion-part-six-making-cps-work.aspx

以下是一些文章,从深入了解CPS开始,并解释了这如何与即将到来的“async”功能一起使用。从下面开始:

http://blogs.msdn.com/b/ericlippert/archive/tags/async/

支持延续传递风格的语言通常拥有一种神奇的控制流原语,称为"call with current continuation",或简称为"call/cc"。在这个stackoverflow问题中,我解释了"await"和"call/cc"之间微不足道的区别: 如何使用call/cc来实现c# 5.0中的新异步特性? 要获取官方"文档"(一堆白皮书)、C#和VB的新"async await"特性的预览版本,以及支持问答的论坛,请访问:

http://msdn.com/vstudio/async


谢谢回复,非常有趣。在即将推出的版本中(我猜是 .Net 5?),是否已经有关于续延传递风格语言实现的官方文档了? - Yochai Timmer
@Yochai:我已经添加了各种支持信息的链接。 - Eric Lippert

7
请看下面的代码:

考虑以下代码:

void Main()
{
    // do something
    A();
    // do something else
}

void A()
{
    // do some processing
    B();
}

void B()
{
}

在这里,函数A最后要做的事情是调用B。在那之后,A立即返回。聪明的优化器可能会优化掉对B调用,并将其替换为跳转到B起始地址的跳转。(不确定当前的C#编译器是否进行这样的优化,但几乎所有的C++编译器都会这么做)。为什么这样做呢?因为栈中有A的调用者的地址,所以当B完成时,它将直接返回到A的调用者,而不是返回到A
因此,可以看出栈不一定包含执行来自哪里的信息,而是应该去哪里的信息。
没有优化的情况下,在B内部,调用堆栈为(我省略了局部变量和其他内容以保持清晰):
----------------------------------------
|address of the code calling A         |
----------------------------------------
|address of the return instruction in A|
----------------------------------------

因此,从 B 返回到 A 并立即退出 `A。

有了优化,调用栈就是这样的

----------------------------------------
|address of the code calling A         |
----------------------------------------

所以B直接返回到Main

在他的答案中,Eric提到了另一种(更复杂的)情况,其中堆栈信息不包含真正的调用者。


但是 C# 的 StackTrace 对象不会显示实际的非优化调用层次结构吗? - Yochai Timmer
3
不完全正确:堆栈跟踪显示的是实际的堆栈。它如何知道“预期”的堆栈?这个信息并不存在。 - Vlad

3
在Eric的帖子中,他所说的是执行指针不需要知道它来自哪里,只需要知道在当前方法结束时它需要去哪里。这两件事表面上看起来似乎是同一件事,但在尾递归等情况下,我们来自哪里和下一步要去哪里可能会有分歧。

1

这个问题比你想象的更为复杂。

在C语言中,完全有可能让程序重写调用栈。实际上,这种技术是一种被称为返回导向编程的利用方式的基础。

我还曾经在一种语言中编写过代码,可以直接控制调用栈。你可以弹出调用你的函数的函数,并将其他函数代替它。你可以复制调用栈顶部的项,使调用函数中余下的代码执行两次,以及其他一些有趣的事情。事实上,直接操作调用栈是该语言提供的主要控制结构。 (挑战:有谁能从这个描述中识别出这种语言吗?)

这确实表明,调用栈指示您要去哪里,而不是您曾经去过哪里。


0

我认为他想表达的是它告诉被调用方法下一步该去哪里。

  • 方法A调用方法B。
  • 方法B完成后,它接下来要去哪里?

它从栈顶弹出被调用方法的地址,然后跳转到那里。

所以方法B知道在完成后要去哪里。方法B并不关心它来自哪里。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接