Contents

【Unity】toLua 中 c# object 与 luavm 之间的交互

ToLua 能够实现 C# 与 Lua 之间的互通,这篇文章分析这一机制的实现原理

1. 如何将一个类型“注入”Lua脚本

在不考虑ToLua这个库的情况下,先来讨论一下,如何将一个自定义好的class作为一个第三方库,放到lua中使用:

将MyPerson类提供给lua调用

MyPerson类的介绍

class MyPerson {
public:
    MyPerson(std::string  name, int age) :name_(std::move(name)), age_(age) {}

    void set_name(const std::string& name) {name_ = name;}
    [[nodiscard]] const std::string& get_name() const {return name_;}

    void set_age(int age) {age_ = age;}
    [[nodiscard]] int get_age() const {return age_;}
private:
    std::string name_;
    int age_;
};

有上面的这个c++类,接下来希望放到lua中使用。

构建mylib库的架子

#define LUA_MY_PERSON "MyPerson"

static const luaL_Reg my_lib[] = {
        {"create_my_person", create_my_person},
        {nullptr, nullptr}
};

static const luaL_Reg my_person_funcs[] = {
        {"get_age", my_person_get_age},
        {"set_age", my_person_set_age},
        {"get_name", my_person_get_name},
        {"set_name", my_person_set_name},
        {nullptr, nullptr}
};


int luaopen_mylib(lua_State* L)
{
    // 创建 MyPerson metatable.
    luaL_newmetatable(L, LUA_MY_PERSON);

    lua_newtable(L);
    luaL_setfuncs(L, my_person_funcs, 0);
    lua_setfield(L, -2, "__index");

    lua_pop(L, -1);
	
    // 清空栈,然后创建lib的table, 并且返回.
    luaL_newlib(L, my_lib);
    return 1;
}

这里从luaopen_mylib出发,做了两件事情:

  1. 创建了一张叫做MyPersonmetatable,然后向该metatable__index表中设置了一些函数,相当于构建了一个这样的metatable:
MyPerson = {
    __index = {
        get_name = function(self) end,
        set_name = function(self, name) end,
        get_age = function(self) end,
        set_age = function(self, age) end,
    }
}
  1. 返回了一个table,作为require当前库时的返回结果,执行require时,拿到的结果实际上是:

    {
        create_my_person = function(name, age) end
    }
    

当调用create_my_person等方法时,lua会将请求转发到我们编写的库中的函数中执行,接下来我们就来实现这些方法。

实现 create_my_person 方法

用户在lua中会编写如下的代码对create_my_person进行调用:

local mylib = require('mylib')

local person = mylib.create_my_person('jack', 18)

在用户调用mylib.create_my_person('jack', 18)时,luavm会调用当前的create_my_person((lua_State* L))函数,调用时栈如下:

learn_tolua.drawio

图1:调用 create my person 的栈

可以使用luaL_checkstring, luaL_checkinteger根据栈上元素的index进行选取,因此可以写出下面的代码:

static int create_my_person(lua_State* L) {
    const std::string name = luaL_checkstring(L, 1);
    const int age = static_cast<int>(luaL_checkinteger(L, 2));

    new(lua_newuserdata(L, sizeof(MyPerson))) MyPerson(name, age);

    luaL_setmetatable(L, LUA_MY_PERSON);
    return 1;
}

其中lua_newuserdataC中的malloc非常相似,开发者可以调用该方法向lua虚拟机请求一片固定大小的内存,lua会对这片内存进行管理。

该方法与malloc一样,只负责开内存,不负责调构造函数,这里这种写法是使用lua_newuserdata开出来内存之后又调用了MyPerson的构造函数。

创建好userdata后,调用luaL_setmetatable,将之前搭mylib时注册的MyPerson表给到了创建好的对象。

函数调用结束后,堆栈如下图所示:

learn_tolua-create_my_person_ret.drawio

图2:create my person 调用结束时的栈

实现 MyPerson 的成员方法

接着上面的代码,用户在拿到person后,就会调用person相关的方法,进行读写操作:

function print_person_info(print_person)
    print(print_person:get_name().."'s age is "..print_person:get_age())
end

print_person_info(person)

print("ten years later")

person:set_name('old_'..person:get_name())
person:set_age(person:get_age() + 10)

print_person_info(person)

用户在调用set_name, set_age时,其实和python一样,对象本身会作为函数的第一个参数self进行传递。因此直接写出下面的方法就可以解决:

static int my_person_get_age(lua_State* L){
    auto* my_person = reinterpret_cast<MyPerson*>(luaL_checkudata(L, 1, LUA_MY_PERSON));
    lua_pushinteger(L, my_person->get_age());
    return 1;
}


static int my_person_set_age(lua_State* L){
    auto* my_person = reinterpret_cast<MyPerson*>(luaL_checkudata(L, 1, LUA_MY_PERSON));
    my_person->set_age(static_cast<int>(luaL_checkinteger(L, 2)));
    return 0;
}


static int my_person_get_name(lua_State* L){
    auto* my_person = reinterpret_cast<MyPerson*>(luaL_checkudata(L, 1, LUA_MY_PERSON));
    lua_pushstring(L, my_person->get_name().c_str());
    return 1;
}


static int my_person_set_name(lua_State* L){
    auto* my_person = reinterpret_cast<MyPerson*>(luaL_checkudata(L, 1, LUA_MY_PERSON));
    my_person->set_name(luaL_checkstring(L, 2));
    return 0;
}

在这些方法中,person作为userdata被推入栈中,我们可以通过luaL_checkudata获取到之前通过lua_newuserdata申请到的内存地址,拿到地址后,将其强转为MyPerson指针,在进行其他操作即可。

在c++中将这个case跑起来

static const std::string kLuaCode = R"(
local mylib = require('mylib')

local person = mylib.create_my_person('jack', 18)

function print_person_info(print_person)
    print(print_person:get_name().."'s age is "..print_person:get_age())
end

print_person_info(person)

print("ten years later")

person:set_name('old_'..person:get_name())
person:set_age(person:get_age() + 10)

print_person_info(person)
)";


LUALIB_API int luaopen_mylib(lua_State* L);

int main() {
    lua_State *L = luaL_newstate();
    luaL_openlibs(L);

    luaL_requiref(L, "mylib", luaopen_mylib, 0);
    lua_pop(L, 1);

    if (int ret =  luaL_dostring(L, kLuaCode.c_str()); ret != 0) {
        std::cout << "error, " << lua_tostring(L, -1) << std::endl;
    }

    return 0;
}

直接起一个luavm来跑这段代码即可,特殊的点在于,需要提前调用luaL_requiref来将mylibopen方法进行注册,这样在lua中调用require('mylib')时,就不会去本地文件中寻找mylib.so,而是直接调用已经注册的luaopen_mylib方法。

mylib代码与上面的代码一起编译,运行就可以得到:

~/luavm/cmake-build-debug/luavm.exe
jack's age is 18
ten years later
old_jack's age is 28

解决MyPerson的内存问题

MyPerson没能正确释放内存

发现问题

上面就把一个简单的case跑通了,但是如果我们在lua中创建一个循环,不断地去跑这个case,就会发现一个问题:

local mylib = require('mylib')

for i = 0, 1000000000000, 1 do
    local person = mylib.create_my_person('jack', 18)
end

当我们运行上述lua代码时,会发现:

image-20240408204907054

图3:循环创建person时,内存很高

这个进程占用的内存会越来越大,这说明某些地方发生了内存泄漏。

问题的原因

这里MyPerson类中,使用了std::stringstd::string对象的内存分布如下:

img

图4:std::string的内存分布(参考自《C++ string 源码实现对比》

当我们创建一个std::string,并且为他赋值时,他会分配一片新的内存,并且将字符串内容存储在新分配的内存中,而luavm管理内存时,只会将_M_dataplus的部分干掉,而不会触发string的析构,因此堆上分配的空间不会被释放。

解决的思路:__gc函数

lua中提供了一种机制,如果你的metatable中含有__gc方法,那么在gc要删除这个对象时,就会先调用你内置的__gc方法,可以通过这个case来体验:

function create_useless_data()
    local test_meta = {
        __gc = function()
            print('gc')
        end
    }

    return setmetatable({}, test_meta)
end

create_useless_data()

collectgarbage("collect")

使用lua运行,可以观察到:

gc

这里创建了一个含有__gc方法的metatable,我们通过create_useless_data函数,创建出来了一个没有用的对象,然后使用collectgarbage("collect")触发了gc的全流程,进行了标记清除,清除useless对象时,由于metatable.__gc方法存在,因此调用该方法,执行结束后,才对对象进行释放。

说明:

  • 在lua 5.1版本中,只有userdata上绑定的__gc方法会被调用

  • 在lua 5.2及其往后的版本中,table上绑定的__gc方法也能被正常调用

基于此,我们可以得到两种方法,来解决内存没法正常释放的问题:

  1. 触发__gc时,显式调用析构函数;
  2. mylib中通过new, delete管理所有内存,luavm中只持有指针;

方案1:显式调用析构函数

MyPerson添加__gc成员方法on_lua_gc,然后创建一个luaCFunction进行调用:

static int my_person_on_lua_gc(lua_State* L){
    auto* my_person = reinterpret_cast<MyPerson*>(luaL_checkudata(L, 1, LUA_MY_PERSON));
    my_person->~MyPerson();
    return 0;
}

将新的my_person_on_lua_gc方法,注册到MyPersonMetatable中:

int luaopen_mylib(lua_State* L)
{
    luaL_newmetatable(L, LUA_MY_PERSON);

    // 新增这两行,将my_person_on_lua_gc这个函数,作为__gc设置给LUA_MY_PERSON
    lua_pushcfunction(L, my_person_on_lua_gc);
    lua_setfield(L, -2, "__gc");
    
    // .. 下面的部分都一样,省略
    lua_pop(L, -1);

    luaL_newlib(L, my_lib);
    return 1;
}

这个方案很好理解,就是在free掉内存之前,先调用默认析构函数,将MyPerson下面的内容进行析构。

方案2: mylib中管理内存, lua中只管理指针

改造create_my_person 方法:

static int create_my_person(lua_State* L) {
    std::string name = luaL_checkstring(L, 1);
    int age = static_cast<int>(luaL_checkinteger(L, 2));

    *reinterpret_cast<MyPerson**>(lua_newuserdata(L, sizeof(MyPerson*))) = new MyPerson(name, age);

    luaL_setmetatable(L, LUA_MY_PERSON);
    return 1;
}

这里有点绕,我们通过lua_newuserdata申请对象时,申请的不再是一整个对象的空间,而是一个指针的空间。

这里的lua_newuserdata返回的是my_person指针的指针,拿到这个指针的指针之后,我们使用new方法创建一个MyPerson,赋值给这个指针的指针。

改造 __gc 方法

static int my_person_on_lua_gc(lua_State* L){
    auto* my_person = *reinterpret_cast<MyPerson**>(luaL_checkudata(L, 1, LUA_MY_PERSON));
    delete my_person;
    return 0;
}

这里获取my_person的方式也要进行修改,因为luavm中记录的userdata不是MyPerson的完整数据了,而是MyPerson的地址。

这里拿到MyPerson地址的地址,反取一下,就可以拿到my_person指针了,对这个地址调用delete方法即可。

改造其他成员方法

这里的改造思路与__gc方法完全一致,就不放重复的代码了。

验证与对比

image-20240408230233531

图5:方案1效果,调用析构函数

image-20240408231349307

图6:方案2效果,lua只持有指针

运行发现实际运行的效果与我们的预期有所不同,他们的内存竟然都在缓慢的增长?

从原理来看,都能够正常的调用到__gc方法,但是给MyPerson的构造函数与析构函数添加计数后发现,方案2中未被释放的对象数量竟然在逐渐上涨。

猜测是因为luagc的速度跟不上分配对象的速度,导致内存一直释放不掉。如果我们在lua代码中加入定期主动gc,可以暂时解决问题:

local mylib = require('mylib')

for i = 0, 1000000000000, 1 do
    local person = mylib.create_my_person('jack', 18)
    
    if (i % 1000000 == 0) then
       collectgarbage("collect") 
    end
end

修改后再重新运行,能够观察到内存稳定在一定区间内。

2. ToLua如何将 C# 对象“注入”Lua脚本

基本用法

在 C# 中执行 lua代码

ToLua包装了Lua,在C#中提供了C#lua互通的能力,用户可以通过C#的接口来创建luastate,然后在里面运行Lua代码:

using UnityEngine;
using LuaInterface;
using System;

public class HelloWorld : MonoBehaviour
{
    void Awake()
    {
        LuaState lua = new LuaState();
        lua.Start();
        string hello =
            @"                
                print('hello tolua#')                                  
            ";
        
        lua.DoString(hello, "HelloWorld.cs");
        lua.CheckTop();
        lua.Dispose();
        lua = null;
    }
}

将 C# 的类型开放给 lua 使用

同样的,通过toLua,我们也能够将C#中已经编写好的代码交给Lua来使用:

创建c#类

public class MyPerson {
    public static MyPerson Create(string name, int age) { return new MyPerson(name, age); }
    public void SetName(string name) { this.name = name; }
    public string GetName() {  return this.name; }
    public void SetAge(int age) { this.age = age; }
    public int GetAge() { return this.age; }

    private MyPerson(string name, int age)
    {
        this.name = name;
        this.age = age;
    }   

    private string name;
    private int age;
}

通过 tolua 生成 wrap 文件

首先需要修改ToLua CustomerSettings.cs文件,修改其中的customTypeList添加刚才编写的MyPerson类型:


public static class CustomSettings
{
	//在这里添加你要导出注册到lua的类型列表
   public static BindType[] customTypeList =
   {
       _GT(typeof(MyPerson)),

                       
       _GT(typeof(LuaInjectionStation)),
       _GT(typeof(InjectType)),
       _GT(typeof(Debugger)).SetNameSpace(null),       
       
       // ... 剩余的忽略.
   }
}

配置好之后,点击unity中的Lua > Generate All可以看到在项目的Source/Generate目录下已经生成了MyPersonWrap文件,这说明可以在lua中使用这个类型了。

c#中使用luabinder绑定库

using UnityEngine;
using LuaInterface;
using System;

public class HelloWorld : MonoBehaviour
{
    void Awake()
    {
        LuaState lua = new LuaState();
        lua.Start();

        LuaBinder.Bind(lua); // 新增这一行,用于绑定所有库.

        string hello =
@"
print_person_info(person) 
";
        
        lua.DoString(hello, "HelloWorld.cs");
        lua.CheckTop();
        lua.Dispose();
        lua = null;
    }
}

编写lua代码

local person = MyPerson.Create('jack', 18)

function print_person_info(print_person)
    print(print_person:GetName().."'s age is "..print_person:GetAge())
end

print_person_info(person)

print('ten years later')

person:SetName('old_'..person:GetName())
person:SetAge(person:GetAge() + 10)

print_person_info(person) 

把之前的case小改一下拿来用即可。

运行

直接在Editor中运行就可以看到结果

image-20240409125541043

图6:lua 调用 c# 方法

C# 对象 bind 原理

LuaBinder.cs

LuaBinder是由toLua自动生成的, 他根据CustomSettings

public static class LuaBinder
{
	public static void Bind(LuaState L)
	{
		float t = Time.realtimeSinceStartup;
		L.BeginModule(null);
		MyPersonWrap.Register(L);
		LuaInterface_DebuggerWrap.Register(L);
		LuaProfilerWrap.Register(L);
		L.BeginModule("LuaInterface");
		LuaInterface_LuaInjectionStationWrap.Register(L);
		LuaInterface_InjectTypeWrap.Register(L);
		L.EndModule();
		L.BeginModule("UnityEngine");
		UnityEngine_ComponentWrap.Register(L);
        // ...
    }
    
    // ...
}

刚才的C#代码中,我们调用LuaBinder.Bind(lua)将对一些unity内置的类型,以及我们编写的C#类型绑定到了lua环境中。

通过代码可以看到,这里分了多个模块:空模块、LuaInterfaceUnityEngine

我们的MyPersonWrap注册在默认模块下,而Compoment等常用的UnityEngine类型封装在UnityEngine模块下。

接下来就顺着这个注册的流程来观察,tolua是怎么操作的。

注册模块到lua

BeginModule

public bool BeginModule(string name)
{
#if UNITY_EDITOR
    if (name != null)
    {                
        LuaTypes type = LuaType(-1);

        if (type != LuaTypes.LUA_TTABLE)
        {                    
            throw new LuaException("open global module first");
        }
    }
#endif
    if (LuaDLL.tolua_beginmodule(L, name))
    {
        ++beginCount;
        return true;
    }

    LuaSetTop(0);
    throw new LuaException(string.Format("create table {0} fail", name));            
}

这里调用了预编译好的LuaDLL中的tolua_beginmodule方法,可以进入tolua_runtime中查看:

void pushmodule(lua_State *L, const char *str)
{    
    luaL_Buffer b;
    luaL_buffinit(L, &b);

    if (sb.len > 0)
    {
        luaL_addlstring(&b, sb.buffer, sb.len);
        luaL_addchar(&b, '.');
    }

    luaL_addstring(&b, str);
    luaL_pushresult(&b);    
    sb.buffer = lua_tolstring(L, -1, &sb.len);    
}

LUALIB_API bool tolua_beginmodule(lua_State *L, const char *name)
{
    if (name != NULL)
    {                
        lua_pushstring(L, name);			//stack key
        lua_rawget(L, -2);					//stack value

        if (lua_isnil(L, -1))  
        {
            lua_pop(L, 1);
            lua_newtable(L);				//stack table

            lua_pushstring(L, "__index");
            lua_pushcfunction(L, module_index_event);
            lua_rawset(L, -3);

            lua_pushstring(L, name);        //stack table name         
            lua_pushstring(L, ".name");     //stack table name ".name"            
            pushmodule(L, name);            //stack table name ".name" module            
            lua_rawset(L, -4);              //stack table name            
            lua_pushvalue(L, -2);			//stack table name table
            lua_rawset(L, -4);   			//stack table

            lua_pushvalue(L, -1);
            lua_setmetatable(L, -2);
            return true;
        }
        else if (lua_istable(L, -1))
        {
            if (lua_getmetatable(L, -1) == 0)
            {
                lua_pushstring(L, "__index");
                lua_pushcfunction(L, module_index_event);
                lua_rawset(L, -3);

                lua_pushstring(L, name);        //stack table name         
                lua_pushstring(L, ".name");     //stack table name ".name"            
                pushmodule(L, name);            //stack table name ".name" module            
                lua_rawset(L, -4);              //stack table name            
                lua_pushvalue(L, -2);           //stack table name table
                lua_rawset(L, -4);              //stack table

                lua_pushvalue(L, -1);
                lua_setmetatable(L, -2);                    
            }
            else
            {
                lua_pushstring(L, ".name");
                lua_gettable(L, -3);      
                sb.buffer = lua_tolstring(L, -1, &sb.len);                    
                lua_pop(L, 2);
            }

            return true;
        }

        return false;
    }
    else
    {                
        lua_pushvalue(L, LUA_GLOBALSINDEX);
        return true;
    }                
}

首次执行时,传入的namenil,会将_G全局Table放在栈顶

再次执行L.BeginModule("LuaInterface")时,其堆栈变化如下:

learn_tolua-tolua_tolua_beginmodule.drawio

图7:执行 tolua_beginmodule 时的栈变化

可以看出,其主要作用就是创建了一张新的table,分别为其设置__index, .name, metatable,并且将其加入了_G全局表中。

EndModule

public void EndModule()
{
    --beginCount;            
    LuaDLL.tolua_endmodule(L);
}
LUALIB_API void tolua_endmodule(lua_State *L)
{
    lua_pop(L, 1);
    int len = (int)sb.len;

    while(len-- >= 0)
    {
        if (sb.buffer[len] == '.')
        {
            sb.len = len;
            return;
        }
    }

    sb.len = 0;
}

EndModule就是将刚才编辑好的_G["ModuleName"]pop掉,然后再将sb中记录的namespace退回一层。

注册Class到Lua

从上面可以看出,Module的结构和C#namespace的结构完全相同,有了namespace后,就要向里面注册各种class了,class的注册就是对各个Wrap调用Register方法:

public class MyPersonWrap
{
	public static void Register(LuaState L)
	{
		L.BeginClass(typeof(MyPerson), typeof(System.Object));
		L.RegFunction("Create", Create);
		L.RegFunction("SetName", SetName);
		L.RegFunction("GetName", GetName);
		L.RegFunction("SetAge", SetAge);
		L.RegFunction("GetAge", GetAge);
		L.RegFunction("__tostring", ToLua.op_ToString);
		L.EndClass();
	}
    
    
	[MonoPInvokeCallbackAttribute(typeof(LuaCSFunction))]
	static int Create(IntPtr L)
	{
		try
		{
			ToLua.CheckArgsCount(L, 2);
			string arg0 = ToLua.CheckString(L, 1);
			int arg1 = (int)LuaDLL.luaL_checknumber(L, 2);
			MyPerson o = MyPerson.Create(arg0, arg1);
			ToLua.PushObject(L, o);
			return 1;
		}
		catch (Exception e)
		{
			return LuaDLL.toluaL_exception(L, e);
		}
	}
    
    // .. 其他方法省略.
}

可以看到MyPersonWrap中根据每个方法的类型、参数数量对原有的C#函数进行了包装,使用luaL_checknumber获取参数,转发到C#中执行。

Register方法,将这些包装好的方法注册到lua环境中,接下来主要看Register方法的实现。

Begin Class

public int BeginClass(Type t, Type baseType, string name = null)
{
  if (beginCount == 0)
  {
      throw new LuaException("must call BeginModule first");
  }

  int baseMetaRef = 0;
  int reference = 0;            

  if (name == null)
  {
      name = GetToLuaTypeName(t);
  }

  if (baseType != null && !metaMap.TryGetValue(baseType, out baseMetaRef))
  {
      LuaCreateTable();
      // public static int LUA_REGISTRYINDEX = -10000;
      baseMetaRef = LuaRef(LuaIndexes.LUA_REGISTRYINDEX);                
      BindTypeRef(baseMetaRef, baseType);
  }

  if (metaMap.TryGetValue(t, out reference))
  {
      LuaDLL.tolua_beginclass(L, name, baseMetaRef, reference);
      RegFunction("__gc", Collect);
  }
  else
  {
      reference = LuaDLL.tolua_beginclass(L, name, baseMetaRef);
      RegFunction("__gc", Collect);                
      BindTypeRef(reference, t);
  }

  return reference;
}

这个函数会确认t, baseType已经有refid,并且确认他们的__gc都已经被注册为Collect函数。

这里LUA_REGISTRYINDEX是一个特殊的index,调用luaL_ref时,传入LUA_REGISTRYINDEX时,会存入lua的注册表中。

通过观察LuaBinder的代码可以看出,这里维护了两个成员,:metaMaptypeMap,他们在c#中记录了tref_id的映射关系。

继续观察tolua_beginclass

[DllImport(LUADLL, CallingConvention = CallingConvention.Cdecl)]
public static extern int tolua_beginclass(IntPtr L, string name, int baseMetaRef, int reference = -1);

可以看出来,当metaMap中能够找到t时,说明t作为其他类的baseType 已经进行过注册,reference会传递其注册过的值,而找不到时,会传-1

static void _addtoloaded(lua_State *L)
{
    lua_getref(L, LUA_RIDX_LOADED);
    _pushfullname(L, -3); // 相当于 lua_pushstring("UnityEngine.Compoment")
    lua_pushvalue(L, -3);
    lua_rawset(L, -3);
    lua_pop(L, 1);
}

LUALIB_API int tolua_beginclass(lua_State *L, const char *name, int baseType, int ref)
{
    int reference = ref;
    lua_pushstring(L, name);                
    lua_newtable(L);      
    _addtoloaded(L);

    if (ref == LUA_REFNIL)        
    {
        lua_newtable(L);
        lua_pushvalue(L, -1);
        reference = luaL_ref(L, LUA_REGISTRYINDEX); 
    }
    else
    {
        lua_getref(L, reference);    
    }

    if (baseType != 0)
    {
        lua_getref(L, baseType);        
        lua_setmetatable(L, -2);
    }
           
    lua_pushlightuserdata(L, &tag);
    lua_pushnumber(L, 1);
    lua_rawset(L, -3);

    lua_pushstring(L, ".name");
    _pushfullname(L, -4);
    lua_rawset(L, -3);

    lua_pushstring(L, ".ref");
    lua_pushinteger(L, reference);
    lua_rawset(L, -3);

    lua_pushstring(L, "__call");
    lua_pushcfunction(L, class_new_event);
    lua_rawset(L, -3);

    tolua_setindex(L);
    tolua_setnewindex(L); 
    return reference;
}
  • 首先会对当前类建立一个table,并且将其加到一张LUA_RIDX_LOADED的表中,

  • 如果这个类型不存在,就创建这个类型,并且refLUA_REGISTRYINDEX中;

  • 如果存在baseType,就将baseTypetable取出,作为metatable塞给当前类型的table

  • 接下来对当前类型的table进行一系列操作:

    meta_table[magic_number] = 1
    meta_table[".name"] = "MyPerson"
    meta_table[".ref"] = ref_id
    meta_table["__call"] = c_func_class_new_event
    
    -- tolua_setindex
    meta_table["__index"] = c_func_class_index_event
    
    -- tolua_setnewindex
    meta_table["__newindex"] = c_func_class_newindex_event
    

tolua_beginclass结束后,C#中还会调用RegFunction("__gc", Collect);c#中的Collect方法注册进来。

接下来就可以继续向这张table中注册其他的方法和成员了。

EndClass

在类型注册结束后,会调用L.EndClass();,简单看一下发生了什么:

public void EndClass()
{
  LuaDLL.tolua_endclass(L);
}
LUALIB_API void tolua_endclass(lua_State *L)
{
	lua_setmetatable(L, -2);
    lua_rawset(L, -3);            
}

这里将编辑完的meta_table设置给了MyPersonTable,并且将其设置到了正在编辑的Module Table中。

learn_tolua-new class.drawio

图9:创建 Class 时的Table关联

RegFunction

Wrap会调用RegFunction来注册函数:

public void RegFunction(string name, LuaCSFunction func)
{
    IntPtr fn = Marshal.GetFunctionPointerForDelegate(func);
    LuaDLL.tolua_function(L, name, fn);            
}
LUALIB_API void tolua_function(lua_State *L, const char *name, lua_CFunction fn)
{
  	lua_pushstring(L, name);
    tolua_pushcfunction(L, fn);
  	lua_rawset(L, -3);
}

tolua_function的实现就与我们之前写的完全一样了,唯一不同的点就在于,这里使用Marshal.GetFunctionPointerForDelegate(func);MyPersonWrap::SetName这样的委托转为一个函数指针,将转换后的地址放到了lua里。

RegVar

一些c#类中,含有一些成员变量,供外部访问,这里也支持使用RegVar方法来对这种变量进行注册:

public void RegVar(string name, LuaCSFunction get, LuaCSFunction set)
{            
    IntPtr fget = IntPtr.Zero;
    IntPtr fset = IntPtr.Zero;

    if (get != null)
    {
        fget = Marshal.GetFunctionPointerForDelegate(get);
    }

    if (set != null)
    {
        fset = Marshal.GetFunctionPointerForDelegate(set);
    }

    LuaDLL.tolua_variable(L, name, fget, fset);
}
LUALIB_API void tolua_variable(lua_State *L, const char *name, lua_CFunction get, lua_CFunction set)
{                
    lua_pushlightuserdata(L, &gettag);
    lua_rawget(L, -2);

    if (!lua_istable(L, -1))
    {
        /* create .get table, leaving it at the top */
        lua_pop(L, 1);
        lua_newtable(L);        
        lua_pushlightuserdata(L, &gettag);
        lua_pushvalue(L, -2);
        lua_rawset(L, -4);
    }

    lua_pushstring(L, name);
    //lua_pushcfunction(L, get);
    tolua_pushcfunction(L, get);
    lua_rawset(L, -3);                  /* store variable */
    lua_pop(L, 1);                      /* pop .get table */

    /* set func */
    if (set != NULL)
    {        
        lua_pushlightuserdata(L, &settag);
        lua_rawget(L, -2);

        if (!lua_istable(L, -1))
        {
            /* create .set table, leaving it at the top */
            lua_pop(L, 1);
            lua_newtable(L);            
            lua_pushlightuserdata(L, &settag);
            lua_pushvalue(L, -2);
            lua_rawset(L, -4);
        }

        lua_pushstring(L, name);
        //lua_pushcfunction(L, set);
        tolua_pushcfunction(L, set);
        lua_rawset(L, -3);                  /* store variable */
        lua_pop(L, 1);                      /* pop .set table */
    }
}

这里就不做太多的讲解了,简单来说,就是在Class MetaTable中维护了两张表,gettag为索引的get_table, settag为索引的settable

通过ClassMetaTable[gettag]["var_name"]就可以找到对应的getter

Lua中对c#对象的创建 & 使用

创建 c# 对象

当用户在lua中调用Vector3(0,0,360)MyPerson.Create()时,之前编写的class_new_eventMypersonWrap.Create会被触发。我们来看看tolua是如何创建c#对象并返回的。

class_new_event 调用构造函数
static int class_new_event(lua_State *L)
{         
    if (!lua_istable(L, 1))
    {
        return luaL_typerror(L, 1, "table");        
    }

    int count = lua_gettop(L); 
    lua_pushvalue(L,1);  

    if (lua_getmetatable(L,-1))
    {   
        lua_remove(L,-2);                      
        lua_pushstring(L, "New");               
        lua_rawget(L,-2);    

        if (lua_isfunction(L,-1))
        {            
            for (int i = 2; i <= count; i++)
            {
                lua_pushvalue(L, i);                    
            }

            lua_call(L, count - 1, 1);
            return 1;
        }

        lua_settop(L,3);
    }    

    return luaL_error(L,"attempt to perform ctor operation failed");    
}

如果我们的类没有仅用默认构造函数,或是编写了public的构造函数,那么tolua会根据我们编写的构造函数创建New方法:

加入我们实现了四种不同的构造函数:

public class MyPerson {
    public MyPerson(string name, int age)
    {
        this.name = name;
        this.age = age;
    }

    public MyPerson(string name)
    {
        this.name = name;
    }

    public MyPerson(int age)
    {
        this.age = age;
    }

    public MyPerson() {}

    private string name;
    private int age;
}

那么在MyPersonWrap中,会有:

[MonoPInvokeCallbackAttribute(typeof(LuaCSFunction))]
static int _CreateMyPerson(IntPtr L)
{
	try
	{
		int count = LuaDLL.lua_gettop(L);

		if (count == 0)
		{
			MyPerson obj = new MyPerson();
			ToLua.PushObject(L, obj);
			return 1;
		}
		else if (count == 1 && TypeChecker.CheckTypes<int>(L, 1))
		{
			int arg0 = (int)LuaDLL.lua_tonumber(L, 1);
			MyPerson obj = new MyPerson(arg0);
			ToLua.PushObject(L, obj);
			return 1;
		}
		else if (count == 1 && TypeChecker.CheckTypes<string>(L, 1))
		{
			string arg0 = ToLua.ToString(L, 1);
			MyPerson obj = new MyPerson(arg0);
			ToLua.PushObject(L, obj);
			return 1;
		}
		else if (count == 2)
		{
			string arg0 = ToLua.CheckString(L, 1);
			int arg1 = (int)LuaDLL.luaL_checknumber(L, 2);
			MyPerson obj = new MyPerson(arg0, arg1);
			ToLua.PushObject(L, obj);
			return 1;
		}
		else
		{
			return LuaDLL.luaL_throw(L, "invalid arguments to ctor method: MyPerson.New");
		}
	}
	catch (Exception e)
	{
		return LuaDLL.toluaL_exception(L, e);
	}
}

tolua会根据参数的数量和类型,调用不同的构造函数,最后调用ToLua.PushObject来将c#对象压栈,进行返回。

PushObject 将 c# 对象转为 userdata

继续来看ToLua.PushObject的实现:

public static void PushObject(IntPtr L, object o)
{
    if (o == null || o.Equals(null))
    {
        LuaDLL.lua_pushnil(L);
    }
    else
    {
        if (o is Enum)
        {
            ToLua.Push(L, (Enum)o);
        }
        else
        {
            PushUserObject(L, o);
        }
    }
}

//o 不为 null
static void PushUserObject(IntPtr L, object o)
{
    Type type = o.GetType();
    int reference = LuaStatic.GetMetaReference(L, type);

    if (reference <= 0)
    {
        reference = LoadPreType(L, type);
    }

    PushUserData(L, o, reference);
}

public static void PushUserData(IntPtr L, object o, int reference)
{
    int index;
    ObjectTranslator translator = ObjectTranslator.Get(L);

    if (translator.Getudata(o, out index))
    {
        if (LuaDLL.tolua_pushudata(L, index))
        {
            return;
        }

        translator.Destroyudata(index);
    }

    index = translator.AddObject(o);
    LuaDLL.tolua_pushnewudata(L, reference, index);
}

这里会在PushUserObject中准备好Objectlua中的ref_id,随后进入PushUserData

PushUserData中,对每一个LuaState都会维护一个ObjectTranslator,这个ObjectTranslator负责为每一个 Object 分配一个id

// ObjectTranslator [objects 为 LuaObjectPool]
public int AddObject(object obj)
{
    int index = objects.Add(obj);

    if (!TypeChecker.IsValueType(obj.GetType()))
    {
        objectsBackMap[obj] = index;
    }

    return index;
}

// LuaObjectPool
public int Add(object obj)
{
    int pos = -1;

    if (head.index != 0)
    {
        pos = head.index;
        list[pos].obj = obj;
        head.index = list[pos].index;
    }
    else
    {
        pos = list.Count;
        list.Add(new PoolNode(pos, obj));
        count = pos + 1;
    }

    return pos;
}

ObjectTranslator下维护了一个LuaObjectPool,这个Pool维护了一个链表,所有正在使用中的Object都会注册在链表中。

Add操作实际上就是向链表中添加了一个节点,获取到的index就是ObjectList中的位置,然后再在ObjectTranslatorobject --> index的映射关系做了缓存

拿到Object对应的id后,就可以调用tolua_pushnewudata将这个id作为userdata压入lua栈中:

新对象

LUALIB_API void tolua_newudata(lua_State *L, int val)
{
	int* pointer = (int*)lua_newuserdata(L, sizeof(int));    
    lua_pushvalue(L, TOLUA_NOPEER);            
    lua_setfenv(L, -2);                        
	*pointer = val;
}

LUALIB_API void tolua_pushnewudata(lua_State *L, int metaRef, int index)
{
	lua_getref(L, LUA_RIDX_UBOX);
	tolua_newudata(L, index);
	lua_getref(L, metaRef);
	lua_setmetatable(L, -2);
	lua_pushvalue(L, -1);
	lua_rawseti(L, -3, index);
	lua_remove(L, -2);	
}

tolua中的每一个userdata都只占用一个int的空间大小,在压入id作为userdata后,通过lua_getref通过类型的ref_id获取到class Table设为userdatametatable。最后以indexkey,将这个userdata设入LUA_RIDX_UBOX中。

同一个对象再次push

同一个对象再次push时,就会从刚才的LUA_RIDX_UBOX中直接拿出来:

LUALIB_API bool tolua_pushudata(lua_State *L, int index)
{
	lua_getref(L, LUA_RIDX_UBOX);			// stack: ubox
	lua_rawgeti(L, -1, index); 				// stack: ubox, obj

	if (!lua_isnil(L, -1))
	{
		lua_remove(L, -2); 					// stack: obj
		return true;
	}

	lua_pop(L, 2);
	return false;
}

这样就完成了对象的创建 & 压栈,创建好的对象能够正确的被赋予metatable

销毁 c# 对象

在注册类型时,就已经对metatable写入了__gc方法,这里的gc方法全部被倒到了Collect函数中处理:

[MonoPInvokeCallbackAttribute(typeof(LuaCSFunction))]
public static int Collect(IntPtr L)
{
    int udata = LuaDLL.tolua_rawnetobj(L, 1);

    if (udata != -1)
    {
        ObjectTranslator translator = GetTranslator(L);
        translator.RemoveObject(udata);
    }

    return 0;
}

这里会使用LuaDLL.tolua_rawnetobj(L, 1)获取UserObject,结合tolua_rawnetobj来看,拿到的就是刚才在translator中分配的index

接下来userObject会调用RemoveObject来回收这个c# Object

//lua gc一个对象(lua 库不再引用,但不代表c#没使用)
public void RemoveObject(int udata)
{            
    //只有lua gc才能移除
    object o = objects.Remove(udata);

    if (o != null)
    {
        // 对于 enum 的特殊处理,暂时不考虑.
        if (!TypeChecker.IsValueType(o.GetType()))
        {
            RemoveObject(o, udata);
        }

        if (LogGC)
        {
            Debugger.Log("gc object {0}, id {1}", o, udata);
        }
    }
}

// objects.Remove ()
public object Remove(int pos)
{
    if (pos > 0 && pos < count)
    {
        object o = list[pos].obj;
        list[pos].obj = null;                
        list[pos].index = head.index;
        head.index = pos;

        return o;
    }

    return null;
}

这里的remove操作就非常简单了,在List中,将相应的节点删除,那么对应ObjectC#的内存空间中,就会变成无用的一片内存,这样c#中对应的元素就会被c#的gc清除。

与此同时,ObjectTranslator中缓存的映射关系也会被清除,清楚后,下一次不同的Object被分配到List上相同index时,由于ObjectTranslator Miss,会重新触发tolua_pushnewudata,来覆盖luavm中的LUA_RIDX_UBOX

调用 c# 对象相关的方法

函数调用

回顾一下之前c#中的函数:

[MonoPInvokeCallbackAttribute(typeof(LuaCSFunction))]
static int SetName(IntPtr L)
{
    try
    {
        ToLua.CheckArgsCount(L, 2);
        MyPerson obj = (MyPerson)ToLua.CheckObject<MyPerson>(L, 1);
        string arg0 = ToLua.CheckString(L, 2);
        obj.SetName(arg0);
        return 0;
    }
    catch (Exception e)
    {
        return LuaDLL.toluaL_exception(L, e);
    }
}

这个函数被注释为MonoPInvokeCallbackAttribute,文档中对其的解释为:

此属性对静态函数有效,Mono 的预前编译器使用它来生成支持本机调用回调到托管代码所需的代码。

您必须指定调用此代码的委托的类型。

所以相当于编译时通过Marshal.GetFunctionPointerForDelegate(func);拿到的函数地址,给到luavm后调用时,会调用到Mono编译器生成的可以直接调用的地址。

getter setter & 下标访问

当我们使用RegVar注册成员变量时,会将该变量的getter, setter注册到该类的metatable下的&getter_tag, &setter_tag这两个key对应的table中。

在lua中访问成员时,由于该成员没有在table中注册,因此会将成员访问请求转发到__index中进行处理。

BeginClass时,为所有metatable设置了__index = class_index_event(),接下来就看一下class_index_event是如何与之前注册的表一起工作的:


static int class_index_event(lua_State *L)
{
	int t = lua_type(L, 1);

    if (t == LUA_TUSERDATA)
    {    	
        lua_getfenv(L,1);

        if (!lua_rawequal(L, -1, TOLUA_NOPEER))     // stack: t k env
        {
            // 由于这里只讨论userdata,所以不会进入到这个分支,直接忽略.
        };

        lua_settop(L,2);                                        				
    	lua_pushvalue(L, 1);						// stack: obj key obj	

    	while (lua_getmetatable(L, -1) != 0)
    	{        	
        	lua_remove(L, -2);						// stack: obj key mt

			if (lua_isnumber(L,2))                 	// check if key is a numeric value
			{		    
		    	lua_pushstring(L,".geti");
		    	lua_rawget(L,-2);                   // stack: obj key mt func

		    	if (lua_isfunction(L,-1))
		    	{
		        	lua_pushvalue(L,1);
		        	lua_pushvalue(L,2);
		        	lua_call(L,2,1);
		        	return 1;
		    	}
			}
			else
        	{
        		lua_pushvalue(L, 2);			    // stack: obj key mt key
        		lua_rawget(L, -2);					// stack: obj key mt value        

        		if (!lua_isnil(L, -1))
        		{
        	    	return 1;
        		}
                
                lua_pop(L, 1);
				lua_pushlightuserdata(L, &gettag);        	
        		lua_rawget(L, -2);					//stack: obj key mt tget

        		if (lua_istable(L, -1))
        		{
        	    	lua_pushvalue(L, 2);			//stack: obj key mt tget key
        	    	lua_rawget(L, -2);           	//stack: obj key mt tget value 

        	    	if (lua_isfunction(L, -1))
        	    	{
        	        	lua_pushvalue(L, 1);
        	        	lua_call(L, 1, 1);
        	        	return 1;
        	    	}                    
        		}
    		}

            lua_settop(L, 3);
        }

        lua_settop(L, 2);
        int *udata = (int*)lua_touserdata(L, 1);

        if (*udata == LUA_NULL_USERDATA)
        {
            return luaL_error(L, "attemp to index %s on a nil value", lua_tostring(L, 2));   
        }
        
        if (toluaflags & FLAG_INDEX_ERROR)
        {
            return luaL_error(L, "field or property %s does not exist", lua_tostring(L, 2));
        }        
    }
    else if(t == LUA_TTABLE)
    {
        // 由于这里只讨论userdata,所以不会进入到这个分支,直接忽略.
    }

    lua_pushnil(L);
    return 1;
}

在用户编写: my_object.target_field时,实际上由class_index_event(my_object, "target_field")方法进行处理。

结合之前BeginClass部分谈到的根据继承关系设置的metatable链条来看,这里会顺着c#中的继承关系逐层向下进行查找。

对于每一层metatable

  • 尝试直接在metatable中通过用户访问的字段直接获取到值,如果能拿到的话就直接返回
  • 尝试获取getter_table,并且在getter_table中通过相应的字段找到getter函数,如果能找到的话,就调用这个getter函数,获取值。
  • 什么都找不到就去找下一层继承关系的metatable(也就是对当前的metatablemetatable)

当遍历完所有的metatable后,还是找不到时,就返回nil

可以看到,这里可能会存在用户使用数字下标来访问的情况,如:

local num = myarray[10]

这种情况就会在递归寻找的过程中查找是否有.geti方法,如果有的话就用.geti来获取。

其中.geti实现如下:

// System_ArrayWrap.cs
[MonoPInvokeCallbackAttribute(typeof(LuaCSFunction))]
static int get_Item(IntPtr L)
{
    try
    {
        Array obj = ToLua.ToObject(L, 1) as Array;

        if (obj == null)
        {
            throw new LuaException("trying to index an invalid object reference");                
        }

        int index = (int)LuaDLL.lua_tointeger(L, 2);

        if (index >= obj.Length)
        {
            throw new LuaException("array index out of bounds: " + index + " " + obj.Length);                
        }

        Type t = obj.GetType().GetElementType();

        if (t.IsValueType)
        {
            // 特殊类型的处理, 这里不重要,忽略...
        }            

        object val = obj.GetValue(index);
        ToLua.Push(L, val);
        return 1;
    }
    catch (Exception e)
    {
        return LuaDLL.toluaL_exception(L, e);
    }
}

3. 对于现有方法的讨论

以刚才的MyPerson为例,他在整体流程中的所有相关操作如下:

UserData 相关交互 (1)

图10:userdata 相关交互流程

现有方案中,luavmc#, luavmtolua_runtime之间的交互中,涉及到app环境与wasm环境的切换,可能产生问题,可能产生问题的环节有三个:

  1. tolua_runtime调用lua_newuserdata
  2. luavm通过RegFunction提供的函数指针调用wasm中的c#函数;
  3. luavm垃圾回收时的内存释放;

接下来分开讨论。

a. lua_newuserdata的处理

lua_newuserdata的功能类似于malloc,会根据调用者给出的参数开出一片特定大小的内存,返回给调用者,供调用者使用。

在当前项目中的问题是lua_newuserdata开的内存开在app环境中,而runtime跑在wasm中,无法直接使用。

能够解决这个问题的方法是,lua_newuserdata时同时开一片同样大小的wasm的内存,作为返回值返回给调用者,但是在实现的细节上存在两种方案:

  1. wrap层调用lua_newuserdata后,调用wasm的内存分配器再分配同一片内存;
  2. 直接修改lualua_newuserdata的代码,在调用luaM_realloc_时动手,只分配wasm内存,而不分配app内存,这样可以少用点空间。

第二种方案会侵入到luavm内部,可能会有一些开发量,但是好处是能节省一半的空间,目前没有仔细研究protobuf,json这些第三方库创建userdata的逻辑,如果只使用tolua中的userdata只记录index的这种思路的话,那这里改动能节省下来的空间就很小,但是如果会大片大片开内存的话,还是能节省很多空间的。

b. luavm到wasm的函数调用

这里il2cpp后,拿到委托的地址,应该是一个wasm地址,那这里 luavmtable拿到回调地址后调用时是如何处理的?能够正确的完成调用么?

c. lauvm垃圾回收时正确回收内存

lua5.1中的垃圾回收分为以下阶段:

lu_byte gcstate:存放GC状态,分别有以下几种:GCSpause(暂停阶段)、GCSpropagate(传播阶段,用于遍历灰色节点检查对象的引用情况)、GCSsweepstring(字符串回收阶段),GCSsweep(回收阶段,用于对除了字符串之外的所有其他数据类型进行回收)和GCSfinalize(终止阶段)。

gcsweep阶段会对执行freeobj来对各种类型的数据进行回收,对于userdata会直接luaM_freemem(L, o, sizeudata(gco2u(o)));来回收相应的内存。

随后在gcsfinalize阶段,会遍历所有待finalizeuserdata,对于每一个userdata,调用GCTM方法尝试调用其注册的__gc方法。

在这个阶段如果不做修改,luavm是无法正确释放wasm中的内存的,这里比较合适的方案就是在GCTM这个阶段,执行完__gc方法后,对于要finalize的对象,找到其对应的wasm地址,进行释放。