打造基于 Lua 的跨平台脚本引擎

Lua 是一门短小精悍的脚本语言,在游戏和图形渲染领域早已有广泛应用。

同时,由于它能方便的和 C 语言交互、以及动态解释执行的特性,易于实现热更新,因此很适合用于二次开发实现跨平台脚本引擎。

JIT 版本的 Lua 虽性能优异,但由于 iOS 审核方面的限制,显然不适合跨平台场景。

Lua 官方的 runtime 本身基于 C 实现,并且自带了和 C 语言交互的各种 API。

下面我们一步步来实现基于 Lua 的跨平台脚本引擎,并顺便简单了解下相关源码。

虚拟机实例的管理

作为基于虚拟的的语言,首先最基础的当然是管理 VM,对于 Lua 来说就是 lua_state

要管理也很简单,就 luaL_newstatelua_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_dofileluaL_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, &gt);
  auxsetstr(L, &gt, 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 &params)
{
    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 userdatalight 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_pushlightuserdatalua_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

参考: