LuaJit分析(四)luajit 64位与32位字节码区别
对一个lua脚本文件,只有一条语句 print(“hello”, “world”),分别生成字节码文件如下:
32位字节码:
1b4c 4a02 022d 0200 0300 0300 0536 0000 0027 0101 0027 0202 0042 0003 014b 0001
000a 776f 726c 640a 6865 6c6c 6f0a 7072 696e 7400
64位字节码:
1b4c 4a02 0a2d 0200 0400 0300 0536 0000 0027 0201 0027 0302 0042 0003 014b 0001
000a 776f 726c 640a 6865 6c6c 6f0a 7072 696e 7400
上述红色字体表示的是有区别的地方,第一处是文件头部的flags标志,32位的是02,64位的是0a,有flags字段的定义:
#define BCDUMP_F_BE 0x01
#define BCDUMP_F_STRIP 0x02
#define BCDUMP_F_FFI 0x04
#define BCDUMP_F_FR2 0x08
可知,64位的BCDUMP_F_FR2为1,即可以从这个字段判断是32位还是64位字节码文件
第二处是原型头部的frame大小,32位是3,64位是4,即64位栈帧大小要比32位多1
第三处和第四处是指令的内容,栈帧变大后,同时指令中的索引也同时增大,对32位字节码反汇编内容如下:
0001 GGET 0 0 ; "print"
0002 KSTR 1 1 ; "hello"
0003 KSTR 2 2 ; "world"
0004 CALL 0 1 3
0005 RET0 0 1
64位字节码反汇编内容如下:
0001 GGET 0 0 ; "print"
0002 KSTR 2 1 ; "hello"
0003 KSTR 3 2 ; "world"
0004 CALL 0 1 3
0005 RET0 0 1
KSTR指令用于获取常量的内容,KSTR 2 1即将函数原型中常量索引为1的常量放入到栈中相对BASE偏移为2的内存处。Luajit中解释器的实现在对应的vm_<arch>.dasc文件中,这里以X86为例,即vm_x86.dasc文件,其中对KSTOR解释执行的代码如下:
case BC_KSTR:
| ins_AND // RA = dst, RD = str const (~)
| mov RD, [KBASE+RD*4]
| mov dword [BASE+RA*8+4], LJ_TSTR
| mov [BASE+RA*8], RD
| ins_next
break;
这里 RA为指令中的目标位置,即相对于即相对于BASE的位置 ,RD为常量的索引,先根据KBASE获取的常量的地址,再保存到RD中,接着把LJ_TSTR标志(字符串类型)和常量地址保存在了RA指定位置的栈中,然后执行下一条指令,可以看出栈中的每一个元素占8字节存储
接着看x64下的BC_KSTR解释:
case BC_KSTR:
| ins_AND // RA = dst, RD = str const (~)
| mov RD, [KBASE+RD*8]
| settp RD, LJ_TSTR
| mov [BASE+RA*8], RD
| ins_next
break;
与32位下不同的是,将RD设置类型后,直接保存在RA指定位置的栈中,因此32位在KSTR保存时,四字节保存LJ_TSTR,四字节保存RD即常量地址。64位在KSTR保存时,直接保存8字节的RD,即使用settp设置好类型后的地址
那么为什么64位中,参数在栈中的位置要比32位加1?
在处理函数调用时,32位和64位有如下区别:
#if LJ_FR2
static TValue *api_call_base(lua_State *L, int nargs)
{
TValue *o = L->top, *base = o - nargs;
L->top = o+1;
for (; o > base; o--) copyTV(L, o, o-1);
setnilV(o);
return o+1;
}
#else
#define api_call_base(L, nargs) (L->top - (nargs))
#endif
LUA_API void lua_call(lua_State *L, int nargs, int nresults)
{
api_check(L, L->status == LUA_OK || L->status == LUA_ERRERR);
api_checknelems(L, nargs+1);
lj_vm_call(L, api_call_base(L, nargs), nresults+1);
}
可以看到,如果是64位,则把当前函数栈中的所有参数往后移动一位,并把多出来的一位置为nil。因此可以解释为什么64位字节码中参数栈位置要加1。
同时x86的 CALl指令解释如下:
case BC_CALL: case BC_CALLM:
| ins_A_C // RA = base, (RB = nresults+1,) RC = nargs+1 | extra_nargs
if (op == BC_CALLM) {
| add NARGS:RD, MULTRES
}
| cmp dword [BASE+RA*8+4], LJ_TFUNC
| mov LFUNC:RB, [BASE+RA*8]
| jne ->vmeta_call_ra
| lea BASE, [BASE+RA*8+8]
| ins_call
break;
它将BASE的位置移到了RA指定值得后一个位置,即第一个参数的位置,最后调用的ins_CALL如下:
|.macro ins_call
| // BASE = new base, RB = LFUNC, RD = nargs+1
| mov [BASE-4], PC
| ins_callt
|.endmacro
|
|.macro ins_callt
| // BASE = new base, RB = LFUNC, RD = nargs+1, [BASE-4] = PC
| mov PC, LFUNC:RB->pc
| mov RA, [PC]
| movzx OP, RAL
| movzx RA, RAH
| add PC, 4
|.if X64
| jmp aword [DISPATCH+OP*8]
|.else
| jmp aword [DISPATCH+OP*4]
|.endif
|.endmacro
32位CALL调用前栈结构:
print | TFUNC | "hello" | TSTR | "world" | TSTR | ||
---|---|---|---|---|
BASE | 0 | 1 | 2 | TOP |
32位CALL指令执行调整栈结构:
print | PC | "hello" | TSTR | "world" | TSTR | ||
---|---|---|---|---|
0 | BASE | 2 | TOP |
它将PC复制到了BASE-4的位置(预调用函数变量的后四字节),变成当前PC的值(理解为保存了返回地址),接着在callt块中执行取指令,获取opcode,然后跳转执行该函数。
X64的 CALl指令解释如下:
case BC_CALL: case BC_CALLM:
| ins_A_C // RA = base, (RB = nresults+1,) RC = nargs+1 | extra_nargs
if (op == BC_CALLM) {
| add NARGS:RDd, MULTRES
}
| mov LFUNC:RB, [BASE+RA*8]
| checkfunc LFUNC:RB, ->vmeta_call_ra
| lea BASE, [BASE+RA*8+16]
| ins_call
break;
它也将BASE移到了第一个参数的位置,与32位不同的是,它多移动了一个位置,因为64位多出了一个nil位置。RB保存的是预调用的函数。64位的ins_call解释如下:
|.macro ins_call
| // BASE = new base, RB = LFUNC, RD = nargs+1
| mov [BASE-8], PC
| ins_callt
|.endmacro
|
|.macro ins_callt
| // BASE = new base, RB = LFUNC, RD = nargs+1, [BASE-8] = PC
| mov PC, LFUNC:RB->pc
| mov RAd, [PC]
| movzx OP, RAL
| movzx RAd, RAH
| add PC, 4
| jmp aword [DISPATCH+OP*8]
|.endmacro
它也将PC(返回地址)保存在了BASE前一个位置,与32位不同的是,此时的PC表示的地址为64位长度,它会占满一个单元的位置,即刚好填充nil的8字节。接下来ins_callt开始执行下一个函数。
64位CALL调用前栈结构:
print | TFUNC | nil | "hello" | TSTR | "world" | TSTR | ||
---|---|---|---|---|---|
BASE | 0 | 1 | 2 | 3 | TOP |
64位CALL指令执行调整栈结构:
print | TFUNC | PC | "hello" | TSTR | "world" | TSTR | ||
---|---|---|---|---|---|
0 | 1 | BASE | 3 | TOP |
总结:64位字节码中,栈中操作参数的索引比32位加1,是因为在执行CALL指令时,栈中要保存当前PC的值,在32位中,PC的值占四字节,可以直接保存在函数变量的后四字节,而64位中,PC值占8字节,因此开辟一个单元的栈空间,把参数全部往后移动一个单元。
前面有提到64位在保存一个常量时,直接RD保存8字节内容,32位是分两个4字节分别保存值和类型。同时对8字节的RD使用settp用于设置类型,settp定义如下:
|.macro settp, dst, reg, tp
| mov64 dst, ((uint64_t)tp<<47)
| or dst, reg
|.endmacro
它将低17位左移到高位,然后与RD取或操作,也就是,在高17位设置了RD的类型
原因如下:
Luajit统一使用64位表示变量,但是32位和64位中变量表示的方法不一样:
1) 表示方法背景:
浮点数类型的编码格式普遍使用IEEE754标准,它的编码包括符号、指数、尾数。其中双精度类型的浮点数double采用64位表示,最高位为符号,后11位为指数,低52位为尾数。
IEEE754标准中,如果指数部分全部为0,尾数部分不全为0时,表示NaN,即表示不是一个数。而尾数部分有52个,只要其中一个为1,那么剩余51位就可以表示其它的类型。如字符串、函数、表等。
2) luajit64位类型表示:
在64位系统中,64位理论可表示的地址空间为16,777,216T,而64位的CPU一般使用48位表示地址,即最大为256T,如AMD要求从第48到63的这16位需要与第47位相同。即地址必须在0到00007FFF'FFFFFFFF 和 FFFF8000'00000000 到 FFFFFFFF'FFFFFFFF这两个范围内,共有256TB的虚拟地址空间。操作系统继续将内存空间分为内核部分和用户层部分,如Linux使用高128T为内核空间,低128T为用户空间。
在luajit64位中,这51位分成了两个部分,其中低47位表示地址,可以表示的最大值为128T,高4位表示类型,因此合并后可以看成高17位表示类型,低47位表示实际的地址,这就对应了x64中使用settp设置类型,luajit64类型定义如下:
#define LJ_TNIL (~0u)
#define LJ_TFALSE (~1u)
#define LJ_TTRUE (~2u)
#define LJ_TLIGHTUD (~3u)
#define LJ_TSTR (~4u)
#define LJ_TUPVAL (~5u)
#define LJ_TTHREAD (~6u)
#define LJ_TPROTO (~7u)
#define LJ_TFUNC (~8u)
#define LJ_TTRACE (~9u)
#define LJ_TCDATA (~10u)
#define LJ_TTAB (~11u)
#define LJ_TUDATA (~12u)
/* This is just the canonical number type used in some places. */
#define LJ_TNUMX (~13u)
当高16位全是1时,即这里4位的值为14,源码中说是lightuserdata类型,可以认为是一个自定义的指针吧
3) luajit32位类型表示:
1) 高16位不全为1,表示一个double型数据
2) 高16位全为1,第47位为0,表示一个指针
3) 其余情况,高32位表示类型,低32位表示实际值
这里就对应了32位和64位对应解释器汇编中对类型的操作方式
总结:
luajit 64位和32位字节码不一样,体现:
1、文件头部的flags表示,64位中有标记 fr2 = 1
2、原型头中的栈帧大小,当原型中有call指令并有参数时,frame大小会比32位的加1
3、参数压入的位置,当存在call指令并且有KSTR等指令压入参数时,压入的位置会加1
4、原因是CALL调用时需要保存返回地址,在32位中,地址占4字节,直接覆盖了栈中压入的函数字段类型的类型部分(4字节),而64位中地址占8字节,因此将栈的大小增加了1,并移动所有参数。