一、语言间相互调用的两种方式
技术在过去的几十年里进步很快,也将在未来的几十年里发展得更快。今天技术的门槛下降得越来越快,原本需要语言间相互调用的两种方式
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源代码都走读完了,但依然还是踩了一些坑。