Lua opcode 解析

每一个虚拟机中最重要的部分,无疑当属 opcode。2023 年的第一篇文章,就交给 opcode 解析吧~ 本文将详细解析 Lua 虚拟机里除了 table 之外的所有 opcode。

在进入正题之前,先区分好几个关键的宏(上一篇文章里也有提到过):

  • GETARG_A: 从指令中取出 A 的部分;
  • RA: 从指令中取出 A 的部分,然后拓展为 base+A
  • RKB: 从指令中取出 B 的部分,如果是常量,拓展为 k+INDEXK(B),如果是寄存器,拓展为 base+B
  • KBx: 从指令中取出 Bx 的部分,拓展为 k+Bx

装载类指令

OP_MOVE

// OP_MOVE      A B     R(A) := R(B)
        setobjs2s(L, ra, RB(i));
        continue;

拓展开来就是 setobj2s(L, base+A, base+B),而 setobj2s 最终拓展为 setobj,也就是:

#define setobj(L,obj1,obj2) \
  { const TValue *o2=(obj2); TValue *o1=(obj1); \
    o1->value = o2->value; o1->tt=o2->tt; \
    checkliveness(G(L),o1); }

如你所见,就是把 valuett 两个字段从 obj2 拷贝到 obj1。这样就一行完成了 OP_MOVE 的指令功能。

OP_LOADK

// OP_LOADK     A Bx    R(A) := Kst(Bx)
        setobj2s(L, ra, KBx(i));
        continue;

OP_MOVE 唯一的区别是 RB 换成了 KBx,拓展开来将变成 setobj2s(L, base+A, k+Bx)。其中 k 被初始化为 cl->p->k,也就是 Proto 中的常量表,在语法分析期间产生。最终效果就是从常量表中取出一个下标为 Bx 的值,并放入 base+A 的寄存器中。

OP_LOADBOOL

// OP_LOADBOOL  A B C   R(A) := (Bool)B; if (C) pc++
        setbvalue(ra, GETARG_B(i));
        if (GETARG_C(i)) pc++;  /* skip next instruction (if C) */
        continue;

如注释所示,将 base+B 转换为布尔值然后替换 base+A 的取值(类似 OP_MOVE)。如果指令的 C 非空则跳过下一条指令。

其中 C 的作用在 exp2reg 函数里,此函数在特定情况下,会生成两条连续的 OP_LOADBOOL 指令,分别对应着 falsetrue 两种情况。其中第一种情况的 C 就为 1,用来跳过第二条指令。(关于这个 C 参数的典型使用场景,可参考后边的 OP_EQ 指令)

OP_LOADNIL

// OP_LOADNIL   A B     R(A) := ... := R(B) := nil
        TValue *rb = RB(i);
        do {
          setnilvalue(rb--);
        } while (rb >= ra);
        continue;

将把从 base+Abase+B 的寄存器全部清空为 nil

OP_GETGLOBAL

// OP_GETGLOBAL A Bx    R(A) := Gbl[Kst(Bx)]
        TValue g;
        TValue *rb = KBx(i);
        sethvalue(L, &g, cl->env);
        lua_assert(ttisstring(rb));
        Protect(luaV_gettable(L, &g, rb, ra));
        continue;

先将 cl->env 取出放置到 g 中,然后从这个表中获取 key 为 KBx,也就是 k+Bx 的值,value 存放到 ra 中。

它与 OP_GETTABLE 代码十分接近,唯一不同的是 table 固定为 cl->env。具体的 luaV_gettable 分析这里就不展开了,背后调用的是 table 模块的 luaH_get 函数,可参考我之前分析 table 的文章

有一个十分常见的 Lua 优化方法:尽量使用 local 变量。在各种知名的 Lua 项目中,经常能在脚本顶部看见大量这种类似代码:

local math = math
local string_find = string.find
// ... local xxx = xxx ...

其背后的原理就是为了节省每次引用全局变量时 OP_GETGLOBAL 带来的额外开销。当将其放置到 local 变量之后,每次引用都是直接引用栈上的寄存器,效率将因此有显著提高。

OP_SETGLOBAL

// OP_SETGLOBAL A Bx    Gbl[Kst(Bx)] := R(A)
        TValue g;
        sethvalue(L, &g, cl->env);
        lua_assert(ttisstring(KBx(i)));
        Protect(luaV_settable(L, &g, KBx(i), ra));
        continue;

与上边 OP_GETGLOBAL 类似,都是在一个固定的 cl->env 表里边进行操作,这里不做详细分析,具体可参考我之前的文章。

OP_GETUPVAL

// OP_GETUPVAL  A B     R(A) := UpValue[B]
        int b = GETARG_B(i);
        setobj2s(L, ra, cl->upvals[b]->v);
        continue;

直接以 B 的取值作为下标(而不是 R(B)),从 cl->upvals 数组里取出对应值,并设置给 base+A

这个指令自身非常简单,就是从 upvals 里取数据而已。重头戏是 upvals 的构造过程,但这个过程并不在这,后边分析 OP_CLOSURE 时还会遇到。

OP_SETUPVAL

// OP_SETUPVAL  A B     UpValue[B] := R(A)
        UpVal *uv = cl->upvals[GETARG_B(i)];
        setobj(L, uv->v, ra);
        luaC_barrier(L, uv, ra);
        continue;

OP_GETUPVAL 类似,都是对 cl->upvals 这个数组进行操作。其中 luaC_barrier 与 GC 有关,暂不分析。

基本运算类指令

OP_ADD、OP_SUB、OP_MUL、OP_DIV、OP_MOD、OP_POW、OPUNM

// 代码都是类似的,以 OP_ADD 为例:
// OP_ADD       A B C   R(A) := RK(B) + RK(C)
        arith_op(luai_numadd, TM_ADD);
        continue;

其中 arith_op 是一个宏,展开来长这个样子:

#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)); \
      }

先将 BC 的值取出来:如果是常量,则从 k 常量数组中偏移得到对应的指针(常量);如果是寄存器,则从 base 栈数组里偏移得到相应的指针(寄存器)。得到 rbrc 之后,可以看到有两个分支:

① 如果两者都是数字,直接调用 op,也就是 luai_numadd 等函数执行运算,并将结果放置到 ra。以 luai_numadd 为例,其实际上是个宏,展开来是:

#define luai_numadd(a,b)	((a)+(b))
// 直接用原生的加法执行运算

② 如果有任何一个参数不是数字,则进入 Arith 函数进行后续处理。这个函数逻辑很简单,就不上代码了:首先利用 luaV_tonumber 分别对两个参数尝试转换为数字,如果都转换成功了,最终还是走 luai_numadd 那一套。如果转换失败了,会尝试从元表里获取函数执行运算。如果都失败了,抛出异常。

OP_NOT

// OP_NOT       A B     R(A) := not R(B)
        int res = l_isfalse(RB(i));  /* next assignment may change this value */
        setbvalue(ra, res);
        continue;

先依据 base+B 指向的值,借助 l_isfalse 执行计算,然后将计算结果放置到 ra 中。这个函数(宏)的代码也比较简单:

#define l_isfalse(o)	(ttisnil(o) || (ttisboolean(o) && bvalue(o) == 0))

OP_LEN

// OP_LEN       A B     R(A) := length of R(B)
        const TValue *rb = RB(i);
        switch (ttype(rb)) {
          case LUA_TTABLE: {
            setnvalue(ra, cast_num(luaH_getn(hvalue(rb))));
            break;
          }
          case LUA_TSTRING: {
            setnvalue(ra, cast_num(tsvalue(rb)->len));
            break;
          }
          default: {  /* try metamethod */
            Protect(
              if (!call_binTM(L, rb, luaO_nilobject, ra, TM_LEN))
                luaG_typeerror(L, rb, "get length of");
            )
          }
        }
        continue;

根据 base+B 的类型分为三个分支:

如果是 table 类型,调用 luaH_getn 函数得到表的长度。该函数在之前解析 table 的时候已做过分析,参考:Lua 表源码解解析#长度计算

如果是字符串类型,直接根据字符串中存储的 len 字段即可获取长度信息(注意是不带空字符的长度)。

否则会尝试从元表中获取 TM_LEN 函数,执行长度计算。

OP_CONCAT

// OP_CONCAT    A B C   R(A) := R(B).. ... ..R(C)
        int b = GETARG_B(i);
        int c = GETARG_C(i);
        Protect(luaV_concat(L, c-b+1, c); luaC_checkGC(L));
        setobjs2s(L, RA(i), base+b);
        continue;

这个指令对应着 Lua 里的 .. 连接操作符。结合下面这段代码来进行分析:

local x = "a".."b".."c"
print(x)

其产生的 opcode 如下:

// 将 a b c 三个字符串分别从常量表中取出,放置到 0 1 2 三个寄存器中
        1       [1]     LOADK           0 -1    ; "a"
        2       [1]     LOADK           1 -2    ; "b"
        3       [1]     LOADK           2 -3    ; "c"
// 调用 OP_CONCAT 指令,将 0 ~ 2 的寄存器值执行合并,并放置到 0 号寄存器中
        4       [1]     CONCAT          0 0 2
// 获取 print 这个全局变量,放置到 1 号寄存器中(print 这个名称在常量表里存储)
        5       [2]     GETGLOBAL       1 -4    ; print
// 将 2 号寄存器赋值为 0 号寄存器的值(就是 abc 拼接的结果)
// Lua 的函数调用约定是将参数依次放置到函数指针后边,后边 OP_CALL 时还会做详细分析
        6       [2]     MOVE            2 0
// 调用 1 号寄存器里的函数(print)
        7       [2]     CALL            1 2 1
        8       [2]     RETURN          0 1

结合这一串 opcode 作为场景,我们再进入 OP_CONCAT 的核心函数 luaV_concat 之中:

// 将以 last 寄存器结尾的 total 个对象,合并到一起,最终结果放置在范围内首个寄存器里
// 当前,last 即为指向 "c" 的寄存器的当前编号(相对 base 栈基的偏移量),此时即为 2
// total = 3
void luaV_concat (lua_State *L, int total, int last) {
  do {
    StkId top = L->base + last + 1;
    // 一次处理两个元素
    // (给下边前两个分支作为默认值,第三个分支其实是一次性把所有字符串都给合并了,里边会覆盖 n 的取值)
    int n = 2;  /* number of elements handled in this pass (at least 2) */
    // 如果三个条件都不满足,可能是一些奇奇怪怪的类型,直接用元表处理
    if (!(ttisstring(top-2) || ttisnumber(top-2)) || !tostring(L, top-1)) {
      if (!call_binTM(L, top-2, top-1, top-2, TM_CONCAT))
        luaG_concaterror(L, top-2, top-1);
    // 如果当前最后一个元素为空字符串,直接将倒数第二个元素转为字符串作为此轮的结果
    } else if (tsvalue(top-1)->len == 0)  /* second op is empty? */
      (void)tostring(L, top - 2);  /* result is first op (as string) */
    else {
      /* at least two string values; get as many as possible */
      size_t tl = tsvalue(top-1)->len;
      char *buffer;
      int i;
      /* collect total length */
      // 从后往前遍历,计算出范围内所有元素(字符串)的长度总和
      for (n = 1; n < total && tostring(L, top-n-1); n++) {
        size_t l = tsvalue(top-n-1)->len;
        // 长度超限报错
        if (l >= MAX_SIZET - tl) luaG_runerror(L, "string length overflow");
        tl += l;
      }
      // 分配一块 tl 长度的内存
      buffer = luaZ_openspace(L, &G(L)->buff, tl);
      tl = 0;
      for (i=n; i>0; i--) {  /* concat all strings */
        size_t l = tsvalue(top-i)->len;
        // 将所有字符串从前往后依次拼接到 buffer 中
        memcpy(buffer+tl, svalue(top-i), l);
        tl += l;
      }
      // 利用 luaS_newlstr 函数创建一个 TString 对象(存入全局字符串表中),并放入第一个字符串对应的寄存器中
      // 详见我的另外一篇解析 Lua 字符串的文章
      setsvalue2s(L, top-n, luaS_newlstr(L, buffer, tl));
    }
    total -= n-1;  /* got `n' strings to create 1 new */
    last -= n-1;
  } while (total > 1);  /* repeat until only 1 result left */
  // 循环的退出条件是只剩一个对象未处理(也就是最终存放结果的那个对象)
}

此函数结束后,字符串即已完成拼接,并放置到了 B 号寄存器中。因此后边还接着一行代码,负责将最终结果赋值到 A 号寄存器中(对于我们的场景,即 0 号寄存器):

        setobjs2s(L, RA(i), base+b);

条件与流程控制类指令

OP_JMP

// OP_JMP       sBx     pc+=sBx
        dojump(L, pc, GETARG_sBx(i));
        continue;

先取出 sBx 的值(如上一篇文章所说明的,sBx 的实际取值需要先相对于 MAXARG_sBx 做一次偏移),然后进入 dojump

#define dojump(L,pc,i)	{(pc) += (i); luai_threadyield(L);}

就是将 pc 指针进行偏移而已。需要注意的是,当前 pc 指针已经在取指阶段就已完成自增,因此该指针目前指向的是「下一条指令」。因此,如果要跳到 OP_JMP 的下下一条指令,则应该时 JMP 1+MAXARG_sBx

OP_EQ、OP_LT、OP_LE

// 这三条指令都是类似结构,以 OP_EQ 为例进行分析
// OP_EQ        A B C   if ((RK(B) == RK(C)) ~= A) then pc++
        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;

看到 RKBRKC 就知道,BC 允许传入常量表的下标也允许传入寄存器编号。取出相应的值之后,借助 equalobj 执行计算:

#define equalobj(L,o1,o2) \
	(ttype(o1) == ttype(o2) && luaV_equalval(L, o1, o2))
// luaV_euqalval 就不往下追了,背后就是针对各种类型做不同的相等性判断而已

equalobj 计算的结果与 A 的取值相等时,即进行 dojump。如 OP_JMP 的分析,其相当于将 pc 指针做一次 sBx 的偏移。而这个偏移将从下一条指令中读取(虽然是读取 *pc,但 pc 指针在取指时已经做完了自增,当前指向的是下一条指令)。为了保证偏移量的正确性,pc 指针在后边还单独进行了一次自增,以跳过下一条指令导致的长度为 1 的偏移。

到此我们先暂停一下代码阅读,来看看实际代码中的 OP_EQ 是怎么应用的。以这段 Lua 代码为例:

local a, b, dest
dest = a == b

其 opcode 序列如下:

// 三个 locals 的下标分别是:a: 0;  b: 1;  dest: 2
// 将 0(a) 与 1(b) 进行比较,如果结果为 0(不相等),那么 pc++ 跳过下一条指令,到达第 3 条指令处
// 如果结果为 1(真),也就是两者相等,将跳到第 4 条指令处
        1       [3]     EQ              1 0 1
// JMP 这条指令并不实际执行,在 EQ 指令中将读取出里边的 sBx,并为其代劳完成跳转
        2       [3]     JMP             1       ; to 4
// 将 false 载入到 2 号寄存器中,而且 C=1,将跳过下一条指令
        3       [3]     LOADBOOL        2 0 1
// 将 true 载入到 2 号寄存器中,而且 C=0,将正常执行下一条指令
        4       [3]     LOADBOOL        2 1 0
// 正常退出
        5       [3]     RETURN          0 1

所以 OP_EQ 的后边,一般都跟着一条 JMP 指令。如果结果符合预期,就会(最终结果像是)正常执行下一条 OP_JMP,借此跳转到 true 的分支;如果不符合预期,则跳过 OP_JMP,进入 false 的分支,而 false 分支的 OP_LOADBOOL 设置了 C=1,因此将继续跳过紧接着的下一条 OP_LOADBOOL true 指令。可以看到,这样的指令结构总共产生了两次有条件跳转,分别对应着满足条件(执行 1, 2, 4, 5 的指令序列,给 2 号寄存器置入了 true)与不满足条件(执行 1, 3, 5 的指令序列,给 2 号寄存器置入了 false)的场景。

OP_TEST

// OP_TEST      A C     if not (R(A) <=> C) then pc++                   */ 
        if (l_isfalse(ra) != GETARG_C(i))
          dojump(L, pc, GETARG_sBx(*pc));
        pc++;
        continue;

可以看到结构与上边的 OP_EQ 是类似的。只不过条件从 BC 的相等判断,变成了 AC 的相等判断(从布尔值上)。这个指令与这类源码结构有关:

local x
if x then
        print("x")
end

其中的第三个参数 C,当取值为 0 时对应着 if x then 的代码结构,当取值为 1 时对应着 if not x then 的代码结构。

OP_TESTSET

// OP_TESTSET   A B C   if (R(B) <=> C) then R(A) := R(B) else pc++     */ 
        TValue *rb = RB(i);
        if (l_isfalse(rb) != GETARG_C(i)) {
          setobjs2s(L, ra, rb);
          dojump(L, pc, GETARG_sBx(*pc));
        }
        pc++;
        continue;

OP_TEST 的唯一区别是,在满足条件之后,还会附带着执行一次 A = B

TEST SET 里的 SET 应当理解为动词「设置」,而非名词「集合」。

OP_FORPREP、OP_FORLOOP

这两个指令都是循环相关的,而且比较复杂,我们先不直接进入到代码中,先看看其在实际的指令序列中是怎么发挥作用的。以这段 Lua 代码为例:

for i = 1, 10, 3 do
        print(i)
end
// 输出 1 4 7 10

一个很简单的循环,看看其 opcode 序列:

        1       [1]     LOADK           0 -1    ; 1
        2       [1]     LOADK           1 -2    ; 10
        3       [1]     LOADK           2 -3    ; 3
        4       [1]     FORPREP         0 3     ; to 8
        5       [2]     GETGLOBAL       4 -4    ; print
        6       [2]     MOVE            5 3
        7       [2]     CALL            4 2 1
        8       [1]     FORLOOP         0 -4    ; to 5
        9       [3]     RETURN          0 1

按照顺序,先来分析下 OP_FORPREP

// OP_FORPREP   A sBx   R(A)-=R(A+2); pc+=sBx
        const TValue *init = ra;      // ra + 0, init=1,初始值
        const TValue *plimit = ra+1;  // ra + 1, plimit=10,极限值(上限/下限)
        const TValue *pstep = ra+2;   // ra + 2, pstep=3,步长
        L->savedpc = pc;  /* next steps may throw errors */
        // 检查 init, plimit, pstep 三个参数都能转换为数字,并将转换后的结果放在原位置
        if (!tonumber(init, ra))
          luaG_runerror(L, LUA_QL("for") " initial value must be a number");
        else if (!tonumber(plimit, ra+1))
          luaG_runerror(L, LUA_QL("for") " limit must be a number");
        else if (!tonumber(pstep, ra+2))
          luaG_runerror(L, LUA_QL("for") " step must be a number");
        // 先让初始值减去步长,然后根据 sBx 执行跳转(会跳转到 OP_FORLOOP 指令,在其中加上步长,抵消了这一次额外的计算)
        // 这里减完仍然放在 ra + 0 的地方
        setnvalue(ra, luai_numsub(nvalue(ra), nvalue(pstep)));
        dojump(L, pc, GETARG_sBx(i));
        continue;

就做了三件事情:校验参数、减去一次步长、跳转到目标指令。结合上边的 opcode 序列,也可以看到 OP_FORPREPsBx 对应着第 8 条指令 OP_FORLOOP。来看看 OP_FORLOOP 的代码:

// OP_FORLOOP   A sBx   R(A)+=R(A+2);
                        if R(A) <?= R(A+1) then { pc+=sBx; R(A+3)=R(A) }*/
        // ra + 0: 初始值(已被减过一次步长),循环期间作为计数值使用
        // ra + 1: 极限值(上限/下限)
        // ra + 2: 步长
        lua_Number step = nvalue(ra+2);
        // 先加步长
        // 在第一次循环时抵消掉了 OP_FORPREP 中那次多余的减步长操作
        // 在后续循环中就是正常的循环步增操作了
        lua_Number idx = luai_numadd(nvalue(ra), step); /* increment index */
        lua_Number limit = nvalue(ra+1);
        // 根据步长的正负值来决定条件判断的方向,判断是否已超过了极限值(上限/下限)
        if (luai_numlt(0, step) ? luai_numle(idx, limit)
                                : luai_numle(limit, idx)) {
          // 如果还没超过极限值……
          // 那么往回跳,执行夹在 OP_FORPREP 与 OP_LOOP 之间的循环体
          dojump(L, pc, GETARG_sBx(i));  /* jump back */
          // 更新 ra + 0、ra + 3 这两个计数值
          // 前者供 OP_FORLOOP 这套逻辑使用,后者供循环体使用
          setnvalue(ra, idx);  /* update internal index... */
          setnvalue(ra+3, idx);  /* ...and external index */
        }
        // 如果已超过极限值,不做任何特殊处理,直接正常执行下一条指令即可
        continue;

观察刚才的 opcode 序列,可以看到 OP_FORLOOPsBx 跳转完刚好到第 5 条指令,也就是循环体第一条指令。从 OP_FORPREPOP_FORLOOP 的范围内,在逻辑上围出了一个循环结构,其间即为循环体。循环计数器存放在了 ra + 3 (当前即 0 + 3 = 3)的地方:

        4       [1]     FORPREP         0 3     ; to 8
        5       [2]     GETGLOBAL       4 -4    ; print
// 从 3 号寄存器读取下标,放置到 5 号寄存器,作为 print 的参数进行调用
// 由于这个循环体对应的 FORLOOP 指令 ra = 0,3 号寄存器刚好就对应着 ra + 3,与代码分析结论一致
        6       [2]     MOVE            5 3
        7       [2]     CALL            4 2 1
        8       [1]     FORLOOP         0 -4    ; to 5

OP_TFORLOOP

除了上边的数值类循环之外,Lua 还有另外一类更普适的循环结构,如下:

local t = {["chen"] = "xiaoyuan"}

for k, v in pairs(t) do
        print(k, v)
end

还是一样,先看看对应的 opcode 序列长什么样子:

// 创建并初始化了一个 table,放置到了 0 号寄存器上
        1       [1]     NEWTABLE        0 0 1
        2       [1]     SETTABLE        0 -1 -2 ; "chen" "xiaoyuan"
// 从全局变量中获取 pairs 函数……
        3       [3]     GETGLOBAL       1 -3    ; pairs
// ……并将 0 号寄存器的内容(我们创建的表)作为参数调用它
        4       [3]     MOVE            2 0
        5       [3]     CALL            1 2 4
// 跳转到第 11 条指令,也就是 OP_TFORLOOP,先进入该指令判断是否进入循环体
        6       [3]     JMP             4       ; to 11
// 以下即为循环体,调用 print 将 k(4号寄存器) 跟 v(5号寄存器) 打印了出来
        7       [4]     GETGLOBAL       6 -4    ; print
        8       [4]     MOVE            7 4
        9       [4]     MOVE            8 5
        10      [4]     CALL            6 3 1
// 循环体到此为止,CALL 执行完之后照常往下执行,即到达 OP_TFORLOOP 指令
// OP_TFORLOOP 指令将判断循环体是否已结束,并作出相应动作,详见后文分析
        11      [3]     TFORLOOP        1 2  ; 备注:这里的 2 是 C 不是 B
// 跳转到第 7 条指令,也就是循环体开始的第一条指令
        12      [4]     JMP             -6      ; to 7
// 正常退出
        13      [5]     RETURN          0 1

基于这段 opcode 序列,可以对 OP_TFORLOOP 有个感性认知,是否进入循环体的判断应该就发生在这条指令里边。但在这之前,我们先研究研究 pairs 函数。根据文档,其返回的有三个值:

pairs (t)

Returns three values: the next function, the table t, and nil, so that the construction

for k,v in pairs(t) do body end

will iterate over all key–value pairs of table t.

其对应的源码也能佐证这一点:

static int luaB_pairs (lua_State *L) {
  luaL_checktype(L, 1, LUA_TTABLE);
  // 往栈里推了三个值,分别对应着 next 函数、表、nil
  lua_pushvalue(L, lua_upvalueindex(1));  /* return generator, */
  lua_pushvalue(L, 1);  /* state, */
  lua_pushnil(L);  /* and initial value */
  return 3;
}

因此在 pairs 函数(第五条指令)执行结束之后,栈上的数据排布应当长这样:

0 - table
1 - pairs 返回的 next 函数
2 - pairs 返回的循环状态(就是跟 0 号寄存器同一个的 table)
3 - pairs 返回的循环变量(nil)

结合 OP_TFORLOOP 的前半部分代码,如下:

// OP_TFORLOOP  A C     R(A+3), ... ,R(A+2+C) := R(A)(R(A+1), R(A+2)); 
                        if R(A+3) ~= nil then R(A+2)=R(A+3) else pc++   */ 
        // cb = ra + 3 是所谓「call base」
        StkId cb = ra + 3;  /* call base */
        // 将 [ra, ra+3) 范围内的寄存器值,依次搬运到 [cb, cb+3) 中,也就是 [ra+3, ra+6)
        setobjs2s(L, cb+2, ra+2);
        setobjs2s(L, cb+1, ra+1);
        setobjs2s(L, cb, ra);
        L->top = cb+3;  /* func. + 2 args (state and index) */
        Protect(luaD_call(L, cb, GETARG_C(i)));

在执行最后一行 luaD_call 之前,栈上的情况应当是这样的:

0 -      table
1 - (ra) pairs 返回的 next 函数
2 -      pairs 返回的循环状态(就是跟 0 号寄存器同一个的 table)
3 -      pairs 返回的循环变量(nil)
4 - (cb) pairs 返回的 next 函数
5 -      pairs 返回的循环状态(就是跟 0 号寄存器同一个的 table)
6 -      pairs 返回的循环变量(nil)
7 - (top)

随后便借助 luaD_call 最终进入到了 cb,也就是 pairs 返回的 next 函数中去。对于该函数,直接上文档

next (table [, index])

Allows a program to traverse all fields of a table. Its first argument is a table and its second argument is an index in this table. next returns the next index of the table and its associated value. When called with nil as its second argument, next returns an initial index and its associated value. When called with the last index, or with nil in an empty table, next returns nil. If the second argument is absent, then it is interpreted as nil. In particular, you can use next(t) to check whether a table is empty.

可以看到,其入参与我们现在栈上的结构一一对应上了。cb 即为该函数,cb+1table 本身,cb+2 为循环变量(目前还是首次循环,所以是 nil,刚好对应上首次调用 next 的情景)。而 next 的返回值有两个:下一个 index 与其对应取值。基于 Lua 的调用规范,在完成 next 调用之后,栈上应该是这样子的:

0 -      table
1 - (ra) pairs 返回的 next 函数
2 -      pairs 返回的循环状态(就是跟 0 号寄存器同一个的 table)
3 -      pairs 返回的循环变量(nil)
4 - (cb) next 返回的下一个 index
5 -      next 返回的下一个 index 的对应 value

基于此,我们再继续往下看 OP_TFORLOOP 的剩余部分:

        L->top = L->ci->top;
        cb = RA(i) + 3;  /* previous call may change the stack */
        if (!ttisnil(cb)) {  /* continue loop? */
          setobjs2s(L, cb-1, cb);  /* save control variable */
          dojump(L, pc, GETARG_sBx(*pc));  /* jump back */
        }
        pc++;

cb 还是那个 cb4 号寄存器),现在对应着的就是下一个 index。如果其非空,则将其赋值给 cb-1,也就是 3 号寄存器,「循环变量」这一部分。然后一个 dojump 替下一个 OP_JMP 代劳完成跳转操作,在外边也有一次额外的 pc++ 以修正正确的 pc 值(还是与上边 OP_EQ 类似的套路)。

至此,将上边的所有的分析结论串联起来,这整个循环结构应当是这么执行的:

首先,执行初始化过程:调用 pairs 函数(或者其他符合 Lua 这一套 OP_TFORLOOP 规范的函数),分别获得 next 函数、循环状态(表)、循环变量(下标)。

然后一个跳转进入 OP_TFORLOOP,该指令将调用 next 函数。如果 next 函数返回的第一个值(下一次的「循环变量」,或者说,对于 pairs 而言的「下一个 index」)不是 nil,将执行紧接的 OP_JMP 指令(于是就跳转到了循环体首条指令);如果是 nil,说明 next 已再无可用值,将跳过紧接着的 OP_JMP 指令,退出整个循环。

同时,在循环体内部,根据以上分析可知,迭代函数每次的返回值,都会依次放置在 cbcb+cb+2……也就是 ra+3ra+4ra+5 上边。因此循环体内部取 k, v 两个值时,分别是从 ra+3 = 4ra+4 = 5 获得的:

// 以下即为循环体,调用 print 将 k(4号寄存器) 跟 v(5号寄存器) 打印了出来
        7       [4]     GETGLOBAL       6 -4    ; print
        8       [4]     MOVE            7 4
        9       [4]     MOVE            8 5
        10      [4]     CALL            6 3 1

循环体每次执行结束后,就再次进入到紧接到循环体后边的 OP_TFORLOOP 指令。在其中会再次调用 next 函数,再次判断返回值,并以此决定是否再次进入循环体或者退出……

函数类指令

OP_CALL

在进入代码之前,我们先看一看发生函数调用时,栈上的数据排布情况:

X+0号寄存器   函数指针(函数结束后放置第一个返回值)
X+1号寄存器   第一个参数(函数结束后放置第二个返回值)
X+2号寄存器   第二个参数
X+3号寄存器   <-- L->top指针
// CALL  X+0函数指针  (3-1)个参数   (3-1)个返回值
// OP_CALL      A B C   R(A), ... ,R(A+C-2) := R(A)(R(A+1), ... ,R(A+B-1))
        // 参数 B 的含义:
        // =0: [ra+1, L->top) 的范围内都是参数
        // >0: (B-1) 为参数个数
        int b = GETARG_B(i);
        // 参数 C 的含义:
        // (C-1) 为返回值个数
        // 所以大多数场景下,C 的典型取值是 2
        int nresults = GETARG_C(i) - 1;
        // 如果参数 B 不是 0,需要调整 top 指针
        // 后续是通过 top 指针来识别参数的
        if (b != 0) L->top = ra+b;  /* else previous instruction set top */
        // 保存当前 pc,这个 pc 在后边 luaD_precall 会被赋值给 L->ci->savedpc
        // 最终在 OP_RETURN 里被用来作为函数退出后跳回的目标地址(指令)
        L->savedpc = pc;
        switch (luaD_precall(L, ra, nresults)) {
          case PCRLUA: {
            // 自增调用栈深度计数器
            nexeccalls++;
            // 如果 ra 这个 closure 是个 Lua 函数,L->savedpc 会被修改为该函数的 proto->code
            // 因此跳回 reentry 之后,重新取的指令就已经是目标函数的了
            // 直到遇到 OP_RETURN 之后,再重新恢复为现有状态,继续被中断执行的“父函数”
            goto reentry;  /* restart luaV_execute over new Lua function */
          }
          case PCRC: {
            // 如果是 C 函数,那么在 luaD_precall 里已经完成调用了
            // 只需要微调一下栈上结构即可,把 top 跟 base 指针恢复一下
            /* it was a C function (`precall' called it); adjust results */
            if (nresults >= 0) L->top = L->ci->top;
            base = L->base;
            continue;
          }
          default: {
            return;  /* yield */
          }
        }

其中 luaD_precall 在之前的文章已经分析过一部分了,今天以 OP_CALL 的视角来重新看看这个函数:

// 函数调用的预处理, func 是函数 closure 所在位置, nresults 是返回值数量
// 如果是 Lua 函数,会创建新的 ci,调整栈上结构、覆盖 L->savedpc 为新函数的 proto->code 等
// 退出此函数后会进入(或跳到开头执行)luaV_execute,此时执行的 L->savedpc 就已经是新函数的指令了
// 如果是 C 函数,也会创建新的 ci,并在此基础上直接调用该 C 函数,然后借助 luaD_poscall 完成函数结束后的一些调整
int luaD_precall (lua_State *L, StkId func, int nresults) {
  LClosure *cl;
  ptrdiff_t funcr;
  if (!ttisfunction(func)) /* `func' is not a function? */
    func = tryfuncTM(L, func);  /* check the `function' tag method */
  // 函数指针距离 stack 的偏移量
  funcr = savestack(L, func);
  cl = &clvalue(func)->l;
  // 保存旧的 pc 到当前 ci(即将成为「上一个 ci」)
  // 用以在 OP_RETURN 中确认返回的目标地址(指令)
  L->ci->savedpc = L->savedpc;
  // Lua 函数的分支
  if (!cl->isC) {  /* Lua function? prepare its call */
    CallInfo *ci;
    StkId st, base;
    Proto *p = cl->p;
    luaD_checkstack(L, p->maxstacksize);
    // 根据 funcr,也就是 func 距离 stack 的偏移量,恢复出 func 指针
    func = restorestack(L, funcr);
    if (!p->is_vararg) {  /* no varargs? */
      // base 设定为 func + 1,也就是第一个参数(若有)的位置
      base = func + 1;
      if (L->top > base + p->numparams)
        L->top = base + p->numparams;
    }
    else {  /* vararg function */
      int nargs = cast_int(L->top - func) - 1;
      base = adjust_varargs(L, p, nargs);
      func = restorestack(L, funcr);  /* previous call may change the stack */
    }
    // 存放新的 CallInfo 信息
    // 自增 L->ci,将指向 CallInfo 数组里的下一项。如若需要,会执行 growCI(L)
    ci = inc_ci(L);  /* now `enter' new function */
    ci->func = func;
    L->base = ci->base = base;
    // ci->top 先默认预留出 p->maxstacksize 的空间
    ci->top = L->base + p->maxstacksize;
    lua_assert(ci->top <= L->stack_last);
    // 将 pc 指向解析出来的 Proto->code,也就是 opcode 数组
    // 退出此函数之后,会进入/跳转到头部 luaV_execute,重新取得的指令序列就是此处设置的 proto->code,因此就成功进入新函数的执行循环了
    L->savedpc = p->code;  /* starting point */
    ci->tailcalls = 0;
    ci->nresults = nresults;
    // 清空从最后一个入参到 maxstacksize 之间的栈上数据
    for (st = L->top; st < ci->top; st++)
      setnilvalue(st);
    // L->top 之前标识着参数数量,现在直接给覆盖了,也不见有哪处做了备份
    // 说明被调用的函数其实不真的关心 L->top 的旧有取值,应该早在生成 opcode 的时候就已经做了约定
    // 函数调用的流程应当是调用者与被调用者共同遵守规范(就如这个参数数量的约定)而完成的
    L->top = ci->top;
    if (L->hookmask & LUA_MASKCALL) {
      L->savedpc++;  /* hooks assume 'pc' is already incremented */
      luaD_callhook(L, LUA_HOOKCALL, -1);
      L->savedpc--;  /* correct 'pc' */
    }
    return PCRLUA;
    // 经过这一番处理之后,各个字段的指向如下所示:
    // newci->top = L->top ->   nil     --+
    //                          ...       |  p->maxstacksize
    // (previous) L->top ->     nil       |
    //                          arg     --+
    //                          ...       |  p->numparams
    // newci->base = L->base -> arg     --+
    // newci->func ->           closure
  }
  else {  /* if is a C function, call it */
    // 如果是 C 函数,也同样会为其创建一个新的 ci
    CallInfo *ci;
    int n;
    luaD_checkstack(L, LUA_MINSTACK);  /* ensure minimum stack size */
    // 从 CallInfo 数组中返回一个 CallInfo 指针
    ci = inc_ci(L);  /* now `enter' new function */
    // 根据之前保存的 funcr 偏移量,从栈中得到函数地址
    ci->func = restorestack(L, funcr);
    // 与 Lua 函数的分支一样,base 指针指向 func+1 的地方
    L->base = ci->base = ci->func + 1;
    ci->top = L->top + LUA_MINSTACK;
    lua_assert(ci->top <= L->stack_last);
    // 期待返回多少个返回值
    ci->nresults = nresults;
    if (L->hookmask & LUA_MASKCALL)
      luaD_callhook(L, LUA_HOOKCALL, -1);
    // 由于进入到 C 的领域,需要把当前虚拟机的锁给解了
    // (但其实这个版本的 Lua 里,lua_unlock 的实现是空的,只是作为一个魔改的入口,供用户自行按需实现)
    lua_unlock(L);
    // 在此处完成了 C 函数的调用,参数为 L,返回值为 int
    n = (*curr_func(L)->c.f)(L);  /* do the actual call */
    // 回到了 Lua 的领域,重新把锁取回
    lua_lock(L);
    if (n < 0)  /* yielding? */
      return PCRYIELD;
    else {
      // 调用结束之后的处理
      // 对于 Lua 函数而言,这一步骤发生在 OP_RETURN 里
      // 对于 C 函数而言,这一步骤(包括真正的函数调用)都发生在 luaD_precall 里,一次性都给完成了
      luaD_poscall(L, L->top - n);
      return PCRC;
    }
  }
}

注释里已经分析得比较清楚了。其中,luaD_poscall 函数等下边 OP_RETURN 再一起分析。

总结一下,对于 OP_CALL 指令,根据 ra 这个 closure 对应的函数类型,分为两大分支:

  • Lua 函数:创建新 ci,调整 topbase 指针等,调整 L->savedpc,在重新进入 luaV_execute 之后即完成「偷梁换柱」,开始执行被调用函数的指令;
  • C 函数:创建新 ci,直接完成 C 函数的调用,然后借助 luaD_poscall 一鼓作气把 ci 也给清理了,然后继续正常执行当前函数的下一条指令;

OP_TAILCALL

// OP_TAILCALL  A B C   return R(A)(R(A+1), ... ,R(A+B-1))
        int b = GETARG_B(i);
        // B 参数的含义与 OP_CALL 一致
        if (b != 0) L->top = ra+b;  /* else previous instruction set top */
        L->savedpc = pc;
        lua_assert(GETARG_C(i) - 1 == LUA_MULTRET);
        // LUA_MULTIRET 在 OP_RETURN 中会用到
        switch (luaD_precall(L, ra, LUA_MULTRET)) {
          case PCRLUA: {
            // 如果是 Lua 函数,在 luaD_precall 中已经创建了新 ci
            // 因此此时 L->ci 是新 ci,而 L->ci - 1 则是触发尾调用的这个即将结束的函数的 ci
            // 主要就是把新 ci 的东西搬到调用者的 ci 里,进行偷梁换柱
            /* tail call: put new frame in place of previous one */
            CallInfo *ci = L->ci - 1;  /* previous frame */
            int aux;
            StkId func = ci->func;
            StkId pfunc = (ci+1)->func;  /* previous function index */
            if (L->openupval) luaF_close(L, ci->base);
            L->base = ci->base = ci->func + ((ci+1)->base - pfunc);
            // 将参数一一挪过去
            for (aux = 0; pfunc+aux < L->top; aux++)  /* move frame down */
              setobjs2s(L, func+aux, pfunc+aux);
            // 调整被替代的 ci 里几个重要的指针,top、savedpc 等,base 指针在上边调整过了
            ci->top = L->top = func+aux;  /* correct top */
            lua_assert(L->top == L->base + clvalue(func)->l.p->maxstacksize);
            ci->savedpc = L->savedpc;
            ci->tailcalls++;  /* one more call lost */
            // 「删除」在 luaD_precall 里创建的新 ci
            L->ci--;  /* remove new frame */
            goto reentry;
          }
          case PCRC: {  /* it was a C function (`precall' called it) */
            // 对于 C 函数而言,还是一样没有太多需要做的事情
            base = L->base;
            continue;
          }
          default: {
            return;  /* yield */
          }
        }

相比于 OP_CALL,刚开始都一样借助 luaD_precall 创建了新 ci,但是这里又把新 ci 的内容都给腾移到了当前函数的 ci 里,玩了一招偷梁换柱。所以尾调用的情况下,调用栈深度、ci、栈等结构,深度都不会增加。

但读完源码感觉尾调用的这个优化有点鸡肋?也就只是在调用深度比较大的时候能省一些空间(甚至可以让栈不再增长),纯个人看法,如果有什么其他见解欢迎评论指教~

OP_RETURN

// OP_RETURN    A B     return R(A), ... ,R(A+B-2)      (see note)
        int b = GETARG_B(i);
        // 参数 B 的含义与 OP_CALL 一致
        if (b != 0) L->top = ra+b-1;
        // 如果有 upvals,需要关闭,详见后边的 OP_CLOSE 指令解析
        if (L->openupval) luaF_close(L, base);
        L->savedpc = pc;
        b = luaD_poscall(L, ra);
        // nexeccalls 是外部传入的参数,典型值是 1,在载入 Lua 脚本之后,函数 luaD_call 中发起调用
        // 每当遇到 OP_CALL(而且得是 Lua 函数) 就会 +1,遇到 OP_RETURN 时就会 -1
        // 当最外层函数也完成执行,进入 OP_RETURN 时,这里就会算出 0 值,因此直接让 luaV_execute 退出
        if (--nexeccalls == 0)  /* was previous function running `here'? */
          return;  /* no: return */
        // 而存在函数调用时,nexeccalls 自减完依然非 0
        // 这种情况下,其实是走了 OP_CALL 指令 goto reentry 的逻辑,从 C 调用栈的角度来看,这一次的 luaV_execute 还没退出呢
        // 所以为了恢复到上一次函数调用时刻的状态,也不应该 return 结束掉这一次的 luaV_execute 调用
        // 由于 luaD_poscall 已经做完了相关工作,所以只需要再简单地把 L->top 指针恢复一下即可
        else {  /* yes: continue its execution */
          if (b) L->top = L->ci->top;
          lua_assert(isLua(L->ci));
          lua_assert(GET_OPCODE(*((L->ci)->savedpc - 1)) == OP_CALL);
          goto reentry;
        }

如代码与注释所示,核心逻辑在 luaD_poscall 里,来看看这个函数的代码:

// 结束完一次函数调用(无论是C还是lua函数)的处理,
// firstResult 是函数第一个返回值的地址,会被依次赋值给当前函数在栈上的闭包所处位置及其后续位置(也就是 OP_CALL 的 ra)
//   在 OP_RETURN 中也就是其 A 参数
// 主要做的事情就是把 ci 回退到上一个的状态,并把当前函数的返回值依照规范放到栈上
int luaD_poscall (lua_State *L, StkId firstResult) {
  StkId res;
  int wanted, i;
  CallInfo *ci;
  if (L->hookmask & LUA_MASKRET)
    firstResult = callrethooks(L, firstResult);
  // 得到当前的 CallInfo 指针,并自减 ci,回退到上一个 ci
  ci = L->ci--;
  // res 用来放置第一个返回值,res+1 放置第二个返回值,以此类推
  // 当前函数都已经结束了,其闭包也就已经没用了,那块寄存器也就被节省出来,放置返回值了
  res = ci->func;  /* res == final position of 1st result */
  // 预期需要多少个返回值
  wanted = ci->nresults;
  // 把 base 和 savedpc 指针恢复到调用当前函数之前的状态
  // 退出此函数之后,luaV_execute 将重新跳回 reentry,此时取到的指令就已经是“父函数”的了
  L->base = (ci - 1)->base;  /* restore base */
  L->savedpc = (ci - 1)->savedpc;  /* restore savedpc */
  /* move results to correct place */
  // 返回值压入栈中
  for (i = wanted; i != 0 && firstResult < L->top; i--)
    setobjs2s(L, res++, firstResult++);
  // 函数产生的返回值不足以填满预期需要的数量,剩余的位置置 nil
  while (i-- > 0)
    setnilvalue(res++);
  // 可以将 top 指针置回调用之前的位置了
  // 注意到此时 top 指针指向的就是函数的首个返回值
  L->top = res;
  return (wanted - LUA_MULTRET);  /* 0 if wanted == LUA_MULTRET */
}

OP_CLOSURE

// OP_CLOSURE   A Bx    R(A) := closure(KPROTO[Bx], R(A), ... ,R(A+n))
        Proto *p;
        Closure *ncl;
        int nup, j;
        // cl 是当前函数的 closure
        // Bx 是新函数在当前函数的 proto 数组里的下标
        p = cl->p->p[GETARG_Bx(i)];
        // nups 在语法分析期间即已获得,upvals 的数量,被一并存放在 proto 里
        nup = p->nups;
        // 创建一个新的 closure
        ncl = luaF_newLclosure(L, nup, cl->env);
        ncl->l.p = p;
        // 紧接着 OP_CLOSURE 的必须是 nup 条 OP_GETUPVAL 或者 OP_MOVE 指令
        // 注意每次循环都会自增 pc 吃掉下一条指令
        for (j=0; j<nup; j++, pc++) {
          if (GET_OPCODE(*pc) == OP_GETUPVAL)
            // 如果是 OP_GETUPVAL(更上层函数的局部变量)
            // 从当前函数的 upvals 中拷贝到新函数的 upvals 里
            ncl->l.upvals[j] = cl->upvals[GETARG_B(*pc)];
          else {
            // 如果是 OP_MOVE(当前函数的局部变量)
            // 从当前函数的局部变量转换为 UpVal,然后拷贝到新函数的 upvals 里
            lua_assert(GET_OPCODE(*pc) == OP_MOVE);
            ncl->l.upvals[j] = luaF_findupval(L, base + GETARG_B(*pc));
          }
        }
        // 将新创建的 closure 存放到 ra 中
        setclvalue(L, ra, ncl);
        Protect(luaC_checkGC(L));
        continue;

用以创建一个新的 closure,放置到 ra 中。在 OP_CLOSURE 必须接着 nOP_GETUPVAL 或者 OP_MOVE 指令,其中 n 对应着新函数的 upvals 数量。当引用的是更上层函数的局部变量时,即对应着 OP_GETUPVAL 的分支,此时 UpVal 对象已存在,直接拷贝其指针即可;当引用的是当前函数的局部变量,那么需要为当前栈上的对象创建一个 UpVal 对象(或复用已有的的 UpVal)。

在继续深入之前,先来看看 UpVal 结构体:

typedef struct UpVal {
  CommonHeader;
  // 指向当前 upval 对应的值
  // 如果当前 upval 还处于打开(open)状态,则指向的是栈上的值
  // 如果当前 upval 已经被关闭了(如:外层函数已结束运行),则指向的是当前 upval 的 upval.u.value
  TValue *v;  /* points to stack or to its own value */
  union {
    // 当这个 upval 被关闭时,栈上的对应值将被拷贝到这里
    // 函数结束后(这个 upval 将被关闭),这里的 value 将被拷贝为栈上对应值的副本,使其依靠这个副本秽土重生~
    // 在外层函数结束后,所有里层函数对同一 upval 的修改也最终会统一反映在这个 value 之上
    TValue value;  /* the value (when closed) */
    // 当这个 upval 还在 open 状态时,将借助这两个指针形成 openupval
    struct {  /* double linked list (when open) */
      struct UpVal *prev;
      struct UpVal *next;
    } l;
  } u;
} UpVal;

我们从中得知了一个重要信息:UpVal 有「打开」与「关闭」之说。当其处于「打开」状态时,v 指针直接指向栈上的数据;当其处于「关闭」状态时,将把栈上的值拷贝一份副本,保存在对应的 UpVal 对象中,使得栈上的数据换了个地方「秽土重生」,在函数结束后仍然能引用到同一个对象(UpVal.u.value)。

在此偷个懒,luaF_findupval 函数我就不费力逐行分析了。简要描述就是:如果已经存在引用了同一栈上对象的 UpVal,直接返回其指针以复用(最终所有指向同一值的 upval 只有一个副本,其他全是相同的指针);如果还没有这个 upval,那就创建一个新的,并接入到全局的 upval 链表里。

OP_CLOSE

// OP_CLOSE     A       close all variables in the stack up to (>=) R(A)*/
        luaF_close(L, ra);
        continue;

代码非常简单,就两行,核心逻辑都在 luaF_close 函数。luaF_close 背后就是把对应 UpVal 给关闭,把栈上的对应数据拷贝到 UpVal.u.value 里。又由于所有对同一对象的引用,都复用了同一个 UpVal 对象,所以在函数结束后,所有对同一对象的修改,都将反映在这同一个 UpVal.u.value 上边。

OP_VARARG

// OP_VARARG/*     A B     R(A), R(A+1), ..., R(A+B-1) = vararg
        // b = B - 1 代表需要的参数数量
        // B = 0 为特殊取值,代表无论传多少参数都全要了
        int b = GETARG_B(i) - 1;
        int j;
        CallInfo *ci = L->ci;
        // n 是实际传入的参数数量
        int n = cast_int(ci->base - ci->func) - cl->p->numparams - 1;
        if (b == LUA_MULTRET) {
          // 当 b == -1,也就是 B == 0 时
          // 设置 b(需要的参数数量) = 传入的参数数量
          // 并调整 L->top 指针,在后续 OP_CALL 可能会用到
          Protect(luaD_checkstack(L, n));
          ra = RA(i);  /* previous call may change the stack */
          b = n;
          L->top = ra + n;
        }
        // 遍历,拷贝传入的参数到 ra + xxx 的地方
        // 如果传入的参数数量小于需要的数量,补上 nil
        for (j = 0; j < b; j++) {
          if (j < n) {
            setobjs2s(L, ra + j, ci->base - n + j);
          }
          else {
            setnilvalue(ra + j);
          }
        }
        continue;

结合以上源码,来看看这段 Lua 脚本:

function myprint(...)
        print("You called with: ", ...)
end

myprint("Hello", "hsiaoxychen")
// 输出:You called with:        Hello   hsiaoxychen

其对应的 opcode 序列为:

main <vararg.lua:0,0> (7 instructions, 28 bytes at 0x55f472405860)
0+ params, 3 slots, 0 upvalues, 0 locals, 3 constants, 1 function
        1       [3]     CLOSURE         0 0     ; 0x55f472405a40
        2       [1]     SETGLOBAL       0 -1    ; myprint
        3       [5]     GETGLOBAL       0 -1    ; myprint
        4       [5]     LOADK           1 -2    ; "Hello"
        5       [5]     LOADK           2 -3    ; "hsiaoxychen"
        6       [5]     CALL            0 3 1
        7       [5]     RETURN          0 1
constants (3) for 0x55f472405860:
        1       "myprint"
        2       "Hello"
        3       "hsiaoxychen"
locals (0) for 0x55f472405860:
upvalues (0) for 0x55f472405860:

function <vararg.lua:1,3> (5 instructions, 20 bytes at 0x55f472405a40)
0+ params, 4 slots, 0 upvalues, 1 local, 2 constants, 0 functions
        1       [2]     GETGLOBAL       1 -1    ; print
        2       [2]     LOADK           2 -2    ; "You called with: "
        3       [2]     VARARG          3 0
        4       [2]     CALL            1 0 1
        5       [3]     RETURN          0 1
constants (2) for 0x55f472405a40:
        1       "print"
        2       "You called with: "
locals (1) for 0x55f472405a40:
        0       arg     1       5
upvalues (0) for 0x55f472405a40:

You called with: 被放置到 2 号寄存器,作为第一个参数。随后调用 OP_VARARG,由于 B=0,因此将把所有传入的参数一一拷贝到 3 号、4 号寄存器上,然后 OP_CALL 发起调用。注意到由于生成 opcode 时不清楚实际调用的参数数量,OP_CALLB 参数被设置为 0,因此 OP_CALL 也会根据 L->top 来获得实际的参数数量。

对于可变参函数的调用者,则与普通函数没什么不同,还是一样按照规范把参数排列好,然后发起调用即可。

OP_SELF

这个 opcode 专门用来处理 : 操作符,如以下 Lua 代码:

local x = "cxy"
print(x:upper())

将生成这样子的 opcode 序列:

        1       [1]     LOADK           0 -1    ; "cxy"
        2       [2]     GETGLOBAL       1 -2    ; print
        3       [2]     SELF            2 0 -3  ; "upper"
        4       [2]     CALL            2 2 0
        5       [2]     CALL            1 0 1
        6       [2]     RETURN          0 1

结合以上场景,我们来进入源码:

// OP_SELF      A B C   R(A+1) := R(B); R(A) := R(B)[RK(C)]
        StkId rb = RB(i);
        setobjs2s(L, ra+1, rb);
        Protect(luaV_gettable(L, rb, RKC(i), ra));
        continue;

上边那一条 OP_SELF 2 0 "upper" 背后发生了这些事情:

  • base+2+1 = base+0,也就是 "cxy" 被拷贝到了 3 号寄存器上;
  • rb,也就是 "cxy" 中,获取 keyRKC(i),也就是 "upper" 的值,放置到 ra,也就是 2 号寄存器上;

随后下一条 OP_CALL 指令调用了 2 号寄存器的函数,参数只有一个,就是 3 号寄存器 "cxy"。据此实现了类似于 string.upper("cxy") 的效果。

注意到 luaV_gettable 中,如果第二个参数不是 table 类型,则会从其元表中查找 TM_INDEX 函数并调用之。所以这里实际上调用的是字符串元表里的 TM_INDEX 函数,以 "upper" 作为参数,从而取得 string.upper 函数。如下面的代码:

void luaV_gettable (lua_State *L, const TValue *t, TValue *key, StkId val) {
  int loop;
  for (loop = 0; loop < MAXTAGLOOP; loop++) {
    const TValue *tm;
    if (ttistable(t)) {  /* `t' is a table? */
      // ... 省略
    }
    // 元表里是否存在 TM_INDEX,如果不存在,报错
    else if (ttisnil(tm = luaT_gettmbyobj(L, t, TM_INDEX)))
      luaG_typeerror(L, t, "index");
    // 如果元表里的 TM_INDEX 是一个函数,调用之
    if (ttisfunction(tm)) {
      callTMres(L, val, tm, t, key);
      return;
    }
    // 否则继续往上层找
    // 因此,元表的 TM_INDEX 可以是另外一个普通的 table,或者其他对象
    t = tm;  /* else repeat with `tm' */
  }
  luaG_runerror(L, "loop in gettable");
}

实际上,元表里的 TM_INDEX 函数也可以是另外一个表,或者另外一个带有元表的对象,Lua 会自动往上递归查找,最大的查找深度为 100


题图来自:Photo by Michael Dziedzic on Unsplash

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据