对于任何的目标架构(如x86、arm、loongarch),它都有一套相应的指令集。以龙芯指令集为例,指令addi.w rd rj si12
,代表的意思为将物理寄存器rj
中的值和立即数si12
相加,将相加的结果存放在物理寄存器rd
中。在CPU的内部,有一个叫做寄存器文件或者寄存器堆的硬件单元,在其里面存放着物理寄存器,每个寄存器以标识符(r0、r1...
)作为地址,CPU根据地址获取/修改寄存器中的值。这些物理寄存器都是真实的存在的。
假设龙芯的处理器中没有寄存器文件这一硬件块,CPU解析执行指令addi.w rd rj si12
,经过译码时就没有rj寄存器可访问、经过写回阶段也无法将数据写到rd寄存器中。这时候原本存在的loongarch指令集显然已无法使用,需要我们再去重新设计和实现一套新的指令集。除了寄存器,能够供用户可见、使用高效且管理方便的就是运行时栈了,以运行时栈作为指令的目的操作数和源操作数。如重新设计加法运算指令为addi.w frame0 frame1 si12
,该指令代表的意思是,将当前函数运行时栈的1
号栈帧中的值和立即数si12
相加的结果存放到0
号栈帧中。
回到LuaJIT,LuaJIT是一个虚拟机,用软件编写的逻辑来充当宿主机的功能,他也有供自己解释执行的指令集Bytecode。但LuaJIT本身是软件层面,没有自己的寄存器和运行时栈,只能在堆内存上面开一块空间,用来代替寄存器和运行时栈的功能。但是用堆内存代替寄存器和运行时栈在虚拟机层面的访问时效是相同(都是访问内存),所以我们可以效仿没有寄存器文件的龙芯处理器,在LuaJIT虚拟机中去除寄存器这一概念,只保留LuaJIT的运行时栈,LuaJIT也正是这么做的。LuaJIT使用的是一个叫做var stack的栈结构(定义在src/lj_frame.h
中),来作为VM的运行时栈,控制着变量存放和VM中函数的调用,其Bytecode也是依据var stack来设计的。
LuaJIT前端parse source直接生成Bytecode,然后开始对Bytecode做解释执行(暂不管JIT模式),也就是执行虚拟机VM的逻辑。在进入VM之前,包括前端的parse以及后续解释计算的准备工作,都是运行C函数,程序的运行时栈符合操作系统的结构。待进入VM,此时当前函数操作系统的运行时栈(#0)已固定,不会随着VM中函数的调用(跳转)而变化,用来控制VM程序的是VM中的var stack。进入VM之前var stack的结构已经被创建好,结构如下:
--------------------------------------------------------------
| proto | nil | arg1 | ... | argn | | ... | top |
--------------------------------------------------------------
func PC base L->top ^-top
func
存放的是VM当前执行函数的闭包proto
PC
存放上一个VM函数的PC_prebase
是当前函数在栈上的基地址,如slot 0代表(base+ 0*8
),slot 1代表(base + 1*8
)。比如说Bytecode指令ADDVV 1 1 0
,将slot 1中的值和slot 0号中的值相加,结果存放在slot 1号中。LuaJIT虚拟机实现这条指令功能对应的伪代码为:
load t0 [base+ 0*8]
load t1 [base+ 1*8]
add t1 t1 t0
store [base+ 1*8] t1
VM中解释器执行函数调用时的整个过程,var stack结构的布局如下。callee的proto和参数传递都是在caller中完成的,其实所有编译器函数调用时都是这么做的。刚开始返回值会在callee的stack上local变量后面存放,等到PC回到caller后,会将返回值的位置往前移。
-------------------------------------------------------------------------------------------------------
| proto | nil | | | | ... | proto | nil | arg0 | ... | argn | ... | ret1| ... | retn| ... |
-------------------------------------------------------------------------------------------------------
caller PC base 1 2 local local
callee PC base 1 2 ^-top
VM中解释器执行从callee函数返回caller函数之后,var stack结构的布局如下。此时返回值已经被移到相应的位置,对照上面ret位置的变化。
------------------------------------------------------------------
| proto | nil | | | | ... | ret1 | ... | retn | ... |
------------------------------------------------------------------
func PC base 1 2 local ^-top
举例来解释一下。
现有如下字节码,对所有内容都已经加了注释。关于字节码的介绍可参考文章《LuaJIT Bytecode介绍》,其中有一个较为整体的介绍,对字节码不熟悉可以互相对照着看。
-- BYTECODE -- test.lua:1-11
0001 KNUM 2 0 ; 12341324 --将12341324放入slot 0
0002 KSHORT 3 0 --将0放入slot 3
0003 ISGE 1 0 --比较slot 1和slot 0中存放的值
0004 JMP 4 => 0007 --如果比较结果为true,跳到0007
0005 MOV 3 0 --将slot 0中的值复制到slot 3中
0006 JMP 4 => 0008 --直接跳到0008
0007 => MOV 3 1 --将slot 1中的值复制到slot 3中
0008 => RET1 3 2 ; result --slot 3存放的返回值,只返回一个值
-- BYTECODE -- test.lua:0-18
0001 FNEW 0 0 --根据slot 0中存放的函数原型,创建一个闭包(函数对象)并存放到slot 0中
0002 GSET 0 1 ; "max" --将slot 0中存放的闭包放到全局符号表中,key为max
0003 KSHORT 0 1 --将常量1存放到slot 0中
0004 KSHORT 1 2 --将常量2存放到slot 1中
0005 GGET 2 1 ; "max" --将全局符号表中key为max的对象放入slot 2中
0006 KSHORT 4 5 --将常量5存放到slot 4中,arg0
0007 KSHORT 5 6 --将常量6存放到slot 5中,arg1
0008 CALL 2 2 3 --slot 2为被调函数的对象,1个返回值,2个参数
0009 RET0 0 1 --slot 0存放的返回值,返回值个数为0(没有返回值)
解释器VM在解释上面字节码时,对应的var stack结构如下,仔细推敲一下。主调函数调用被调函数之前,会先把被调函数的stack结构布置好,函数对象、参数放入指定位置,并在stack上留下存放PC的空间(这个PC以后再介绍吧,其实就是放函数调用返回后,将要执行的指令的)。
-----------------------------------------------------------------------------------
| proto | nil | 1 | 2 | max | | 5 | 6 |12341324| result | | | ... |
-----------------------------------------------------------------------------------
func PC base 1 2 3 4 5
func PC base 1 2 3