一、语言间相互调用的两种方式

  技术在过去的几十年里进步很快,也将在未来的几十年里发展得更快。今天技术的门槛下降得越来越快,原本需要语言间相互调用的两种方式

RPC
  用通讯来实现相互调用,这不是本文的重点,有兴趣的童鞋可以看看这里《RPC的原理和问题》。
  利用语言扩展API
  大多数语言都提供了C语言的扩展,那么我们可以用C语言作为桥梁实现语言间相互调用,这篇文章主要讲的是这种,下面我们将结合一个实际项目,说下是怎么做的。

二、C#和Lua的相互调用

1、C#和C的交互

  C#的C语言接口是P-Invoke,所见过最简单的C语言接口就它了,要调用一个C的函数,只要声明一个对应参数和返回值的(extern)函数声明,并加上一个DllImport属性,跟着就可以像普通C#函数那样使用导入的C函数。如果C函数有个参数是函数指针,把一个对应(声明)的C#函数传过去即可。编译器会自动帮你完成这些交互所需的代码。
  我们看看示例,后面那个封装的是带函数指针参数的C函数:

public class LuaAPI
{
public delegate int lua_CSFunction(IntPtr luaState);
    [DllImport("lua", CallingConvention = CallingConvention.Cdecl)]
public static extern double lua_tonumber(IntPtr L, int idx);
    [DllImport("lua", CallingConvention = CallingConvention.Cdecl)]
    public static extern void lua_pushcclosure(IntPtr L, lua_CSFunction fn, int n);
}

  C#里头写出C函数的原型,然后用DllImport标签声明一下所在的dll名,以及遵从C调用规则,然后就可以在C#里头愉快的使用了C函数了!
2、Lua和C的交互
  相对C#而言,Lua和C的交互则要麻烦得多,Lua虚拟机是基于栈的,它提供了一套栈操作的C API来完成和Lua的互操作。在Lua中要调用一个的C函数,需要写个封装函数,从栈上取出调用参数,调用C函数后把结果放到栈上。而C要调用Lua函数,也要把参数一个个放到栈上,用Lua的API完成调用后,从栈上取出结果。
3、C#和Lua的交互
  由于P-Invoke的易用性,C#和Lua的交互编程上基本和C和Lua的交互基本一样。现在用最简单的情况来展示C#和Lua间如何完成调用。
  我们展示下怎么封装一个简单的静态C#函数给Lua使用,我们有个这样的函数:

public class Calc
{
    public static double Add(double a, double b)
    {
        return a + b;
    }
}

  对应的封装函数

[MonoPInvokeCallback(typeof(LuaAPI.lua_CSFunction))]
static int Calc_Add_Wrap(IntPtr L)
{
    double a = LuaAPI.lua_tonumber(L, 1);
    double b = LuaAPI.lua_tonumber(L, 2);
    double ret = Calc.Add( a, b );
    LuaAPI.lua_pushnumber(L, ret);             
    return 1;
}

  注:MonoPInvokeCallback标签可以保证在禁止了JIT的环境下也能运行。
  最后把Calc_Add_Wrap注册到lua的全局变量csharp_calc_add。
LuaAPI.lua_pushcclosure(L, Calc_Add_Wrap, 0);
LuaAPI.lua_setglobal(L, "csharp_calc_add");
  然后Lua就可以直接调用csharp_calc_add使用到我们所封装的C#静态函数Calc.Add了。
  而C#调用Lua,简单起见,我们假定封装的lua函数是全局的。我们可以写这样的封装类:

public class LuaGlobal
{
    IntPtr L;
    public double Add(double a, double b)
    {
        LuaAPI.lua_getglobal(L, "add");
        LuaAPI.lua_pushnumber(L, a);
        LuaAPI.lua_pushnumber(L, b);
        LuaAPI.lua_call(L, 2, 1);
        double ret = LuaAPI.lua_tonumber(L, -1);
        LuaAPI.lua_pop(L, 1);
        return ret;
    }
}

  我们就可以通过这个LuaGlobal.Add函数使用lua里头的add全局函数。
  C#和Lua可以交互了,任务完成!然后,我们可以愉快的玩耍了。。。吗?
  很快你会发现你要面对这些问题:
· 函数那么多,每个都要手写封装函数,不得累死?
· 上面演示参数都是基本类型,复杂类型咋整?
· 上面演示的是静态方法,对象上的方法怎么整?对象上的属性呢?操作符怎么处理?
· 既然都引入对象,要知道两个语言都是带GC的,要是使用对方对象的过程中,对方回收了该对象怎么办?
· 参数还有输入输出属性?还有参数默认值?
· 。。。
  还没完,还有各种C#或者Lua或者C#和Lua之间的坑等着你跳呢。
  接下来,我们讲下手写代码问题的解决以及典型的坑。

三、可以不用手写封装代码吗?

  先说答案:可以!
  用到的几个关键技术是:C#的反射,Lua的Method Missing,代码生成。
1、C#的反射
  借助反射,我们可以做到:1、枚举一个类的所有方法、属性信息;2、通过一个类名以及静态方法名,调用静态方法、属性;3、通过一个对象及方法名,调用成员方法、属性;
2、Lua的Method Missing
  Lua的Method Missing特性通过它的metatable提供,分别是:
__index,可以是一个函数(C或者Lua都可以),当读取的属性不存在时触发会被回调,参数是被操作table以及属性名。
__newindex,可以是一个函数(C或者Lua都可以),当设置的属性不存在时触发会被回调,参数是被操作table、属性名以及值。

四、免手工封装初级篇

  有了以上两个利器,我们就可以实现Lua到C#的访问,BB了那么久,轮到代码兄上场的时候了,下面是Lua访问C#的代码(由于篇幅的关系,接下来的代码都是简化过了的,想看实际代码的可以看本文附带的项目工程链接):

[MonoPInvokeCallback(typeof(LuaCSFunction))]
public static int objectIndex(RealStatePtr L)
{
    object obj = objects_pool[GetCSObjectId(L, 1)];
    Type objType = obj.GetType();
    string index = LuaAPI.lua_tostring(L, 2);
    MethodInfo method = objType.GetMethod(index);
PushCSFunction(L, (IL) =>
{
        ParameterInfo[] parameters = method.GetParameters();
        object[] args = new object[parameters.Length];
        for(int i = 0; i < parameters.Length; i++)
        {
            args = GetAsType(IL, i + 2, parameters.ParameterType);
        }
        object ret = method.Invoke(obj, args);
        PushCSObject(IL, ret);
        return 1;
    });
    return 1;
}

  代码说明:
1、我们把C#对象都映射到Lua的Userdata,该Userdata只保留了一个信息:该对象在C#测objects_pool的索引信息。而GetCsObjectId则是在指定栈位置上取出该索引;
2、我们拿到对象,就可以通过反射获取到由参数2指定的方法信息;
3、PushCSFunction把一个满足LuaCSFunction定义的Delegate压回栈中;
4、Delegate的实现是,通过MethodInfo的参数信息从Lua栈上取出调用方法所需的信息,用反射方式调用后,结果压栈,返回;GetAsType是把栈上Lua对象转换成指定类型的C#对象,PushCsObject是把一个C#对象按映射规则压到Lua栈上;
5、上面的objectIndex设置为所有C#对象的metatable的__index字段;
  而C#访问Lua也可以有个统一的实现:

public object[] Call(params object[] args)
{
    int old_top = LuaAPI.lua_gettop(L);
    LuaAPI.lua_getref(L, func_ref);
    for(int i = 0; i < args.Length; i++)
    {
        Type arg_type = args.GetType();
        if (arg_type == typeof(double))
        {
            LuaAPI.lua_pushnumber(L, (double)args);
        }
        // ... other c# type
    }
    LuaAPI.lua_call(L, args.Length, -1);
    object[] ret = new object[LuaAPI.lua_gettop(L) - old_top];
    for(int i = 0; i < ret.Length; i++)
    {
        int idx = old_top + i + 1;
        if(LuaAPI.lua_isnumber(L, idx))
        {
            ret = LuaAPI.lua_tonumber(L, idx);
        }
        // ... other lua type
    }
    return ret;
}


五、免手工封装进阶篇

  初级篇中,我们用反射实现Lua到C#的调用,用object数组配合反射实现C#到Lua的调用,这方案有不少的缺陷:
1、很多地方有反射,boxing,unboxing的开销,因而性能不佳;
2、如果编译开启了Stripping,Lua调用C#可能会失效(Unity下默认是Strip Engine Code,也就是引擎代码没有被C#引用过的地方,反射也没法调用)。
3、通过反射调用泛化方法,会触发JIT(IOS下会异常)。
4、C#采用object数组访问Lua,丧失了C#静态检查的优势。
  我们刚开始介绍的手写代码没有上述问题,只是工作量太大,而且套路太固定,写起来很枯燥。。。咦,套路固定?那我们可以让机器帮我们写么?可以!
  要实现机器帮我们写,可以分两步:一步是让机器看懂我们要封装的代码,一步是根据让它根据“看到”的信息去写代码。
  让机器看懂代码,一个方案是写语法解析器,借助flex,bison这类工具倒不难,只是工作量不少,而且后续C#语法的升级也会带来维护问题,为此,我们选择了另外一个方案:反射。通过反射,我们几乎可以得到整个代码的语法树信息,没列入“几乎”之内的包括注释以及预处理信息。
  而生成代码可以根据语法树信息拼接字符串或者基于模版去生成。个人更倾向后者:更清晰,更精简。
  而这个方案,也有其缺点:
1、反射由于不含预处理信息,有时会造成一些不便,比如类通过宏定义了一个Editor专属的方法,build安装包的时候会报找不到该方法。这也仅仅是不便而已,知道这个特点后对业务代码稍作调整,或者用黑名单来避免这些方法的生成就可以了。
2、生成代码会增大安装包的大小。为了减轻这个影响,我们通常不会对所有的类都生成,而是通过白名单配置自己要用的类。另外,性能要求不高又没有被trip的API可以通过反射来访问。

六、两个神坑

  能称得上神坑,至少得有这俩特征:1、引发问题的地方没有逻辑错误;2、出现问题的地方是随机的,包括时间和地点,而且那些代码看起来一切正常。而我们这个库的实现就碰到两个。
1、坑一:C# GC线程破坏Lua环境
  一开始使用发现偶尔会出现一些Lua变量突然变成nil了,看代码怎么也看不出问题来。加个打印或者debug又不出现,各种走读代码几天没查出来。觉得像多线程破坏Lua环境导致的,但由于在我们项目在Unity下跑,它宣称是单线程的,一开始也没往这想。抱着试一试的心态把所有Lua的API调用都加了锁,问题果然解决了,才知道自己忽视了GC线程。当然,知道问题后也不用在Lua API加锁,用个清理任务队列即可。
2、坑二:longjmp破坏C#环境
  随着使用的深入,又发现了另外一个诡异现象:进程会有一定的几率crash,而且栈很奇怪。定位了一把,发现限制了Unity单帧打印就不crash了,还以为是Unity的Bug。
  还以为大功告成的时候,测试童鞋发现在Mac下还是会有几率crash。通过注释代码最后定位到和pcall抓C#的异常有关(调用C#会先try-catch,catch到C#异常后会调用lua_error抛lua异常)。网上看到有人说在C++中用lua_error会导致内存泄漏,因为lua的异常默认是用longjmp,会导致栈变量的析构函数得不到调用。会不会C#也是类似的原因呢:C#虚拟机还没完成一个C#函数调用,就longjmp跳到非托管环境,会导致一些必要的操作没执行,因而破坏了C#虚拟机环境。按这个思路修改代码,问题果然得到解决。把日志放开狂打也没事了。

七、总结

  总体来说,让两个语言通过扩展API实现交互还是挺多事情,特别是两门语言差异比较大的时候。像C#和Lua,C#远远比Lua要复杂,包括语法以及数据类型,把Lua暴露给C#使用比较简单,反过来就比较难了。我们需要设计如何在Lua测表达C#,有的C#特性甚至到现在都难以找到可以接受的表达方式,比如C#的泛化(类似C++的模版),比如Lua里头没有的操作符,又比如C#数据类型元多于Lua所导致的重载识别问题。幸好都可以通过对C# Extension Methods的支持实现曲线救国。
  除此之外,还需要对两门语言的运行机制有较深了解,才可能避免一些性能、稳定性方面的坑,笔者为此把Lua源代码都走读完了,但依然还是踩了一些坑。