OpenResty Lua FFI

2021-08-12 16:51 更新

FFI 库,是 LuaJIT 中最重要的一个扩展库。它允许从纯 Lua 代码调用外部 C 函数,使用 C 数据结构。有了它,就不用再像 Lua 标准 ​math ​库一样,编写 Lua 扩展库。把开发者从开发 Lua 扩展 C 库(语言/功能绑定库)的繁重工作中释放出来。学习完本小节对开发纯 ​ffi ​的库是有帮助的,像 lru-resty-lrucache 中的 ​pureffi.lua​,这个纯 ​ffi​ 库非常高效地完成了 ​lru ​缓存策略。

简单解释一下 Lua 扩展 C 库,对于那些能够被 Lua 调用的 C 函数来说,它的接口必须遵循 Lua 要求的形式,就是 ​typedef int (*lua_CFunction)(lua_State* L)​,这个函数包含的参数是 ​lua_State ​类型的指针 L 。可以通过这个指针进一步获取通过 Lua 代码传入的参数。这个函数的返回值类型是一个整型,表示返回值的数量。需要注意的是,用 C 编写的函数无法把返回值返回给 Lua 代码,而是通过虚拟栈来传递 Lua 和 C 之间的调用参数和返回值。不仅在编程上开发效率变低,而且性能上比不上 FFI 库调用 C 函数。

FFI 库最大限度的省去了使用 C 手工编写繁重的 ​Lua/C​ 绑定的需要。不需要学习一门独立/额外的绑定语言——它解析普通 C 声明。这样可以从 C 头文件或参考手册中,直接剪切,粘贴。它的任务就是绑定很大的库,但不需要捣鼓脆弱的绑定生成器。

FFI 紧紧的整合进了 LuaJIT(几乎不可能作为一个独立的模块)。​JIT ​编译器在 C 数据结构上所产生的代码,等同于一个 C 编译器应该生产的代码。在 ​JIT ​编译过的代码中,调用 C 函数,可以被内连处理,不同于基于 ​Lua/C API​ 函数调用。

ffi 库 词汇

nounExplanation
cdeclA definition of an abstract C type(actually, is a lua string)
ctypeC type object
cdataC data object
ctC type format, is a template object, may be cdecl, cdata, ctype
cbcallback object
VLAAn array of variable length
VLSA structure of variable length

ffi.* API

功能: Lua ffi 库的 API,与 LuaJIT 不可分割。

毫无疑问,在 ​lua ​文件中使用 ​ffi ​库的时候,必须要有下面的一行。

local ffi = require "ffi"

ffi.cdef

语法: ffi.cdef(def)

功能: 声明 C 函数或者 C 的数据结构,数据结构可以是结构体、枚举或者是联合体,函数可以是 C 标准函数,或者第三方库函数,也可以是自定义的函数,注意这里只是函数的声明,并不是函数的定义。声明的函数应该要和原来的函数保持一致。

ffi.cdef[[
typedef struct foo { int a, b; } foo_t;  /* Declare a struct and typedef.   */
int printf(const char *fmt, ...);        /* Declare a typical printf function. */
]]

注意: 所有使用的库函数都要对其进行声明,这和我们写 C 语言时候引入 .h 头文件是一样的。

顺带一提的是,并不是所有的 C 标准函数都能满足我们的需求,那么如何使用 第三方库函数 或 自定义的函数 呢,这会稍微麻烦一点,不用担心,你可以很快学会。: ) 首先创建一个 ​myffi.c​,其内容是:

int add(int x, int y)
{
  return x + y;
}

接下来在 Linux 下生成动态链接库:

gcc -g -o libmyffi.so -fpic -shared myffi.c

为了方便我们测试,我们在 ​LD_LIBRARY_PATH ​这个环境变量中加入了刚刚库所在的路径,因为编译器在查找动态库所在的路径的时候其中一个环节就是在 ​LD_LIBRARY_PATH ​这个环境变量中的所有路径进行查找。命令如下所示。

export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:your_lib_path

在 Lua 代码中要增加如下的行:

ffi.load(name [,global])

ffi.load​ 会通过给定的 ​name ​加载动态库,返回一个绑定到这个库符号的新的 C 库命名空间,在 POSIX 系统中,如果 ​global ​被设置为 ​ture​,这个库符号被加载到一个全局命名空间。另外这个 ​name ​可以是一个动态库的路径,那么会根据路径来查找,否则的话会在默认的搜索路径中去找动态库。在 POSIX 系统中,如果在 ​name 这个字段中没有写上点符号 .,那么 ​.so​ 将会被自动添加进去,例如 ​ffi.load("z") ​会在默认的共享库搜寻路径中去查找 ​libz.so​,在 ​windows ​系统,如果没有包含点号,那么 ​.dll​ 会被自动加上。

下面看一个完整例子:

local ffi = require "ffi"
local myffi = ffi.load('myffi')

ffi.cdef[[
int add(int x, int y);   /* don't forget to declare */
]]

local res = myffi.add(1, 2)
print(res)  -- output: 3   Note: please use luajit to run this script.

除此之外,还能使用 ​ffi.C​ (调用 ffi.cdef 中声明的系统函数) 来直接调用 ​add ​函数,记得要在 ​ffi.load​ 的时候加上参数 ​true​,例如 ​ffi.load('myffi', true)​。

完整的代码如下所示:

local ffi = require "ffi"
ffi.load('myffi',true)

ffi.cdef[[
int add(int x, int y);   /* don't forget to declare */
]]

local res = ffi.C.add(1, 2)
print(res)  -- output: 3   Note: please use luajit to run this script.

ffi.typeof

语法: ctype = ffi.typeof(ct)

功能: 创建一个 ctype 对象,会解析一个抽象的 C 类型定义。

local uintptr_t = ffi.typeof("uintptr_t")
local c_str_t = ffi.typeof("const char*")
local int_t = ffi.typeof("int")
local int_array_t = ffi.typeof("int[?]")

ffi.new

语法: cdata = ffi.new(ct [,nelem] [,init...])

功能: 开辟空间,第一个参数为 ctype 对象,ctype 对象最好通过 ctype = ffi.typeof(ct) 构建。

顺便一提,可能很多人会有疑问,到底 ​ffi.new​ 和 ​ffi.C.malloc​ 有什么区别呢?

如果使用 ​ffi.new​ 分配的 ​cdata ​对象指向的内存块是由垃圾回收器 ​LuaJIT GC​ 自动管理的,所以不需要用户去释放内存。

如果使用 ​ffi.C.malloc​ 分配的空间便不再使用 LuaJIT 自己的分配器了,所以不是由 ​LuaJIT GC​ 来管理的,但是,要注意的是 ​ffi.C.malloc​ 返回的指针本身所对应的 ​cdata ​对象还是由 ​LuaJIT GC​ 来管理的,也就是这个指针的 ​cdata ​对象指向的是用 ​ffi.C.malloc​ 分配的内存空间。这个时候,你应该通过 ​ffi.gc()​ 函数在这个 C 指针的 ​cdata ​对象上面注册自己的析构函数,这个析构函数里面你可以再调用 ​ffi.C.free​,这样的话当 C 指针所对应的 ​cdata ​对象被 ​Luajit GC​ 管理器垃圾回收时候,也会自动调用你注册的那个析构函数来执行 C 级别的内存释放。

请尽可能使用最新版本的 ​Luajit​,​x86_64 ​上由 ​LuaJIT GC​ 管理的内存已经由 ​1G->2G​,虽然管理的内存变大了,但是如果要使用很大的内存,还是用 ​ffi.C.malloc​ 来分配会比较好,避免耗尽了 ​LuaJIT GC​ 管理内存的上限,不过还是建议不要一下子分配很大的内存。

local int_array_t = ffi.typeof("int[?]")
local bucket_v = ffi.new(int_array_t, bucket_sz)

local queue_arr_type = ffi.typeof("lrucache_pureffi_queue_t[?]")
local q = ffi.new(queue_arr_type, size + 1)

ffi.fill

语法: ffi.fill(dst, len [,c])

功能: 填充数据,此函数和 memset(dst, c, len) 类似,注意参数的顺序。

ffi.fill(self.bucket_v, ffi_sizeof(int_t, bucket_sz), 0)
ffi.fill(q, ffi_sizeof(queue_type, size + 1), 0)

ffi.cast

语法: cdata = ffi.cast(ct, init)

功能: 创建一个 scalar cdata 对象。

local c_str_t = ffi.typeof("const char*")
local c_str = ffi.cast(c_str_t, str)       -- 转换为指针地址

local uintptr_t = ffi.typeof("uintptr_t")
tonumber(ffi.cast(uintptr_t, c_str))       -- 转换为数字

cdata 对象的垃圾回收

所有由显式的 ​ffi.new()​, ​ffi.cast() ​etc. 或者隐式的 ​accessors ​所创建的 ​cdata ​对象都是能被垃圾回收的,当他们被使用的时候,你需要确保有在 ​Lua stack,upvalue​,或者 ​Lua table​ 上保留有对 ​cdata ​对象的有效引用,一旦最后一个 ​cdata ​对象的有效引用失效了,那么垃圾回收器将自动释放内存(在下一个 ​GC ​周期结束时候)。另外如果你要分配一个 ​cdata ​数组给一个指针的话,你必须保持这个持有这个数据的 ​cdata ​对象活跃,下面给出一个官方的示例:

ffi.cdef[[
typedef struct { int *a; } foo_t;
]]

local s = ffi.new("foo_t", ffi.new("int[10]")) -- WRONG!

local a = ffi.new("int[10]") -- OK
local s = ffi.new("foo_t", a)
-- Now do something with 's', but keep 'a' alive until you're done.

相信看完上面的 ​API ​你已经很累了,再坚持一下吧!休息几分钟后,让我们来看看下面对官方文档中的示例做剖析,希望能再加深你对 ​ffi ​的理解。

调用 C 函数

真的很用容易去调用一个外部 C 库函数,示例代码:

local ffi = require("ffi")
ffi.cdef[[
int printf(const char *fmt, ...);
]]
ffi.C.printf("Hello %s!", "world")

以上操作步骤,如下:

  1. 加载 FFI 库。
  2. 为函数增加一个函数声明。这个包含在 中括号 对之间的部分,是标准 C 语法。
  3. 调用命名的 C 函数——非常简单。

事实上,背后的实现远非如此简单:③ 使用标准 C 库的命名空间 ​ffi.C​。通过符号名 ​printf ​索引这个命名空间,自动绑定标准 C 库。索引结果是一个特殊类型的对象,当被调用时,执行 ​printf ​函数。传递给这个函数的参数,从 Lua 对象自动转换为相应的 C 类型。

再来一个源自官方的示例代码:

local ffi = require("ffi")
ffi.cdef[[
unsigned long compressBound(unsigned long sourceLen);
int compress2(uint8_t *dest, unsigned long *destLen,
        const uint8_t *source, unsigned long sourceLen, int level);
int uncompress(uint8_t *dest, unsigned long *destLen,
         const uint8_t *source, unsigned long sourceLen);
]]
local zlib = ffi.load(ffi.os == "Windows" and "zlib1" or "z")

local function compress(txt)
  local n = zlib.compressBound(#txt)
  local buf = ffi.new("uint8_t[?]", n)
  local buflen = ffi.new("unsigned long[1]", n)
  local res = zlib.compress2(buf, buflen, txt, #txt, 9)
  assert(res == 0)
  return ffi.string(buf, buflen[0])
end

local function uncompress(comp, n)
  local buf = ffi.new("uint8_t[?]", n)
  local buflen = ffi.new("unsigned long[1]", n)
  local res = zlib.uncompress(buf, buflen, comp, #comp)
  assert(res == 0)
  return ffi.string(buf, buflen[0])
end

-- Simple test code.
local txt = string.rep("abcd", 1000)
print("Uncompressed size: ", #txt)
local c = compress(txt)
print("Compressed size: ", #c)
local txt2 = uncompress(c, #txt)
assert(txt2 == txt)

解释一下这段代码。我们首先使用 ​ffi.cdef​ 声明了一些被 zlib 库提供的 C 函数。然后加载 zlib 共享库,在 Windows 系统上,则需要我们手动从网上下载 zlib1.dll 文件,而在 POSIX 系统上 libz 库一般都会被预安装。因为 ffi.load 函数会自动填补前缀和后缀,所以我们简单地使用 z 这个字母就可以加载了。我们检查 ​ffi.os​,以确保我们传递给 ​ffi.load​ 函数正确的名字。

一开始,压缩缓冲区的最大值被传递给 ​compressBound ​函数,下一行代码分配了一个要压缩字符串长度的字节缓冲区。​[?]​ 意味着他是一个变长数组。它的实际长度由 ​ffi.new​ 函数的第二个参数指定。

我们仔细审视一下 ​compress2 ​函数的声明就会发现,目标长度是用指针传递的!这是因为我们要传递进去缓冲区的最大值,并且得到缓冲区实际被使用的大小。

在 C 语言中,我们可以传递变量地址。但因为在 Lua 中并没有地址相关的操作符,所以我们使用只有一个元素的数组来代替。我们先用最大缓冲区大小初始化这唯一一个元素,接下来就是很直观地调用 ​zlib.compress2​ 函数了。使用 ​ffi.string​ 函数得到一个存储着压缩数据的 Lua 字符串,这个函数需要一个指向数据起始区的指针和实际长度。实际长度将会在 ​buflen ​这个数组中返回。因为压缩数据并不包括原始字符串的长度,所以我们要显式地传递进去。

使用 C 数据结构

cdata ​类型用来将任意 C 数据保存在 Lua 变量中。这个类型相当于一块原生的内存,除了赋值和相同性判断,Lua 没有为之预定义任何操作。然而,通过使用 ​metatable​(元表),程序员可以为 ​cdata ​自定义一组操作。​cdata ​不能在 Lua 中创建出来,也不能在 Lua 中修改。这样的操作只能通过 C API。这一点保证了宿主程序完全掌管其中的数据。

我们将 C 语言类型与 ​metamethod​(元方法)关联起来,这个操作只用做一次。​ffi.metatype​ 会返回一个该类型的构造函数。原始 C 类型也可以被用来创建数组,元方法会被自动地应用到每个元素。

尤其需要指出的是,​metatable ​与 C 类型的关联是永久的,而且不允许被修改,​__index ​元方法也是。

下面是一个使用 C 数据结构的实例
local ffi = require("ffi")
ffi.cdef[[
typedef struct { double x, y; } point_t;
]]

local point
local mt = {
  __add = function(a, b) return point(a.x+b.x, a.y+b.y) end,
  __len = function(a) return math.sqrt(a.x*a.x + a.y*a.y) end,
  __index = {
    area = function(a) return a.x*a.x + a.y*a.y end,
  },
}
point = ffi.metatype("point_t", mt)

local a = point(3, 4)
print(a.x, a.y)  --> 3  4
print(#a)        --> 5
print(a:area())  --> 25
local b = a + point(0.5, 8)
print(#b)        --> 12.5
附表:Lua 与 C 语言语法对应关系
IdiomC codeLua code
Pointer dereferencex = *px = p[0]
int *p*p = yp[0] = y
Pointer indexingx = p[i]x = p[i]
int i, *pp[i+1] = yp[i+1] = y
Array indexingx = a[i]x = a[i]
int i, a[]a[i+1] = ya[i+1] = y
struct/union dereferencex = s.fieldx = s.field
struct foo ss.field = ys.field = y
struct/union pointer derefx = sp->fieldx = sp.field
struct foo *spsp->field = ys.field = y
int i, *py = p - iy = p - i
Pointer dereferencex = p1 - p2x = p1 - p2
Array element pointerx = &a[i]x = a + i

小心内存泄漏

所谓“能力越大,责任越大”,FFI 库在允许我们调用 C 函数的同时,也把内存管理的重担压到我们的肩上。 还好 FFI 库提供了很好用的 ffi.gc 方法。该方法允许给 cdata 对象注册在 GC 时调用的回调,它能让你在 Lua 领域里完成 C 手工释放资源的事。

C++ 提倡用一种叫 RAII 的方式管理你的资源。简单地说,就是创建对象时获取,销毁对象时释放。我们可以在 LuaJIT 的 FFI 里借鉴同样的做法,在调用 ​resource = ffi.C.xx_create​ 等申请资源的函数之后,立即补上一行 ​ffi.gc(resource, ...)​ 来注册释放资源的函数。尽量避免尝试手动释放资源!即使不考虑 error 对执行路径的影响,在每个出口都补上一模一样的逻辑会够你受的(用 goto 也差不多,只是稍稍好一点)。

有些时候,​ffi.C.xx_create​ 返回的不是具体的 cdata,而是整型的 handle。这会儿需要用 ​ffi.metatype​ 把 ​ffi.gc​ 包装一下:

local resource_type = ffi.metatype("struct {int handle;}", {
    __gc = free_resource
})

local function free_resource(handle)
    ...
end

resource = ffi.new(resource_type)
resource.handle = ffi.C.xx_create()

如果你没能把申请资源和释放资源的步骤放一起,那么内存泄露多半会在前方等你。写代码的时候切记这一点。


以上内容是否对您有帮助:
在线笔记
App下载
App下载

扫描二维码

下载编程狮App

公众号
微信公众号

编程狮公众号