Lua5.1.1的一个隐藏BUG

在最近的lua maillist上,三巨头之一的Roberto Ierusalimschy报告了一个5.1.1版本的bug:当一个函数里用到了超过 255个不同的全局变量和常量时,由数字常量和访问table的表达式组成的复合表达式(比如"2*a.x")就有可能产生很奇怪的结果。比如 下面这段程序:
kkk = 2;
v1=2;  v2=2;  v3=2;  v4=2;  v5=2;  v6=2;  v7=2;  v8=2;
v9=2; v10=2; v11=2; v12=2; v13=2; v14=2; v15=2; v16=2;
v17=2; v18=2; v19=2; v20=2; v21=2; v22=2; v23=2; v24=2;
v25=2; v26=2; v27=2; v28=2; v29=2; v30=2; v31=2; v32=2;
v33=2; v34=2; v35=2; v36=2; v37=2; v38=2; v39=2; v40=2;
v41=2; v42=2; v43=2; v44=2; v45=2; v46=2; v47=2; v48=2;
v49=2; v50=2; v51=2; v52=2; v53=2; v54=2; v55=2; v56=2;
v57=2; v58=2; v59=2; v60=2; v61=2; v62=2; v63=2; v64=2;
v65=2; v66=2; v67=2; v68=2; v69=2; v70=2; v71=2; v72=2;
v73=2; v74=2; v75=2; v76=2; v77=2; v78=2; v79=2; v80=2;
v81=2; v82=2; v83=2; v84=2; v85=2; v86=2; v87=2; v88=2;
v89=2; v90=2; v91=2; v92=2; v93=2; v94=2; v95=2; v96=2;
v97=2; v98=2; v99=2; v100=2; v101=2; v102=2; v103=2; v104=2;
v105=2; v106=2; v107=2; v108=2; v109=2; v110=2; v111=2; v112=2;
v113=2; v114=2; v115=2; v116=2; v117=2; v118=2; v119=2; v120=2;
v121=2; v122=2; v123=2; v124=2; v125=2; v126=2; v127=2; v128=2;
v129=2; v130=2; v131=2; v132=2; v133=2; v134=2; v135=2; v136=2;
v137=2; v138=2; v139=2; v140=2; v141=2; v142=2; v143=2; v144=2;
v145=2; v146=2; v147=2; v148=2; v149=2; v150=2; v151=2; v152=2;
v153=2; v154=2; v155=2; v156=2; v157=2; v158=2; v159=2; v160=2;
v161=2; v162=2; v163=2; v164=2; v165=2; v166=2; v167=2; v168=2;
v169=2; v170=2; v171=2; v172=2; v173=2; v174=2; v175=2; v176=2;
v177=2; v178=2; v179=2; v180=2; v181=2; v182=2; v183=2; v184=2;
v185=2; v186=2; v187=2; v188=2; v189=2; v190=2; v191=2; v192=2;
v193=2; v194=2; v195=2; v196=2; v197=2; v198=2; v199=2; v200=2;
v201=2; v202=2; v203=2; v204=2; v205=2; v206=2; v207=2; v208=2;
v209=2; v210=2; v211=2; v212=2; v213=2; v214=2; v215=2; v216=2;
v217=2; v218=2; v219=2; v220=2; v221=2; v222=2; v223=2; v224=2;
v225=2; v226=2; v227=2; v228=2; v229=2; v230=2; v231=2; v232=2;
v233=2; v234=2; v235=2; v236=2; v237=2; v238=2; v239=2; v240=2;
v241=2; v242=2; v243=2; v244=2; v245=2; v246=2; v247=2; v248=2;
v249=2; v250=2; v251=2; v252=2; v253=2; v254=2; v255=2; v256=2;

obj = { kkk = 7 };
print(5 * obj.kkk)
 

它打印出的值不是35而是49。有的朋友可能会认为上面这一大堆代码并没有处于某个函数体中。其实不然,一个lua文件里的所有代码 组成了一个主代码块(chunk),编译器会为主代码块隐式地生成一个函数定义,随后虚拟机调用这个函数完成对该lua文件的解释执行, 所以上面的代码确实是处于一个(编译器为我们自动生成的)函数体中的。由于全局变量名实际上就是一个常量字符串(参见[ 1]),因此下面的代码也会出现同样的bug。
local g_vars = {
"kkk",
"v1", "v2", "v3", "v4", "v5", "v6", "v7", "v8",
"v9", "v10", "v11", "v12", "v13", "v14", "v15", "v16",
"v17", "v18", "v19", "v20", "v21", "v22", "v23", "v24",
"v25", "v26", "v27", "v28", "v29", "v30", "v31", "v32",
"v33", "v34", "v35", "v36", "v37", "v38", "v39", "v40",
"v41", "v42", "v43", "v44", "v45", "v46", "v47", "v48",
"v49", "v50", "v51", "v52", "v53", "v54", "v55", "v56",
"v57", "v58", "v59", "v60", "v61", "v62", "v63", "v64",
"v65", "v66", "v67", "v68", "v69", "v70", "v71", "v72",
"v73", "v74", "v75", "v76", "v77", "v78", "v79", "v80",
"v81", "v82", "v83", "v84", "v85", "v86", "v87", "v88",
"v89", "v90", "v91", "v92", "v93", "v94", "v95", "v96",
"v97", "v98", "v99", "v100", "v101", "v102", "v103", "v104",
"v105", "v106", "v107", "v108", "v109", "v110", "v111", "v112",
"v113", "v114", "v115", "v116", "v117", "v118", "v119", "v120",
"v121", "v122", "v123", "v124", "v125", "v126", "v127", "v128",
"v129", "v130", "v131", "v132", "v133", "v134", "v135", "v136",
"v137", "v138", "v139", "v140", "v141", "v142", "v143", "v144",
"v145", "v146", "v147", "v148", "v149", "v150", "v151", "v152",
"v153", "v154", "v155", "v156", "v157", "v158", "v159", "v160",
"v161", "v162", "v163", "v164", "v165", "v166", "v167", "v168",
"v169", "v170", "v171", "v172", "v173", "v174", "v175", "v176",
"v177", "v178", "v179", "v180", "v181", "v182", "v183", "v184",
"v185", "v186", "v187", "v188", "v189", "v190", "v191", "v192",
"v193", "v194", "v195", "v196", "v197", "v198", "v199", "v200",
"v201", "v202", "v203", "v204", "v205", "v206", "v207", "v208",
"v209", "v210", "v211", "v212", "v213", "v214", "v215", "v216",
"v217", "v218", "v219", "v220", "v221", "v222", "v223", "v224",
"v225", "v226", "v227", "v228", "v229", "v230", "v231", "v232",
"v233", "v234", "v235", "v236", "v237", "v238", "v239", "v240",
"v241", "v242", "v243", "v244", "v245", "v246", "v247", "v248",
"v249", "v250", "v251", "v252", "v253", "v254", "v255", "v256"
}

obj = { kkk = 7 };
print(5 * obj.kkk)


Roberto说这个问题是由register-allocation algorithm引起的,但具体在何处则未提及。这引发了我的好奇,挽起袖子开始翻 源代码,经过一个晚上的努力终于有了点眉目。为了更好地说明这个问题,首先要简单介绍一点Lua虚拟机指令的知识。

Lua 5.1的所有的虚拟机指令的位长都是32 bits,共有3种类型,格式如下图(摘自 [2])所示:
       31            24 23            16 15             8 7              0
      |________________|________________|________________|________________|
iABC  |       B:9        |       C:9        |       A:8      |  Opcode:6  |
      |__________________|__________________|________________|____________|
iABx  |                Bx:18                |       A:8      |  Opcode:6  |
      |_____________________________________|________________|____________|
iAsBx |               sBx:18                |       A:8      |  Opcode:6  |
      |_____________________________________|________________|____________|
 

其中Opcode(6 bits)表示指令的类型,A、B、C、Bx和sBx这些域则存放指令需要的操作数(注意并不是每条指令都会把所有的操作数 域都用上)。从现在开始我们约定:

<1>、R(X)根据上下文可表示X号寄存器或它的内容,寄存器实际就是数据栈单元,关于它的解释请参考 [3];

<2>、Gbl[X]表示用X为键对全局环境检索得到的值;

<3>、Kst(X)表示在当前函数常量列表中第X个元素;

<4>、RK(X)表示X号寄存器的内容(当X <= 255)或者常量列表中第X - 256个元素(当X >= 256);

我们讨论的问题会涉及到以下几种指令 ([2]):

<1>、getglobal A Bx,iABx格式,将Gbl[Kst(Bx)]读入R(A);

<2>、loadk A Bx,iABx格式,将Kst(Bx)读入R(A);

<3>、gettable A B C,iABC格式,将R(B)中键RK(C)对应的值读入R(A);

<4>、mul A B C,iABC格式,将RK(B)与RK(C)相乘,并把它们的积存入R(A);


对于第一个示例程序中最后的那行print(5 * obj.kkk),编译器为了计算参数表达式的结果并将其压栈(有关细节请参考 [3])而产生的字节码如下:
[1] getglobal  1   258      ; obj
[2] loadk      2   261      ; 5
[3] gettable   2   1   256  ; "kkk"
[4] mul        1   2   2 
 

第1、2条指令把全局变量obj和常数5分别load进R(1)和R(2),此时print函数的栈框架布局是:
     |    .    |             
     |    .    |  更上层函数的栈框架
     |    .    |      |
     |---------|<-----|
     |R(1)=obj |      |
     |---------|    print栈框架
     | R(2)=5  |      |
     |---------|<-----|
     |         |
     |---------|<---栈顶
 

注意第3条指令,它以常量表中第0(256 - 256的值等于0)个元素(即字符串"kkk")为key检索R(1)中的表(即obj)的值,并把该值 存入R(2)。看出来了吗?常数5本来还老老实实在R(2)那待着,这么一来它就被踢走了,让obj.kkk抢占了R(2)山头,结果此时的布局 就是:
     |      .      |             
     |      .      |  更上层函数的栈框架
     |      .      |      |
     |-------------|<-----|
     |  R(1)=obj   |      |
     |-------------|    print栈框架
     |R(2)=obj.kkk |      |
     |-------------|<-----|
     |             |
     |-------------|<---栈顶
 

最后一条乘法指令把R(2)(此时已变成obj.kkk)和R(2)自己相乘,并将结果写入R(1),从而完成了实参的计算和压栈。由于示例程序 中obj.kkk等于7,所以输出49也就顺理成章了。

现在我们终于知道了bug其然,但是却还不知道其所以然。爱动脑筋的读者一定会问,为什么gettable指令取得obj.kkk后不能把它 放在R(2)后面的R(3)上?请看lcode.c中的luaK_dischargevars函数,据我理解它应该是负责生成读变量进栈指令的。其中有这么 一段:
case VINDEXED: {
    freereg(fs, e->u.s.aux);
    freereg(fs, e->u.s.info);
    e->u.s.info = luaK_codeABC(fs, OP_GETTABLE, 0, e->u.s.info, e->u.s.aux);
    e->k = VRELOCABLE;
    break;
}

e是指向表达式结构体的指针,e->k是表达式的类型。当e->k为VINDEXED(即表检索表达式)时,R(e-u.s.info)就是载入table的 位置,而RK(e->u.s.aux)则将是key,luaK_codeABC会根据上述信息生成gettable指令。此时生成的gettable指令的A域为0, 因为相应参数传的是0,但是这并不就是最终结果,该指令将来会被重定位,所以e->k随后被设为VRELOCABLE。这里重定位指的是重新 设置A域的值,即gettable检索得到的值的保存位置,它的值是执行重定位操作时编译器认为的第一个可用寄存器(该信息可由 fs->freereg得到)。问题来了,请注意前面的2个freereg函数调用,它的函数体是:
static void freereg (FuncState *fs, int reg) {
  if (!ISK(reg) && reg >= fs->nactvar) {
    fs->freereg--;
    lua_assert(reg == fs->freereg);
  }
}

其中ISK(reg)用来判断reg参数是表示栈单元还是常量列表中的序号(通过判断是否大于255)。很明显这个函数是为了回收那些不再需要 的栈单元的。对于我们的第一个示例,obj.kkk表达式相关的e->u.s.info等于1(因为obj被load进R(1)),e->u.s.aux等于256( 因为字符串常量"kkk"是常量列表的第0个元素),所以freereg(fs,e->u.s.info)将令fs->freereg自减1而 freereg(fs,e->u.s.aux)不会。在此之前,读入常量5的loadk指令已经生成,并且用掉了2号寄存器,因此fs->freereg被设为3。 但fs->freereg经过自减1后变为2,于是编译器认为2号寄存器是个可用单元,便将gettable指令的A域重定位为2,这就导致了 obj.kkk覆盖常量5。

如果读者分别把第一个例子中的第一行:
kkk = 2;

第二个例子中的
"kkk",

删除掉,便会惊讶地发现运行结果又是对的。到底怎么回事?别着急,且听我慢慢说。

gettable指令使用C域来取得key操作数,而C域又只能表达[0,511]区间(它只有9 bits),其中[0,255]表示寄存器号, [256,511]表示常量列表中第[0,255]个元素。删除掉指定部分后,字符串常量"kkk"的首次出现位置将大大靠后,这导致了它在常量 列表中排位的后移,如果序号大于了255就不能被直接编码进gettable指令的C域,于是不得不先用loadk(它使用的Bx域有18位)把常量 读到栈上,再进行gettable操作,相应的指令就是:
[1] getglobal  1   257      ; obj
[2] loadk      2   258      ; "kkk"
[3] loadk      3   261      ; 5
[4] gettable   2   1   2 
[5] mul        1   3   2 

这次obj.kkk没有占用常量5的位置,而是把"kkk"的位置占了,没有关系,反正找到obj.kkk后"kkk"就没有用了,占了也就占了吧。 最后mul把R(3)里的常量5和R(2)里的obj.kkk相乘得到正确的结果。为什么这回gettable不会把结果存到R(3)上呢?呵呵,要弄清 这个问题不如再扮演一回编译器的角色,体验机械思维的快感。e->u.s.info当然仍然等于1,e->u.s.aux等于2(因为"kkk"将会在 R(2)里),所以freereg(fs,e->u.s.info)和freereg(fs,e->u.s.info)都会使fs->freereg自减1。而在此之前,读入常量5的 loadk指令已经生成,并且用掉了3号寄存器,因此fs->freereg被设为4。但fs->freereg经过2次自减1后变为2,于是编译器认为2号 寄存器是个可用单元,便将gettable指令的A域重定位为2,于是结果便(歪打正着地)正确了。如果不删除那些代码,仅仅是把 5 * obj.kkk改成obj.kkk * 5,得到的结果也无误(想想为什么)。

读者会问,针对VINDEXED型表达式,为什么要加两个freereg(fs,reg)函数调用?不调它们不就结了吗?这可不行,因为那样就会 使得许多栈单元被白白浪费了,更严重的是会使得栈框架的布局依赖参数表达式的类型,这将令Lua与C的交互极其困难。仔细观察示例1 对应的指令,会发现对于5 * obj.kkk这样一个二元表达式,取左操作数的指令并非在最前面,而是被塞进了读取obj.kkk的2条指令的 中间,luak_dischargeargs从未预料过这种情况,于是一直受信任的寄存器分配策略便失败了。实际上,只要保证“读入表,读入键 (如果需要的话),读入键对应的值”这三步动作是一气呵成的,即中间不插入任何指令的话,那么原本的分配策略还是工作得非常好的。 可为什么三步走连续动作会被打断呢?这就要说到Lua编译器的解析算法了。

Lua编译器采用了手写的递归下降解析算法,并且边解析边生成代码,因此只需要扫描一遍源文件就可以了,这也是Lua为什么快的原因 之一。lparser.c是解析算法的实现,解析表达式由expr函数负责,它只是简单地调用subexpr函数,而subexpr又会调用simpleexp。 subexpr和simpleexp的定义如下:
static BinOpr subexpr (LexState *ls, expdesc *v, unsigned int limit) {
  BinOpr op;
  UnOpr uop;
  enterlevel(ls);
  uop = getunopr(ls->t.token);
  if (uop != OPR_NOUNOPR) {
    luaX_next(ls);
    subexpr(ls, v, UNARY_PRIORITY);
    luaK_prefix(ls->fs, uop, v);
  }
  else simpleexp(ls, v);
  /* expand while operators have priorities higher than `limit' */
  op = getbinopr(ls->t.token);

  /* 对于5 * obj.kkk来说会进入这个循环 */
  while (op != OPR_NOBINOPR && priority[op].left > limit) {
    expdesc v2;
    BinOpr nextop;
    luaX_next(ls);
    /* 进入这个循环时,*v已经被设置为VKNUM型表达式 */
    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);
    op = nextop;
  }
  leavelevel(ls);
  return op;  /* return first untreated operator */
}

static void simpleexp (LexState *ls, expdesc *v) {
  /* simpleexp -> NUMBER | STRING | NIL | true | false | ... |
                  constructor | FUNCTION body | primaryexp */
  switch (ls->t.token) {
    case TK_NUMBER: {
      /* 常量5将使得*v被设置为VKNUM型的表达式 */
      init_exp(v, VKNUM, 0);
      v->u.nval = ls->t.seminfo.r;
      break;
    }
    ...
    ...
    ...
    default: {
      /* obj.kkk将由primaryexp解析 */
      primaryexp(ls, v);
      return;
    }
  }
  luaX_next(ls);
}
 

subexpr扫描到5时,调用simpleexp设置一个类型为VKNUM的表达式结构体。因为它后面跟了个二元运算符乘号,所以进入处理二元 运算符的循环。进入循环后要首先调用luaK_infix(ls->fs, op, v),这个函数对于VKNUM型表达式不做任何事情(原因下文将述)。 接着开始执行
nextop = subexpr(ls, &v2, priority[op].right);

从而进入递归调用。这次的递归调用最终会通过simpleexp跑到primaryexp里去,它会继续扫描进二元操作符的右操作数。对于 obj.kkk来说,primaryexp做的事情主要就是生成读取obj的getglobal指令(因为"kkk"不用读到栈上,所以不会生成 读取key的指令),并把相应的结构体设置为VINDEXED型。这步做完了以后便是调用luaK_posfix(ls->fs, op, v, &v2)。 而它又会调用codearith来最终完成生成二元表达式指令的工作。codearith的定义如下:
static void codearith (FuncState *fs, OpCode op, expdesc *e1, expdesc *e2) {
  if (constfolding(op, e1, e2))
    return;
  else {
    int o1 = luaK_exp2RK(fs, e1);
    int o2 = (op != OP_UNM && op != OP_LEN) ? luaK_exp2RK(fs, e2) : 0;
    freeexp(fs, e2);
    freeexp(fs, e1);
    e1->u.s.info = luaK_codeABC(fs, op, 0, o1, o2);
    e1->k = VRELOCABLE;
  }
}

请注意第一行的constfolding函数调用,它的目的是常量压缩,即在编译期就算出那些数字常量表达式从而减少运行期开销,对Lua来说 更重要的是只需把常量压缩后的结果存进常量列表中而其它常量则可抛弃,这是一种基本的编译器优化动作。现在可以解释为什么 luaK_infix不对VKNUM表达式做任何事情了,因为它被调用的时候还无法知道传进来的表达式会否被常量压缩掉。当然对于我们的例子是 不会进行常量压缩的,于是常量5表达式将由
int o1 = luaK_exp2RK(fs, e1);

处理。诸如mul这样的二元算术指令的格式都是iABC,也就是左右操作数既可以是栈单元又可以是常量列表中的元素。数字常量是否要先 读进栈上,又该如何编码进算术指令中,这些都将由fs->nk(函数常量列表的长度)决定。当常量列表的长度大于255时,编译器将不得不 生成loadk指令将数字常量先载入栈中。在我们的程序中,常量列表确实大于了255,于是指令
loadk      2   261      ; 5

生成了。接下来才由
int o2 = (op != OP_UNM && op != OP_LEN) ? luaK_exp2RK(fs, e2) : 0;

语句处理右操作数(obj.kkk)。而它也最终会通过luaK_exp2RK生成gettable指令,可是此时三步走动作已经不连贯了,错误注定将要 发生。

这个bug的来龙去脉终于被我们弄清楚了,隐蔽得真TNND深,而且牵扯甚广,难怪Roberto说一下子找不到既简单又好的修复方案。作者 倒是自己动手打了个补丁,经试验觉得应该解决了问题,但是用的方法很脏,所以也就不细说了。关键的思想是让作为左操作数的数字常量 能首先读进栈,这样就不会打断检索表的3步走动作了。不过有一点要注意,即修复方案一定得让常量压缩优化还能进行,否则就没有 意义了。
参考文献

[1] Roberto Ierusalimschy. Programming in Lua, Chapter 14 .

[2] Kein-Hong Man. A No-Frills Introduction to Lua 5.1 VM Instructions .

[3] soloist. Lua的debug hook功能探究与改造--上篇 . 

你可能感兴趣的:(算法,框架,虚拟机,lua,Constructor,编译器)