分段堆栈如何工作

How do segmented stacks work

分段堆栈如何工作?这个问题也适用于 Boost.Coroutine 所以我在这里也使用 C++ 标签。主要的疑问来自这个 article 看起来他们所做的是在堆栈底部保留一些 space 并通过在分配的内存中注册某种信号处理程序来检查它是否已损坏(也许通过 mmapmprotect?)然后当他们检测到 space 中有 运行 时,他们继续分配更多内存,然后从那里继续。 3 个问题

  1. 这不是构造用户space嘛?他们如何控制新堆栈的分配位置以及程序编译指令如何了解这一点?

    push指令基本上就是给栈指针加一个值,然后把这个值存到栈上的一个寄存器中,那么push指令怎么知道新栈从哪里开始,pop又怎么知道呢?知道什么时候必须将堆栈指针移回旧堆栈吗?

  2. 他们也说

    After we've got a new stack segment, we restart the goroutine by retrying the function that caused us to run out of stack

    这是什么意思?他们会重启整个 goroutine 吗?这不会导致非确定性行为吗?

  3. 他们如何检测到程序已经超过运行堆栈?如果他们在底部保留一个 canary-ish 内存区域,那么当用户程序创建一个足够大的数组溢出时会发生什么?这不会导致堆栈溢出并且是一个潜在的安全漏洞吗?

如果 Go 和 Boost 的实现不同,我很乐意知道它们是如何处理这种情况的

我将简要介绍一种可能的实施方式。

首先,假设大多数堆栈帧都小于某个大小。对于更大的,我们可以在入口处使用更长的指令序列来确保有足够的堆栈space。假设我们在一个有 4k 页的架构上,我们选择 4k - 1 作为快速路径处理的最大堆栈帧大小。

堆栈在底部分配了一个保护页。也就是说,未映射为写入的页面。在函数入口处,栈指针减栈帧大小,小于一页大小,然后程序安排在新分配的栈帧的最低地址写入一个值。如果已到达堆栈的末尾,则此写入将导致处理器异常并最终变成从 OS 到用户程序的某种向上调用——例如UNIX 系列中的一个信号 OSes.

信号处理程序(或等效程序)必须能够根据出错指令的地址和它正在写入的地址来确定这是一个堆栈扩展错误。这是可确定的,因为指令在函数的序言中,而要写入的地址在当前线程的堆栈保护页中。 prolog 中的指令可以通过在函数开始时要求非常特定的指令模式来识别,或者可能通过维护有关函数的元数据来识别。 (可能使用回溯表。)

此时处理程序可以分配一个新的堆栈块,将堆栈指针设置为块的顶部,做一些事情来处理解除堆栈块的链接,然后再次调用出错的函数。第二次调用是安全的,因为错误出现在编译器生成的函数序言中,并且在验证是否有足够的堆栈 space 之前不允许产生副作用。 (代码可能还需要为自动将其压入堆栈的体系结构修正 return 地址。如果 return 地址在一个寄存器中,则在第二个时它只需要在同一个寄存器中已拨打电话。)

可能处理解链的最简单方法是将一个小堆栈帧推到新的扩展块上,以便在 returned 时解链新的堆栈块并释放分配的内存。然后,它 return 将处理器注册到它们在进行导致堆栈需要扩展的调用时所处的状态。

这种设计的好处是函数入口序列指令很少,在非扩展情况下速度很快。缺点是在确实需要扩展栈的情况下,处理器会抛出异常,其代价可能比调用函数要大得多。

如果我理解正确的话,Go 实际上并没有使用保护页。相反,函数 prolog 显式检查堆栈限制,如果新的堆栈框架不适合它,它会调用一个函数来扩展堆栈。

Go 1.3 改变了它的设计,不使用堆栈块的链表。这是为了避免在某个调用模式中多次在两个方向上跨越扩展边界时的陷阱成本。他们从一个小堆栈开始,并使用类似的机制来检测扩展的需要。但是当堆栈扩展错误确实发生时,整个堆栈被移动到一个更大的块。这消除了完全解除链接的需要。

这里掩盖了很多细节。 (例如,可能无法在信号处理程序本身中进行堆栈扩展。相反,处理程序可以安排暂停线程并将其交给管理器线程。可能必须使用专用信号堆栈来处理信号还有。)

这种事情的另一个常见模式是运行时要求在当前堆栈帧下面有一定数量的有效堆栈 space 用于信号处理程序或调用特殊例程运行。 Go 以这种方式工作,堆栈限制测试保证在当前帧下方有一定固定数量的堆栈 space 可用。一个可以例如只要保证它们不会消耗超过固定的堆栈保留量,就可以在堆栈上调用普通 C 函数。 (理论上可以使用它来调用 C 库例程,尽管其中大多数都没有正式说明它们可能使用多少堆栈。)

堆栈帧中的动态分配,例如 alloca 或堆栈分配的可变长度数组,为实现增加了一些复杂性。如果例程可以计算序言中帧的整个最终大小,那么它就相当简单了。例程 运行 时帧大小的任何增加都可能必须建模为新调用,尽管使用允许移动堆栈的 Go 新架构,例程中的分配点可能会这样所有状态都允许在那里发生堆栈移动。