soluna 外挂 C 模块
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 不支持动态库,可能必须静态链接),只需要采用以下编译方案即可正确工作:
- 静态链接 sample 模块
- 不要链接 extlua/extlua.c
- 将
luaapi_init()定义为一个空函数 - 把
extlua_init()这个入口函数导入 - 用 soluna.extlua.load(入口函数) 加载