文章目录
一、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 FORL
local 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 JMP
local 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-JMP
local 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 ITERL
fruits = { "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 函数调用的设置代码。
- 所有编号较高的字节码指令都作为快速函数的伪头指令使用,它们同样从不被实际生成,仅用于分派到相应快速函数的机器码。