lua虚拟机设置内存上限 lua 虚拟机_lua 调用文件中的函数调用


本篇文章是Lua设计与实现专栏的第三篇,主要结合了《Lua设计与实现》书中的第五章(虚拟机),以及lua5.3源码进行一些总结,由于原书中主要是基于lua5.1进行书写的,所以可能会有跟书中列举代码不一致的地方,不过大体上是保持一致的。

同时,本文虚拟机的概念和类型划分的内容主要参考了这篇blog的,里面讲的挺详细的。

虚拟机基本概念

虚拟机指借助软件系统对物理机器指令执行进行的一种模拟。首先,对于物理机器的执行,主要是机器从内存中fetch指令,通过总线传输到CPU,然后进行译码、执行、结果存储等步骤。既然虚拟机是对其进行的一种模拟,那么也逃不过以下几个特点:

  • 将源码编译成VM所能执行的字节码。
  • 字节码格式(指令格式),例如三元式、四元式、波兰式等。
  • 函数调用的相关栈结构,函数的出入口和传参方式。
  • 指令指针,类似于物理机的指令寄存器(EIP)。
  • 虚拟CPU。 instruction dispatcher。
  • 取指:通过IP fetch下一条指令
  • 译码:对指令进行翻译,得到指令类型,并且解析其操作数。
  • 执行:跳到对应逻辑块进行执行。

栈式虚拟机和寄存器式虚拟机

虽然虚拟机的实现都逃不过以上几步,但是以具体实现来看,又分为两大类:栈式和寄存器式。

栈式虚拟机

采用栈式虚拟机的语言有JVM、CPython以及.Net CLR等。 它的概念很简单,就是所有的指令执行,都是基于一个操作数栈的。你想要执行任何指令时,对不起,得先入栈,然后算完了再给我出栈。流程如下图:


lua虚拟机设置内存上限 lua 虚拟机_寄存器_02


lua虚拟机设置内存上限 lua 虚拟机_lua 调用文件中的函数调用_03


总的来说,就是抽象出了一个高度可移植的操作数栈,所有代码都会被编译成字节码,然后字节码就是在玩这个栈。 好处是实现简单,移植性强。坏处是指令条数比较多,数据转移次数比较多,因为每一次入栈出栈都牵涉数据的转移。

寄存器式虚拟机

采用寄存器式的虚拟机有lua和Dalvik等。 这种实现没有操作数栈这一概念,但是会有许多的虚拟寄存器。这类虚拟寄存器有别于CPU的寄存器,因为CPU寄存器往往是定址的(比如DX本身就是能存东西),而寄存器式的虚拟机中的寄存器通常有两层含义:(1)寄存器别名(比如lua里的RA、RB、RC、RBx等),它们往往只是起到一个地址映射的功能,它会根据指令中跟操作数相关的字段计算出操作数实际的内存地址,从而取出操作数进行计算;(2)实际寄存器,有点类似操作数栈,也是一个全局的运行时栈,只不过这个栈是跟函数走的,一个函数对应一个栈帧,栈帧里每个slot就是一个寄存器,第1步中通过别名映射后的地址就是每个slot的地址。具体的栈帧可以参考后文讲CallInfo时的栈帧图。 好处是指令条数少,数据转移次数少。坏处是单挑指令长度较长。

具体来看,lua里的实际寄存器数组是用TValue结构的栈来模拟的,这个栈也是lua和C进行交互的虚拟栈。 lua里的字节码叫做opcode,本文正文将对"源码->字节码生成->字节码执行"这整个流程进行介绍,并对其中的关键函数和数据结构进行源码级别的剖析。

关键函数和结构分析

luaL_dofile:包含了luaL_loadfile和lua_pcall两个步骤,分别对应了函数的解析和执行阶段。

luaL_loadfile:会调用具体的parser,对lua文件进行进行词法和语法分析,把source转化成opcode,并创建Proto结构保存该opcode和该函数的元信息。 Proto结构如下:


lua虚拟机设置内存上限 lua 虚拟机_lua虚拟机设置内存上限_04


该结构基本涵盖了parse阶段该函数的所有分析信息。主要包括以下几部分:

  • 常量表。比如在函数里写了a = 1 + 2,那这里的1和2就会放在常量表里。
  • 局部变量信息。包含了局部变量的名字和它在函数中的生存周期区间(用pc来衡量)。
  • Upvalue信息。包含了该upvalue的名字和它是否归属于本函数栈还是外层函数栈的标记。
  • opcode列表。包含了该函数实际调用的所有指令。其实就是一个int32类型的列表,因为lua虚拟机里每个指令对应一个int32.

lua_pcall:这个函数最终会调到luaD_call,也就是lua虚拟机里函数执行的主要函数。


lua虚拟机设置内存上限 lua 虚拟机_lua 调用文件中的函数调用_05


从代码里可以看出,luaD_call的调用分为两步:

  • luaD_precall:
  • 如果是C函数或者C闭包,会直接创建单个函数调用的运行时结构CallInfo,来完成函数的进栈和出栈。
  • 如果是lua闭包,在precall中只会做函数调用前的准备工作,实际执行会在后一步luaV_execute中进行。这里的准备工作主要包括:(1)处理lua的不定长参数、参数数量不够时的nil填充等。(2)分配CallInfo结构,并填充该函数运行时所需的base、top、opcode等信息,注意CallInfo结构里还有个很关键的func字段,它指向栈里对应的LClosure结构,这个结构为虚拟机后续执行提供upvalue表和常量表的查询,毕竟后续对常量和upvalue的read操作,都是需要把它们从这两个表中加载到寄存器里的。
  • luaV_execute:这一步就是我们前面提到的lua虚拟机的CPU了,因为所有指令的实际执行都是在这个函数里完成的。它做的主要工作,就是在一个大循环里,不断的fetch和dispatch指令。每次的fetch就是把pc加1,而dispatch就是一个大的swtich-case,每个不同类型的opcode对应不同的执行逻辑。举一个创建table的例子:


lua虚拟机设置内存上限 lua 虚拟机_lua 调用文件中的函数调用_06


在该指令中,会首先对32位指令进行位操作,得到该table的初始数组和hash表部分的大小b和c,然后调用luaH_new来创建table,最后根据b和c的值,对table进行resize操作。

另外,前面提到的CallInfo结构,包含了单个函数调用,lua虚拟机所需要的辅助数据结构,它的结构如下:


lua虚拟机设置内存上限 lua 虚拟机_lua虚拟机设置内存上限_07


下图是lua虚拟机在执行第二个函数时的一个栈示意图:


lua虚拟机设置内存上限 lua 虚拟机_字节码_08


我们来看下lua_State里与之相关的几个字段:

  • stack。TValue*类型,记录了"内存"起始地址。
  • base。TValue*类型,记录当前函数的第一个参数位置。
  • top。TValue*类型,记录当前函数的栈顶。
  • base_ci。当前栈里所有的函数调用CallInfo数组。
  • ci。当前函数的CallInfo。

可以发现,通过这样的组织结构,luavm可以方便的获取到任意函数的位置以及其中的所有参数位置。而每个CallInfo里又记录了函数的执行pc,因此vm对函数的执行可以说是了如指掌了。

指令格式

前文已经提到,lua虚拟机的单条指令长度为32位。其位分布如下图所示:


lua虚拟机设置内存上限 lua 虚拟机_寄存器_09


这里的OpCode,就是指令的类型,由于其只有6位,所有lua最多支持63种指令类型。而对于A、B、C、Bx、sBx等,都是该指令的参数,参数的值通常指的是一个相对偏移,例如相对于当前函数base的偏移,相对于常量表头的偏移等。另外,根据指令的不同,参数个数和类型也可能不同。来看几个常用的例子:

  • 从变量赋值:


lua虚拟机设置内存上限 lua 虚拟机_lua虚拟机设置内存上限_10


其实就是简单的把寄存器RB(i)的值赋值到寄存器RA(i)中去,这里的寄存器指的就是我们栈里头的某个坑位。 所以这里的RA和RB宏,都是一个栈地址获取操作,全部的定义如下:


lua虚拟机设置内存上限 lua 虚拟机_字节码_11


这些宏的内部实现主要分为2步:

  • 通过GETARG_XXX(i)从当前指令中获取参数XXX的值
  • 用函数base或者常量表base去加这个参数值得到最终的栈(寄存器)地址。
  • 从常量赋值:


lua虚拟机设置内存上限 lua 虚拟机_lua_12


与变量赋值唯一的不同,就是RB是基于常量表的偏移。

  • 设置table字段:


lua虚拟机设置内存上限 lua 虚拟机_lua_13


这里不上代码了,这里RK是一个条件宏,因为我有可能是t[a] = b, 也可能是t1 = b,key如果是变量a,说明a肯定是在函数栈里头的变量,对应的寻址就用RB,而如果key是1,说明它不存在函数栈里头,而是在函数常量表里头,寻址就用KB。

简单例子

我们结合一个简单的lua chunk,decompile一下它生成的byte code(这里decode使用的也是书中介绍的ChunkSpy工具,目前已支持了5.3),从而加深理解:


lua虚拟机设置内存上限 lua 虚拟机_lua虚拟机设置内存上限_14


我们先来看下该chunk对应的函数,在生成的字节码里,称之为level 1 function:


lua虚拟机设置内存上限 lua 虚拟机_lua虚拟机设置内存上限_15


一个函数最终的字节码,基本就包含三块:

  • 常量表
  • upvalue表
  • code。所有的字节指令,都是在玩常量表、upvalue表和寄存器栈。可以结合具体的指令来理解。

我们再来看下定义在chunk里的testFunc函数,它被称为level 2函数,如果我在testFunc里还嵌套了子函数,称为level 3函数,以此类推。该函数的字节码与level 1的格式基本一致,这里就直接上图,不逐行解释了:


lua虚拟机设置内存上限 lua 虚拟机_字节码_16


总结

虚拟机执行流程图

在梳理完整个lua虚拟机的源码分析,opcode的生成和执行逻辑以后,我们可以上书中的一个总流程图来回顾一下:


lua虚拟机设置内存上限 lua 虚拟机_字节码_17


这个图中最核心的两块,一个是Proto结构,它是分析阶段和执行阶段的桥梁;另一个是OpCode的执行,这一块可以结合前面虚拟机概念,以及Stack-based和Register-based VM的区别一起理解,包括但不限于:从CallInfo里fetch指令,指令执行时的switch case跳转和操作数的寻址,运行时的栈帧布局,lua_State中的关键字段等等。 本文的总结就到这里,后面有时间可能会啃一啃书中第六章“指令的解析与执行”,因为这两章其实联系比较紧密,到时候如果有新的收获也会同步到这边来。