四、底层与栈(本节面向有一定基础的读者。不关心这些内容的读者只需要知道,每个独立并行运行的、用asynccall调用的和系统直接调用的函数,都有自己的栈,并且所有参数、局部变量都储存在栈中即可。)
每个脚本运行时都有自己独立的栈。在三国2脚本中,不存在寄存器;因此,除了全局变量和预设变量以外的所有数据都是储存在栈中的。栈的结构如下图所示:

其中,黑色的位置指示了当前函数的0号位置。0号和-1号位置由系统自动压栈,储存了调用者的PC和SP,也即调用者调用时的代码位置指针和调用者的堆栈位置指针。往下的-2, -3, -4等位置是当前函数的参数;当前函数的调用者在调用时,将需要的参数一个一个压栈,然后调用当前函数,因此参数处在0号位置的下面。
往上的1号和2号位置是脚本伪代码编译器预留的位置,用于在编译一些特殊的语法结构时,储存中间数据(该部分在奥汀的代码里是不存在的,奥汀的局部变量直接从1号位置开始)。
从3号位置开始往上的蓝色的区域储存着当前函数的所有局部变量。所有在函数中声明的局部变量都储存在这一区域,按照编译时读取到的声明的顺序,从3号开始往上逐个排列。
再往上就是所谓的“表达式求值过程”的部分了。由于没有寄存器,所有的表达式运算都是在栈中完成的。我们举个简单的例子。在计算a = (2 * b) + 3时,假设变量a占用3号位置,b占用4号位置,并且没有别的局部变量了;那么在运行这段代码之前,栈顶在4号位置。这段代码编译出来的汇编代码是这样的:
PUSH 2 ; 将常数2压栈
PUSHARG 4 ; 将4号位置的值(局部变量b)压栈
MUL ; 弹出栈顶的两个数,将它们的乘积压栈
PUSH 3 ; 将常数3压栈
ADD ; 弹出栈顶的两个数,将它们的和压栈
POPN 3 ; 弹出栈顶的数,将其储存在栈中的3号位置(局部变量a)
可以看到,这些操作都在栈的最顶端完成,并且当所有表达式都求值完毕时,所有多余的值都已被弹栈(上面的代码正好压栈5次,弹栈5次),栈顶指针重新指向最后一个局部变量。
一个比较特殊的情况是调用函数。当使用常规方法调用函数时,底层上,会做以下这些事情:
1. 将所有参数从左到右依次压入栈中;
2. 调用函数;系统自动压入当前函数的堆栈相对位置和当前执行到的代码位置的指针,也即调用者SP和调用者PC;
3. 跳转到被调用的函数的开头;
4. 栈顶指针向上移动,为局部变量和预留的1、2号位置腾出空间(一般而言,在编译完成的汇编代码中,该操作是函数体的第一条指令);
5. 执行函数体。
因此,从左到右的多个参数,它们排列的顺序也是“从低到高”的(这里的低和高是数字大小的含义),即从最小的负值一直排到-2. 比如,有三个参数,则第一个参数占据 -4号位置,第二个占据-3,第三个占据-2.
当函数返回时,会根据0号和-1号栈的内容,调整栈顶位置和接下来执行的代码。此时新增的栈都会全部退回原位。
需要说明的是,图中描述的一个函数叠一个函数的情况,只适用于常规方法调用的函数的情况。如果调用的函数是用asynccall或回调函数方式调用的,那么在调用时,系统会创建一个新的运行时。这个运行时有独立的栈,并且参数会被依次压在栈底;因此当前函数参数的部分再往下,就不再是调用者函数的部分了。