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 | lua_getglobal(L, "func"); |
- 使用 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 函数需要遵循一定的协议:
- 函数原型
int (*lua_CFunction)(lua_State *L);
; - 函数体从 Lua 栈中取出参数;
- 函数体执行具体操作;
- 函数体将执行完的结果压入栈中;
- return 返回值的个数,所以函数原型的返回值必须是
int
; - 使用
lua_pushfunction
将函数引用压入栈; 使用
lua_setglobal
将函数引用从栈中取出并赋值给一个 Lua 全局变量。一个简单的例子:
1 | //定义 C 函数 |
除了函数,变量也可以注册到 Lua 环境中去,同样使用 lua_setglobal
设置一个 Lua 全局变量即可;事实上,函数在 Lua 中也是一种数据类型,也是通过变量来保存函数引用。
1 | int id = 999; |
要注意的是,Lua 注册 C 变量的时候是以赋值方式进行的,即 C 变量的值改了,Lua 变量并不会跟着改,而是要重新注册。
批量注册
每个 C 函数都要手动调用 lua_setglobal
进行注册,太过麻烦,而且每个函数都注册为一个全局变量,管理上也不方便。C API 提供了一种批量注册的方法,将多个 C 函数组织成一个模块,然后注册这个模块。
1 | static int add(lua_State *L) |
上面的代码定义了三个 C 函数,然后使用结构体 struct luaL_Reg
将它们组成为一个模块 libs
,然后通过 C API luaL_register
注册这个模块,以全局变量 algebra
保存,这样在 Lua 中就可以访问 algebra
模块及使用 algebra.cadd
等函数。
luaopen_mathlib
是对外开放的一个接口,通过该接口可以一键注册所有要导出的函数,类似于 luaL_openlibs
,这个方法用于注册 Lua 系统库的函数。使用的时候调用这个函数即可:
1 | int main() |
这个例子中,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 | require("mathlib") |
tolua++
为什么
批量注册虽然可以提高一定的效率,但还不够,从 C++ 导出到 Lua,仍需要我们手动为每一个函数做一层封装,以便与 Lua 交互,主要的工作就是从栈中取出参数,将返回值压入栈中,最后返回返回值个数,如下:
1 | int niubilityFunction(int, CustomType*); |
这个过程要手写几乎是不可能的,所以必须借助三方工具来自动生成,这个工具就是 tolua++。tolua 可以自动导出 C/C++ 中的常量、变量、函数、类等,提供给 Lua 直接访问和调用,这个过程称之为 Lua 绑定,这是 tolua 官方文档 及 tolua 下载地址。
编译
从 Github 下载的 tolua 是源代码,必须先编译,编译之后我们将得到两个文件 tolua.exe
和 tolua.lib
。tolua.exe
是一个可执行工具,运行这个工具就可以从包文件(.pkg) 中生成对应的 C/C++ 文件,这个 C/C++ 文件就是导出的可供 Lua 调用的代码。tolua.lib
则是 tolua 源码编译后的库,要在程序中使用 tolua,则必须包括这个库文件。
下载源码后使用 Cmake 生成工程,然后打开工程编译即可。具体的过程就不细说了,需要注意的一点是必须先将 Lua 库和头文件所在路径添加到环境变量,保证 Cmake 可以找到 Lua 库和 Lua 头文件,否则 Cmake 生成工程会失败。
绑定
- 第一步,根据 C/C++ 代码编写对应的 Package 文件,包文件语法基本与 .h 头文件一样,但需要注意下面几点:
- 不能有
public, private
等作用域修饰符,只能导出共有的成员,默认就是public
; - 函数原型和头文件保持一致,包括函数名参数和返回值;
- 虚函数不能导出;
- 类构造函数和析构函数也得导出;
- 在文件头使用
$#include "test.h"
包含头文件。
- 第二步,打开控制台,输入命令 `tolua -n xxx -o xxx.hpp xxx.pkg”;
-n
指定模块名,tolua 会为每一个要导出的函数封装一个上层静态函数,然后提供一个模块注册接口,由这个接口负责将所有静态函数注册 Lua 环境,这个过程和上面讲的批量注册是一样的,-n
指定的就是这个模块名,如果不指定,则默认使用包文件名;-o
生成的文件名,这是源文件,一般为*.c, *.cpp
,不会生成对应的头文件,所以使用的时候需要手动写一个.h
头文件,为了方便可直接将导出文件指定为*.hpp
;*.pkg
就是第一步写的包文件了。
下面是示例,使用 C++ 编写一个简单类 MyClass
,然后将其导出到 Lua。
1 | //MyClass.h |
将 MyClass.h, MyClass.cpp, MyClass.pkg
放在一个目录下,然后打开控制台,输入命令 tolua -n tlib -o LuaMyClass.hpp MyClass.pkg
即可在同目录下生成一个 LuaMyClass.hpp
文件。
使用
- 新建一个 C++ 工程;
- 添加 Lua 头文件和库文件依赖;
- 附加包含目录
tolua.h
; - 把上面生成的
tolua.lib
添加到附加依赖库或者直接将 tolua 源码lib
目录下的文件直接拷到工程; - 将
MyClass.h, MyClass.cpp, LuaMyClass.hpp
拷贝到工程目录下; - 编写代码。
1 |
|
Lua 代码 test.lua
:
1 | local myClass = MyClass:new(1, 2) |
如果在控制台看到打印结果 3
,则表示 Lua 文件成功执行了,也就是说自定义类 MyClass
绑定到 Lua 成功了。
tolua_tlib_open
用于注册刚导出的库,从而将导出的所有函数注册到 Lua 环境,这样执行 Lua 代码的时候就会回调到导出的函数,再由导出函数去调用源函数。因为我们上面导出的时候指定了 -n tlib
,所以生成的注册函数是 tolua_tlib_open
,如果不指定,则默认会生成的是 tolua_MyClass_open
。
解析
接下来看一下生成文件 LuaMyClass.hpp
的内容,分析下 tolua 的工作原理。
1 | TOLUA_API int tolua_tlib_open(lua_State *tolua_S) |
这里只贴出了简化后的核心代码,总共 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
将模块注册为一个新的用户类型。