我将为您简要介绍一种可能的实现方式。
首先,假设大多数堆栈帧都小于某个大小。对于更大的,我们可以在入口处使用更长的指令序列来确保有足够的堆栈空间。假设我们在一个有 4k 页面的架构上,我们选择 4k - 1 作为快速路径处理的最大堆栈帧大小。
堆栈在底部分配有一个保护页。即,未映射为写入的页面。在函数入口处,堆栈指针递减堆栈帧的大小,小于一页的大小,然后程序安排在新分配的堆栈帧的最低地址写入一个值。如果已到达堆栈的末尾,则此写入将导致处理器异常并最终转变为从操作系统到用户程序的某种向上调用——例如 UNIX 系列操作系统中的信号。
信号处理程序(或等效的)必须能够根据出错指令的地址和它正在写入的地址来确定这是堆栈扩展错误。这是可以确定的,因为指令位于函数的序言中,而要写入的地址位于当前线程的堆栈保护页中。可以通过在函数开始时要求非常特定的指令模式来识别序言中的指令,或者可能通过维护有关函数的元数据来识别。(可能使用回溯表。)
此时,处理程序可以分配一个新的堆栈块,将堆栈指针设置为块的顶部,做一些事情来处理解除堆栈块的链接,然后再次调用出错的函数。第二次调用是安全的,因为错误出现在编译器生成的函数 prolog 中,并且在验证是否有足够的堆栈空间之前不允许出现副作用。(代码可能还需要为自动将其压入堆栈的架构修复返回地址。如果返回地址在寄存器中,则在进行第二次调用时,它只需要在同一个寄存器中。)
处理解链的最简单方法可能是将一个小的堆栈帧推送到新的扩展块上,以便在返回时解链新的堆栈块并释放分配的内存。然后,它将处理器寄存器返回到它们在进行导致堆栈需要扩展的调用时所处的状态。
这种设计的优点是函数入口序列指令很少,在非扩展情况下速度非常快。缺点是在确实需要扩展堆栈的情况下,处理器会引发异常,这可能比函数调用花费更多。
如果我理解正确,Go 实际上并没有使用保护页。相反,函数序言显式地检查堆栈限制,如果新的堆栈框架不适合,它会调用一个函数来扩展堆栈。
Go 1.3 改变了它的设计,不使用堆栈块的链表。这是为了避免在某个调用模式中在两个方向上多次跨越扩展边界时的陷阱成本。他们从一个小堆栈开始,并使用类似的机制来检测扩展的需要。但是当堆栈扩展故障确实发生时,整个堆栈被移动到一个更大的块中。这消除了完全解链的需要。
这里有很多细节被掩盖了。(例如,可能无法在信号处理程序本身中进行堆栈扩展。相反,处理程序可以安排暂停线程并将其交给管理器线程。可能必须使用专用的信号堆栈来处理信号也一样。)
这种事情的另一个常见模式是运行时要求在当前堆栈帧下方有一定数量的有效堆栈空间,用于信号处理程序或在运行时调用特殊例程。Go 以这种方式工作,堆栈限制测试保证在当前帧下方有一定的固定堆栈空间可用。例如,可以在堆栈上调用普通 C 函数,只要保证它们不会消耗超过固定堆栈保留量。(理论上可以使用它来调用 C 库例程,尽管其中大多数没有正式规范它们可能使用多少堆栈。)
堆栈帧中的动态分配,例如 alloca 或堆栈分配的可变长度数组,给实现增加了一些复杂性。如果例程可以计算序言中帧的整个最终大小,那么它相当简单。例程运行时帧大小的任何增加都可能必须建模为新调用,尽管使用允许移动堆栈的 Go 的新架构,可以使例程中的分配点使得所有状态都允许堆栈移动发生在那里。