Lua 虚拟机的封装

云风 2018-08-15 16:06

我打算就我们在开发客户端引擎框架时最近遇到的两个问题写两篇 Blog ,这里先谈第一个问题。

我们的框架技术选型是用 Lua 做开发。和很多 C++ 开发背景(现有大部分的游戏客户端引擎使用 C++ 开发)的人的认知不同,我们并不把 Lua 作为一个嵌入式脚本来看待,而是把它当成一种通用语言来设计整个引擎框架。

其实这更接近 HTML5 流行之后,用 javascript 设计游戏引擎框架:虽然 javascript 的虚拟机本身是用 C++ 开发的,但和游戏引擎相关的部分全部用 javascript 实现,直到涉及渲染的部分,又通过 WebGL 回到 C++ 编写的代码中。这里,我只是把 javascript 换成了 Lua 而已。

选择 Lua 有很大成分是因为我的个人偏好,另一部分原因是 Lua 有优秀的和 C/C++ 代码交互的能力。可以方便地把性能热点模块,在设计上做出良好的抽象后,用 C/C++ 编写高性能的模块,交给 Lua 调用。

但和 Javascript 不同,我们在做原生 App 时,和操作系统打交道的部分还是得用操作系统的语言,C/C++/Objective C/Java 等等。Lua 虚拟机还是要通过一个 C 模块实现嵌入到 App 中去,这个工作得我们自己来完成。

让 Lua VM 置入 App 和操作系统打交道的这部分代码显然是平台相关的,Lua 的 C API 固然简洁,但是还是很庞大的。如果每个平台都直接用 Lua C API 控制虚拟机,这些平台相关的代码还是略显繁杂。我认为,把平台相关代码约束到一个足够小的范围,还需要对 Lua C API 再做一次抽象。

我们面临的需求是:创建一个(或几个)Lua 虚拟机,定期驱动它运行。重点在,定期驱动它运行。这可视为向虚拟机发送消息,虚拟机本质上是消息驱动的(其实 Windows 程序也是)。通常,有一个时钟驱动的行为,或者有一个渲染帧驱动的行为;然后,有外部输入消息,例如触摸屏消息等。

由于整个业务逻辑都是在 Lua 中完成,所以我们并不需要从 Lua VM 中获取什么东西。如果操作系统需要些什么,更多的是传入一个外部库,由 Lua 代码把内部的信息通过库传递出去,而不是外部去获取。裁剪掉后者这个需求,Lua 的 C API 中绝大多数 API 都是不必要的。

我最后设计出来的封装接口只有 5 个 C API :

struct luavm * luavm_new();
const char * luavm_init(struct luavm *L, const char * source, const char *format, ...);
void luavm_close(struct luavm * L);
const char * luavm_register(struct luavm * L, const char * source, const char *chunkname, int *handle);
const char * luavm_call(struct luavm *L, int handle, const char *format, ...);

new init close 是 VM 的构建销毁 API ,我们可以在 init 时传入初始化脚本,从外部注入 Lua 的扩展模块。注意:通过 Lua 的原生 require 机制,在部分平台(如 ios)上,是无法取得外部的 C 模块的。

我的解决方案是写一个符合 lua package searcher 的函数,在初始化的时候替换掉原生的 C 模块 searcher :

static int
preload_searcher(lua_State *L) {
    const char * modname = luaL_checkstring(L,1);
    int i;
    for (i=0;preload[i].name != NULL;i++) {
        if (strcmp(modname, preload[i].name) == 0) {
            lua_pushcfunction(L, preload[i].func);
            return 1;
        }
    }
    lua_pushfstring(L, "\n\tno preload C module '%s'", modname);
    return 1;
}

然后我们就可以把所需的 C 模块的luaopen_xxx静态链接到程序中,放在 preload 数组里就可以让 Lua VM 顺利 require 到。

这里,只提供了一对 register/call API 让外部可以调用一个 VM 中的方法。

我们可以在 register 时注入一段简单的脚本,返回一个 Lua 函数。框架给它绑定一个数字 id ,之后 call 就可以调用这个 handle 对应的函数了。在引擎中,需要暴露到从原生代码直接调用的函数并不会太多,可能只有 update draw message 等寥寥几个。

所有 Lua 调用都有可能抛出 error ,在接口上,我全部用 const char * 来示意是否有 error ,方便原生代码在使用时处理。

但这只是一个后备方案。我更倾向于提供第二种途径,让 Lua VM 内部也可以拿到这些错误信息,这样可以做的更完备。例如,可以把错误 log 通过网络途径发送走,或做更复杂的过滤等。我的方案是,在 init 的时候,框架构建出一个 table ,传给 init 代码。这代码可以把这个 table 记录下来,例如是放在全局变量中。然后每次 VM 的入口函数在运行前,都可以检查这个 table 中有没有新的内容,那就是最后发生的错误信息,处理它再将 table 清空。框架则在每次有新的错误信息后,追加在这个 table 末尾。

让 C 代码向原生代码传递参数,我才用了 C 传统的 format ... 的可变参数形式。format 串中,n 表示 double ,i 表示 integer ,b 表示 boolean ,s 表示 const char * , p 表示 void * ,f 表示lua_CFunction。

另外,我还支持了接收 call 方法从 Lua 中返回一些简单数据类型,用对应的大写字母即可。call 的时候传递指针。比如,向接收一个 int 返回值,就传一个 int * ,这和 scanf 的风格一致。

最后值得一提的时,虽然这个小的 lua vm 封装模块只完成了很微小的工作,但要把事情做对,还是需要谨慎实现的,需要考虑调用 lua c api 时可能发生的任何错误。比如很容易被忽略掉的内存不足的 error 。(lua_pushstring就是有可能抛出异常的,不能裸调)

从 lua 中返回 const char * 也要警惕,避免把可能 gc 的字符串指针返回。我的解决方案是,在虚拟机启动后,创建一个独立的 coroutine 专门作为和外界交换数据的区域。所有可能返回到外界的字符串,但暂时放在这里。所以直到下次在调用 lua 虚拟机前,都可以认为上次返回的字符串是安全的。

最后,放上我的代码做参考。注意,这段代码是从我们开发中的引擎中抽出来的片段,仅作参考,该代码片段不会(因为 bug )持续维护。

[返回] [原文链接]