文章目录
一、Introduction
关于Bytecode介绍的官方文档:http://wiki.luajit.org/Bytecode-2.0
最近发现作者将Bytecode和SSA IR的介绍的文档删除了,也不知道是为什么。
发现Tarantool的wiki中保存着Bytecode原来的官方介绍:https://github.com/tarantool/tarantool/wiki/LuaJIT-Bytecodes
在LuaJIT的源码中,关于Bytecode的指令格式的定义,在src/lj_bc.h中。
在使用LuaJIT时,可以加上参数-bl可以列出所执行lua脚本生成的ByteCode,如:luajit -bl test.lua
指令格式:
ByteCode指令占4个byte(32bit),有两种格式:一种是有ABC三个操作数,一种是只有AD两个操作数。A一般是dst操作数,占8位;B、C为src操作数,占8位;D一般是src操作数,占16位。
/* Bytecode instruction format, 32 bit wide, fields of 8 or 16 bit:
**
** +----+----+----+----+
** | B | C | A | OP | Format ABC
** +----+----+----+----+
** | D | A | OP | Format AD
** +--------------------
** MSB LSB
**
** In-memory instructions are always stored in host byte order.
*/
假设0xbbccaa1e是一条ByteCode编码,则:op = 0x1e (src/lj_bc.h中有一个枚举类型BCOp,从中可以查看具体值对应的opcode),A = 0xaa,B = 0xbb,C = 0xcc;或者op = 0x1e,A = 0xaa,D = bbcc。
在src/lj_bc.h定义了get/set指令操作数和操作码的的宏定义。
/* Macros to get instruction fields. */
#define bc_op(i) ((BCOp)((i)&0xff))
#define bc_a(i) ((BCReg)(((i)>>8)&0xff))
#define bc_b(i) ((BCReg)((i)>>24))
#define bc_c(i) ((BCReg)(((i)>>16)&0xff))
#define bc_d(i) ((BCReg)((i)>>16))
#define bc_j(i) ((ptrdiff_t)bc_d(i)-BCBIAS_J)
/* Macros to set instruction fields. */
#define setbc_byte(p, x, ofs) \
((uint8_t *)(p))[LJ_ENDIAN_SELECT(ofs, 3-ofs)] = (uint8_t)(x)
#define setbc_op(p, x) setbc_byte(p, (x), 0)
#define setbc_a(p, x) setbc_byte(p, (x), 1)
#define setbc_b(p, x) setbc_byte(p, (x), 3)
#define setbc_c(p, x) setbc_byte(p, (x), 2)
#define setbc_d(p, x) \
((uint16_t *)(p))[LJ_ENDIAN_SELECT(1, 0)] = (uint16_t)(x)
指令定义:
关于指令的定义,在下面的这个结构中有说明,#define BCDEF(_)这个宏中定义了每个指令格式和其操作数类型,可以查阅。
/* Bytecode opcode numbers. */
typedef enum {
#define BCENUM(name, ma, mb, mc, mt) BC_##name,
BCDEF(BCENUM)
#undef BCENUM
BC__MAX
} BCOp;
指令名后面会跟个后缀,用于区分操作数B、C或D的不同类型。如ADDVN和ADDNV:前者表示dst = V + N,即操作数B是一个变量,操作数C是一个常量;后者表示dst = N + V,即操作数B是一个常量,操作数C是一个变量。这种后缀代表含义如下:
/*
** The opcode name suffixes specify the type for RB/RC or RD:
** V = variable slot
** S = string const
** N = number const
** P = primitive type (~itype)
** B = unsigned byte literal
** M = multiple args/results
*/
V对应的操作数类型是变量,LuaJIT用一个Stack维护VM的运行时,variable slot表示的是变量在Stack中的index,关于var stack的介绍请阅读《LuaJIT 栈帧布局(stack frames layout)》。
N和S表示对应操作数类型是number和string。string操作数的中的值肯定是string在其存放结构中的index;number如果是位数小于16位的整数,则操作数的值是number字面值,否则就是number值存放结构中的index。其实string和number存放在同一个结构中,我把它叫做常量数组,同Stack一样都是很重要的结构,关于常量数组的介绍请阅读《LuaJIT 常量数组(constant array)》。
P表示操作数是一个primitive类型,B就是一个字节的字面值,M表示多个args/results.
二、Comparison、JMP、Test and Copy
Comparison、test及copy指令后面会紧跟一条JMP跳转指令,如果comparison或test的运算结果为true,JMP会跳转到指定的目标处,否则就会继续执行JMP之后的指令。
2.1 Comparison
comparison指令语法为OP A D,用于求操作数A、D的关系运算结果,所有操作在条件为真时分支执行,否则直接向下执行。当comparison指令的运算结果为true时,JMP的跳转目标(jump)是对应的是if语句之外的语句或者else块中的语句。比如source code内容为:if(a > b) then stat1; end;,则对应的指令如下:
ISGE b a
JMP target
stat1
...
| OP | A | D | Description |
|---|---|---|---|
| ISLT | var | var | Jump if A < D |
| ISGE | var | var | Jump if A ≥ D |
| ISLE | var | var | Jump if A ≤ D |
| ISGT | var | var | Jump if A > D |
| ISEQV | var | var | Jump if A = D |
| ISNEV | var | var | Jump if A ≠ D |
| ISEQS | var | str | Jump if A = D |
| ISNES | var | str | Jump if A ≠ D |
| ISEQN | var | num | Jump if A = D |
| ISNEN | var | num | Jump if A ≠ D |
| ISEQP | var | pri | Jump if A = D |
| ISNEP | var | pri | Jump if A ≠ D |
| JMP | rbase | jump | Jump |
2.2 JMP
JMP 指令的操作数A,它存储第一个未使用的槽位。操作数D则是跳转目标jump,对其解释为: branch target, relative to next instruction, biased with 0x8000。
- branch target(分支目标):指的是跳转目标,即程序将要跳转到的指令位置。
- relative to next instruction(相对于下一条指令):跳转的目标地址是 相对地址,不是绝对地址。也就是说,目标地址是基于 当前指令的下一条指令 来计算的,而不是一个固定的内存地址。
- biased with 0x8000(偏移 0x8000):表示跳转偏移值是带有 0x8000(32768) 的偏移基准。这意味着,偏移值是一个 有符号数,即可以是正数(向前跳转)或负数(向后跳转)。
- 在编码时,跳转偏移会在存储前被 加上 0x8000,使得它变成无符号数,方便存储和解析。在执行时,需要 减去 0x8000 以恢复原始的相对偏移值。
- 假设某条跳转指令的偏移字段值是 0x8010,那么存储时,它实际表示的偏移量 =
0x8010 - 0x8000 = 0x10(即 16),这意味着程序将跳转到 下一条指令之后的第 16 条指令 处。 - 如果偏移字段是 0x7FF0,那么存储的值 =
0x7FF0 - 0x8000 = -16,这表示程序 向后跳转 16 条指令。 - 这么做的原因是:指令中的跳转偏移量是一个 有符号数,但Bytecode的RD操作数是以无符号数形式存储的。通过 加 0x8000,我们可以将一个 有符号的
-32768 ~ 32767的范围 转换成 无符号的0 ~ 65535,从而在 16 位字段 内表示 正负偏移,而无需额外的符号位。解码操作时,也不需要符号扩展,只要减0x8000即可。
2.3 Test and Copy
test和copy指令用于检查一个boolean变量在上下文中的计算结果,如果计算结果是nil或false,指令认为值为false,其他任何计算结果指令都认为是true。
copy指令ISTC A D:如果D为true,copy D的值到A中,执行后面紧跟的JMP指令跳转到指定的目标处,否则不跳转继续执行JMP之后的指令。指令ISFC A D则是在D为false时,先copy再jmp。
/* copy ops. */ \
_(ISTC, dst, ___, var, ___) \ // if D==true,则A=D且执行JMP
_(ISFC, dst, ___, var, ___) \ // if D==false,则A=D且执行JMP
test指令IST D,如果D为true,执行后面紧跟的JMP指令跳转到指定的目标处,否则不跳转继续执行JMP之后的指令。指令ISTYPE A D,A是个变量,D是类型值,如果A的类型值(itype,后面会专门写一片文章讲TValue结构,会涉及到这个知识)和D的值相等,则啥也不干,否则将A的类型转换为D的类型。ISNUM与ISTYPE类似。
/* Unary test ops. */ \
_(IST, ___, ___, var, ___) \ // if D == true, 则执行JMP
_(ISF, ___, ___, var, ___) \ // if D == false, 则执行JMP
_(ISTYPE, var, ___, lit, ___) \
_(ISNUM, var, ___, lit, ___) \
三、Unary and Binary ops
3.1 Unary
一元运算指令有4条。MOV A D将D中的值复制到A中;NOT A D对D中的值做按位逻辑非运算,运算结果放入A;UNM是对D中的值取反;LEN是求字符串或table的长度。
| OP | A | D | Description |
|---|---|---|---|
| MOV | dst | var | Copy D to A |
| NOT | dst | var | Set A to boolean not of D |
| UNM | dst | var | Set A to -D (unary minus) |
| LEN | dst | var | Set A to #D (object length) |
3.2 Binary
二元运算指令有17条,上文介绍指令定义 中有使用过这部分的例子。基本的运算是加减乘除取余;POW a b c => a = b^c;CAT表示字符串连接操作,将B和C连接,结果放入A。
| OP | A | B | C | Description |
|---|---|---|---|---|
| ADDVN | dst | var | num | A = B + C |
| SUBVN | dst | var | num | A = B - C |
| MULVN | dst | var | num | A = B * C |
| DIVVN | dst | var | num | A = B / C |
| MODVN | dst | var | num | A = B % C |
| ADDNV | dst | var | num | A = C + B |
| SUBNV | dst | var | num | A = C - B |
| MULNV | dst | var | num | A = C * B |
| DIVNV | dst | var | num | A = C / B |
| MODNV | dst | var | num | A = C % B |
| ADDVV | dst | var | var | A = B + C |
| SUBVV | dst | var | var | A = B - C |
| MULVV | dst | var | var | A = B * C |
| DIVVV | dst | var | var | A = B / C |
| MODVV | dst | var | var | A = B % C |
| POW | dst | var | var | A = B ^ C |
| CAT | dst | rbase | rbase | A = B … C |
四、Upvalue and function ops
这里介绍Lua中有关于函数操作相关的两个概念,知道这两个概念再去理解相关指令就很好理解:一个是外部局部变量,也称为upvalue,另一个是closure,翻译过来叫闭包。
4.1 外部局部变量(upvalue)
Lua 中的函数是一阶类型值(first-class value),定义函数就象创建普通类型值一样(只不过函数类型值的数据主要是一条条指令而已),所以在函数体中仍然可以定义函数。假设函数f2定义在函数f1中,那么就称f2为f1的内嵌(inner)函数,f1为f2的外包(enclosing)函数,外包和内嵌都具有传递性,即f2的内嵌必然是f1 的内嵌,而f1的外包也一定是f2的外包。内嵌函数可以访问外包函数已经创建的所有局部变量,这种特性便是所谓的词法定界(lexical scoping),而这些局部变量则称为该内嵌函数的外部局部变量(external local variable)或者upvalue(是变量而不是值)。
19 function f1(n)
20 local function f2()
21 print(n)
22 end
23 return f2
24 end
25
26 do
27 g1 = f1(1024)
28 g1()
29 end
-- BYTECODE -- test_while.lua:20-22
0001 GGET 0 0 ; "print"
0002 UGET 2 0 ; n
0003 CALL 0 1 2
0004 RET0 0 1
-- BYTECODE -- test_while.lua:19-24
0001 FNEW 1 0 ; test_while.lua:20
0002 UCLO 0 => 0003
0003 => RET1 1 2
-- BYTECODE -- test_while.lua:0-30
0001 FNEW 0 0 ; test_while.lua:19
0002 GSET 0 1 ; "f1"
0003 GGET 0 1 ; "f1"
0004 KSHORT 2 1024
0005 CALL 0 2 2
0006 GSET 0 2 ; "g1"
0007 GGET 0 2 ; "g1"
0008 CALL 0 1 1
0009 RET0 0 1
函数f1的参数n是函数f2的upvalue,当执行完g1 = f1(1024)后,局部变量n的生命周期本该结束,但因为它已经成了内嵌函数f2的upvalue,并且它又被赋给了变量g1,所以它仍然能以某种形式继续“存活”下来,从而令g1()打印出正确的值。
4.2 闭包(closure)
Lua解析一个函数编译生成字节码时,会为它生成一个原型(prototype),其中包含了函数体对应的ByteCode指令、函数用到的常量值(数字字面量,文本字符串等等)和一些调试信息。在运行时,每当Lua执行一个形如function...end 这样的表达式时,它就会根据原型创建一个新的数据对象(ByteCode的FNEW指令),其中包含了相应函数原型的引用、环境(environment,用来查找全局变量的表)的引用以及一个由所有upvalue引用组成的数组,而这个数据对象就称为闭包。由此可见,函数是编译期概念,是静态的,而闭包是运行期概念,是动态的。
这部分操作的指令总共有7条,除了FNEW之外其余都是以U开头。
| OP | A | D | Description |
|---|---|---|---|
| UGET | dst | uv | Set A to upvalue D |
| USETV | uv | var | Set upvalue A to D |
| USETS | uv | str | Set upvalue A to string constant D |
| USETN | uv | num | Set upvalue A to number constant D |
| USETP | uv | pri | Set upvalue A to primitive D |
| UCLO | rbase | jump | Close upvalues for slots ≥ rbase and jump to target D |
| FNEW | dst | func | Create new closure from prototype D and store it in A |
对上面这几条指令逐一解释:
UGET dst uv指令:将upvale值uv复制到solt dst中。注意按照指令格式与dst(对应A)和uv(对应D)对位,后面也是。USETV uv var指令:将solt var中的值复制到uv,这一条指令同上一条指令不一样,需要考虑GC,即三色标记的白色不能赋值给黑色这一问题。USETS、USETN和USETP同USETV一样,只是类型不同而已。UCLO rbase jump指令:close栈上满足条件slots ≥ rbase的所有upvalue值,upvalue有两种状态,open和close。FNEW dst func指令:根据函数原型func创建一个函数闭包,放进solt dst中。该指令一般在函数调用时会使用,如调用一个自定义lua函数之前,需要创建函数的闭包。
五、Table ops
Lua中的table是一个关联数组,除了nil以外的任何值都可以做为key,大小不固定,可动态扩容。
这里说一下关联数组和哈希表的区别:哈希表在存值时,必须使用key值,你可以将key设置位1、2、3这种类似于数组index的形式,但必须有key,如array = {"123" = "Lua", "456" = "Tutorial"};关联数组则同时兼具了普通数组和哈希表的功能,普通数组可以在存值时不用index(key),只要有value,会有默认的index,如array = {"Lua", "Tutorial"}。
| OP | A | B | C/D | Description |
|---|---|---|---|---|
| TNEW | dst | lit | Set A to new table with size D (see below) | |
| TDUP | dst | tab | Set A to duplicated template table D | |
| GGET | dst | str | A = _G[D] | |
| GSET | var | str | _G[D] = A | |
| TGETV | dst | var | var | A = B[C] |
| TGETS | dst | var | str | A = B[C] |
| TGETB | dst | var | lit | A = B[C] |
| TSETV | var | var | var | B[C] = A |
| TSETS | var | var | str | B[C] = A |
| TSETB | var | var | lit | B[C] = A |
| TSETM | base | num* | (A-1)[D], (A-1)[D+1], … = A, A+1, … |
配合下面给出的解释和例子,对上面这几条指令逐一解释:
TNEW指令的D操作数占16位,低11位存放数组size,也就是要将table当作普通数组使用;高5位存放一个叫做hsize的值,也就是要将table当作哈希表使用,用2^hsize结果作为哈希表的大小。GGET和GSET指令,可以理解为是在当前函数执行环境的全局符号表中获取或存放对象,_G就是全局符号表。TGETS、TSETS等指令是以C作为B的index存取值,下面例子中的指令TGETS 0 0 5 ; "sort"的意思是:在名字是table的table中取出key = sort的value。TSETM指令,一般在有变参的函数中使用,solt(A-1)中存放的肯定是个table,如下图所示,TSETM需要做table[val] = solt A、table[val + 1] = solt(A + 1)、table[val] = solt(A + 2)……直到MULTRES为零的时候,赋值结束。

这是一个对table操作的例子:
do
fruits = {"banana","orange","apple","grapes"}
for k,v in ipairs(fruits) do
print(k,v)
end
table.sort(fruits)
for k,v in ipairs(fruits) do
print(k,v)
end
end
0001 TDUP 0 0
0002 GSET 0 1 ; "fruits"
0003 GGET 0 2 ; "ipairs"
0004 GGET 2 1 ; "fruits"
0005 CALL 0 4 2
0006 JMP 3 => 0011
0007 => GGET 5 3 ; "print"
0008 MOV 7 3
0009 MOV 8 4
0010 CALL 5 1 3
0011 => ITERC 3 3 3
0012 ITERL 3 => 0007
0013 GGET 0 4 ; "table"
0014 TGETS 0 0 5 ; "sort"
0015 GGET 2 1 ; "fruits"
0016 CALL 0 1 2
0017 GGET 0 2 ; "ipairs"
0018 GGET 2 1 ; "fruits"
0019 CALL 0 4 2
0020 JMP 3 => 0025
0021 => GGET 5 3 ; "print"
0022 MOV 7 3
0023 MOV 8 4
0024 CALL 5 1 3
0025 => ITERC 3 3 3
0026 ITERL 3 => 0021
0027 RET0 0 1
六、Calls and vararg handling
函数调用相关指令总共有8条,有多参数的,迭代器相关的以及变参的,T = tail call:
| OP | A | B | C/D | Description |
|---|---|---|---|---|
| CALLM | base | lit | lit | Call: A,…,A+B-2 = A(A+1,…,A+C+MULTRES) |
| CALL | base | lit | lit | Call: A,…,A+B-2 = A(A+1,…,A+C-1) |
| CALLMT | base | lit | Tailcall: return A(A+1,…,A+D+MULTRES) | |
| CALLT | base | lit | Tailcall: return A(A+1,…,A+D-1) | |
| ITERC | base | lit | lit | Call iterator: A, A+1, A+2 = A-3, A-2, A-1; A,…,A+B-2 = A(A+1, A+2) |
| ITERN | base | lit | lit | Specialized ITERC, if iterator function A-3 is next() |
| VARG | base | lit | lit | Vararg: A,…,A+B-2 = … |
| ISNEXT | base | jump | Verify ITERN specialization and jump |
- 所有的调用指令都需要特殊的设置:被调用的函数(或对象)位于slot A,后续连续的slot中是参数;操作数 B = 返回值数量 + 1,若B为 0,则表示返回所有结果(并设置
MULTRES变量);操作数 C = 固定参数数量 + 1。 - 对于有多个参数的调用(CALLM 或 CALLMT),操作数 C 表示固定参数数量,实际传递参数的数量 = 固定参数数量 + MULTRES。这些特殊的调用形式会将
MULTRES(上一条指令返回的值数量)也加入参数中,用来支持f(a, b, unpack(t))这类语法。 - 为了保持一致性,特化的调用指令
ITERC、ITERN和可变参数指令VARG使用相同的的操作数格式。ITERC和ITERN的操作数 C 始终是 3(即 1+2),表示传递 2 个参数给迭代器函数。VARG的 C 被重用来存储外层函数的固定参数数量,这样可以更快地访问伪帧中的可变参数部分。VARG 的操作数 C 被重新用于保存封闭函数的固定参数数量。这加快了对下面可变参数伪框架的可变参数部分的访问速度。 MULTRES是一个内部变量,用于记录前一次调用或VARG返回的结果数量。它被CALLM/CALLMT、RETM(多返回值)和表初始化器(TSETM)使用。
优化:pairs() 和 next() 的识别
Lua 的解析器会启发式地判断某处循环中是否可能使用了 pairs() 或 next(),如果是,它会把原本的 JMP 和迭代器调用 ITERC 替换为特化指令 ISNEXT 和 ITERN。ISNEXT 会在运行时检查以下条件是否满足:
- 迭代器确实是
next()函数; - 参数是一个表;
- 控制变量是
nil。
如果满足,它就会将控制变量的slot的低 32 位清零,并跳转到迭代器调用,用该数字来高效遍历表的键。如果这些假设不成立,字节码会在运行时“反特化”,重新回到常规的 JMP 和 ITERC。
概括来说,Lua 通过静态分析加速常见的 for k,v in pairs(t) 循环,如果能确定用的是 next() 并且是标准形式,就用特化指令加速执行;如果运行时发现情况不符合,它还能“降级”回原始形式。
下例是一个函数调用的实例,对其中使用到的指令做出如下解释:
43 function add(a, b)
44 return a + b;
45 end
46 do
47 local a = 111;
48 local b = 222;
49 local c = add(a, b);
50 --print(c);
51 end
-- BYTECODE -- test_while.lua:43-45
0001 ADDVV 2 0 1
0002 RET1 2 2
-- BYTECODE -- test_while.lua:0-58
0001 FNEW 0 0 ; test_while.lua:43
0002 GSET 0 1 ; "add"
0003 KSHORT 0 111
0004 KSHORT 1 222
0005 GGET 2 1 ; "add"
0006 MOV 4 0
0007 MOV 5 1
0008 CALL 2 2 3
0009 RET0 0 1
第一个Bytecode lua:43-45为函数add的原型,在原型中参数参数默认依次放在slot 0、1...中。
ADDVV:slot 2 = slot 0 + slot 1。RET1:第一个参数是存放返回值的slot number,第二个参数是返回值个数+1。
第二个Bytecode lua:0-58为do...end块的字节码,是从这里创建要给add函数的闭包,再去调用函数的。
FNEW:根据slot 0中存放的函数原型,创建一个闭包并存放到slot 0中。GSET:将slot 0中存放的闭包放到全局符号表中,key为add。KSHORT:将常量111、222存放到slot 0、slot 1中。GGET:以key=add,取出全局符号表中存放给的闭包存放到slot 2。MOV:copy slot 0 to slot 4,copy slot 1 to slot 5。CALL:第一个参数为被调函数闭包slot number,第二个参数为返回值个数+1的常数值,第三个参数为被调函数参数个数+1的常数值。RET0:这是虚拟机自己补的一条指令,把do...end当作一个函数来处理,编译器前端基本上都是这么去处理,把source最外层的一个块当作中间码的一个函数去处理。RET0只改变控制流程,返回值个数为0。
这里再说几个官方文档中的一些不好理解的点。如CALL指令的A, ..., A+B-2 = A(A+1, ..., A+C-1),现在的LuaJIT版本已经这种结构,而是A, ..., A+B-2 = A(A+2, ..., A+C),其中solt A存放callee函数,B表示返回值个数+1,C表示参数个数+1,结构var stack如图所示:

比如说call 2 2 3,调用前statck上A处(slot 2)存放被调函数的闭包,实参是从A+2开始,一直到A+C(A+3);调用结束后,参数个数为2-1=1个,第一个返回值存放在statck上A处(slot 2)。
七、Return
RET比较好理解,总共有4条,主要是将被掉函数的返回结果传到主调函数调用帧中的规定位置。
| OP | A | D | Description |
|---|---|---|---|
| RETM | base | lit | return A, …, A+D+MULTRES-1 |
| RET | rbase | lit | return A, … A+D-2 |
| RETO | rbase | lit | return |
| RET1 | rbase | lit | return A |
-
所有返回指令都会把从slot A 开始的返回结果,复制到从base slot下面两个slot位开始的位置(base-16)。
-
RET0 和 RET1 指令是 RET 指令的特殊版本,前者返回值个数为0,后者返回值个数为1。
-
操作数 D 的值等于“要返回的结果数 + 1”。例如,如果返回 0 个结果,那么 D 的值是 1;返回 1 个结果时,D 为 2,以此类推。
-
对于 RETM 指令,操作数 D 表示固定返回值的数量。另外,还有一个内部变量 MULTRES,它保存了上一次调用或 vararg 指令返回的多结果数量。实际返回的结果数是:操作数 D 加上 MULTRES 的值。
如RET A D(return A, ..., A+D-2),A是存放第一个返回值的slot number,D是返回值个数+1。从slot A连续开始,一直到 slot A+D-2都是要返回的值。slot A的值会被复制到base-16的位置,其他值依次按序赋值。
八、Loops and branches
Lua中有4种循环方式,3种基本的循环语句,1种用于迭代器的循环。根据下图种给出的例子对照阅读。
-
1)for循环:
for i=start,stop,step do body end=>set start,stop,step FORI body FORLlocal a = 5; local sum = 0; for i = 1, 5, 1 do sum = sum + i; end -- BYTECODE -- 0001 KSHORT 0 5 0002 KSHORT 1 0 0003 KSHORT 2 1 0004 KSHORT 3 5 0005 KSHORT 4 1 0006 FORI 2 => 0009 0007 => ADDVV 1 1 5 0008 FORL 2 => 0007 0009 => RET0 0 1 -
2)while循环:
while cond do body end=>inverse-cond-JMP LOOP body JMPlocal a = 5; local sum = 0; while (a > 1) do sum = sum + a; a = a - 1; end -- BYTECODE -- 0001 KSHORT 0 5 0002 KSHORT 1 0 0003 => KSHORT 2 1 0004 ISGE 2 0 0005 JMP 2 => 0010 0006 LOOP 2 => 0010 0007 ADDVV 1 1 0 0008 SUBVN 0 0 0 ; 1 0009 JMP 2 => 0003 0010 => RET0 0 1 -
3)repeat循环:
repeat body until cond=>LOOP body cond-JMPlocal a = 5; local sum = 0; repeat a = a + 1; sum = sum + a; until(a > 5) -- BYTECODE -- 0001 KSHORT 0 5 0002 KSHORT 1 0 0003 => LOOP 2 => 0009 0004 ADDVN 0 0 0 ; 1 0005 ADDVV 1 1 0 0006 KSHORT 2 5 0007 ISGE 2 0 0008 JMP 2 => 0003 0009 => RET0 0 1 -
4)迭代器:
for vars... in iter,state,ctl do body end=>set iter,state,ctl JMP body ITERC ITERLfruits = { "banana","orange","apple","grapes"} for k,v in ipairs(fruits) do print(k,v); end -- BYTECODE -- KGC 0 table KGC 1 "fruits" KGC 2 "ipairs" KGC 3 "print" 0001 TDUP 0 0 0002 GSET 0 1 ; "fruits" 0003 GGET 0 2 ; "ipairs" 0004 GGET 2 1 ; "fruits" 0005 CALL 0 4 2 0006 JMP 3 => 0011 0007 => GGET 5 3 ; "print" 0008 MOV 7 3 0009 MOV 8 4 0010 CALL 5 1 3 0011 => ITERC 3 3 3 0012 ITERL 3 => 0007 0013 RET0 0 1 -
5)
break和goto语句会被转换为 无条件 的JMP或UCLO指令。
循环和分支跳转相关的指令总共有12条,会涉及到切换JIT模式的热点跟踪和计数。break和goto语句被转换成了无条件CMP或UCLO。
| OP | A | D | Description |
|---|---|---|---|
| FORI | base | jump | Numeric for loop init |
| JFORI | base | jump | Numeric for loop init, JIT-compiled |
| FORL | base | jump | Numeric for loop |
| IFORL | base | jump | Numeric for loop, force interpreter |
| JFORL | base | lit | Numeric for loop, JIT-compiled |
| ITERL | base | jump | Iterator for loop |
| IITERL | base | jump | Iterator for loop, force interpreter |
| JITERL | base | lit | Iterator for loop, JIT-compiled |
| LOOP | rbase | jump | Generic loop |
| ILOOP | rbase | jump | Generic loop, force interpreter |
| JLOOP | rbase | lit | Generic loop, JIT-compiled |
| JMP | rbase | jump | Jump |
对上面几条指令作出简单解释。
-
1)操作数 A 存储不同指令的基址:
对于JMP指令,它存储第一个未使用的槽位。
对于*FOR*指令,它存储 循环控制变量(idx、stop、step、扩展索引ext idx)的基址。
对于*ITERL指令,它存储 迭代器返回的结果的基址(包含func、state和ctl)。 -
2)JFORL、JITERL 和 JLOOP 指令:
这些指令的操作数 D 存储 JIT 追踪编号(trace number),JFORI指令则从对应的JFORL指令中获取该编号。
如果没有 JIT 追踪编号,操作数 D 指向循环后的第一条指令。 -
3)FORL、ITERL 和 LOOP 指令:
负责热点检测(hotspot detection),即判断循环是否执行足够频繁。
如果循环被多次执行,则 触发 JIT 追踪(trace recording) 进行优化。 -
4)IFORL、IITERL 和 ILOOP 指令:这些指令由 JIT 编译器用于屏蔽(blacklist)无法编译的循环, 不会进行热点检测,强制该循环在 解释器(interpreter)模式下执行。
-
5)JFORI、JFORL、JITERL 和 JLOOP 指令:如果 循环入口条件为真,则进入 JIT 编译的追踪(JIT-compiled trace)。
-
6)FORL 指令 执行流程:
a. 首先执行idx = idx + step。
b. 检查循环条件:如果step >= 0,则检查idx <= stop;如果step < 0,则检查idx >= stop。
c. 如果条件为真:idx被复制到 扩展索引槽位(ext idx),作为循环体内可见的循环变量;进入循环体 或 进入 JIT 编译的追踪。
d. 否则:跳出循环,继续执行FORL之后的下一条指令。 -
7)ITERL 指令 执行流程:
a. 检查槽 A 中迭代器返回的第一个结果是否为非 nil。
b. 如果结果非 nil:该值被复制到 slot A-1,作为循环体内的迭代变量;进入循环体 或 进入 JIT 编译的追踪。 -
8)LOOP 指令:
本质上是一个空操作(no-op),除了用于热点检测,不会执行跳转。
操作数 A 和 D 仅供 JIT 编译器使用,以加速 数据流(data-flow)和控制流(control-flow)分析。
JIT 编译器需要该指令,以便在 循环优化时进行patch修改,让循环进入 JIT 编译的追踪。
九、Function headers
在VM执行每个函数之前,都会执行这部分BC。函数调用需要增加Lua Stack,所以检查Stack是否够用是这部分的工作。还需要检查函数参数是否匹配,比如有的形参个数是3个,实参个数只有两个,这时候就要给缺少的那个形参的位置填充一个值(nil)。
前缀I/J = interp/JIT,后缀F/V/C = fixarg/vararg/C func:
| OP | A | D | Description |
|---|---|---|---|
| FUNCF | rbase | Fixed-arg Lua function | |
| IFUNCF | rbase | Fixed-arg Lua function, force interpreter | |
| JFUNCF | rbase | lit | Fixed-arg Lua function, JIT-compiled |
| FUNCV | rbase | Vararg Lua function | |
| IFUNCV | rbase | Vararg Lua function, force interpreter | |
| JFUNCV | rbase | lit | Vararg Lua function, JIT-compiled |
| FUNCC | rbase | Pseudo-header for C functions | |
| FUNCCW | rbase | Pseudo-header for wrapped C functions | |
| FUNC* | rbase | Pseudo-header for fast functions |
函数的热调用也是在这一块触发的,还有这一部分的作用主要是配合call指令,改变var stack的结构,为callee VM函数构建VM运行时栈,如图所示:

对上面几条指令作出简单解释。
- 操作数 A 存放函数的帧大小。操作数 D 存放 JFUNCF 和 JFUNCV 指令的追踪编号。
- 对于 Lua 函数,省略的固定参数会被设为 nil,多余的参数则会被忽略。设置可变参数函数时,会创建一个特殊的 vararg 帧来保存超出固定参数之外的参数;固定参数会被复制到常规 Lua 函数帧中,而它们在 vararg 帧中的槽位则被置为 nil。
- FUNCF 和 FUNCV 指令用于设置固定参数或可变参数 Lua 函数的帧,并执行热点检测。如果函数被执行的次数足够多,将会触发追踪记录。
- IFUNCF 和 IFUNCV 指令供 JIT 编译器使用,用于将无法编译的函数加入黑名单。它们不会进行热点检测,而是强制在解释器中执行。
- JFUNCF 和 JFUNCV 指令在初始设置完成后,会进入 JIT 编译的追踪。
- FUNCC 和 FUNCCW 指令是由 C 闭包的 pc 字段指向的伪头指令,它们从不会被实际生成,仅用于分派到 C 函数调用的设置代码。
- 所有编号较高的字节码指令都作为快速函数的伪头指令使用,它们同样从不被实际生成,仅用于分派到相应快速函数的机器码。
本文深入解析LuaJIT的字节码指令,涵盖指令格式、操作数类型及各类指令的功能,包括比较、测试、复制、跳转、一元与二元运算等,并介绍了Lua中的闭包和表操作。
9835

被折叠的 条评论
为什么被折叠?



