9.1 虚拟栈:C 与 Lua 的唯一通道


文档摘要

9.1 虚拟栈:C 与 Lua 的唯一通道 本节摘要:宿主(C/C++)与 Lua 交换数据只有一条路:虚拟栈。每个 Lua 状态自带一座栈,C 侧 push 压值进、to 系取值出、is 系查验类型。栈解决了三种语言的深层矛盾——内存归属(GC 只认 Lua 值)、类型系统(C 无动态类型)、调用约定(栈中立)。本节讲透栈的操作族与栈顶管理,走完一次完整调用的栈轨迹。 见面先递栈 设想宿主要调用脚本里的一个函数 ,msg 是一张表。第一反应的方案是"传指针":C 侧构造个结构体,把地址递过去。这条路走不通,三道坎摆在眼前: 内存归属。

9.1 虚拟栈:C 与 Lua 的唯一通道

本节摘要:宿主(C/C++)与 Lua 交换数据只有一条路:虚拟栈。每个 Lua 状态自带一座栈,C 侧 push 压值进、to 系取值出、is 系查验类型。栈解决了三种语言的深层矛盾——内存归属(GC 只认 Lua 值)、类型系统(C 无动态类型)、调用约定(栈中立)。本节讲透栈的操作族与栈顶管理,走完一次完整调用的栈轨迹。

见面先递栈

设想宿主要调用脚本里的一个函数 on_message(msg),msg 是一张表。第一反应的方案是"传指针":C 侧构造个结构体,把地址递过去。这条路走不通,三道坎摆在眼前:

内存归属。Lua 的值由它的垃圾回收器管理——C 侧malloc出来的内存 GC 不认识,脚本把指针存进表里,GC 一转身发现这个值没有任何 Lua 引用链(或更糟,把 Lua 值的指针当普通地址用),要么泄漏要么野指针。谁分配谁负责必须是一条铁律,指针直传等于铁律失守。

类型系统。Lua 的表是"数组段加哈希段"的动态结构,C 里没有对应物;C 的结构体布局对 Lua 完全不可见。两边互相看对方都是"一团不透明字节"。

调用约定。两边的函数调用规范、参数传递、返回值布局完全不同,直连需要为每个函数生成粘合代码。

栈把三道坎一次解决:交换的每个值都变成 Lua 值(归属清楚,GC 全程认账);类型信息随值走(栈上每个槽位自带类型标签);调用约定统一成"操作栈"(两边都只做 push/pop,谁也不用懂谁的 ABI)。代价是啰嗦——传一张三个字段的表要在栈上倒腾七八次——但这份啰嗦买来的是三十年的稳定接口:Lua 5.1 到 5.4,栈的用法几乎没变过。

09-01-fig01

栈的操作族

C API 的函数名有规律:lua_push* 压值、lua_to* 取值(不弹出)、lua_is* 查验、luaL_* 是辅助库的高层封装。核心子集过一遍(用 C 代码示例):

/* 压栈族:把 C 世界的东西变成栈上的 Lua 值 */ lua_pushnil(L); lua_pushnumber(L, 3.14); lua_pushinteger(L, 42); lua_pushboolean(L, 1); lua_pushstring(L, "host"); /* 复制内容进 Lua,C 侧原串可立即释放 */ lua_pushlstring(L, buf, len); /* 带长度版本:可含内嵌零字节 */ /* 取值族:按索引读栈(不改变栈) */ double d = lua_tonumber(L, 1); /* 类型不符返回 0 */ lua_Integer i = lua_tointeger(L, -1); const char *s = lua_tostring(L, 2); /* 指向 Lua 内部内存,勿跨调用持有 */ int b = lua_toboolean(L, 3); /* 查验族:先验型再取值是正序 */ if (lua_isstring(L, 1)) { /* ... */ } if (lua_istable(L, -1)) { /* ... */ } /* 栈管理 */ int top = lua_gettop(L); /* 当前高度 */ lua_settop(L, 0); /* 清空 */

四条纪律配套。负数下标从栈顶数,-1 永远是栈顶——写库代码用负数下标最稳(不用记压了几个)。lua_tostring 返回的指针指向 Lua 内部缓冲,下次 GC 或栈变动后可能失效,要长期持有就复制(或压进注册表,9.2 节)。C 函数的返回值是"我往栈上放了几个结果"——返回 2 表示栈顶两个值是给 Lua 的返回值,这是 C 函数与 Lua 约定好的协议。进出要配平:函数返回时栈上应恰好留着你声明的那几个值,多一个少一个都是 bug(多则泄漏到调用方视野,少则读到别人的值)。

一次完整调用的栈轨迹

把"宿主调用脚本函数 on_message(msg)"的栈轨迹完整走一遍。C 侧代码:

/* 假设全局表里有全局函数 on_message */ lua_getglobal(L, "on_message"); /* 1. 压入函数 栈: fn */ lua_newtable(L); /* 2. 造空表 栈: fn, t */ lua_pushstring(L, "kind"); /* 3. 压键 栈: fn, t, "kind" */ lua_pushstring(L, "chat"); /* 4. 压值 栈: fn, t, "kind", "chat" */ lua_settable(L, -3); /* 5. t.kind="chat" 弹两 栈: fn, t */ lua_pushstring(L, "size"); lua_pushinteger(L, 128); lua_settable(L, -3); /* 6. t.size=128 栈: fn, t */ if (lua_pcall(L, 1, 1, 0) != LUA_OK) { /* 7. 调用:1参1返回值 */ const char *err = lua_tostring(L, -1); fprintf(stderr, "script error: %s\n", err); lua_pop(L, 1); /* 8. 错误对象也要弹掉 */ return; } if (lua_toboolean(L, -1)) { /* 9. 读返回值 */ /* 脚本返回 true:已处理 */ } lua_pop(L, 1); /* 10. 弹返回值,栈清空 */

十个步骤,每步的栈状态都标在注释里。三个 API 值得圈点。lua_settable(L, -3) 的索引指向表所在的位置,键值从栈顶弹出——压键、压值、落表的固定舞步。lua_pcall 的三个参数是(参数个数、期望返回值个数、错误处理函数索引)——它就是第 6 章 pcall 的 C 侧真身,宿主的一切"安全调用脚本"最终都落到这一行。lua_getglobal 是语法糖,等价于查全局环境表的一个键(5.2 之后就是查 _ENV 表——第 1 章埋的线在 C 侧接上)。

反过来,脚本调 C 函数时的栈是什么样?C 函数被调用时,栈上已经摆好了脚本传来的参数(从下标 1 开始),返回时把结果压栈、返回个数。9.2 节写完整的 C 函数。

为什么这套接口三十年稳定

回头看设计。栈接口的每个决定都指向"最小公分母":交换单位是值不是引用(内存安全)、操作原语只有压弹查(约定中立)、类型标签随值走(类型安全)、C 函数只声明"返回几个"(调用协议极简)。没有宏、没有代码生成、没有 IDL——绑定任何一个宿主,写的就是这些 push 和 to 的组合。接口越薄,寿命越长:Lua 的 C API 与它的语言核心一样,靠克制赢得三十年兼容。对比同时代那些带复杂 ABI 的嵌入方案大多已成历史,这份"礼数"的回报够大。

⚠️ 常见坑四枚。其一,栈不配平:调试现象是"返回值串位"或"栈溢出报警",养成"进入时记录基线、退出时对齐"的习惯(lua_settop(L, base))。其二,持有 lua_tostring 的指针跨调用:下次操作后内存可能已被回收挪用。其三,lua_pcall 忘了弹错误对象:错误处理分支也要清栈。其四,正数下标数错位:压了三个再用 1 取值,取到的是最早的——写库统一用负数下标可以根治。

栈配平的防御性写法

C 侧 bug 的第一来源是栈失衡,防御性写法值得立为规范——进入函数时记录基线,所有出口对齐:

static int process_batch(lua_State *L) { int base = lua_gettop(L); /* 进入时的栈高(应为参数个数) */ luaL_checktype(L, 1, LUA_TTABLE); /* ... 中间任意压弹操作 ... */ lua_settop(L, base); /* 对齐基线,弹掉全部中间值 */ lua_pushboolean(L, 1); /* 压唯一返回值 */ return 1; }

三行防御(记基线、对齐、压结果)能消灭一大类"偶发串值"。配套习惯:中间临时值用完立即弹(lua_pop 跟在每次 get 后面),不留隔夜栈;错误分支同样清栈再返回。写 C 绑定时的自检手段:在函数入口与出口各断言一次栈高(开发版保留、发布版摘除),失衡当场报警而不是污染下游。

读取表字段与数组元素

宿主从脚本世界取数据的高频动作,两套舞步:

/* 取 t.field(字符串键) */ lua_getfield(L, 1, "field"); /* 压出值,栈高加一 */ if (lua_isstring(L, -1)) { const char *v = lua_tostring(L, -1); /* 使用 v */ } lua_pop(L, 1); /* 弹掉,配平 */ /* 取 t[i](整数键,数组元素) */ lua_geti(L, 1, i); /* 5.3 起的便捷形式 */ /* 同上处理与弹出 */

lua_getfield 与 lua_geti 都是"压出结果"的形态——取值必压栈、用完必弹出,这两个"必"就是 C 侧的栈呼吸。配合 5.3 起的 lua_geti(老代码用 lua_rawgeti),遍历一张表拿数据的循环写起来相当规整:外层 lua_next 逐键推进、内层按类型分派处理、每轮弹净。首次写这类代码容易在 lua_next 的"键留在栈上"约定翻车——手册的示例循环值得逐行抄一遍再改。

从 C 遍历一张表:综合操练

把栈操作串成完整任务的操练——C 侧遍历脚本的表、按类型分派处理(这是"宿主读配置"的最小真实场景):

/* 遍历栈上下标 1 的表:数字求和、字符串收集、表递归一层 */ static double sum_table(lua_State *L, int idx, char **names, int *n_names) { double sum = 0; lua_pushnil(L); /* 首键:压 nil 作游标起点 */ while (lua_next(L, idx) != 0) { /* 此刻栈顶:键在 -2,值在 -1 */ if (lua_type(L, -1) == LUA_TNUMBER) { sum += lua_tonumber(L, -1); } else if (lua_type(L, -1) == LUA_TSTRING && *n_names < 8) { names[(*n_names)++] = strdup(lua_tostring(L, -1)); } else if (lua_type(L, -1) == LUA_TTABLE) { sum += sum_table(L, lua_gettop(L), names, n_names); /* 递归一层 */ } lua_pop(L, 1); /* 弹值留键:lua_next 的推进约定 */ } return sum; }

三处约定是这段代码的全部难点。lua_pushnil 加 lua_next 是标准遍历舞步:nil 压栈作首游标,lua_next 弹游标、压出下一对键值,返回 0 表示走完。每轮只弹值不弹键——键留给下一轮的 lua_next 当游标,弹错了就跳键或死循环。递归时用 lua_gettop 取值的实时下标——递归调用后栈高在变,写死 2 会错位。配上 9.1 节的防御三件套(基线对齐),这段代码就是 C 侧读配置的成品内核;字符串收集用 strdup 拷贝,因为 tostring 的指针在下次栈操作后可能失效——每个坑都对应前文的一条纪律,操练的意义正在于此。

本节要点回顾

  • 栈是三重矛盾的和解:内存归属(值皆是 Lua 的,GC 认账)、类型(槽位带标签)、约定(双方只会压弹)——指针直传三条都违反;
  • 操作三族:push 压、to 取(不弹出)、is 验;luaL_ 前缀是高层封装;
  • 负数下标从栈顶数,库代码统一用负数最稳;tostring 的指针勿跨调用持有;
  • C 函数的返回值是结果个数,进出栈必须配平——失衡是 C 侧第一 bug 源;记基线、对齐、压结果是防御三件套;
  • 取值必压栈、用完必弹出:getfield/geti 的栈呼吸,lua_next 的键驻栈约定要照手册抄;
  • lua_pcall 是第 6 章 pcall 的 C 侧真身,宿主安全调用的终点站;
  • 接口薄则寿命长:三十年兼容不是运气,是"最小公分母"设计的复利。

下一节把栈用起来:从 C 侧给脚本递函数(扩展),从宿主位驱动脚本(嵌入)。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U