LuaJIT通过ffi接口偶现无法执行C动态库中的C函数问题追踪

1. LuaJIT介绍

1.1 什么是LuaJIT

官方介绍:https://luajit.org/luajit.html
LuaJIT目前最新的版本是2.1,其支持的最新Lua语法格式是Lua5.1(最新的Lua版本是Lua5.4)。

1.2 LuaJIT可以用来干什么

LuaJIT包括两大块儿:luajit库、luajit程序

1.2.1 luajit程序

luajit程序是基于luajit库开发的一个LuaJIT的命令行程序。通过luajit程序可以执行lua脚本,也可以编写和执行lua程序。

1.2.2 luajit库

luajit库是luajit的核心,其实现了Lua5.1的所有基础语法,同时又实现了JIT功能。此外,通过LuaJIT的FFI库,可以实现从Lua脚本直接调用C动态库中的相关C函数。

2. 出问题项目中LuaJIT应用介绍

我们有个项目为了方便快捷的进行功能拓展开发,需要将Lua和C一起联合进行编码。
主体架构是:

  1. C开发了一个进程foo;
  2. 在进程foo中通过luajit库执行一些lua脚本(lua脚本是一些快速迭代的小功能);
  3. 为了提效,luajit执行的lua脚本中有部分功能是直接通过LuaJIT的FFI库加载C的动态库libfeature.so,并通过ffi.C方法直接调用动态库libfeature.so中的C函数(C函数名称:test_luajit_c_func);

补充说明:

  1. foo进程执行lua脚本是通过lua接口lua_pcall()函数调用lua脚本中的某个Lua函数(Lua函数名称:lua_function);
  2. foo进程维护了一个lua_State对象(本lua_State对象创建一次,长期使用,除非其关联的lua脚本内容变化);
  3. foo进程执行lua脚本的方式是通过lua_pcall()接口调用lua脚本中的一个指定名称的函数(lua_function)。

3. 问题现象描述

3.1 lua脚本

local ffi = require("ffi")
ffi.load("feature", true)
ffi.cdef([[
void test_luajit_c_func(const char *str);
]])
lua_function = function()
    ffi.C.test_luajit_c_func("test luajit C function called")
end

3.2 问题现象

foo进程在运行过程中,通过lua_State对象执行上述的lua脚本时,偶尔会出现调用test_luajit_c_func()函数失败的情况。失败时LuaJIT库通过error信息提示找不到C函数test_luajit_c_func对应的符号*(undefined symbol: test_luajit_c_func)*。

4. 问题原因分析

4.1 问题分析和复现

通过问题现象分析,本问题是找不到C动态库的C函数。接下来通过分析LuaJIT的FFI源码,分析FFI库操作C库的逻辑。

4.1.1 FFI操作逻辑梳理

在分析了LuaJIT源码后发现FFI库调用C动态库中的C函数的逻辑如下:

  1. LuaJIT中通过专门的CLibrary结构体对象来和C动态库进行交互。包括:
    1.1 和C动态库的交互:dlopen()、dlsym()、dlclose()的调用,及其dlopen()返回handle的维护;
    1.2 缓存dlsym()查找到的C函数地址(CLibrary中有个GCtab对象,专门用于C函数地址缓存的),避免相同函数的重复查找。
  2. 调用ffi.load(“feature”, true)时,LuaJIT库会调用lj_clib_load()函数,再嵌套调用clib_loadlib()函数,通过clib_loadlib()函数调用了dlopen()函数加载C动态库;
  3. FFI加载的C动态库在卸载时会调用lj_clib_unload()函数,再嵌套调用clib_unloadlib()函数,通过clib_unloadlib()函数调用dlclose()函数卸载C动态库。lj_clib_unload()函数是在ffi对象被Lua虚拟机GC时调用的;
  4. 调用ffi.C.test_luajit_c_func()时,LuaJIT库会调用lj_clib_index()函数,在嵌套调用clib_getsym()函数,通过clib_getsym()函数调用dlsym()函数从C动态库中查找对应的C函数“test_luajit_c_func”。最后会还会在lj_clib_index()函数中缓存C函数地址在LuaJIT库中(缓存使用的是TValue对象);
  5. FFI库中还有一个默认的CLibrary对象,指向进程全局的动态库符号映射。该CLibrary对象的handle值是NULL。

4.1.2 LuaJIT库加入调试日志验证

针对LuaJIT的FFI库操作C动态库的相关逻辑分析,我们在FFI库的clib_loadlib()、clib_getsym()、lj_clib_default()、lj_clib_load()、lj_clib_unload()函数中加入调试日志,用于追踪LuaJIT操作C动态库feature的过程。增加的日志代码如下(以git diff形式呈现):

diff --git a/src/lj_clib.c b/src/lj_clib.c
index 513528c..94d1a4e 100644
--- a/src/lj_clib.c
+++ b/src/lj_clib.c
@@ -54,6 +54,10 @@ LJ_NORET LJ_NOINLINE static void clib_error_(lua_State *L)
 #define CLIB_SOEXT     "%s.so"
 #endif
 
+#define LJ_LOGF(fmt, args...) printf("<%s@%d> " fmt "\n", __func__, __LINE__, ##args)
+
 static const char *clib_extname(lua_State *L, const char *name)
 {
   if (!strchr(name, '/')
@@ -128,11 +132,14 @@ static void *clib_loadlib(lua_State *L, const char *name, int global)
     if (!err) err = "dlopen failed";
     lj_err_callermsg(L, err);
   }
+  LJ_LOGF("L:%p, libname:%s, global:%s, handle:%p",
+            L, name, global ? "true" : "false", h);
   return h;
 }
 
 static void clib_unloadlib(CLibrary *cl)
 {
+    LJ_LOGF("cl:%p, cl->handle:%p", cl, cl->handle);
   if (cl->handle && cl->handle != CLIB_DEFHANDLE)
     dlclose(cl->handle);
 }
@@ -140,6 +147,8 @@ static void clib_unloadlib(CLibrary *cl)
 static void *clib_getsym(CLibrary *cl, const char *name)
 {
   void *p = dlsym(cl->handle, name);
+    LJ_LOGF("cl:%p, cl->handle:%p, funcname:%s, func:%p",
+            cl, cl->handle, name, p);
   return p;
 }
 
@@ -390,6 +399,8 @@ TValue *lj_clib_index(lua_State *L, CLibrary *cl, GCstr *name)
       lj_gc_anybarriert(L, cl->cache);
     }
   }
+  LJ_LOGF("L:%p, cl:%p, cl->handle:%p, funcname:%s, func-tv:%p",
+            L, cl, cl->handle, strdata(name), tv);
   return tv;
 }
 
@@ -415,11 +426,14 @@ void lj_clib_load(lua_State *L, GCtab *mt, GCstr *name, int global)
   void *handle = clib_loadlib(L, strdata(name), global);
   CLibrary *cl = clib_new(L, mt);
   cl->handle = handle;
+  LJ_LOGF("L:%p, libname:%s, global:%s, cl:%p, cl->handle:%p",
+            L, strdata(name), global ? "true" : "false", cl, cl->handle);
 }
 
 /* Unload a C library. */
 void lj_clib_unload(CLibrary *cl)
 {
+    LJ_LOGF("cl:%p, cl->handle:%p", cl, cl->handle);
   clib_unloadlib(cl);
   cl->handle = NULL;
 }
@@ -429,6 +443,7 @@ void lj_clib_default(lua_State *L, GCtab *mt)
 {
   CLibrary *cl = clib_new(L, mt);
   cl->handle = CLIB_DEFHANDLE;
+    LJ_LOGF("L:%p, cl:%p, cl->handle:%p", L, cl, cl->handle);
 }
 
 #endif
4.1.2.1 正常时日志情况
<lj_clib_default@446> L:0x7f1299216380, cl:0x7f1299223710, cl->handle:(nil)
<clib_loadlib@135> L:0x7f1299216380, libname:feature, global:true, handle:0x7f1298eedb00
<lj_clib_load@429> L:0x7f1299216380, libname:feature, global:true, cl:0x7f1299224130, cl->handle:0x7f1298eedb00
<clib_getsym@150> cl:0x7f1299223710, cl->handle:(nil), funcname:test_luajit_c_func, func:0x7f129a0539b3
<lj_clib_index@402> L:0x7f1299216380, cl:0x7f1299223710, cl->handle:(nil), funcname:test_luajit_c_func, func-tv:0x7f1299224278
<lj_clib_index@402> L:0x7f1299216380, cl:0x7f1299223710, cl->handle:(nil), funcname:test_luajit_c_func, func-tv:0x7f1299224278
4.1.2.2 出问题时日志情况
<lj_clib_default@446> L:0x7ff033ca8380, cl:0x7ff033ca5eb8, cl->handle:(nil)
<clib_loadlib@135> L:0x7ff033ca8380, libname:feature, global:true, handle:0x7fefff5c9000
<lj_clib_load@429> L:0x7ff033ca8380, libname:feature, global:true, cl:0x7ff033ca6508, cl->handle:0x7fefff5c9000
<lj_clib_unload@436> cl:0x7ff033ca6508, cl->handle:0x7fefff5c9000
<clib_unloadlib@142> cl:0x7ff033ca6508, cl->handle:0x7fefff5c9000
<clib_getsym@150> cl:0x7ff033ca5eb8, cl->handle:(nil), funcname:test_luajit_c_func, func:(nil)
<clib_getsym@150> cl:0x7ff033ca5eb8, cl->handle:(nil), funcname:test_luajit_c_func, func:(nil)
<clib_getsym@150> cl:0x7ff033ca5eb8, cl->handle:(nil), funcname:test_luajit_c_func, func:(nil)

4.1.3 根据日志分析结果

通过对比分析正常运行时和出错时的LuaJIT日志,发现:

  1. C动态库load时的CLibrary对象和ffi.C.test_luajit_c_func()调用C函数时的CLibrary对象不同;
  2. ffi.C.test_luajit_c_func()调用C函数时的CLibrary对象恒定为LuaJIT的default-CLibrary对象(请细看CLibrary的内存地址)。因此,每次ffi.C.test_luajit_c_func()调用其实都是从进程的全局空间中查找C函数的;
  3. 发生问题时,从日志可以清晰看出在ffi.C.test_luajit_c_func()函数调用前,通过ffi.load(“feature”, true)加载的C动态库已经被成功卸载了(日志中有lj_clib_unload()和clib_unloadlib()两个函数的调用)。说明ffi.load(“feature”, true)加载的对象被GC了;

4.2 本问题偶现原因分析

进程foo加载lua脚本时,在脚本ffi.load(“feature”, true)处LuaJIT库加载了C动态库(C进程中加载lua脚本时有调用lua_pcall()函数(调用方式:lua_pcall(0,0,0,0)))。且通过分析可以发现C动态库加载后的CLibrary对象是放在LuaJIT的lua栈空间的。
但是ffi.load(“feature”, true)加载C动态库的CLibrary对象在lua_pcall()函数调用结束时,已经从Lua栈空间中弹出(处于无引用,待回收状态)。
结合日志进一步分析得出:正常运行时ffi.load(“feature”, true)加载的CLibrary对象为被释放(未被GC);异常时被释放(被GC)。
同时,ffi.load(“feature”, true)加载的C动态库是加载到了foo进程的全局空间中的。所以,即使ffi.C.test_luajit_c_func()每次调用时的CLibrary对象不是正确的ffi.load(“feature”, true)得到的(default-CLibrary对象),但由于ffi.load(“feature”, true)的CLibrary对象还未被释放,则可以通过进程全局空间找到C函数test_luajit_c_func()并执行。

5. 问题解决方案

5.1 快速解决方案

由于我们在本次项目中是选择通过FFI库将C动态库加载到进程全局空间中的。所以,我们可以通过让FFI库加载的CLibrary对象在lua接口函数lua_pcall()调用退出后仍然有被引用(ffi.load()执行的结果赋值给一个变量即可),则可以保证其不会被释放。具体修改如下:

local ffi = require("ffi")
local clib_feature = ffi.load("feature", true)
ffi.cdef([[
void test_luajit_c_func(const char *str);
]])
lua_function = function()
    ffi.C.test_luajit_c_func("test luajit C function called")
end

5.2 更好的解决方案

5.2.1 “快速解决方案”的弊端

前面的“快速解决方案”虽然解决了当前项目中遇到的问题,但是不够优雅和通用。主要原因如下:
4. 通过ffi.load(“feature”, false)加载C动态库,无法使用(因为其未加载到进程全局空间,所以用default-CLibrary对象无法找C动态库中的C函数);
5. 由于无法使用ffi.load(“feature”, false)模式加载C动态库,如果在项目中有多处通过LuaJIT潜入lua脚本,不同lua脚本中通过ffi.load()加载不同的C动态库,不同的C动态库中如果存在相同名称的接口函数。则使用ffi.load(“xxx”, true)方式加载所有的C动态库,然后通过ffi.C.xxx_func调用C函数就有可能会出问题。

5.2.2 优雅的解决方案

我个人认为更加优雅的解决方案需要具备如下特点:

  1. 通过ffi.load()加载的CLibrary对象不应该保存在lua的栈中;
  2. 可以应用ffi.load(“xxx”, false)方式将C动态库加载私有的空间中,从而实现不同lua_State对象加载的CLibrary对象在空间的隔离,最终保证其应用不会有交叉;
5.2.2.1 具体的修改方案

将所有ffi.load()加载的CLi brary对象保存在lua_State对象中。同时,在lua_State对象中维护一个全局的C函数地址的缓存,将所有ffi.C.xxx_c_func()的第一次调用得到C函数的地址缓存到这个全局的缓存中。
我clone了一份LuaJIT的最新代码,并按照上述方案思想进行了对应的修改。
本方案具体实现代码:https://github.com/xunmengzhe/LuaJIT

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值