一、问题
和C相比,Lua是一种限制比较松散的语言,这个在函数相关的处理中更加明显。函数可以有多个参数,函数返回值可以被赋值给变量列表(Lua manual中的varlist),函数可以return表达式列表(Lua manual中的explist),这些其实也不是很混乱,问题在于这些特性放在一起的时候就可能有些让人头大了。考虑下面的函数实现:
tsecer@harry: cat lua-call.lua
function tsecer(x, y)
return x + x, x - x, x * x + x * x;
end
local l1,l2
l1, g1, l2, g2 = tsecer(1)
print(l1, l2, g1, g2)
tsecer@harry: /home/tsecer/Download/lua-5.3.4/src/lua ./lua-call.lua
2 2 0 nil
tsecer@harry:
是不是感觉这个代码写的狗屁不通?那就对了!例如,函数tsecer定义的时候有两个参数(x,y),函数return的表达式列表包涵3个表达式(x + x, x - x, x * x + x * x);在函数调用的地方,希望将结果赋值给4个变量(v1, v2, v3, v4),这个调用和函数的定义没有一个地方是匹配的,但是这个程序依然可以运行,这个在C++语言中是不可想象的,并且这个还不是Lua设计的缺陷,而是Lua的一个特性,在Manual中有明确的说明:
When a function is called, the list of arguments is adjusted to the length of the list of parameters, unless the function is a vararg function, which is indicated by three dots ('...') at the end of its parameter list. A vararg function does not adjust its argument list; instead, it collects all extra arguments and supplies them to the function through a vararg expression, which is also written as three dots. The value of this expression is a list of all actual extra arguments, similar to a function with multiple results. If a vararg expression is used inside another expression or in the middle of a list of expressions, then its return list is adjusted to one element. If the expression is used as the last element of a list of expressions, then no adjustment is made (unless that last expression is enclosed in parentheses).
既然这个地方是一个明确的语言特性,Lua在实现的时候应该是做过特殊处理的,具体如何处理,这个是一个比较有意思的问题。由于函数调用在所有语言中都是最为重要的一个基本功能,所以接下来尝试分析下Lua对于这个功能的实现。
二、虚拟机代码
为了有一个直观的认识,下面是对虚拟机指令的一个注释,其中尽量详细解释了每条指令的意义。虽然这些细节在绝大部分情况下都是不必关注的,但是如果想细致了解下这方面的内容,这些注释可以作为一个备注,在需要的时候翻一下。
tsecer@harry: /home/tsecer/Download/lua-5.3.4/src/luac -o lua-call.i ./lua-call.lua
tsecer@harry: /home/tsecer/Download/lua-5.3.4/src/luac -l -l lua-call.i
main <./lua-call.lua:0,0> (17 instructions at 0x9ce5f30)
0+ params, 7 slots, 1 upvalue, 2 locals, 5 constants, 1 function
创建tsecer函数的Closure,结果保存在寄存器0中,操作数0,0分别表示接收寄存器和函数内函数定义编号
1 [3] CLOSURE 0 0 ; 0x9ce60c8
将创建的Closure保存到全局变量tsecer中,这也就是Manual中说函数定义的syntax sugur,
也就是function f () body end 被转换为f = function () body end的实现
2 [1] SETTABUP 0 -1 0 ; _ENV "tsecer"
将寄存器编号区间0-1置为nil,这两个寄存器分别对应代码中定义的两个局部变量 local l1,l2,Lua默认为置位确定的nil
3 [4] LOADNIL 0 1
将tsecer的Closure从TABUP装载到寄存器2中
4 [5] GETTABUP 2 0 -1 ; _ENV "tsecer"
将常量1的值装载到寄存器3中
5 [5] LOADK 3 -4 ; 1
函数调用tsecer,2表示函数调用的Closure放在寄存器2中,2表示参数的个数为2-1=1个(即x),
5表示返回值接收变量为5-1=4个(即l1, g1, l2, g2)
6 [5] CALL 2 2 5
虚拟机在执行被调用函数的return指令时,会将return中的表达式列表的内容依次赋值到从call第一个参数开始的寄存器中,第6条指令CALL 2 2 5指明的第一个参数为2,这表示在2这个寄存器中保存的是将要调用函数的Closure,在调用的时候,从该寄存器开始,之后的寄存器保存的是函数参数列表,在被调用函数return的时候,函数的返回值将会保存在从该地址开始的寄存器中。所以这个地方可以看到的现象是,在函数调用的时候,函数的参数要按照寄存器序号连续的顺序赋值,接收函数返回值的时候也要同样如此。
将寄存器5的内容赋值到全局变量g2中,其中0表示upvalue编号,对应下面upvalues (1) for 0x9ce5f30中第一项,也就是_ENV,-3的负数表示这个是一个常量(K,相对于寄存器R),取反之后对应constants (5) for 0x9ce5f30中第三项,也就是"g2",所以这个指令就是将寄存器5的值装载到_ENV表中通过"g2"索引获得的变量中
7 [5] SETTABUP 0 -3 5 ; _ENV "g2" 。
将寄存器4的内容赋值到寄存器1中,也就是l2变量中
8 [5] MOVE 1 4
将寄存器3的内容赋值到全局变量g1中
9 [5] SETTABUP 0 -2 3 ; _ENV "g1"
将寄存器2的内容赋值到寄存器1中,也就是l2变量中
10 [5] MOVE 0 2
print的定义放入寄存器2中
11 [7] GETTABUP 2 0 -5 ; _ENV "print"
寄存器0内容移入寄存器3中,其中寄存器0存储的内容为l1
12 [7] MOVE 3 0
寄存器1内容移入寄存器4中,其中寄存器1存储的内容为l2
13 [7] MOVE 4 1
ENV "g1" 全局变量g1的内容放入寄存器5中
14 [7] GETTABUP 5 0 -2 ; _
全局变量g2的内容放入寄存器6中
15 [7] GETTABUP 6 0 -3 ; _ENV "g2"
调用print,寄存器2保存print函数Closure定义,调用参数数量为5-1=4个,
返回值接收个数为1 -1 = 0个,因为没有人接收print的返回值
16 [7] CALL 2 5 1
函数没有主动的执行return,所以这个地方编译器自动加上一个return指令,
其中的1表示返回值个数为1-1=0个,也就是没有返回值
17 [7] RETURN 0 1
constants (5) for 0x9ce5f30:
1 "tsecer"
2 "g1"
3 "g2"
4 1
5 "print"
locals (2) for 0x9ce5f30:
0 l1 4 18
1 l2 4 18
upvalues (1) for 0x9ce5f30:
0 _ENV 1 0
function <./lua-call.lua:1,3> (7 instructions at 0x9ce60c8)
2 params, 6 slots, 0 upvalues, 2 locals, 0 constants, 0 functions
其中0表示参数x的内容
1 [2] ADD 2 0 0 x + x的结果放入寄存器2中
2 [2] SUB 3 0 0 x - x的结果放入寄存器3中
3 [2] MUL 4 0 0 x * x的结果放入急促请4中
4 [2] MUL 5 0 0 x * x的结果放入寄存器5中
5 [2] ADD 4 4 5 寄存器4(x * x) 和 寄存器5(x * x) 的和放入寄存器4中。
这里要注意的是其中的结果在寄存器存放的时候始终是连续的,也就是3个返回值依次放入从2到4的寄存器中。接下来的返回指令RETURN 2 4表示从寄存器2开始,返回 4 -1 = 3个值。
6 [2] RETURN 2 4
7 [3] RETURN 0 1
constants (0) for 0x9ce60c8:
locals (2) for 0x9ce60c8:
0 x 1 8
1 y 1 8
upvalues (0) for 0x9ce60c8:
tsecer@harry:
三、虚拟机对调用(CALL)时参数不一致的处理
1、call指令的虚拟机处理
luaV_execute:vmcase(OP_CALL)===>>>luaD_precall
case LUA_TLCL: { /* Lua function: prepare its call */
StkId base;
Proto *p = clLvalue(func)->p;
//这里的L->top在OP_CALL指令执行的时候已经设置为L->top = ra+b;所以这个减法相当于还原出b的数值,也就是call指令中第二个参数的值,也就是调用时提供的参数个数
int n = cast_int(L->top - func) - 1; /* number of real arguments */
int fsize = p->maxstacksize; /* frame size */
checkstackp(L, fsize, func);
if (p->is_vararg)
base = adjust_varargs(L, p, n);
else { /* non vararg function */
for (; n < p->numparams; n++)
setnilvalue(L->top++); /* complete missing arguments */
base = func + 1;
}
ci = next_ci(L); /* now 'enter' new function */
ci->nresults = nresults;
ci->func = func;
ci->u.l.base = base;
L->top = ci->top = base + fsize;
lua_assert(ci->top <= L->stack_last);
ci->u.l.savedpc = p->code; /* starting point */
ci->callstatus = CIST_LUA;
if (L->hookmask & LUA_MASKCALL)
callhook(L, ci);
return 0;
}
2、当调用参数比函数定义参数少时
执行OP_CALL指令的时候,此时已经找到了调用函数的定义,因此也就知道了函数定义中的参数个数,这个值也就是p->numparams,在luaD_precall函数中:
for (; n < p->numparams; n++)
setnilvalue(L->top++); /* complete missing arguments */
语句将调用时缺少的参数设置为nil,这也即是manual中说明的函数调用时不足参数补充为确定nil值的具体实现。
3、当调用时参数比函数定义的参数多时
这个地方其实不需要做任何处理,多出的参数对于被调用函数来说不可见,没毛病。
4、被调用函数如何使用寄存器
由于函数中通过base = func + 1;ci->u.l.base = base;设置了新调用函数的栈帧基地址,并且参数从func开始依次压入堆栈,所以在被调用函数中通过寄存器0就可以访问到调用时压入的第一个参数,并依次类推。
在解析函数定义时, funcstat===>>>body===>>>parlist===>>>new_localvar,依次为参数绑定寄存器
static void new_localvar (LexState *ls, TString *name) {
FuncState *fs = ls->fs;
Dyndata *dyd = ls->dyd;
int reg = registerlocalvar(ls, name);
checklimit(fs, dyd->actvar.n + 1 - fs->firstlocal,
MAXVARS, "local variables");
luaM_growvector(ls->L, dyd->actvar.arr, dyd->actvar.n + 1,
dyd->actvar.size, Vardesc, MAX_INT, "local variables");
dyd->actvar.arr[dyd->actvar.n++].idx = cast(short, reg);
}
并在parlist中为这些变量保留栈空间。
static void parlist (LexState *ls) {
……
adjustlocalvars(ls, nparams);
f->numparams = cast_byte(fs->nactvar);
luaK_reserveregs(fs, fs->nactvar); /* reserve register for parameters */
}
四、当函数返回(RETURN)个数和接收变量个数不一致时
1、RETURN如何知道接收变量个数
看函数返回的时候,还是要再回顾下函数调用(CALL),在这个指令中,其实已经指明了函数返回值有多少个接收变量,它被编码到CALL指令的操作数中。虚拟机在执行CALL指令时,luaD_precall函数通过ci->nresults = nresults;将接收变量个数保存在Call Info结构中,在执行RETURN指令时可以使用这个信息。
2、接收变量数量从哪里来
开始例子中接收变量nvars的值为4,而表达式nexps的数量为1,所以adjust_assign===>>>luaK_setreturns会重新修改之前call指令编码中的接收参数个数
static void assignment (LexState *ls, struct LHS_assign *lh, int nvars) {
{ /* assignment -> '=' explist */
int nexps;
checknext(ls, '=');
nexps = explist(ls, &e);
if (nexps != nvars)
adjust_assign(ls, nvars, nexps, &e);
else {
luaK_setoneret(ls->fs, &e); /* close last expression */
luaK_storevar(ls->fs, &lh->v, &e);
return; /* avoid default */
}
}
……
void luaK_setreturns (FuncState *fs, expdesc *e, int nresults) {
if (e->k == VCALL) { /* expression is an open function call? */
SETARG_C(getinstruction(fs, e), nresults + 1);
}
else if (e->k == VVARARG) {
Instruction *pc = &getinstruction(fs, e);
SETARG_B(*pc, nresults + 1);
SETARG_A(*pc, fs->freereg);
luaK_reserveregs(fs, 1);
}
else lua_assert(nresults == LUA_MULTRET);
}
3、虚拟机OP_RETURN指令时不一致的处理
luaD_poscall===>>>moveresults
如果接收参数wanted小于返回值数量,只将需要的参数进行转移;反过来,如果接收参数多于返回个数,会将有效返回值数量(nres)进行转移,剩余的内容使用setnilvalue置空。
default: {
int i;
if (wanted <= nres) { /* enough results? */
for (i = 0; i < wanted; i++) /* move wanted results to correct place */
setobjs2s(L, res + i, firstResult + i);
}
else { /* not enough results; use all of them plus nils */
for (i = 0; i < nres; i++) /* move all results to correct place */
setobjs2s(L, res + i, firstResult + i);
for (; i < wanted; i++) /* complete wanted number of results */
setnilvalue(res + i);
}
break;
这里其实要注意:这些内容的转移都是由RETURN指令触发的,在生成的虚拟机指令中没有体现。
再次回顾下CALL指令,CALL指令只是指定了接收寄存器组的起始位置,而RETURN指令指明了自己返回表达式从哪个寄存器开始,到哪个寄存器结束,这个也是一个区间,而这里的寄存器组之间的转移是由RETURN指令背后的虚拟机代码完成。
4、接收变量的转移
RETURN指令只是将函数返回值赋值到CALL指令指定的(连续)寄存器区间中,但是,接收变量可能是局部变量或者全局变量等,这些变量其实已经有官方地址,例如对于l1, g1, l2, g2 = tsecer(1)这样的指令,在tsecer(1)函数返回之后,还需要将连续寄存器组中的变量逐个赋值给接收变量(l1, g1, l2, g2)中。这个对应指令的生成在函数assignment===>>>luaK_storevar(ls->fs, &lh->v, &e);函数将会根据接收变量进行转移,这些赋值指令在虚拟机代码中可以看到。这一点也不难理解,因为接收变量是由调用函数确定的,需要各个调用场景自己将函数生成的表达式列表进行手动转移。
五、如何保证一组编号连续的寄存器
1、一个可能会破坏寄存器编号连续的场景
在前面的流程中,其实有一个重要的隐含限制:要有一组编号连续的寄存器,这一点在CALL函数各个参数、RETURN中返回的表达式列表,接收变量的赋值等均是一个隐含前提。我们以返回列表为例来看下这个问题
x + x, x - x, x * x + x * x;
这个地方中要求这三个表达式放在连续的寄存器中,而比较有代表意义的是x * x + x * x,这个地方需要两个临时寄存器来存放中间结果,那么如果这个中间变量占用了两个寄存器的话,是否是最终生成的寄存器是不连续的(因为两个乘积必然要占用两个中间寄存器来存放)?下面是生成的机器指令中的内容:
3 [2] MUL 4 0 0 x * x的结果放入急促请4中
4 [2] MUL 5 0 0 x * x的结果放入寄存器5中
5 [2] ADD 4 4 5 寄存器4(x * x) 和 寄存器5(x * x) 的和放入寄存器4中。
可以看到,的确是同时使用两个中间变量4和5来保存加法的两个操作数,但是在保存最终的加法结果的时候还是保存在了连续的寄存器4中,也就是保证了寄存器的连续。
2、如何规避
对于这里遇到的算数运算问题,这个逻辑是通过subexpr函数来完成的。
static BinOpr subexpr (LexState *ls, expdesc *v, int limit) {
……
while (op != OPR_NOBINOPR && priority[op].left > limit) {
expdesc v2;
BinOpr nextop;
int line = ls->linenumber;
luaX_next(ls);
luaK_infix(ls->fs, op, v);
/* read sub-expression with higher priority */
nextop = subexpr(ls, &v2, priority[op].right);
luaK_posfix(ls->fs, op, v, &v2, line);
op = nextop;
}
……
}
在执行加法运算时luaK_posfix===>>>codebinexpval
static void codebinexpval (FuncState *fs, OpCode op,
expdesc *e1, expdesc *e2, int line) {
int rk2 = luaK_exp2RK(fs, e2); /* both operands are "RK" */
int rk1 = luaK_exp2RK(fs, e1);
freeexps(fs, e1, e2);
e1->u.info = luaK_codeABC(fs, op, 0, rk1, rk2); /* generate opcode */
e1->k = VRELOCABLE; /* all those operations are relocatable */
luaK_fixline(fs, line);
}
在这个执行流程中,luaK_exp2RK(fs, e2) 和 luaK_exp2RK(fs, e1) 会为这些没有分配临时寄存器的变量分配接收寄存器(当然如果已经有对应的寄存器的话就不用再分配了,例如x * x 这个运算的的两个操作数x都是已经有寄存器了,所以不用分配新的临时寄存器),然后立马释放这两个中间结果使用的临时变量(同样,非临时变量就不用释放),这样在为最终结果分配寄存器之前,所有的中间寄存器都已经释放。
3、以x * x + x * x为例来说明寄存器分配
第一个 x * x 通过codebinexpval生成一个VRELOCABLE类型的表达式,后一个 x * x 生成一个VRELOCABLE的表达式,但是这两个表达式都还没有真正分配临时寄存器,在执行x * x + x * x时luaK_exp2RK(fs, e2)为左边表达式分配第一个空闲寄存器4,luaK_exp2RK(fs, e1)为第二个VRELOCABLE类型的表达式分配寄存器5,然后freeexps(fs, e1, e2)动态释放这两个寄存器(4和5),从而下一个空闲的寄存器就是4,也就是
5 [2] ADD 4 4 5
中最终接收寄存器的值4。按照这个流程,直观上想是可以保证最终结果会保存在该表达式执行之前的那个空闲寄存器。当然这个只是一个直观上的感觉,具体是否严谨可能有些地方已经有说明,这里就不再继续深入了。