9.2 扩展与嵌入:双向集成 本节摘要:C API 的两个使用方向。扩展 Lua:用 C 写性能敏感或需要系统能力的函数,注册成模块供脚本 require——luaLReg 清单、luaopen 入口、返回表三件套。嵌入 Lua:在自己的程序里创建解释器实例、执行脚本、取回结果——newstate、openlibs、dostring、pcall 的最小宿主循环。最后对比 LuaJIT FFI 这条"不走栈"的旁门,说清两条路的取舍。 双向的两扇门 寄宿关系是双向的。房客带行李(扩展:宿主给脚本递新函数),房东给房间(嵌入:宿主创建并驱动虚拟机)。第 8 章四大现场全是两扇门同时开着:游戏引擎既是宿主(驱动脚本跑剧情)也注册了成百个 C 函数(渲染、物理接口);
本节摘要:C API 的两个使用方向。扩展 Lua:用 C 写性能敏感或需要系统能力的函数,注册成模块供脚本 require——luaL_Reg 清单、luaopen 入口、返回表三件套。嵌入 Lua:在自己的程序里创建解释器实例、执行脚本、取回结果——newstate、openlibs、dostring、pcall 的最小宿主循环。最后对比 LuaJIT FFI 这条"不走栈"的旁门,说清两条路的取舍。
寄宿关系是双向的。房客带行李(扩展:宿主给脚本递新函数),房东给房间(嵌入:宿主创建并驱动虚拟机)。第 8 章四大现场全是两扇门同时开着:游戏引擎既是宿主(驱动脚本跑剧情)也注册了成百个 C 函数(渲染、物理接口);Redis 嵌了虚拟机(EVAL 执行)也注入了 redis 表(call/pcall 两个 C 函数)。本节把两扇门各写一个完整例程。
目标:给脚本提供一个 fast 模块,里面有 sum(整数数组求和,性能敏感)与 now_ns(高精度时钟,系统能力)。完整 C 代码:
/* fast.c —— 编译为动态库后即可被 require 加载 */ #include <lua.h> #include <lauxlib.h> #include <time.h> /* 脚本调用 fast.sum(t) 时进入这里 */ static int l_sum(lua_State *L) { luaL_checktype(L, 1, LUA_TTABLE); /* 参数一必须是表,否则抛错 */ lua_Integer acc = 0; lua_Integer n = luaL_len(L, 1); /* 取数组段长度 */ for (lua_Integer i = 1; i <= n; i++) { lua_geti(L, 1, i); /* 把 t[i] 压栈 */ acc += luaL_checkinteger(L, -1); /* 参数级校验并取整 */ lua_pop(L, 1); /* 弹掉,栈配平 */ } lua_pushinteger(L, acc); /* 压返回值 */ return 1; /* 告诉 Lua:返回 1 个值 */ } static int l_now_ns(lua_State *L) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); lua_pushinteger(L, (lua_Integer)ts.tv_sec * 1000000000 + ts.tv_nsec); return 1; } /* 注册清单:脚本侧名字 与 C 函数指针 的对照表 */ static const luaL_Reg fast_funcs[] = { { "sum", l_sum }, { "now_ns", l_now_ns }, { NULL, NULL } /* 哨兵结尾 */ }; /* 入口:require("fast") 时被调用,应返回一张表(模块) */ LUAMOD_API int luaopen_fast(lua_State *L) { luaL_newlib(L, fast_funcs); /* 造表并按清单填函数 */ return 1; /* 栈顶这张表就是模块 */ }
四件套拆解。函数体:进入时参数已在栈上(下标从 1 起),luaL_checktype/checkinteger 这类带 check 的函数在类型不符时直接抛 Lua 错误(脚本侧可 pcall 捕获——第 6 章的壳闭环到 C 侧);离开时压返回值、返回个数。注册清单:luaL_Reg 数组就是一张"脚本名到 C 指针"的映射表——第 3 章"注册表与分发"的 C 版。入口命名:luaopen_模块名 是约定名,require 按 6.1 节的 cpath 搜索到动态库后按此名找入口。返回一张表:模块即表(第 6 章军规)在 C 侧的同构——luaL_newlib 造表填函数,return 1 交出去。
脚本侧的使用与普通模块无异:
local fast = require("fast") local total = fast.sum({ 10, 20, 30, 40 }) print(total, fast.now_ns() > 0) -- 100 true
编译与部署:源文件与 Lua 头文件一起编译成共享库,落进 cpath 指向的目录。跨平台差异只在扩展名与编译命令,接口层完全一致——这也是"栈礼数"的跨平台红利。
三条判据。热点证明:profile 显示某段纯 Lua 逻辑占了大头(游戏帧内循环、网关每请求的编解码)。系统能力:脚本层根本没有的能力(时钟、共享内存、系统调用)。既有资产:复用成熟的 C 库(加密、压缩、协议解析)。三条都不占就别写——C 扩展的维护成本(双语言调试、内存边界、版本编译矩阵)远高于几十行 Lua。先测量,再下沉,是性能工程的正序。
LuaJIT 提供 FFI 库:在脚本里直接声明 C 函数原型与结构体布局,调用时绕过栈、直达机器码:
local ffi = require("ffi") ffi.cdef[[ typedef struct { double x, y; } point_t; double hypot(double, double); ]] local p = ffi.new("point_t", { 3, 4 }) print(ffi.C.hypot(p.x, p.y)) -- 5.0,直调 C 库函数
不必写一行 C 胶水、调用开销接近原生。OpenResty 生态大量用它包装系统库。代价清单也硬:内存不归 Lua GC 管(ffi.new 的对象要自管生命周期,或依赖 cdata 的析构钩子);错误检查靠自觉(C 的段错误直接带崩 worker,没有 pcall 保护);绑定与平台布局强耦合(结构体对齐、字长)。栈是礼数,FFI 是快门——安全与速度的取舍,项目按风险偏好选。
换个方向:给自己的程序装一个脚本引擎。最小宿主(宿主侧 C 代码):
#include <lua.h> #include <lauxlib.h> #include <lualib.h> int main(void) { /* 1. 创建独立的 Lua 状态:一个互不干扰的虚拟机实例 */ lua_State *L = luaL_newstate(); if (L == NULL) { fprintf(stderr, "cannot create state\n"); return 1; } /* 2. 装标准库:把 string/table/io 等注册进环境 (沙箱场景换成手工逐个装,或干脆不装——第 6 章白名单) */ luaL_openlibs(L); /* 3. 注入宿主能力:往全局表放一张 host 表(第 1 章示例的真身) */ lua_newtable(L); lua_pushcfunction(L, l_host_log); /* C 函数压栈 */ lua_setfield(L, -2, "log"); /* host.log = 函数,弹栈 */ lua_pushcfunction(L, l_host_config); lua_setfield(L, -2, "config"); lua_setglobal(L, "host"); /* 全局 host = 那张表 */ /* 4. 执行一段脚本(这里内嵌字符串;文件用 luaL_dofile) */ if (luaL_dostring(L, "print(host.config('name'))") != LUA_OK) { const char *err = lua_tostring(L, -1); fprintf(stderr, "script error: %s\n", err); lua_pop(L, 1); } /* 5. 关门:释放状态与其全部对象 */ lua_close(L); return 0; }
五步就是"成为宿主"的全部:newstate 造房间、openlibs 摆家当、注入能力挂窗帘、dostring 请房客活动、close 退房。每一步都有第 6、8 章的对应物:openlibs 的裁剪版就是沙箱第一层(只装 math 与 string)、注入的 host 表就是四大现场的 redis 表与 ngx 表、dostring 外面那层 if 就是 pcall 壳。
多状态与线程。一个进程可以开多个 lua_State——互相隔离的两个虚拟机(全局表、GC、执行流都不共享)。用途:每插件一个状态(强隔离)、每工作线程一个状态(并行跑脚本,状态间靠宿主转发消息)。注意 Lua 状态本身不是线程安全的——同一个状态不能被两个系统线程同时操作,要么单线程独占、要么宿主加锁调度。游戏引擎的"主线程脚本加渲染线程脚本各一个状态"是常见布局。
驱动循环。真实的宿主不会只 dostring 一次,而是长成一个循环:每帧(或每事件)从注册表里找出脚本回调、压参数、lua_pcall、收结果。把 9.1 节的十步轨迹装进 while 循环,配上第 6 章的 safe_call 壳与错误日志——第 8.1 节那张架构图中间的绑定层,C 代码形态至此全部拼完。
C 函数要记住点东西(缓存的句柄、上次的状态、回调的引用),不能存 C 全局变量吗?可以,但多状态场景会串。正规做法是注册表——每个 Lua 状态自带的一张对脚本不可见的表,C 侧以唯一键存取:
/* 存一个值进注册表 */ lua_pushvalue(L, 1); /* 把参数一复制到栈顶 */ lua_setfield(L, LUA_REGISTRYINDEX, "mylib.last_ctx"); /* 注册表["mylib.last_ctx"] = 值 */ /* 取出来 */ lua_getfield(L, LUA_REGISTRYINDEX, "mylib.last_ctx"); if (!lua_isnil(L, -1)) { /* 有上次的状态可用 */ } lua_pop(L, 1);
LUA_REGISTRYINDEX 是注册表的魔法下标,约定上键名带模块前缀防撞(mylib 点开头)。轻量替代是 upvalue:C 函数也能带闭包——lua_pushcclosure 把 C 函数与若干栈值绑定,函数内用 lua_upvalueindex(1) 访问。计数器的 C 版就是这么写的。注册表适合"模块级共享",upvalue 适合"函数私有"——与 Lua 侧"模块表与 local"的分工同构,心智模型直接平移。
把"宿主每帧调脚本"的循环补完整(伪 C,接 9.1 的十步轨迹):
while (running) { pump_host_events(); /* 引擎自己的事件:输入、网络、定时 */ /* 1. 找到脚本的 on_tick(登记在注册表里,热更时可被替换) */ lua_getfield(L, LUA_REGISTRYINDEX, "host.tick_fn"); if (lua_isfunction(L, -1)) { lua_pushnumber(L, frame_dt); /* 2. 参数:帧间隔 */ if (lua_pcall(L, 1, 0, 0) != LUA_OK) { /* 3. 保护调用,无返回值 */ report_script_error(L); /* 4. 记日志、报警、弹错误对象 */ lua_pop(L, 1); } } else { lua_pop(L, 1); /* 不是函数也要弹掉 */ } render_frame(); /* 引擎继续自己的工作 */ }
循环体四步:取回调、压参数、保护调用、处理错误。回调从注册表来是点睛之笔——热更时宿主只改注册表里那一项,下一帧自动用新函数,8.1 节"登记表重绑"的 C 版实现。这个二十行的循环就是游戏宿主脚本层的全部心跳,配上 9.4 节的 reload 通道,热更闭环完成。
双向集成的最后一块拼图:把一个 C 对象(这里是一个两维向量)暴露成脚本值。完整流程——造 userdata、挂元表、注册方法:
/* vec.c:C 向量暴露给脚本 */ #include <lua.h> #include <lauxlib.h> typedef struct { double x, y; } Vec; /* 元表名是全局约定的唯一键:类型检查与命名空间 */ #define VEC_META "host.vec" static int vec_new(lua_State *L) { Vec *v = lua_newuserdatauv(L, sizeof(Vec), 0); /* 分配并压栈 */ luaL_getmetatable(L, VEC_META); /* 取元表压栈 */ lua_setmetatable(L, -2); /* 给 userdata 挂上,弹元表 */ v->x = luaL_checknumber(L, 1); v->y = luaL_checknumber(L, 2); return 1; /* userdata 已在栈顶 */ } static int vec_dot(lua_State *L) { Vec *a = luaL_checkudata(L, 1, VEC_META); /* 类型检查:不对即抛错 */ Vec *b = luaL_checkudata(L, 2, VEC_META); lua_pushnumber(L, a->x * b->x + a->y * b->y); return 1; } static int vec_tostring(lua_State *L) { Vec *v = luaL_checkudata(L, 1, VEC_META); lua_pushfstring(L, "Vec(%f, %f)", v->x, v->y); return 1; } static const luaL_Reg vec_methods[] = { { "dot", vec_dot }, { "__tostring", vec_tostring }, { NULL, NULL } }; LUAMOD_API int luaopen_vec(lua_State *L) { luaL_newmetatable(L, VEC_META); /* 注册表里造元表 */ luaL_setfuncs(L, vec_methods, 0); /* 方法直接挂元表(含 tostring) */ lua_pushvalue(L, -1); lua_setfield(L, -2, "__index"); /* __index 指向自己:实例找方法 */ lua_pushcfunction(L, vec_new); lua_setfield(L, -2, "new"); /* vec.new */ return 1; /* 返回元表当模块(它兼有方法表身份) */ }
脚本侧的使用体验与本地类型无异:
local vec = require("vec") local a, b = vec.new(3, 4), vec.new(1, 2) print(a:dot(b)) --> 11.0 print(a) --> Vec(3.000000, 4.000000)
逐行对应本册机制:lua_newuserdatauv 分配 C 结构并让 GC 记账(内存归属的铁律);元表名 VEC_META 是注册表里的全局键,luaL_checkudata 靠它做类型检查(挂错元表的假向量在这里现形);__index 指向自己是 5.3 节类模板的 C 版——方法挂在元表上,实例(userdata)经查找链找到它们;冒号调用照常工作,self 就是 userdata 自己。第 8.1 节"引擎对象以 userdata 暴露"的每一句承诺,在这三十行里全部兑现——寄宿之道的房东侧,最后一扇门也打开了。
通道与两扇门都齐了。下一节讲在桥上长期行走的工程习惯,然后迎来全册收官:热更新。