普通视图

发现新文章,点击刷新页面。
今天 — 2026年3月11日首页

soluna 外挂 C 模块

作者 云风
2026年3月11日 14:49

soluna 集成了 lua 虚拟机,但默认构建方式是将 lua 库静态链接到唯一的执行文件中。这将导致无法以动态库的形式外挂 Lua 的 C 扩展。

这是因为,如果独立编译 Lua 的 C 扩展库,通常需要链接 Lua 的 C API 。标准的方法是动态链接 lua 实现,如果静态链接 liblua.a ,会导致进程中有多份 lua 的实现。在 Lua 的历史版本中,这将导致运行期错误。

这是因为,Lua 的实现中有一个静态的“空”对象,所有的 nil 都指向这个对象。如果进程空间中有多份 Lua 实现,就会出现多个空对象。运行时的数据结构中会引用这个空对象,而不同副本的实现将“空”和自身保留的“空”对象引用做比较时,就会出现错误的判断。

在更早期的版本,出现这种链接出现的项目,bug 会隐藏得很深。所以后来 Lua 增加了 luaL_checkversion() ,倡议在外部库初始化时调用,除了检查版本号,还会检查当前执行的 lua 实现是否和虚拟机创建时用的实现是同一个副本。

但不知道怎样正确链接 lua 的项目(保证进程中只有一份 Lua 实现)还是太多,从 Lua 5.4 以后,这个“空”对象就被移入了 lua_State 这个运行期结构。以牺牲一点运行时的代价,挽救那些似乎永远也搞不懂“加载和链接”的程序员。终于,错误的链接 Lua 也能不出错了。

但我还是认为,在同一进程中置入多份 Lua 实现是不好的。

注:这也是 Windows 动态库的一个独有问题。因为 Windows 的 DLL 不允许有未完成的符号,必须在编译链接时指定所有符号(Lua C API)的来源;如果是 Linux ,可以不链接 Lua C API 的库,在运行时加载动态库,加载器就能把进程内的对应符号装载起来。


回到题头的问题:soluna 静态链接了 Lua ,并未导出 C API ,要用 C 写额外的库怎么办?

曾经在 Ant Engine 中,我采用了一个方法:提供一个假的代理动态库,提供所有 Lua C API 的符号。外部库可以动态链接它,而它将所有 Lua C API 调用转发到 engine 内部链接的 Lua 实现中。

这样做的好处是,即使是预编译好的 Lua C 库,只要它正确的以动态链接形式链接了 Lua ,就能直接被 Ant Engine 加载。如果不需要外部库,这个代理库也可以不发布。

今天,我想给 soluna 加上类似的特性,但尝试了新的方案:外部库在构建时额外实现一个简单的入口函数,它不依赖真的 Lua 实现,而是链接 soluna 项目中的 extlua/extlua.c 这个 Lua API 代理实现。再由 soluna 的定制加载器来加载这个外部库。

比如,我有一个叫做 foobar 的外部库,原本的实现是这样的:

static int
lhello(lua_State *L) {
    lua_pushstring(L, "Hello World");
    return 1;
}

extern int
luaopen_foobar(lua_State *L) {
    luaL_Reg l[] = {
        { "hello", lhello },
        { NULL, NULL },
    };
    luaL_newlib(L, l);
    return 1;
}

当我们编译成动态库时,导出的 luaopen_foobar() 是库的入口。lua 的 require 可以正确的导入它。但这个实现依赖若干 lua C APIs ,例如 lua_pushstring() 等。

如何在 soluna 里正确加载它呢?我们需要在调用 luaopen_foobar() 这个入口函数前,将进程中的 Lua C APIs 注入这个动态库。

在这个方案中,只需要链接 soluna 项目中的 extlua/extlua.c 单个文件,然后导出一个额外的库入口函数:

extern int
extlua_init(lua_State *L) {
    luaapi_init(L);
    luaL_Reg l[] = {
        { "ext.foobar", luaopen_foobar },
        { NULL, NULL },
    };
    luaL_newlib(L, l);
    return 1;
}

这个函数的第一行需要调用 luaapi_init(L) ,它的实现在 extlua.c 中。然后用 luaL_newlib() 注入原有的模块入口函数即可。

luaapi_init(L) 并不依赖任何 Lua 的内部实现,只依赖 Lua 的一个官方宏 lua_getextraspace() 完成了注入 Lua C APIs 的魔法。

这是个有趣的技巧:

lua_getextraspace(L) 的官方定义是这样的:

#define lua_getextraspace(L)    ((void *)((char *)(L) - LUA_EXTRASPACE))

每个 Lua_State 结构前都保留有一个指针的空间,可以用来传递数据。soluna.external.load 会构建一个空的 Lua 虚拟机,并把所有 Lua C APIs 的引用放在它的 extraspace 。因为上面的 extlua_init() 是一个标准的 lua_CFunction ,所以可以用标准函数 package.loadlib 读出。传入这个带 C APIs 的空 Lua 虚拟机,luaapi_init() 就能正确的导入所有 API 了。随后的 luaL_newlib() 会把所有真正的入口函数放在这个空虚拟机中。当然,只是一些字符串(入口名)和 C 函数指针。

接下来,soluna.external.load 再从这个虚拟机中把整个入口函数表复制到当前虚拟机,并销毁掉这个临时虚拟机,就完成了整个外部模块的动态加载。


soluna.extlib(name) 的实现是这样的:

function soluna.extlib(name)
    local extlua = require "soluna.extlua"
    local filename = assert(package.searchpath(name, package.cpath))
    settings = settings and soluna.settings()
    local entry = assert(package.loadlib(filename, settings.extlua_entry))
    return extlua.load(entry)
end

要使用上面例子中的放在 sample.dll 中的库 ext.foobar 只需要这样:

local libs = soluna.extlib "sample"
local foobar = require "ext.foobar"
assert(libs["ext.foobar"] == foobar)

即使要静态链接 sample 模块(iOS 不支持动态库,可能必须静态链接),只需要采用以下编译方案即可正确工作:

  1. 静态链接 sample 模块
  2. 不要链接 extlua/extlua.c
  3. luaapi_init() 定义为一个空函数
  4. extlua_init() 这个入口函数导入
  5. 用 soluna.extlua.load(入口函数) 加载
❌
❌