如何实现两门语言互相调用
一、语言间相互调用的两种方式
在过去几十年里,技术取得了飞速进步,并且在未来几十年还将持续快速发展。如今,技术门槛不断降低,实现语言间相互调用主要有以下两种方式:
RPC(Remote Procedure Call)
通过通讯来实现相互调用。由于这并非本文重点,有兴趣的读者可以查看《RPC的原理和问题》。
利用语言扩展API
大多数编程语言都提供了C语言的扩展接口,因此可以借助C语言作为桥梁,实现不同语言之间的相互调用。本文将结合实际项目,详细介绍这种实现方式。
二、C#和Lua的相互调用
1. C#和C的交互
C#通过P - Invoke(Platform Invoke)机制与C语言进行交互,这是一种极为简单的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函数。
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#函数:
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(Just - In - Time Compilation)的环境下也能正常运行。
最后,将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全局函数。
然而,在实际使用中会遇到以下问题:
- 函数众多,每个都手写封装函数工作量巨大。
- 示例中的参数均为基本类型,对于复杂类型该如何处理。
- 示例展示的是静态方法,对象的方法、属性、操作符该如何处理。
- 两种语言都带有垃圾回收机制(GC),在使用对方对象时,如果对象被回收该如何处理。
- 参数存在输入输出属性和默认值的情况该如何处理。
- 还有各种C#、Lua以及两者之间的潜在问题。
接下来,将介绍手写代码问题的解决方案以及典型的坑。
三、可以不用手写封装代码吗?
答案是肯定的。实现这一目标的关键技术包括:C#的反射、Lua的Method Missing和代码生成。
1. C#的反射
借助反射,可以实现以下功能:
- 枚举一个类的所有方法和属性信息。
- 通过类名和静态方法名调用静态方法和属性。
- 通过对象和方法名调用成员方法和属性。
2. Lua的Method Missing
Lua的Method Missing特性通过元表(metatable)提供,主要包括:
__index:可以是一个函数(C或Lua均可),当读取的属性不存在时会触发回调,参数为被操作的表和属性名。__newindex:可以是一个函数(C或Lua均可),当设置的属性不存在时会触发回调,参数为被操作的表、属性名和值。
四、免手工封装初级篇
利用C#的反射和Lua的Method Missing特性,就可以实现Lua到C#的访问。以下是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[i] = GetAsType(IL, i + 2, parameters[i].ParameterType);
}
object ret = method.Invoke(obj, args);
PushCSObject(IL, ret);
return 1;
});
return 1;
}
代码说明:
- 将C#对象映射到Lua的用户数据(Userdata),该用户数据仅保留对象在C#端
objects_pool中的索引信息,GetCsObjectId用于从指定栈位置取出该索引。 - 通过反射获取由参数2指定的方法信息。
PushCSFunction将一个满足LuaCSFunction定义的委托压入栈中。- 委托的实现是通过
MethodInfo的参数信息从Lua栈中取出调用方法所需的信息,使用反射方式调用方法后,将结果压入栈中并返回。GetAsType用于将栈上的Lua对象转换为指定类型的C#对象,PushCsObject用于将C#对象按映射规则压入Lua栈中。 - 将
objectIndex设置为所有C#对象元表的__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[i].GetType();
if (arg_type == typeof(double))
{
LuaAPI.lua_pushnumber(L, (double)args[i]);
}
// ... 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[i] = LuaAPI.lua_tonumber(L, idx);
}
// ... other lua type
}
return ret;
}
五、免手工封装进阶篇
初级篇中,使用反射实现Lua到C#的调用,用object数组配合反射实现C#到Lua的调用,该方案存在以下缺陷:
- 大量使用反射、装箱和拆箱操作,性能不佳。
- 如果编译开启了代码剥离(Stripping),Lua调用C#可能会失效(Unity下默认开启Strip Engine Code,未被C#引用的引擎代码无法通过反射调用)。
- 通过反射调用泛化方法会触发JIT(在IOS下会异常)。
- C#使用
object数组访问Lua,丧失了C#静态检查的优势。
手写代码虽然不存在上述问题,但工作量大且套路固定。可以让机器帮助生成代码,具体分两步实现:一是让机器理解要封装的代码,二是根据理解的信息生成代码。
让机器理解代码的一种方案是编写语法解析器,但工作量大且后续C#语法升级会带来维护问题,因此选择反射。通过反射几乎可以获取整个代码的语法树信息,但不包括注释和预处理信息。
代码生成可以根据语法树信息拼接字符串或基于模板生成,个人更倾向于后者,因为它更清晰、更精简。
该方案也存在一些缺点:
- 反射不包含预处理信息,可能会带来不便,例如类通过宏定义了一个编辑器专属的方法,在打包安装包时可能会报错。可以通过调整业务代码或使用黑名单避免生成这些方法。
- 生成代码会增加安装包的大小。为减轻影响,通常通过白名单配置需要生成代码的类,对于性能要求不高且未被剥离的API可以通过反射访问。
六、两个神坑
能被称为神坑的问题通常具有以下特征:引发问题的代码没有逻辑错误,问题出现的时间和地点随机,且代码看起来正常。在实现过程中遇到了两个这样的问题。
1. 坑一:C# GC线程破坏Lua环境
最初使用时,偶尔会出现Lua变量突然变为nil的情况,检查代码却找不到问题。添加打印或调试时问题又不出现,经过几天的代码审查仍未找到原因。怀疑是多线程破坏了Lua环境,但由于项目在Unity下运行,Unity宣称是单线程的,一开始并未往这方面考虑。尝试对所有Lua的API调用加锁后,问题得到解决,这才意识到忽视了C#的GC线程。实际上,知道问题原因后,使用清理任务队列即可,无需在Lua API加锁。
2. 坑二:longjmp破坏C#环境
随着使用的深入,发现进程有一定几率崩溃,且栈信息异常。限制Unity单帧打印后不再崩溃,曾以为是Unity的Bug。但测试人员反馈在Mac下仍有几率崩溃,通过注释代码最终定位到与pcall捕获C#异常有关(调用C#时先进行try - 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#的泛化、Lua中没有的操作符以及C#数据类型多于Lua导致的重载识别问题。不过,可以通过支持C#扩展方法来解决这些问题。
此外,深入了解两种语言的运行机制对于避免性能和稳定性问题至关重要。尽管对Lua源代码进行了详细研究,但仍可能遇到一些问题。