栈溢出:从硬件 CPU 到 JavaScript 引擎

789 阅读4分钟

寄存器是位于中央处理单元(CPU)内部的一小块高速处理器,用来存储和执行「指令、数据、中间计算结果」,每个 CPU 都有一组寄存器。

寄存器有极快的访问速度,远快于主内存,因此,CPU 可以更快对寄存器中数据进行操作,从而提高程序的执行效率。

一个 x86-64 的 CPU,包含 16 个寄存器(%rax 到 %rsp 8 个寄存器,又增加了 8 个扩展寄存器 %r8 - %r15)。

什么是栈

在常见的程序里不同的寄存器扮演不同的角色,最特别的是栈指针「%rsp」用来标记运行时栈的结束位置。在 CPU 中有一个特殊的寄存器,叫做栈指针寄存器。

栈是一种数据结构,可以添加或者删除,遵循「后进先出」原则,通过 push 操作把数据压入栈中,通过 pop 操作删除数据,「删除的值永远是最近被压入而且仍然在栈中的值」。

栈可以实现为一个数组,总是从数组的一端插入和删除元素,这一端被称为栈顶。

栈地址

在栈中,数据是从高地址向低地址生长的,栈指针指向栈的顶部,每次压入一个值到栈中,都需要将栈指针向下移动来分配新的空间。栈是向下增长的,这样一来,栈顶元素的地址是所有栈中元素地址中最低的。

当我们需要将一个四字的值(一字等于两字节,四字等于八字节)压入栈时,会将栈指针向下移动 8个字节的位置。

具体来说,如果当前栈指针地址为 X,将栈指针减 8 后,栈指针的地址为 X-8 的位置,这个是新值存储的位置。

当我们弹出一个四字的操作的时候,会从栈顶位置读取数据,然后将栈指针加 8,恢复到上一个指针地址。

根据惯例,在画图的时候,我们的栈是倒过来画的,栈顶在图的底部。

CPU 栈溢出

在程序执行过程中,由于函数调用过深或者递归调用过深,导致 CPU 的栈空间指针一直减少,当栈指针减少到足够低的数值时,它会在地址空间回绕,从最大的值开始减少,当绕回到栈尾指针的时候,这种情况就会导致栈溢出,引发程序错误。

当用 C 语言编写一个函数时,然后递归调用自身,每次函数调用都会在栈上添加一个新的函数调用信息,当这个函数没有设置退出条件的时候,无限递归下去,就会消耗完所有的栈空间,引发栈溢出。

上面是 CPU 中栈指针寄存器的溢出,JavaScript 不是也栈溢出么?

我们在编写 JS 代码时,也会遇到栈溢出的问题。JavaScript 的栈溢出与 CPU 的栈寄存器的溢出在概念上是相似的,因为它们都涉及到了「栈」这种数组结构的使用,也都是因为栈空间不足引起的,只不过,他们发生在不同的层次。

JavaScript 栈溢出

JavaScript 的运行环境通常会提供一个抽象的执行环境,包括 JavaScript 引擎的调用栈,这个执行境是由运行环境管理的,不是由 CPU 直接管理的。

当引擎在执行代码的时候,会为每个函数创建一个执行上下文,并将它压入调用栈,这个上下文包含函数的参数、局部变量、返回地址等信息。当函数执行完后,其执行上下文会被弹出调用栈。

function foo() {
    foo();
}
foo();

相同的,如果函数的调用过深或者递归调用过深,没有正确的退出条件,超过了引擎的最大的调用深度,同样会发生栈溢出。

有什么区别?

CPU 的栈寄存器溢出发生在硬件级别的 CPU 中,当溢出会导致程序崩溃、数据丢失、安全漏洞等问题。

对于 JavaScript 来说,虽然运行环境对调用栈有一定的保护和限制,但也会导致程序崩溃或冻结,当发生栈溢出的时候,引擎会抛出一个错误,然后停止执行 JavaScript 代码,这个过程是在引擎的管理下进行的,相对于 CPU 的栈溢出,JavaScript 栈溢出不会影响到 CPU 的硬件栈。

无论是 CPU 的栈溢出还是 JavaScript 的栈溢出,都需要开发者进行适当的代码设计和测试,以避免发生这些问题,并确保程序的稳定性和安全性。


内容来源:《深入理解计算机系统》

如果您对本篇文章中提到的问题有任何疑问或想法,请在评论区留言,我将尽力回复。

微信公众号「小道研究」,获取更多关于前端技术的深入分析和实践经验。