C 扩展开发指南:从 Lua 到 skynet 的完整链路
读完这篇你会明白:C 扩展到底是什么、skynet 怎么加载它、怎么从零写一个、C++ 怎么写、有哪些坑。
一、前置概念:这不是 skynet 发明的
很多人以为「skynet C 扩展」是 skynet 特有的东西,其实本质是 Lua 语言自带的能力。
任何嵌入了 Lua 虚拟机的程序(nginx、Redis、魔兽世界、Wireshark),都能用同一套机制加载 .so 扩展。
skynet 做的事情只有两件:
- 告诉 Lua 去哪里找
.so(配置lua_cpath路径) - 用自己的
skynet.require包了一层协程管理 (底层最终还是调 Lua 原生require)
lua
-- config.path 里这行就是 skynet 所做的「全部工作」
lua_cpath = root .. "luaclib/?.so;" .. "./chatroom/luaclib/?.so"
-- 然后 Lua 的 require 就能找到你的 pbkdf2.so 了
local pbkdf2 = require "pbkdf2"
二、加载链路:从 require "xxx" 到你的 C 函数被执行
当你写 local pbkdf2 = require "pbkdf2",完整链路如下:
Lua 代码: require "pbkdf2"
│
▼
Lua 虚拟机: package.searchpath("pbkdf2", package.cpath)
│ package.cpath = "xxx/luaclib/?.so"
│ 遍历每个路径,把 ? 替换成 pbkdf2
│ → 找到 xxx/luaclib/pbkdf2.so ✓
│
▼
package.loadlib("xxx/luaclib/pbkdf2.so", "luaopen_pbkdf2")
│ ├─ dlopen() 把 .so 加载到进程内存
│ └─ dlsym() 查找名叫 luaopen_pbkdf2 的 C 函数
│
▼
luaopen_pbkdf2(L) 被调用
│ 你的代码在这里注册函数:
│ luaL_Reg lib[] = { {"hash", lpbkdf2_hash}, {NULL, NULL} };
│ luaL_newlib(L, lib); // 生成模块表 { hash = function }
│ return 1;
│
▼
Lua 拿到: pbkdf2 = { hash = <C函数指针> }
调用: pbkdf2.hash("password", "salt", 10000)
│
▼
lpbkdf2_hash(L) 执行
│ 从 Lua 栈读参数 → 计算 → 结果压回 Lua 栈
│ return 1 // 返回 1 个值
│
▼
Lua 拿到返回值: "3f8a9b..."
三、上手:从零写一个 C 扩展(完整示例)
3.1 最小骨架
c
// mymath.c --- 一个最简单的 Lua C 扩展
#include <lua.h> // Lua 核心 API: lua_State, lua_push*, luaL_error...
#include <lauxlib.h> // 辅助宏: luaL_check*, luaL_newlib, luaL_Reg...
// 暴露给 Lua 的函数: 计算 a + b
static int ladd(lua_State *L) {
// 从 Lua 栈读取参数(栈底索引 = 1)
double a = luaL_checknumber(L, 1); // 第 1 个参数
double b = luaL_checknumber(L, 2); // 第 2 个参数
// 计算结果,压入 Lua 栈作为返回值
lua_pushnumber(L, a + b);
// return 1: 告诉 Lua "我返回了 1 个值"
return 1;
}
// 模块入口: 函数名必须是 luaopen_<模块名>
int luaopen_mymath(lua_State *L) {
// luaL_Reg: Lua 名字 → C 函数指针 的映射表
luaL_Reg funcs[] = {
{ "add", ladd }, // Lua 调 mymath.add() → C 执行 ladd()
{ NULL, NULL } // 必须以 NULL 结尾
};
luaL_newlib(L, funcs); // 创建模块表并压栈
return 1; // 模块表作为唯一返回值
}
编译:
bash
gcc -shared -fPIC -o mymath.so mymath.c -I/usr/include/lua5.3
Lua 测试:
lua
local mymath = require "mymath"
print(mymath.add(1, 2)) -- 输出: 3.0
3.2 我们的 PBKDF2 扩展(实际项目代码)
源码: chatroom/cservice/pbkdf2.c
c
#include <lua.h>
#include <lauxlib.h>
#include <string.h>
#include <openssl/evp.h>
static int lpbkdf2_hash(lua_State *L) {
// 从 Lua 栈读参数
size_t pass_len, salt_len;
const char *password = luaL_checklstring(L, 1, &pass_len);
const char *salt = luaL_checklstring(L, 2, &salt_len);
int rounds = (int)luaL_checkinteger(L, 3);
if (rounds <= 0) {
luaL_error(L, "rounds must be > 0");
}
// PBKDF2-HMAC-SHA256: 输出 32 字节密钥
unsigned char out[32];
PKCS5_PBKDF2_HMAC(password, (int)pass_len,
(const unsigned char *)salt, (int)salt_len,
rounds, EVP_sha256(), 32, out);
// 转 64 字符 hex
char hex[65];
for (int i = 0; i < 32; i++) {
sprintf(hex + i * 2, "%02x", out[i]);
}
hex[64] = '\0';
lua_pushstring(L, hex);
return 1;
}
int luaopen_pbkdf2(lua_State *L) {
luaL_Reg lib[] = {
{ "hash", lpbkdf2_hash },
{ NULL, NULL }
};
luaL_newlib(L, lib);
return 1;
}
Lua 侧调用:
lua
local pbkdf2 = require "pbkdf2"
local hash = pbkdf2.hash("mypassword", "a1b2c3d4e5f6a7b8", 10000)
-- hash = "3f8a9b2c..." (64 字符 hex)
四、编写规则速查表
| # | 规则 | 说明 |
|---|---|---|
| 1 | 头文件 | #include <lua.h> + <lauxlib.h>,C++ 用 <lua.hpp> |
| 2 | 入口命名 | 必须是 luaopen_<模块名>,require "foo" 找 luaopen_foo |
| 3 | 函数签名 | int func(lua_State *L),参数从栈读,返回值压栈 |
| 4 | 读参数 | luaL_checklstring(字符串)、luaL_checkinteger(整数)、luaL_checknumber(浮点) |
| 5 | 报错 | 只能用 luaL_error(L, "msg") ,不能用 exit() 或 return -1 |
| 6 | 返回值 | lua_pushstring / lua_pushinteger / lua_pushnumber 压栈,return N 告知个数 |
| 7 | 注册模块 | luaL_Reg 数组做名字映射,luaL_newlib 生成模块表 |
| 8 | 栈平衡 | luaopen_xxx 必须 return 1(模块表);普通函数 return 返回值个数 |
| 9 | 编译 | 输出 .so 到 luaclib/,lua_cpath 指向它 |
| 10 | 名字映射 | { "lua函数名", c函数指针 },两者可以完全不同 |
五、skynet 的两个 "cpath"(容易混淆)
skynet config 里有两个 cpath 相关配置,职责完全不同:
┌──────────────────────────────────────────────────────────┐
│ config 里的 cpath = root.."cservice/?.so" │
│ ───────────────────────────────────── │
│ 给 skynet 自己的 C Service 用 │
│ 接口: _create / _init / _release / _signal │
│ 例子: snlua.so, gate.so, harbor.so, logger.so │
│ 加载者: skynet_module.c (skynet 自己的代码) │
│ │
│ config.path 里的 lua_cpath = root.."luaclib/?.so" │
│ ───────────────────────────────────── │
│ 给 Lua 标准 C 模块用 │
│ 接口: luaopen_xxx │
│ 例子: md5.so, sproto.so, pbkdf2.so │
│ 加载者: Lua 虚拟机 package.loadlib │
└──────────────────────────────────────────────────────────┘
一句话区分 :cpath 是 skynet 的;lua_cpath 是 Lua 的。
六、C++ 写 Lua 扩展
C++ 和 C 的核心差异就三点:
6.1 extern "C" 阻止名字改编
cpp
// C++ 编译器会把 luaopen_pbkdf2 改名成 _Z15luaopen_pbkdf2P9lua_State
// Lua 的 dlsym("luaopen_pbkdf2") 就找不到了
// 必须用 extern "C" 包住入口函数
extern "C" int luaopen_pbkdf2(lua_State *L) {
luaL_Reg lib[] = {
{ "hash", lpbkdf2_hash },
{ nullptr, nullptr }
};
luaL_newlib(L, lib);
return 1;
}
6.2 异常不能穿透 Lua 栈
cpp
static int lpbkdf2_hash(lua_State *L) {
try {
// 你的 C++ 代码...
std::string result = do_something();
lua_pushstring(L, result.c_str());
return 1;
} catch (const std::exception& e) {
// 必须在这里捕获,转成 Lua 错误
luaL_error(L, "internal error: %s", e.what());
}
return 0;
}
6.3 longjmp 跳过析构(最大坑)
luaL_error 内部用的是 longjmp,会直接跳过 C++ 栈上对象的析构函数,导致内存泄漏。
cpp
// ❌ 危险写法
static int bad_func(lua_State *L) {
std::string s = "hello"; // RAII 对象
std::vector<int> v = {1,2,3}; // 堆内存
if (something_wrong)
luaL_error(L, "oops"); // longjmp!! s 和 v 不会被析构!!
}
// ✅ 安全写法: 用作用域 + try/catch 隔离
static int good_func(lua_State *L) {
try {
std::string s = "hello"; // RAII 对象在 try 块内
std::vector<int> v = {1,2,3};
if (something_wrong)
throw std::runtime_error("oops"); // 抛异常(不跨 longjmp)
return do_work(L, s, v); // 正常返回,s/v 正确析构
} catch (const std::exception& e) {
// 到这里所有 RAII 对象都已析构,安全调 luaL_error
luaL_error(L, "%s", e.what());
}
return 0;
}
七、常见问题
Q1: C 扩展和 skynet C Service 是一回事吗?
不是。 C 扩展是 Lua 标准模块(luaopen_xxx 入口),被 require 加载后在 Lua 服务里调用。skynet C Service(如 snlua.so)是 skynet 自己的服务框架,需要 _create/_init/_release/_signal 四个接口,有独立的消息队列和调度。
Q2: 能在 C 扩展里调 skynet API 吗?
不能。 C 扩展只是被 require 加载的一个函数库,运行在调用它的 Lua 服务的线程里,没有自己的 skynet_context。如果要访问 skynet 的 send/call/timer 等功能,需要通过 Lua 层传过来。
Q3: 性能提升有多大?
以我们的 PBKDF2 为例:旧的纯 Lua 方案 10000 次 MD5 循环约需 5ms(每次都要 Lua↔C FFI 边界跨越),C 扩展一包到底只需要 ~0.1ms。性能差距 ≈ 50 倍,原因就是省掉了维度跨越开销。
Q4: Rust / Go 能写 Lua 扩展吗?
Rust 可以(#[no_mangle] pub extern "C" fn luaopen_xxx),社区有成熟的 mlua/rlua 库。Go 理论上也能(cgo + //export),但不推荐------Go 的 goroutine 调度器和 Lua 的 longjmp 不兼容,一不小心就 segfault。
八、文件目录约定
chatroom/
├── cservice/ ← C 扩展源码放这里
│ └── pbkdf2.c
├── luaclib/ ← 编译出的 .so 放这里(gitignore)
│ └── pbkdf2.so
├── Makefile ← 编译命令
└── etc/
└── config.path ← lua_cpath 指向 luaclib/
九、相关源码索引
| 想看什么 | 文件 |
|---|---|
| 编译脚本 | Makefile |
| Lua 侧调用入口 | service/auth.lua#L48 |
| skynet loader 设置 cpath | skynet/lualib/loader.lua |
| skynet 自己的 C Service 加载 | skynet/skynet-src/skynet_module.c |
| skynet 自带的 crypt 扩展示例 | skynet/lualib-src/lua-crypt.c |