【Lua】Lua 与 C++ 交互及 Lua 绑定

Lua 是一个使用 C 编写的库,可以通过 C API 可以很简单的实现 Lua 与 C 之间的交互,Lua 虚拟机会维护一个栈,所有的 C API 都是通过操作这个栈来实现交互。C 调用 Lua 很简单,直接通过虚拟机编译执行 Lua 代码即可,而 Lua 调用 C 则需要先将 C 函数导出,注册到虚拟机环境中去;C 函数导出可以单个导出,也可以使用模块进行批量导出,而最高效的方法是借用第三方库进行自动导出,比如 tolua++。

Lua 是一个用 C 编写的库,但这个库不仅能编译成动态链接库和静态链接库给其它 C 程序使用,也能编译成单独的可执行文件,比如用于编译 Lua 源代码的 Lua编译器 和 Lua 独立运行环境 Lua解释器。有了解释器,理论上 Lua 代码是可以独立执行的,不需要嵌入到任何高级语言的程序中去,而且,反过来 Lua 代码还可以访问 C 代码编写的一些库。但是,现实中 Lua 解释器的运用场景并不多,更多的还是将 Lua 代码嵌入到高级语言程序中去,同时 Lua 也会调用高级程序语言的一些库,也就是 Lua 和 C 相互调用。比如在使用 cocos2dx 引擎实现的游戏中,游戏引擎使用 C++ 实现,游戏逻辑使用 Lua 实现,然后引擎执行 Lua 代码(C++调用Lua),而 Lua 代码又会使用到引擎库的功能(Lua调用C++)。

graph LR
cocos2dx引擎 --> |执行| game[游戏逻辑]
game --> |调用| cocos2dx库
C++程序 --> |执行| lua[Lua代码]
lua --> |调用| C++库

C 调 Lua

Lua 源码是用 C 语言写的,可以把 Lua 源码看成是一个 C 语言库,C 程序只要包含了这个库,就具备了与 Lua 交互的条件。C 程序通过 C API 创建 Lua 虚拟机、执行 Lua 代码、访问 Lua 变量和函数。

首先,C 程序会创建一个 Lua 虚拟机 lua_State,这个虚拟机会维护一个栈,所有的 Lua&C 交互都基于这个栈。

栈的索引有两种,栈底为 1,然后往栈顶递增,栈顶索引未知,即从栈底到栈顶索引为 1,2,3,…;栈顶为 -1,然后往栈底递减,栈底索引未知,即从栈顶到栈底索引为 -1,-2,-3,…。

C API

基础操作

  • luaL_newstate 创建 Lua 执行环境
  • luaL_openlibs 打开所有 Lua 标准库
  • lua_loadbuffer 加载 Lua 代码块并压入栈中
  • lua_pcall 将代码块从栈中弹出并执行
  • lua_close 关闭 Lua 执行环境

操作栈

  • lua_pushinteger 压入某种数据类型的值进栈
  • lua_isinteger 判断某个栈索引的值是否是某种数据类型
  • lua_tointeger 获取某个栈索引的值
  • lua_gettop 获取栈顶索引,即栈元素个数
  • lua_settop 设置栈顶索引,即栈元素个数,多删少补nil
  • lua_pop 从栈顶删除几个元素

访问 Lua 变量

  • lua_getglobal 用于获取 Lua 全局变量的值,获取之后压入栈中,即为栈顶元素的值。

    lua_getglobal(L, "id")

  • 如果要获取 table 的某个域,则先获取 table 的引用压入栈中,再把 key 压入栈中,最后通过 lua_gettable 获取对应的域值

    lua_getglobal(L, "tbl") lua_pushstring(L, "key") lua_gettable(L, -1)

  • lua_getfield 可简化获取 table 域值的操作

    lua_getglobal(L, "tbl") lua_getfield(L, "key")

  • lua_setglobal 设置 Lua 全局变量的值,在此之前先将值压入栈中

    lua_pushnumber(L, 10) lua_setglobal(L, "id")

  • lua_settable 用于设置 table 的某个域的值,在些之前先将 key 和要设置的值压入栈中

    lua_getglobal(L, "tbl") lua_pushstring(L, "key") lua_pushstring(L, "test") lua_settable(L, -3)

  • lua_setfield 可以简化设置 table 域值的操作

    lua_getglobal(L, "tbl") lua_pushstring("test") lua_setfield(L, -2, "key")

执行 Lua 函数

1
2
3
4
5
lua_getglobal(L, "func");
lua_pushinteger(L, 5);
lua_pushinteger(L, 2);
lua_pcall(L, 2, 1, 0);
lua_tointeger(L, -1);
  • 使用 lua_getglobal 获取全局变量 func(lua 中函数也是一种数据类型,当变量处理),压入栈中;
  • 使用 lua_pushxxx 将函数需要的参数依次压入栈中,第一个参数最先压入;
  • 使用 lua_pcall 执行函数,执行完之后会将函数变量、参数都会栈中移除,将返回结果压入栈中;
  • 使用 lua_toxxx 从栈中取出返回值,有多个返回依次获取即可,第一个返回值最先压入栈中,最后一个返回值在栈顶。

总结

Lua 作为 C 语言实现的一个库,使得 C 程序调用 Lua 非常简单。首先,环境配置十分简单,只需要将 Lua 库包含到项目中即可,无论是源代码的形式,还是动态链接库或静态链接库的形式。然后,通过 luaL_newstate 创建一个 Lua 执行环境,加载标准库或其它自定义库。最后,通过 C API 执行 Lua 代码或随意的操作 Lua 环境中的全局变量和全局函数。

Lua 调 C

注册 C 函数

和 C 调用 Lua 一样,Lua 调用 C 同样通过 栈和 C API 实现,C 程序调用 Lua 函数时,需要手动将函数和参数依次压入栈中,Lua 函数执行完会自动将返回值压入栈中,C 调用者再从栈中取出返回值;类似的,Lua 调用 C 函数时,会自动将参数压入栈中,C 函数再从栈中取出参数,执行完函数体之后手动将返回值压入栈中,Lua 调用者自动从栈中取出返回值。可以看出,C 调用 Lua 时,需要调用者手动压入参数和取出返回值,而 Lua 调用 C 时,需要被调用函数手动取出参数和压入返回值。其实很好理解,因为 Lua 源码是 C 编写的,只提供 C API,所有 Lua 和 C 的交互都在 C 端处理,而 Lua 只是运行在 C 程序提供的一个 Lua 环境上而已。

所以,Lua 调用 C 函数不用做任何处理,像调用 Lua 函数一样简单,只是被调用的 C 函数需要遵循一定的协议:

  1. 函数原型 int (*lua_CFunction)(lua_State *L);
  2. 函数体从 Lua 栈中取出参数;
  3. 函数体执行具体操作;
  4. 函数体将执行完的结果压入栈中;
  5. return 返回值的个数,所以函数原型的返回值必须是 int
  6. 使用 lua_pushfunction 将函数引用压入栈;
  7. 使用 lua_setglobal 将函数引用从栈中取出并赋值给一个 Lua 全局变量。

    一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
//定义 C 函数
int sum(lua_State *L)
{
int n = lua_gettop(L);
double sum = 0;
int i;

for (i = 1; i <= n; i++)
{
if (lua_isnumber(L, i))
{
sum += lua_tonumber(L, i);
}
}

lua_pushnumber(L, sum);
lua_pushnumber(L, sum / n);
return 2;
}

int main()
{
lua_State *L = luaL_newstate();
luaL_openlibs(L);

//注册 C 函数
lua_pushcfunction(L, sum);
lua_setglobal(L, "csum");

//在 Lua 中使用 C 函数
luaL_dostring(L, "return csum(5, 8.5, 9.7)");
printf("%f %f \n", lua_tonumber(L, -1), lua_tonumber(L, -2));

lua_close(L);
return 0;
}

除了函数,变量也可以注册到 Lua 环境中去,同样使用 lua_setglobal 设置一个 Lua 全局变量即可;事实上,函数在 Lua 中也是一种数据类型,也是通过变量来保存函数引用。

1
2
3
4
5
6
int id = 999;
lua_pushinteger(L, id);
lua_setglobal(L, "gid"); //in lua, gid = 999
id = 1000; //in lua, gid = 999
lua_pushinteger(L, id);
lua_setglobal(L, "gid"); //in lua, gid = 1000

要注意的是,Lua 注册 C 变量的时候是以赋值方式进行的,即 C 变量的值改了,Lua 变量并不会跟着改,而是要重新注册。

批量注册

每个 C 函数都要手动调用 lua_setglobal 进行注册,太过麻烦,而且每个函数都注册为一个全局变量,管理上也不方便。C API 提供了一种批量注册的方法,将多个 C 函数组织成一个模块,然后注册这个模块。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
static int add(lua_State *L)
{
//...
}

static int sub(lua_State *L)
{
//...
}

static int mul(lua_State *L)
{
int r = lua_tointeger(L, 1) * lua_tointeger(L, 2);
lua_pushinteger(L, r);
return 1;
}

static const luaL_Reg libs[] = {
{"cadd", add},
{"csub", sub},
{"cmul", mul}};

int luaopen_mathlib(lua_State *L)
{
luaL_register(L, "algebra", libs);
return 1;
}

上面的代码定义了三个 C 函数,然后使用结构体 struct luaL_Reg 将它们组成为一个模块 libs,然后通过 C API luaL_register 注册这个模块,以全局变量 algebra 保存,这样在 Lua 中就可以访问 algebra 模块及使用 algebra.cadd 等函数。

luaopen_mathlib 是对外开放的一个接口,通过该接口可以一键注册所有要导出的函数,类似于 luaL_openlibs,这个方法用于注册 Lua 系统库的函数。使用的时候调用这个函数即可:

1
2
3
4
5
6
7
8
int main()
{
// ...
lua_State *L = luaL_newstate();
luaopen_mathlib(L);
luaL_dostring(L, "return algebra.cadd(5, 8)");
// ...
}

这个例子中,C 函数的定义和使用是在同一个程序中的,通常这样做是没有意义的,如果是同一个 C 程序,直接由 C 函数调用 C 函数即可,又何必将 C 函数注册到 Lua 环境,然后执行 Lua 代码,再由 Lua 代码去调用前面注册的 C 函数呢?实际的应用场景是将 C 函数导出为一个库,然后由其它用 Lua 写逻辑的程序调用或者直接在 Lua 解释器中加载使用。

Lua 中的 require 方法不仅能够加载其它 Lua 模块,也能直接加载由 C 程序导出的链接库,其原理就是根据链接库的名字 xxx 去找到函数 luaopen_xxx,然后执行它。所以上面的 luaopen_mathlib 并不是随便起的名字,其意味着如果要导出动态链接库,其名字必须为 mathlib.dll,如果改成其它名字比如 alglib.dll,Lua 代码在 require 这个库的时候就会报错 error loading module 'alglib' from file '.\alglib.dll'

另外,luaopen_xxx 因为是提供给外部程序调用的接口,所以要加上关键字 __declspec(dllexport),所以上面的函数定义要改为 __declspec(dllexport) int luaopen_mathlib(lua_State *L);

之后在 Lua 中直接 require 这个 mathlib.dll 即可,

1
2
3
4
5
require("mathlib")
print(algebra.cadd(5,5)) -- 10

local alg = require("mathlib")
print(alg.csub(1,2)) -- -1

tolua++

为什么

批量注册虽然可以提高一定的效率,但还不够,从 C++ 导出到 Lua,仍需要我们手动为每一个函数做一层封装,以便与 Lua 交互,主要的工作就是从栈中取出参数,将返回值压入栈中,最后返回返回值个数,如下:

1
2
3
4
5
6
7
8
9
10
int niubilityFunction(int, CustomType*);

int lua_niubilityFunction(lua_State *L)
{
int arg1 = lua_tointeger(L, 1);
CustomType* arg2 = (CustomType*)lua_touserdata(L, 2);
int ret = niubilityFunction(arg1, arg2);
lua_pushinteger(L, ret);
return 1;
}

这个过程要手写几乎是不可能的,所以必须借助三方工具来自动生成,这个工具就是 tolua++。tolua 可以自动导出 C/C++ 中的常量、变量、函数、类等,提供给 Lua 直接访问和调用,这个过程称之为 Lua 绑定,这是 tolua 官方文档tolua 下载地址

编译

从 Github 下载的 tolua 是源代码,必须先编译,编译之后我们将得到两个文件 tolua.exetolua.libtolua.exe 是一个可执行工具,运行这个工具就可以从包文件(.pkg) 中生成对应的 C/C++ 文件,这个 C/C++ 文件就是导出的可供 Lua 调用的代码。tolua.lib 则是 tolua 源码编译后的库,要在程序中使用 tolua,则必须包括这个库文件。

下载源码后使用 Cmake 生成工程,然后打开工程编译即可。具体的过程就不细说了,需要注意的一点是必须先将 Lua 库和头文件所在路径添加到环境变量,保证 Cmake 可以找到 Lua 库和 Lua 头文件,否则 Cmake 生成工程会失败。

绑定

  1. 第一步,根据 C/C++ 代码编写对应的 Package 文件,包文件语法基本与 .h 头文件一样,但需要注意下面几点:
  • 不能有 public, private 等作用域修饰符,只能导出共有的成员,默认就是 public
  • 函数原型和头文件保持一致,包括函数名参数和返回值;
  • 虚函数不能导出;
  • 类构造函数和析构函数也得导出;
  • 在文件头使用 $#include "test.h" 包含头文件。
  1. 第二步,打开控制台,输入命令 `tolua -n xxx -o xxx.hpp xxx.pkg”;
  • -n 指定模块名,tolua 会为每一个要导出的函数封装一个上层静态函数,然后提供一个模块注册接口,由这个接口负责将所有静态函数注册 Lua 环境,这个过程和上面讲的批量注册是一样的,-n 指定的就是这个模块名,如果不指定,则默认使用包文件名;
  • -o 生成的文件名,这是源文件,一般为 *.c, *.cpp,不会生成对应的头文件,所以使用的时候需要手动写一个 .h 头文件,为了方便可直接将导出文件指定为 *.hpp
  • *.pkg 就是第一步写的包文件了。

下面是示例,使用 C++ 编写一个简单类 MyClass,然后将其导出到 Lua。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//MyClass.h
class MyClass
{
public:
MyClass(int a, int b);
~MyClass();
int execute();

private:
int a;
int b;
};

//MyClass.pkg
$#include "MyClass.h"
class MyClass
{
MyClass(int a, int b);
~MyClass();
int execute();
};

MyClass.h, MyClass.cpp, MyClass.pkg 放在一个目录下,然后打开控制台,输入命令 tolua -n tlib -o LuaMyClass.hpp MyClass.pkg 即可在同目录下生成一个 LuaMyClass.hpp 文件。

使用

  1. 新建一个 C++ 工程;
  2. 添加 Lua 头文件和库文件依赖;
  3. 附加包含目录 tolua.h
  4. 把上面生成的 tolua.lib 添加到附加依赖库或者直接将 tolua 源码 lib 目录下的文件直接拷到工程;
  5. MyClass.h, MyClass.cpp, LuaMyClass.hpp 拷贝到工程目录下;
  6. 编写代码。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <iostream>
#include "LuaMyClass.hpp"

extern "C"
{
#include "lualib.h"
#include "lauxlib.h"
}

int main()
{
lua_State* state = luaL_newstate();

luaL_openlibs(state);
tolua_tlib_open(state);

if (luaL_dofile(state, "test.lua") != 0)
{
std::cout << "execute lua file failed!" << std::endl;
lua_close(state);
return 1;
}

lua_close(state);
return 0;
}

Lua 代码 test.lua

1
2
3
local myClass = MyClass:new(1, 2)
local result = myClass:execute()
print(result)

如果在控制台看到打印结果 3,则表示 Lua 文件成功执行了,也就是说自定义类 MyClass 绑定到 Lua 成功了。

tolua_tlib_open 用于注册刚导出的库,从而将导出的所有函数注册到 Lua 环境,这样执行 Lua 代码的时候就会回调到导出的函数,再由导出函数去调用源函数。因为我们上面导出的时候指定了 -n tlib,所以生成的注册函数是 tolua_tlib_open,如果不指定,则默认会生成的是 tolua_MyClass_open

解析

接下来看一下生成文件 LuaMyClass.hpp 的内容,分析下 tolua 的工作原理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
TOLUA_API int tolua_tlib_open(lua_State *tolua_S)
{
lua_pushcfunction(tolua_S, luaopen_tlib);
lua_pushstring(tolua_S, "tlib");
lua_call(tolua_S, 1, 0);
return 1;
}

LUALIB_API int luaopen_tlib(lua_State *tolua_S)
{
tolua_open(tolua_S);
tolua_reg_types(tolua_S);
tolua_module(tolua_S, NULL, 0);
tolua_beginmodule(tolua_S, NULL);
tolua_cclass(tolua_S, "MyClass", "MyClass", "", tolua_collect_MyClass);
tolua_beginmodule(tolua_S, "MyClass");
tolua_function(tolua_S, "new", tolua_tlib_MyClass_new00);
tolua_function(tolua_S, "delete", tolua_tlib_MyClass_delete00);
tolua_function(tolua_S, "execute", tolua_tlib_MyClass_execute00);
tolua_endmodule(tolua_S);
tolua_endmodule(tolua_S);
return 1;
}

static void tolua_reg_types(lua_State *tolua_S)
{
tolua_usertype(tolua_S, "MyClass");
}

static int tolua_collect_MyClass(lua_State *tolua_S)
{
MyClass *self = (MyClass *)tolua_tousertype(tolua_S, 1, 0);
tolua_release(tolua_S, self);
delete self;
return 0;
}

static int tolua_tlib_MyClass_new00(lua_State *tolua_S)
{
int a = ((int)tolua_tonumber(tolua_S, 2, 0));
int b = ((int)tolua_tonumber(tolua_S, 3, 0));
{
MyClass *tolua_ret = (MyClass *)new MyClass(a, b);
tolua_pushusertype(tolua_S, (void *)tolua_ret, "MyClass");
}
return 1;
}

static int tolua_tlib_MyClass_delete00(lua_State *tolua_S)
{
MyClass *self = (MyClass *)tolua_tousertype(tolua_S, 1, 0);
tolua_release(tolua_S, self);
delete self;
return 0;
}

static int tolua_tlib_MyClass_execute00(lua_State *tolua_S)
{
MyClass *self = (MyClass *)tolua_tousertype(tolua_S, 1, 0);
int tolua_ret = (int)self->execute();
tolua_pushnumber(tolua_S, (lua_Number)tolua_ret);
return 1;
}

这里只贴出了简化后的核心代码,总共 7 个函数,包括 4 个通用函数和 3 个自定义函数。

3 个自定义函数就是类 MyClass 的三个成员函数,构造函数 new,析构函数 delete 及普通函数 execute

  • tolua_tlib_MyClass_new00 先从栈中取出两个参数,再执行 new MyClass(a, b) 创建一个对象,然后把对象压入栈中,返回 1 表示栈中放了一个返回值;
  • tolua_tlib_MyClass_delete00 先从栈中取出对象,然后执行 tolua_release(self) 在 Lua 层释放对象,再执行 delete self 在 C++ 层释放对象;
  • tolua_tlib_MyClass_execute00 先从栈中取出对象,再调用 self->execute() 执行具体的方法,再将执行结果压入栈中,返回 1 表示栈中放了一个返回值。

tolua_collect_MyClass 做的事情和 tolua_tlib_MyClass_delete00 是一样的,仔细看会发现它们的核心代码是完全一样的,都是回收对象。

tolua_tlib_open, luaopen_tlib 都是对外提供的一键注册模块接口,这两个接口做的事情也是一样的,其实 tolua_tlib_open 做的事情就是调用接口 luaopen_tlib,所以真正将模块注册到 Lua 环境的是 luaopen_tlib 接口,这个接口又通过 tolua_reg_types 将模块注册为一个新的用户类型。