写在前面
这个端午节的三天假期,本来我是打算写一篇《详解JavaScript的Event Loop》的,准备的过程中读到了这篇博客《Microtask and Macrotask: A Hands-on Approach》,它在讲解Call Stack的时候提到了一些汇编语言、CPU以及寄存器相关的处理过程,这一块内容我看得不是很懂,而且它也建议读者先去了解一下操作系统底层的工作原理以便能更好地理解Call Stack等内存的工作机制,于是乎我就开始了为期三天的、苦逼的汇编语言学习之旅。虽然这件事情吞噬了我的整个假期,不过最终“我觉得我应该是搞懂了”汇编语言中这块内容的相关知识点了,不过JavaScript与汇编语言在这块的处理上有哪些相似性和差异性,我仍然没能完全弄明白。所以接下来,我会给大家分享一下我目前所知道的汇编语言中的Call Stack,以及相关的处理在JavaScript中我的一些猜想和推测,欢迎留言讨论!
内存的划分
内存是计算机系统中的稀缺资源,在一个典型的计算机系统架构中,程序会在运行时把内存划分为四个区块,分别是:Code区块、Static/Global区块、Stack区块以及Heap区块。
Code区块:用于装载程序运行的指令,其实就是你所编写的代码最终编译成的机器指令;
Static/Global区块(以下简称:Static区块):用于存放全局变量,定义在函数内的变量只能在该函数内可见,在该函数外是无法访问到的,但是定义在这里的变量在任何函数中都能访问得到;
Stack区块:用于存放函数运行时的数据和信息,包括:函数调用时的参数、函数内定义的变量、函数运行结束后返回的地址等等;
Heap区块:函数运行时的基本数据类型的数据会直接保存在Stack中,而对象类型的数据则会在Heap区块中分配内存进行存储,然后返回内存地址以保存在Stack中的变量中。
在这些区块中,Code区块、Static区块以及Stack区块的大小都是程序运行时不变的,是在程序运行前就已经确定下来的,所以它们中的任何一个如果在运行过程中的使用量超过了所分配的大小的话,就会导致OOM(Out Of Memory)的发生,进而导致程序的崩溃。唯有Heap区块的大小是程序运行时可变的,是可以在程序运行过程中动态分配和回收的,但是即便如此,如果最终的使用量超过了机器内存的最大可分配量的话,依然会引起OOM的发生,同样会导致程序的崩溃。所以我们在开发时要特别注意这一点。
Call Stack
Call Stack简称Stack,用于保存函数运行时所需要的数据和信息,这是一个很笼统的说法。细一点说的话,每个函数运行时都会在Stack中开辟一块专属的内存区域,我们称之为Stack Frame。每一个Stack Frame会保存该函数运行时的调用参数、函数内定义的变量、函数运行结束后返回的地址、以及调用该函数的函数的Stack Frame地址等等信息。
这么说有一些抽象,我们来看一个具体的例子:
#include <stdio.h>
int total;
int sq(int x) { // square
return x * x;
}
int sos(int x, int y) { // sum of square
int z = sq(x + y);
return z;
}
int main() {
int a = 3, b = 4;
total = sos(a, b);
printf("total = %d", total);
}
这是一段简单的c语言代码,其中sq函数会返回输入参数的平方值,sos函数返回输入的两个参数之和的平方值,所以main函数运行的结果就是打印出“total = 49”。我们来看一下这段函数在运行过程中的内存变化。
第一步:装载代码
可以看到,代码已经被装载进了Code区块,Code区块左侧的数字代表内存的地址,右侧是装载的代码指令。当然,实际的内存地址和代码指令肯定不会是这个样子的,在这里我只是示意而已。其中,因为total被声明为全局变量,所以它被放在了Static区块中(C语言中int类型的变量初始值为0,如果我没记错的话)。
再来看看Stack区块,Stack区块是一个LIFO(Last In First Out)的标准栈类型数据结构,它只有两种操作:Push进栈和Pop出栈。我们假设程序初始化时内存地址0xff00 ~ 0xf000被划分为Stack区块,按照约定俗成的最佳实践,Stack区块中高位地址(high address)是栈底方向,低位地址(low address)是栈顶方向,所以Push进Stack栈的数据是从高到低占用Stack的内存空间的。
上图中还有两个小东西,分别是ESP和EIP,它们都是给CPU使用的寄存器(Register)。ESP是Stack Pointer,它始终指向Stack的栈顶的地址,初始化时Stack栈中还没有数据,所以此时ESP会指向栈底地址+1的地址,也就是图中的0xff01。EIP是Instruction Pointer,它指向Code区块中当前正在运行的指令的地址,main函数执行之前我也不知道它的值是什么,所以这里姑且以四个问号替代吧。一切准备就绪之后,我们开始执行我们的main函数。
第二步:执行main函数
此时我们已经进入了main函数的执行,可以看到Code区块中的蓝色方框代表我们执行到了0x0003地址的变量a和变量b的初始化这里,所以EIP寄存器保存的也是0x0003这个地址值。
与此同时,main函数执行时的上下文数据被Push进了Stack栈中,包括函数的调用参数(这里没有),函数内定义的变量(a和b)等等。我们假设这部分数据占用了0xff00 ~ 0xfe00这段内存空间,这一部分我们就称之为:Stack Frame。关于Stack Frame具体保存的数据是怎样的,我们后面再说,在这里我们只需要有这么一个概念即可。
第三步:调用sos函数
然后,我们开始调用sos函数。由于被调用函数的Stack Frame空间是由调用函数在调用之前就预先申请好的,所以可以看到在sos函数真正执行之前,它的Stack Frame(sos)就已经准备就绪了(我们假设这段内存空间是0xfe00 ~ 0xfd00)。Stack Frame(sos)里面包括了函数调用参数x = 3和y = 4,以及函数内声明的变量z = 0。为什么z是0呢?因为int类型的z变量的空间是预先申请好的,而此时它还没有被赋值,所以它的默认值就是0。
注意到此时ESP的值是0xfd00,而EIP的值是0x0002,原因我就不再赘述了。
第四步:调用sq函数
同样,sos函数调用了sq函数,内存方面的变化如图所示,请读者朋友们自行分析。
第五步:sq函数返回
到这里,我们已经完成了sq函数的执行,此时开始执行sq函数的返回以及Stack Frame(sq)的回收工作。首先,sq函数会将返回结果保存在CPU的Accumulator寄存器或者Stack栈中以供调用函数(在这里就是sos函数)使用;然后,它会将除了函数调用参数(在这里就是x参数)之外的所有Stack Frame(sq)数据清除,主要是函数内声明的变量(在这里没有)等等,那函数调用参数由谁来清除呢?按照推荐的编码规范,函数调用参数将由调用函数负责清除,在这里就是sos函数;最后,因为Stack Frame会保存函数运行结束后返回的地址,所以控制权将会重新回到sos函数手里。
第六步:sos函数返回
运行到这里,sos函数已经执行完毕,控制权将回到main函数这里,此时Stack Frame(sos)已被清除,相应的ESP寄存器和EIP寄存器的值也已更新。待sos函数返回后,main函数也拿到了它的返回值49并赋值给全局变量total。
第七步:调用printf函数
printf函数也是函数,调用过程跟上面的很类似,次数不再赘述,printf函数返回的过程也略过。
第八步:main函数返回
到这里,所有的代码就都执行结束了,计算机会执行内存回收工作,包括清空Code区块、Static区块以及Stack区块,EIP的值置为我仍然不知道的某个值,同时ESP被恢复成0xff01以表示此时Stack栈中无数据。