Lua源码分析 -- 虚拟机以及指令解释

Lua一直把虚拟机执行代码的效率作为一个非常重要的设计目标。而采用什么样的指令系统的对于虚拟机的执行效率来说至关重要。

Stack based vs Register based VM

根据指令获取操作数方式的不同,我们可以把虚拟机的实现分为stack based和register based。

Stack based vm

对于大多数的虚拟机,比如JVM,Python,都采用传统的stack based vm。

Stack based vm的指令一般都是在当前stack中获取和保存操作数的。比如一个简单的加法赋值运算:a=b+c,对于stack based vm,一般会被转化成如下的指令:

[plain] view plain copy
  1. push b; // 将变量b的值压入stack  
  2. push c; // 将变量c的值压入stack  
  3. add;    // 将stack顶部的两个值弹出后相加,将结果压入stack  
  4. mov a;  // 将stack顶部结果放到a中  

由于Stack based vm的指令都是基于当前stack来查找操作数的,这就相当于所有操作数的存储位置都是运行期决定的,在编译器的代码生成阶段不需要额外为在哪里存储操作数费心,所以stack based的编译器实现起来相对比较简单直接。也正因为这个原因,每条指令占用的存储空间也比较小。

但是,对于一个简单的运算,stack based vm会使用过多的指令组合来完成,这样就增加了整体指令集合的长度。vm会使用同样多的迭代次数来执行这些指令,这对于效率来说会有很大的影响。并且,由于操作数都要放到stack上面,使得移动这些操作数的内存复制大大增加,这也会影响到效率。

Register based vm

Lua 采用的是register based vm。

Register based vm的指令都是在已经分配好的寄存器中存取操作数。对于上面的运算,register based vm一般会使用如下的指令:

[plain] view plain copy
  1. add a b c; // 将b与c对应的寄存器的值相加,将结果保存在a对应的寄存器中  

Register based vm的指令可以直接对应标准的3地址指令,用一条指令完成了上面多条指令的计算工作,并且有效地减少了内存复制操作。这样的指令系统对于效率有很大的帮助。

不过,在编译器设计上,就要在代码生成阶段对寄存器进行分配,增加了实现的复杂度。并且每条指令所占用的存储空间也相应的增加了。

Lua虚拟机指令简介

Lua的指令使用一个32bit的unsigned integer表示。所有指令的定义都在lopcodes.h文件中,使用一个enum OpCode代表指令类型。在lua5.2中,总共有40种指令(id从0到39)。根据指令参数的不同,可以将所有指令分为4类:


除了sBx之外,所有的指令参数都是unsigned integer类型。sBx可以表示负数,但表示方法比较特殊。sBx的18bit可表示的最大整数为262143,这个数的一半131071用来表示0,所以-1可以表示为-1+131071,也就是131070,而+1可以表示为+1+131071,也就是131072。

ABC一般用来存放指令操作数据的地址,而地址可以分成3种:

  • 寄存器id
  • 常量表id
  • upvalue id
Lua使用当前函数的stack作为寄存器使用,寄存器id从0开始。当前函数的stack与寄存器数组是相同的概念。stack(n)其实就是register(n)。

每一个函数prototype都有一个属于本函数的常量表,用于存放编译过程中函数所用到的常量。常量表可以存放nil,boolean,number和string类型的数据,id从1开始。

每一个函数prototype中都有一个upvalue描述表,用于存放在编译过程中确定的本函数所使用的upvalue的描述。在运行期,通过OP_CLOSURE指令创建一个closure时,会根据prototype中的描述,为这个closure初始化upvalue表。upvalue本身不需要使用名称,而是通过id进行访问。

A被大多数指令用来指定计算结果的目标寄存器地址。很多指令使用B或C同时存放寄存器地址和常量地址,并通过最左面的一个bit来区分。在指令生成阶段,如果B或C需要引用的常量地址超出了表示范围,则首先会生成指令将常量装载到临时寄存器,然后再将B或C改为使用该寄存器地址。

在lopcodes.h中,对于每个指令,在源码注释中都有简单的操作描述。本文接下来将针对每一个指令做更详细的描述,并给出关于这个指令的示例代码。示例代码可以帮助我们构建出一个指令使用的具体上下文,有助于进一步理解指令的作用。对指令上下文的理解还可以作为进一步研究lua的编译和代码生成系统的基础。

在分析过程中,我们使用luac来显示示例代码所生成的指令。luac的具体使用方式为:

[plain] view plain copy
  1. luac -l -l test.lua  



Lua首先将源程序编译成为字节码,然后交由虚拟机解释执行.对于每一个函数,Lua的编译器将创建一个原型(prototype),它由一组指令及其使用到的常量组成[1].最初的Lua虚拟机是基于栈的.1993,Lua5.0版本,采用了基于寄存器的虚拟机,使得Lua的解释效率得到提升,

 

1、指令系统

与虚拟机和指令相关的文件主要有两个: lopcodes.clvm.c.从名称可以看出来,这两个文件分别用于描述操作码(指令)和虚拟机.

(1)指令列举

Lua共有38条指令,在下面两处地方分别描述了这些指令的名称和模式,如下:
lopcodes.c:16

const char*const luaP_opnames[NUM_OPCODES+1] = {
  "MOVE",
  "LOADK",
  "LOADBOOL",
  "LOADNIL",
  "GETUPVAL",
  "GETGLOBAL",
  "GETTABLE",
  "SETGLOBAL",
  "SETUPVAL",
  "SETTABLE",
  "NEWTABLE",
  "SELF",
  "ADD",
  "SUB",
  "MUL",
  "DIV",
  "MOD",
  "POW",
  "UNM",
  "NOT",
  "LEN",
  "CONCAT",
  "JMP",
  "EQ",
  "LT",
  "LE",
  "TEST",
  "TESTSET",
  "CALL",
  "TAILCALL",
  "RETURN",
  "FORLOOP",
  "FORPREP",
  "TFORLOOP",
  "SETLIST",
  "CLOSE",
  "CLOSURE",
  "VARARG",
  NULL
};

#define opmode(t,a,b,c,m) (((t)<<7) | ((a)<<6) | ((b)<<4) |((c)<<2) | (m))

const lu_byte luaP_opmodes[NUM_OPCODES] = {
/*       T  A   B       C    mode           opcode   */
  opmode(0, 1, OpArgR, OpArgN, iABC)        /* OP_MOVE */
 ,opmode(0, 1, OpArgK, OpArgN, iABx)       /* OP_LOADK */
 ,opmode(0, 1, OpArgU, OpArgU, iABC)       /* OP_LOADBOOL */
 ,opmode(0, 1, OpArgR, OpArgN, iABC)       /* OP_LOADNIL */
 ,opmode(0, 1, OpArgU, OpArgN, iABC)       /* OP_GETUPVAL */
 ,opmode(0, 1, OpArgK, OpArgN, iABx)       /* OP_GETGLOBAL */
 ,opmode(0, 1, OpArgR, OpArgK, iABC)       /* OP_GETTABLE */
 ,opmode(0, 0, OpArgK, OpArgN, iABx)       /* OP_SETGLOBAL */
 ,opmode(0, 0, OpArgU, OpArgN, iABC)       /* OP_SETUPVAL */
 ,opmode(0, 0, OpArgK, OpArgK, iABC)       /* OP_SETTABLE */
 ,opmode(0, 1, OpArgU, OpArgU, iABC)       /* OP_NEWTABLE */
 ,opmode(0, 1, OpArgR, OpArgK, iABC)       /* OP_SELF */
 ,opmode(0, 1, OpArgK, OpArgK, iABC)       /* OP_ADD */
 ,opmode(0, 1, OpArgK, OpArgK, iABC)       /* OP_SUB */
 ,opmode(0, 1, OpArgK, OpArgK, iABC)       /* OP_MUL */
 ,opmode(0, 1, OpArgK, OpArgK, iABC)       /* OP_DIV */
 ,opmode(0, 1, OpArgK, OpArgK, iABC)       /* OP_MOD */
 ,opmode(0, 1, OpArgK, OpArgK, iABC)       /* OP_POW */
 ,opmode(0, 1, OpArgR, OpArgN, iABC)       /* OP_UNM */
 ,opmode(0, 1, OpArgR, OpArgN, iABC)       /* OP_NOT */
 ,opmode(0, 1, OpArgR, OpArgN, iABC)       /* OP_LEN */
 ,opmode(0, 1, OpArgR, OpArgR, iABC)       /* OP_CONCAT */
 ,opmode(0, 0, OpArgR, OpArgN, iAsBx)       /* OP_JMP */
 ,opmode(1, 0, OpArgK, OpArgK, iABC)       /* OP_EQ */
 ,opmode(1, 0, OpArgK, OpArgK, iABC)       /* OP_LT */
 ,opmode(1, 0, OpArgK, OpArgK, iABC)       /* OP_LE */
 ,opmode(1, 1, OpArgR, OpArgU, iABC)       /* OP_TEST */
 ,opmode(1, 1, OpArgR, OpArgU, iABC)       /* OP_TESTSET */
 ,opmode(0, 1, OpArgU, OpArgU, iABC)       /* OP_CALL */
 ,opmode(0, 1, OpArgU, OpArgU, iABC)       /* OP_TAILCALL */
 ,opmode(0, 0, OpArgU, OpArgN, iABC)       /* OP_RETURN */
 ,opmode(0, 1, OpArgR, OpArgN, iAsBx)       /* OP_FORLOOP */
 ,opmode(0, 1, OpArgR, OpArgN, iAsBx)       /* OP_FORPREP */
 ,opmode(1, 0, OpArgN, OpArgU, iABC)       /* OP_TFORLOOP */
 ,opmode(0, 0, OpArgU, OpArgU, iABC)       /* OP_SETLIST */
 ,opmode(0, 0, OpArgN, OpArgN, iABC)       /* OP_CLOSE */
 ,opmode(0, 1, OpArgU, OpArgN, iABx)       /* OP_CLOSURE */
 ,opmode(0, 1, OpArgU, OpArgN, iABC)       /* OP_VARARG */
};


前面一个数组容易理解,表示了每条指令的名称.后面一个数组表示的是指令的模式.奇怪的符号让人有些费解.在看模式之前,首先来看Lua指令的格式:



如上图,Lua的指令可以分成三种形式.即在上面的模式数组中也可以看到的iABC, iABx iAsBx.对于三种形式的指令来说,前两部分都是一样的,分别是6位的操作码和8A操作数;区别在于,后面部是分割成为两个长度为9位的操作符(B, C),一个无符号的18位操作符Bx还是有符号的18位操作符sBx.这些定义的代码如下:
lopcodes.c : 34

/*
** size and position of opcode arguments.
*/
#define SIZE_C        9
#define SIZE_B        9
#define SIZE_Bx        (SIZE_C + SIZE_B)
#define SIZE_A        8

#define SIZE_OP        6

#define POS_OP        0
#define POS_A        (POS_OP + SIZE_OP)
#define POS_C        (POS_A + SIZE_A)
#define POS_B        (POS_C + SIZE_C)
#define POS_Bx        POS_C


(2)指令的操作模式

 Lua使用一个字节来表示指令的操作模式.具体的含义如下:
1)
使用最高位来表示是否是一条测试指令.之所以将这一类型的指令特别地标识出来,是因为Lua的指令长度是32,对于分支指令来说,要想在这32位中既表示两个操作数来做比较,同时还要表示一个跳转的地址,是很困难的.

因此将这种指令分成两条,第一条是测试指令,紧接着一条无条件跳转.如果判断条件成立则将PC(Program Counter,指示下一条要执行的指令)加一,跳过下一条无条件跳转指令,继续执行;否则跳转.
2)
第二位用于表示A操作数是否被设置
3) 
接下来的二位用于表示操作数B的格式,OpArgN表示操作数未被使用, OpArgU表示操作数被使用(立即数?), OpArgR表示表示操作数是寄存器或者跳转的偏移量, OpArgK表示操作数是寄存器或者常量.

2、Lua虚拟机的体系结构


给出Lua虚拟机的体系结构图(根据源代码分析得出):



首先,我们注意到,Lua的解释器还是一个以栈为中心的结构.

lua_State这个结构中,有许多个字段用于描述这个结构.stack用于指向绝对栈底,base指向了当前正在执行的函数的第一个参数,top指向栈顶的第一个空元素.
我们可以看到,这个体系结构中并没有独立出来的寄存器.


从以下代码来看:

lvm.c:343

#define RA(i)   (base+GETARG_A(i))
/* to be used after possible stack reallocation */
#define RB(i)    check_exp(getBMode(GET_OPCODE(i)) == OpArgR,base+GETARG_B(i))
#define RC(i)    check_exp(getCMode(GET_OPCODE(i)) == OpArgR,base+GETARG_C(i))
#define RKB(i)    check_exp(getBMode(GET_OPCODE(i)) == OpArgK, /
    ISK(GETARG_B(i)) ? k+INDEXK(GETARG_B(i)) : base+GETARG_B(i))
#define RKC(i)    check_exp(getCMode(GET_OPCODE(i)) == OpArgK, /
    ISK(GETARG_C(i)) ? k+INDEXK(GETARG_C(i)) : base+GETARG_C(i))
#define KBx(i)    check_exp(getBMode(GET_OPCODE(i)) == OpArgK,k+GETARG_Bx(i))

当指令操作数的类型是寄存器时,它的内容是以base为基址在栈上的索引值.如图所示.寄存器实际是base之上栈元素的别名;当指令操作数的类型的常数时,它首先判断B操作数的最位是否为零.如果是零,则按照和寄存器的处理方法一样做,如果不是零,则在常数表中找相应的值.


我们知道Lua中函数的执行过程是这样的.首先将函数压栈,然后依次将参数压栈,形成图中所示的栈的内容.因此R[0]R[n]也分别表示了Arg[1]Arg[N+1].在第一个参数之下,就是当前正在执行的函数,对于Lua的函数(相对C函数)来说,它是指向类型为 PrototypeTValue,Prototype中字段code指向了一个数组用来表示组成这个函数的所有指令,字段k指向一个数组来表示这个函数使用到的所有常量.最后,Lua在解释执行过程中有专门的变量pc来指向下一条要执行的指令.

3、指令解释

有了前面对指令格式和体系结构的介绍,

现在我们可以进入正题,来看看Lua的指令是如何执行的了.主函数如下:

(1)执行lua指令函数

lvm.c:373
void luaV_execute (lua_State *L, int nexeccalls){
  LClosure *cl;
  StkId base;
  TValue *k;
  const Instruction *pc;
 reentry:  /* entry point */
  lua_assert(isLua(L->ci));
  pc = L->savedpc;
  cl = &clvalue(L->ci->func)->l;
  base = L->base;
  k = cl->p->k;
这是最开始的初始化过程.

其中, pc被初始化成为了L->savedpc,base被初始化成为了L->base,即程序从L->savedpc开始执行(在下一篇专题中,将会介绍到 L->savedpc在函数调用的预处理过程中指向了当前函数的code),L->base指向栈中当前函数的下一个位置.cl表示当前正在执行闭包(当前可以理解成为函数),k指向当前闭包的常量表.
接下来(注意,为了专注主要逻辑,我将其中用于Debugger支持,断言等代码省略了):
  /* main loop of interpreter */
  for (;;) {
    const Instruction i = *pc++;
    StkId ra;
    /* 
省略Debugger支持和Coroutine支持*/
    /* warning!! several calls may realloc the stack andinvalidate `ra' */
    ra = RA(i);
    /* 
省略断言 */
    switch (GET_OPCODE(i)) {
入到解释器的主循环,处理很简单,取得当前指令,pc递增,初始化ra,然后根据指令的操作码进行选择.接下来的代码是什么样的,估计大家都能想到,一大串的case来指示每条指令的执行.具体的实现可以参考源码,在这里不对每一条指令展开,只是对其中有主要的几类指令进行说明:

传值类的指令,MOVE为代表:
lvm.c:403
      case OP_MOVE: {
        setobjs2s(L, ra, RB(i));
        continue;
      }
lopcodes:154
OP_MOVE,/*    A B    R(A) :=R(B)                   */
lobject.h:161
#define setobj(L,obj1,obj2) /
  { const TValue *o2=(obj2); TValue *o1=(obj1); /
    o1->value = o2->value; o1->tt=o2->tt; /
    checkliveness(G(L),o1); }


/*
** different types of sets, according to destination
*/

/* from stack to (same) stack */
#define setobjs2s    setobj
从注释来看,这条指令是将操作数A,B都做为寄存器,然后将B的值给A.而实现也是简单明了,只使用了一句.宏展开以后,可以看到, R[A],R[B]的类型是TValue,只是将这两域的值传过来即可.对于可回收对象来说,真实值不会保存在栈上,所以只是改了指针,而对于非可回收对象来说,则是直接将值从R[B]赋到R[A].

数值运算类指令,ADD为代表:
lvm.c:470
      case OP_ADD: {
        arith_op(luai_numadd, TM_ADD);
        continue;
      }
lvm.c:360
#define arith_op(op,tm) { /
        TValue *rb = RKB(i); /
        TValue *rc = RKC(i); /
        if (ttisnumber(rb) &&ttisnumber(rc)) { /
          lua_Number nb =nvalue(rb), nc = nvalue(rc); /
          setnvalue(ra, op(nb,nc)); /
        } /
        else /
          Protect(Arith(L, ra, rb,rc, tm)); /
      }
lopcodes.c:171
OP_ADD,/*    A B C    R(A) := RK(B) +RK(C)               */
如果两个操作数都是数值的话,关键的一行是:
setnvalue(ra,op(nb,nc));
即两个操作数相加以后,把值赋给R[A].值得注意的是,操作数B,C都是RK,即可能是寄存器也可能是常量,这最决于最BC的最高位是否为1,如果是1,则是常量,反之则是寄存器.具体可以参考宏ISK的实现.
如果两个操作数不是数值,即调用了Arith函数,它尝试将两个操作转换成数值进行计算,如果无法转换,则使用元表机制.该函数的实现如下:
lvm.c:313
static void Arith (lua_State *L, StkId ra, const TValue *rb,
                  const TValue *rc, TMS op) {
  TValue tempb, tempc;
  const TValue *b, *c;
  if ((b = luaV_tonumber(rb, &tempb)) != NULL &&
      (c = luaV_tonumber(rc, &tempc)) != NULL) {
    lua_Number nb = nvalue(b), nc = nvalue(c);
    switch (op) {
      case TM_ADD: setnvalue(ra, luai_numadd(nb, nc));break;
      case TM_SUB: setnvalue(ra, luai_numsub(nb, nc));break;
      case TM_MUL: setnvalue(ra, luai_nummul(nb, nc));break;
      case TM_DIV: setnvalue(ra, luai_numdiv(nb, nc));break;
      case TM_MOD: setnvalue(ra, luai_nummod(nb, nc));break;
      case TM_POW: setnvalue(ra, luai_numpow(nb, nc));break;
      case TM_UNM: setnvalue(ra, luai_numunm(nb));break;
      default: lua_assert(0); break;
    }
  }
  else if (!call_binTM(L, rb, rc, ra, op))
    luaG_aritherror(L, rb, rc);
}
在上面call_binTM用于调用到元表中的元方法,因为在Lua以前的版本中元方法也被叫做tag method, 所以函数最后是以TM结尾的.
lvm:163
static int call_binTM (lua_State *L, const TValue *p1, const TValue *p2,
                      StkId res, TMS event) {
  const TValue *tm = luaT_gettmbyobj(L, p1, event);  /* try firstoperand */
  if (ttisnil(tm))
    tm = luaT_gettmbyobj(L, p2, event);  /* try secondoperand */
  if (!ttisfunction(tm)) return 0;
  callTMres(L, res, tm, p1, p2);
  return 1;
}
这个函数中,试着从二个操作数中找到其中一个操作数的元方法(第一个操作数优先),这里event表示具体哪一个元方法,找到了之后,再使用函数callTMres()去调用相应的元方法. callTMres()的实现很简单,只是将元方法,第一,第二操作数先后压栈,再调用并取因返回值.具体如下:
lvm.c:82
static void callTMres (lua_State *L, StkId res, const TValue *f,
                       const TValue *p1, const TValue *p2) {
  ptrdiff_t result = savestack(L, res);
  setobj2s(L, L->top, f);  /* push function */
  setobj2s(L, L->top+1, p1);  /* 1st argument */
  setobj2s(L, L->top+2, p2);  /* 2nd argument */
  luaD_checkstack(L, 3);
  L->top += 3;
  luaD_call(L, L->top - 3, 1);
  res = restorestack(L, result);
  L->top--;
  setobjs2s(L, res, L->top);
}

逻辑运算类指令,EQ为代表:
lvm.c:541
      case OP_EQ: {
        TValue *rb = RKB(i);
        TValue *rc = RKC(i);
        Protect(
          if (equalobj(L, rb, rc)== GETARG_A(i))
            dojump(L,pc, GETARG_sBx(*pc));
        )
        pc++;
        continue;
      }
lopcodes.c:185
OP_EQ,/*    A B C    if ((RK(B) == RK(C)) ~= A)then pc++        */
这条指令实现的过程中,equalobj与之前的算术运算类似,读者可以自行分析.关键看它是如果实现中跳转的,如果RK[B]==RK[C]并且A1的情况下(即条件为真),则会使用pc取出下一条指令,调用dojump进行跳转,否则pc++,挂空紧接着的无条件跳转指令. dojump的实现如下:
lvm.c:354
#define dojump(L,pc,i)    {(pc) += (i); luai_threadyield(L);}
luai_threadyield
只是顺序地调用lua_unlocklua_lock,这里为释放一次锁,使得别的线程可以得到调度.

函数调用类指令,CALL为代表:
lvm.c:582
      case OP_CALL: {
        int b = GETARG_B(i);
        int nresults = GETARG_C(i) - 1;
        if (b != 0) L->top = ra+b; /* else previous instruction set top */
        L->savedpc = pc;
        switch (luaD_precall(L, ra,nresults)) {
          case PCRLUA: {
           nexeccalls++;
            gotoreentry;  /* restart luaV_execute over new Lua function */
          }
          case PCRC: {
            /* it was aC function (`precall' called it); adjust results */
            if (nresults>= 0) L->top = L->ci->top;
            base =L->base;
            continue;
          }
          default: {
           return;  /* yield */
          }
        }
      }
lopcodes.c:192
OP_CALL,

/*   A B C    R(A), ... ,R(A+C-2) := R(A)(R(A+1), ... ,R(A+B-1)) */


这一条指令将在下一个介绍Lua函数调用规范的专题中详细介绍.在这里只是简单地说明CALL指令的R[A]表示的是即将要调用的函数,BC则分别表示参数个数加1,和返回值个数加1.

之所以这里需要加1,其原因是:BC使用零来表示变长的参数和变长的返回值,而实际参数个数就向后推了一个.

指令的介绍就先到此为止了,其它的指令的实现也比较类似.

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章