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字段的定义:

cpp 复制代码
#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位字节码反汇编内容如下:

Lua 复制代码
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位字节码反汇编内容如下:

Lua 复制代码
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解释执行的代码如下:

Lua 复制代码
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解释:

Lua 复制代码
  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位有如下区别:

cpp 复制代码
#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指令解释如下:

Lua 复制代码
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如下:

Lua 复制代码
|.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指令解释如下:

Lua 复制代码
  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解释如下:

Lua 复制代码
|.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定义如下:

Lua 复制代码
|.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类型定义如下:

cpp 复制代码
#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,并移动所有参数。

相关推荐
m5127几秒前
LinuxC语言
java·服务器·前端
IU宝5 分钟前
C/C++内存管理
java·c语言·c++
湫ccc5 分钟前
《Python基础》之pip换国内镜像源
开发语言·python·pip
瓜牛_gn6 分钟前
依赖注入注解
java·后端·spring
fhvyxyci6 分钟前
【C++之STL】摸清 string 的模拟实现(下)
开发语言·c++·string
hakesashou7 分钟前
Python中常用的函数介绍
java·网络·python
qq_459730038 分钟前
C 语言面向对象
c语言·开发语言
佚先森16 分钟前
2024ARM网络验证 支持一键云注入引流弹窗注册机 一键脱壳APP加固搭建程序源码及教程
java·html
菜鸟学Python17 分钟前
Python 数据分析核心库大全!
开发语言·python·数据挖掘·数据分析
一个小坑货24 分钟前
Cargo Rust 的包管理器
开发语言·后端·rust