打造基于 Lua 的跨平台脚本引擎
Lua 是一门短小精悍的脚本语言,在游戏和图形渲染领域早已有广泛应用。
同时,由于它能方便的和 C 语言交互、以及动态解释执行的特性,易于实现热更新,因此很适合用于二次开发实现跨平台脚本引擎。
JIT 版本的 Lua 虽性能优异,但由于 iOS 审核方面的限制,显然不适合跨平台场景。
Lua 官方的 runtime 本身基于 C 实现,并且自带了和 C 语言交互的各种 API。
下面我们一步步来实现基于 Lua 的跨平台脚本引擎,并顺便简单了解下相关源码。
虚拟机实例的管理
作为基于虚拟的的语言,首先最基础的当然是管理 VM,对于 Lua 来说就是 lua_state
。
要管理也很简单,就 luaL_newstate
和 lua_close
两个接口。
源码简析
我们先简单看下 lua_state
定义:
struct lua_State {
//...
lu_byte status;
//...
global_State *l_G;
CallInfo *ci; /* call info for current function */
StkIdRel stack_last; /* end of stack (last element + 1) */
StkIdRel stack; /* stack base */
//...
GCObject *gclist;
struct lua_State *twups; /* list of threads with open upvalues */
struct lua_longjmp *errorJmp; /* current error recover point */
CallInfo base_ci; /* CallInfo for first level (C calling Lua) */
//...
l_uint32 nCcalls; /* number of nested (non-yieldable | C) calls */
int oldpc; /* last pc traced */
//...
};
可以看到,主要包含:状态、调用信息、栈、GC 相关数据等。
而创建 VM 主要就是对这些状态、GC、以及线程的初始化:
LUA_API lua_State *lua_newstate (lua_Alloc f, void *ud, unsigned seed) {
//...
lua_State *L;
global_State *g;
//...
L->marked = luaC_white(g);
preinit_thread(L, g);
g->allgc = obj2gco(L); /* by now, only object is the main thread */
L->next = NULL;
//...
g->frealloc = f;
//...
g->mainthread = L;
//...
g->gcstate = GCSpause;
//...
g->sweepgc = NULL;
//...
g->GCtotalbytes = sizeof(LG);
g->GCmarked = 0;
//...
return L;
}
销毁 VM 就是一些对象释放工作:
static void close_state (lua_State *L) {
global_State *g = G(L);
if (!completestate(g)) /* closing a partially built state? */
luaC_freeallobjects(L); /* just collect its objects */
else { /* closing a fully built state */
L->ci = &L->base_ci; /* unwind CallInfo list */
luaD_closeprotected(L, 1, LUA_OK); /* close all upvalues */
luaC_freeallobjects(L); /* collect all objects */
luai_userstateclose(L);
}
luaM_freearray(L, G(L)->strt.hash, cast_sizet(G(L)->strt.size));
freestack(L);
//...
(*g->frealloc)(g->ud, fromstate(L), sizeof(LG), 0); /* free main block */
}
动态加载 Lua 脚本
初始化完 VM,我们就可以动态加载 Lua 脚本。
luaxlib.h
中提供的 luaL_dofile
和 luaL_dostring
可直接加载 Lua 脚本文件和文本:
#if !defined(__EMSCRIPTEN__)
bool loadFile(const std::string &file)
{
const std::lock_guard<std::recursive_mutex> lock(this->mutex);
int ret = luaL_dofile(this->lstate, file.c_str());
if (ret != LUA_OK)
{
return false;
}
return true;
}
#endif
bool loadScript(const std::string &script)
{
const std::lock_guard<std::recursive_mutex> lock(this->mutex);
int ret = luaL_dostring(this->lstate, script.c_str());
if (ret != LUA_OK)
{
return false;
}
return true;
}
因为每次加载都会影响 VM 状态,所以这里使用了锁;
此外还有个坑(可能跟 luaL_dofile
内部调用了 fopen
有关),在 WebAssembly 平台它会自动拉起 Prompt 弹窗,因此这里暂时通过宏禁用掉。
C 与 Lua 互操作
在正式涉及 C 和 Lua 的互操作之前,先强调几点:
前文有介绍,Lua VM 是基于栈的,几乎所有 C 相关 API 也是基于栈的;
Lua 另一个很重要且很有特色的概念就是
table
,元信息等都存在表里,甚至面向对象也通过它实现;Lua 默认全都是全局变量。
C 和 Lua 相互调用时,读取参数、返回值等全都是围绕 stack、table、global 这三者。
将 C 函数注册到 Lua 环境
我们知道,C 调用一个函数是通过函数指针;因此:
Java/JS/Lua 等任何 VM 注册 C 函数的本质就是:建立函数指针和方法签名字符串的映射表。
而 lua_CFunction
本质也是个函数指针:
typedef int (*lua_CFunction) (lua_State *L);
Lua 提供了两种方式注册 C 函数。
注册单个 C 函数
lua_register
可将单个 C 函数注册到 Lua 环境:
void bindFunc(const std::string &funcName, int (*funcPointer)(lua_State *))
{
lua_register(this->lstate, funcName.c_str(), funcPointer);
}
而在 C 函数内部:
通过
luaL_checkstring
读取参数(为了代码复用和跨平台考虑,我们选择使用 JSON 传参);执行完具体业务逻辑后,通过
lua_pushxxx
将对应数据类型的返回值压栈;
为了方便批量注册,这里我们自定义一些宏:
#define DEF_LUA_FUNC_VOID(fL, fS) \
int fL(lua_State *L) \
{ \
const char *json = luaL_checkstring(L, 1); \
fS(json); \
return 1; \
}
#define DEF_LUA_FUNC_STRING(fL, fS) \
int fL(lua_State *L) \
{ \
const char *json = luaL_checkstring(L, 1); \
const std::string res = fS(json); \
lua_pushstring(L, res.c_str()); \
return 1; \
}
#define DEF_LUA_FUNC_INTEGER(fL, fS) \
int fL(lua_State *L) \
{ \
const char *json = luaL_checkstring(L, 1); \
auto res = fS(json); \
lua_pushinteger(L, res); \
return 1; \
}
#define DEF_LUA_FUNC_BOOL(fL, fS) \
int fL(lua_State *L) \
{ \
const char *json = luaL_checkstring(L, 1); \
bool res = fS(json); \
lua_pushboolean(L, res); \
return 1; \
}
#define DEF_LUA_FUNC_FLOAT(fL, fS) \
int fL(lua_State *L) \
{ \
const char *json = luaL_checkstring(L, 1); \
double res = fS(json); \
lua_pushnumber(L, res); \
return 1; \
}
源码简析:
lua_register
实际上是一个宏定义:
#define lua_register(L,n,f) (lua_pushcfunction(L, (f)), lua_setglobal(L, (n)))
本质就是:先将 C 函数指针对象放到全局 table:
LUA_API void lua_setglobal (lua_State *L, const char *name) {
TValue gt;
lua_lock(L);
getGlobalTable(L, >);
auxsetstr(L, >, name);
}
然后压栈:
LUA_API void lua_pushcclosure (lua_State *L, lua_CFunction fn, int n) {
lua_lock(L);
//...
else {
int i;
CClosure *cl;
//...
cl = luaF_newCclosure(L, n);
//...
luaC_checkGC(L);
}
lua_unlock(L);
}
限于篇幅,这里不详细展开源码实现细节(后面单独讨论),大致可以看到,里面还有锁操作,且会触发 GC。
批量注册 C 函数
由于 Lua 标准库很精简,很多时候我们需要注册一整个复杂模块(比如网络),内部包含一系列相关联的 C 函数;
通过 luaL_newlib
即可实现:
#define xxx_lua_register_lib(L, lib, funcs) \
{ \
luaL_newlib(L, funcs); \
lua_setglobal(L, lib); \
}
static const luaL_Reg xxx_lua_lib_timer_funcs[] = {
{"add", xxx_lua_util_timer_add},
{"remove", xxx_lua_util_timer_remove},
{NULL, NULL} /* sentinel */
};
LuaBridge()
{
//...
xxx_lua_register_lib(this->lstate, "Timer", xxx_lua_lib_timer_funcs);
//...
}
源码简析:
这里 luaL_newlib
也是个宏:
#define luaL_newlib(L,l) \
(luaL_checkversion(L), luaL_newlibtable(L,l), luaL_setfuncs(L,l,0))
这里会先创建表:
#define luaL_newlibtable(L,l) \
lua_createtable(L, 0, sizeof(l)/sizeof((l)[0]) - 1)
然后,luaL_setfuncs
内部仍然是调用上面提到的 lua_pushcclosure
:
LUALIB_API void luaL_setfuncs (lua_State *L, const luaL_Reg *l, int nup) {
//...
for (; l->name != NULL; l++) {
//...
for (i = 0; i < nup; i++) {
//...
lua_pushcclosure(L, l->func, nup);
}
lua_setfield(L, -(nup + 2), l->name);
}
lua_pop(L, nup);
}
系统内置函数
其实系统内置了一些库,它们需要通过 luaL_openlibs
加载。简单看下源码:
#define luaL_openlibs(L) luaL_openselectedlibs(L, ~0, 0)
LUALIB_API void luaL_openselectedlibs (lua_State *L, int load, int preload) {
int mask;
const luaL_Reg *lib;
luaL_getsubtable(L, LUA_REGISTRYINDEX, LUA_PRELOAD_TABLE);
for (lib = stdlibs, mask = 1; lib->name != NULL; lib++, mask <<= 1) {
if (load & mask) { /* selected? */
//...
lua_pop(L, 1); /* remove result from the stack */
}
else if (preload & mask) { /* selected? */
lua_pushcfunction(L, lib->func);
//...
}
}
//...
lua_pop(L, 1); /* remove PRELOAD table */
}
可以看到,主要还是通过 lua_pushcfunction
操作全局的表。
C 调用 Lua 函数
经过前面铺垫,这里理解起来就相对容易,无非仍然是对 stack、table 的操作:
const std::string callFunc(const std::string &func, const std::string ¶ms)
{
std::string s;
const std::lock_guard<std::recursive_mutex> lock(this->mutex);
lua_getglobal(this->lstate, func.c_str());
lua_pushstring(this->lstate, params.c_str());
int ret = lua_pcall(lstate, 1, 1, 0);
if (ret != LUA_OK)
{
return s;
}
const char *res = lua_tostring(this->lstate, -1);
s = std::move(std::string(res ?: ""));
lua_pop(this->lstate, 1);
return s;
}
在全局表找到函数指针,并压栈;
通过
lua_pcall
调用 Lua 函数;通过
lua_tostring
将执行结果转成字符串;通过
lua_pop
出栈,释放资源。
需要注意的是,有时候可能存在递归调用的情况:
Native 函数
cFuncA()
先调用 Lua 函数lFuncA()
;Lua
lFuncA()
内部又回调 native 函数cFuncB()
;Native
cFuncB()
执行完后又再次调用调用 Lua 函数lFuncB()
;
这是典型的需要使用递归锁的场景,因此我们直接使用 std::recursive_mutex
。
异常处理
注意我们上面是通过 lua_pcall
调用 Lua 函数,其实当然有 lua_call
,差异就是前者调用失败会抛异常,也可以读取异常:
int ret = lua_pcall(lstate, 1, 1, 0);
if (ret != LUA_OK)
{
const char *luaErrMsg = lua_tostring(L, -1);
if (luaErrMsg != NULL)
{
std::cout << luaErrMsg << std::endl;
}
PRINT_L_ERROR(this->lstate, "`lua_pcall` error:");
return s;
}
源码简析:
可以看到,lua_call
内部直接调用的 luaD_call
(最终调用 luaV_execute
),而 lua_pcall
内部会收集调用栈信息:
l_sinline void ccall (lua_State *L, StkId func, int nResults, l_uint32 inc) {
CallInfo *ci;
L->nCcalls += inc;
//...
if ((ci = luaD_precall(L, func, nResults)) != NULL) { /* Lua function? */
ci->callstatus |= CIST_FRESH; /* mark that it is a "fresh" execute */
luaV_execute(L, ci); /* call it */
}
L->nCcalls -= inc;
}
void luaD_call (lua_State *L, StkId func, int nResults) {
ccall(L, func, nResults, 1);
}
LUA_API void lua_callk (lua_State *L, int nargs, int nresults,
lua_KContext ctx, lua_KFunction k) {
//...
func = L->top.p - (nargs+1);
if (k != NULL && yieldable(L)) { /* need to prepare continuation? */
L->ci->u.c.k = k; /* save continuation */
L->ci->u.c.ctx = ctx; /* save context */
luaD_call(L, func, nresults); /* do the call */
}
else /* no continuation or no yieldable */
luaD_callnoyield(L, func, nresults); /* just do the call */
//...
}
LUA_API int lua_pcallk (lua_State *L, int nargs, int nresults, int errfunc,
lua_KContext ctx, lua_KFunction k) {
struct CallS c;
//...
c.func = L->top.p - (nargs+1); /* function to be called */
if (k == NULL || !yieldable(L)) { /* no continuation or no yieldable? */
c.nresults = nresults; /* do a 'conventional' protected call */
status = luaD_pcall(L, f_call, &c, savestack(L, c.func), func);
}
else { /* prepare continuation (call is already protected by 'resume') */
CallInfo *ci = L->ci;
ci->u.c.k = k; /* save continuation */
ci->u.c.ctx = ctx; /* save context */
/* save information for error recovery */
ci->u2.funcidx = cast_int(savestack(L, c.func));
ci->u.c.old_errfunc = L->errfunc;
L->errfunc = func;
setoah(ci, L->allowhook); /* save value of 'allowhook' */
ci->callstatus |= CIST_YPCALL; /* function can do error recovery */
luaD_call(L, c.func, nresults); /* do the call */
ci->callstatus &= ~CIST_YPCALL;
L->errfunc = ci->u.c.old_errfunc;
status = LUA_OK; /* if it is here, there were no errors */
}
//...
return status;
}
C 回调 Lua function
Lua 支持将 function
作为参数实现函数式编程,而某些场景也需要实现 C 异步回调 Lua 的 function
(如 C 函数实现网络或下载等耗时操作,执行完异步回调结果给 Lua)。
这里涉及到 Lua 另外几个重要的概念:
Userdata
userdata 分为 full userdata 和 light userdata:前者是 对 C 内存块(即 buffer)的封装,而后者是对 C 裸指针的封装;
源码简析:
我们简单看下源码:
#define lua_newuserdata(L,s) lua_newuserdatauv(L,s,1)
LUA_API void *lua_newuserdatauv (lua_State *L, size_t size, int nuvalue) {
Udata *u;
//...
u = luaS_newudata(L, size, cast(unsigned short, nuvalue));
//...
return getudatamem(u);
}
Udata *luaS_newudata (lua_State *L, size_t s, unsigned short nuvalue) {
Udata *u;
//...
o = luaC_newobj(L, LUA_VUSERDATA, sizeudata(nuvalue, s));
//...
return u;
}
GCObject *luaC_newobj (lua_State *L, lu_byte tt, size_t sz) {
return luaC_newobjdt(L, tt, sz, 0);
}
GCObject *luaC_newobjdt (lua_State *L, lu_byte tt, size_t sz, size_t offset) {
global_State *g = G(L);
char *p = cast_charp(luaM_newobject(L, novariant(tt), sz));
GCObject *o = cast(GCObject *, p + offset);
o->marked = luaC_white(g);
o->tt = tt;
o->next = g->allgc;
g->allgc = o;
return o;
}
#define luaM_newobject(L,tag,s) luaM_malloc_(L, (s), tag)
可以看到:userdata 底层就是分配了一块内存区域,并封装为一个 GCObject
。
另外,lua_pushlightuserdata
和 lua_touserdata
用于 C 裸指针在 Lua VM 表中的存取;
Registry 和 Reference
Registry 可以理解为一张特殊的表(或许灵感正是 Windows 的注册表?),它内部会保证 key 的唯一,但要求外部必须通过 LUA_REGISTRYINDEX
存取(不能自己传 key),而它负责返回唯一的 key,这个 key 就叫做 Reference。
为什么需要这两个东西呢?
Lua 不允许直接操纵 VM 内部对象的指针,因而必须通过 Registery 和 Reference。
luaL_ref
弹出栈顶元素,并存到注册表,返回 Reference;lua_rawgeti
将注册表中的 Reference 加载到栈顶(进而可以通过pcall
调用);luaL_unref
用于释放 Reference;
源码简析:
很容易看到,核心仍然是对 table 的操作:
LUALIB_API int luaL_ref (lua_State *L, int t) {
int ref;
//...
t = lua_absindex(L, t);
if (lua_rawgeti(L, t, 1) == LUA_TNUMBER)
//...
else {
//...
lua_rawseti(L, t, 1);
}
//...
if (ref != 0) {
lua_rawgeti(L, t, ref);
lua_rawseti(L, t, 1);
}
else
//...
lua_rawseti(L, t, ref);
return ref;
}
LUALIB_API void luaL_unref (lua_State *L, int t, int ref) {
//...
t = lua_absindex(L, t);
lua_rawgeti(L, t, 1);
//...
lua_rawseti(L, t, ref);
//...
lua_rawseti(L, t, 1);
//...
}
LUA_API int lua_rawgeti (lua_State *L, int idx, lua_Integer n) {
Table *t;
//...
t = gettable(L, idx);
luaH_fastgeti(t, n, s2v(L->top.p), tag);
return finishrawget(L, tag);
}
LUA_API void lua_rawseti (lua_State *L, int idx, lua_Integer n) {
Table *t;
//...
t = gettable(L, idx);
luaH_setint(L, t, n, s2v(L->top.p - 1));
//...
}
使用 libuv 实现 Lua Timer
Timer 对于异步编程是一个很重要的特性,但 Lua 标准库并未提供,因此我们选择基于 libuv 手动实现。
它是一个高性能的跨平台异步 I/O 库,底层基于 epoll/kqueue 等,被 NodeJS 等开源项目广泛采用;它内部提供了定时器和事件循环等机制,很适合我们这个场景。
值得注意的是,鸿蒙已经内置了 libuv,也更坚信了我们这个技术选型。
数据结构定义
首先定义一个结构体,用于持有 Lua 的 function
以及相关参数:
typedef struct
{
lua_State *lState;
int lFuncRef;
size_t timeout;
bool repeat;
bool finished;
} xxx_lua_timer_data;
libuv 事件循环
然后对 libuv 事件循环的初始化和释放做下简单封装:
void xxx_lua_uv_loop_init()
{
uv_loop_init(uv_default_loop());
}
void xxx_lua_uv_loop_prepare()
{
uv_run(uv_default_loop(), UV_RUN_DEFAULT);
}
void xxx_lua_uv_loop_stop()
{
if (!uv_loop_alive(uv_default_loop()))
{
return;
}
uv_stop(uv_default_loop());
uv_loop_close(uv_default_loop());
}
存取 Lua function 相关参数
接下来就是面向 Lua 的 C 函数入口,主要就是拿到 Lua 定时器的相关参数和 function
指针,然后保存到结构体:
static int xxx_lua_util_timer_add(lua_State *L)
{
auto timeout = lua_tointeger(L, 1);
auto repeat = lua_toboolean(L, 2);
auto timer_data = reinterpret_cast<xxx_lua_timer_data *>(malloc(sizeof(xxx_lua_timer_data)));
timer_data->lFuncRef = luaL_ref(L, LUA_REGISTRYINDEX);
timer_data->lState = L;
timer_data->timeout = timeout;
timer_data->repeat = repeat != 0;
auto timer = xxx_lua_uv_timer_start(timer_data);
lua_pushlightuserdata(L, timer);
return 1;
}
static int xxx_lua_util_timer_remove(lua_State *L)
{
auto timer = reinterpret_cast<uv_timer_t *>(lua_touserdata(L, 1));
if (timer != NULL)
{
xxx_lua_uv_timer_stop(timer, true);
}
return 0;
}
通过 libuv 驱动定时器
最后就是核心的:
因为定时器核心逻辑一般是耗时操作,直接丢到后台线程异步执行;
通过
uv_timer_xxx
相关 API 建立定时器的调度流程;在 libuv 定时器的回调内,通过上面提到的注册表和引用等回调 Lua 的
function
;回调完取消对 Lua
function
的引用,并释放 libuv 的定时器;
void xxx_lua_uv_timer_cb(uv_timer_t *timer)
{
auto timer_data = reinterpret_cast<xxx_lua_timer_data *>(timer->data);
lua_rawgeti(timer_data->lState, LUA_REGISTRYINDEX, timer_data->lFuncRef);
lua_pcall(timer_data->lState, 0, 0, 0);
}
uv_timer_t* xxx_lua_uv_timer_start(xxx_lua_timer_data *timer_data)
{
auto timer = reinterpret_cast<uv_timer_t *>(malloc(sizeof(uv_timer_t)));
timer->data = timer_data;
std::thread([timer = timer]() {
uv_timer_init(uv_default_loop(), timer);
auto timer_data = reinterpret_cast<xxx_lua_timer_data *>(timer->data);
uv_timer_start(timer, xxx_lua_uv_timer_cb, timer_data->timeout, timer_data->repeat ? timer_data->timeout : 0);
xxx_lua_uv_loop_prepare();
}).detach();
return timer;
}
void xxx_lua_uv_timer_stop(uv_timer_t *timer, bool release)
{
auto timer_data = reinterpret_cast<xxx_lua_timer_data *>(timer->data);
luaL_unref(timer_data->lState, LUA_REGISTRYINDEX, timer_data->lFuncRef);
if (!timer_data->finished)
{
uv_timer_set_repeat(timer, 0);
uv_timer_stop(timer);
timer_data->finished = true;
}
if (release)
{
free(timer->data);
free(timer);
}
}
Lua 端使用
通过前文的方式将上述两个 C 函数注册到 Lua 环境后,我们就可以直接在 Lua 代码使用定时器:
function TestTimer()
local count = 0
local timer
local timerF = function()
count = count + 1
--
if count == 3 then
Timer.remove(timer)
end
end
timer = Timer.add(1234, true, timerF)
end
参考: