lua5.3 栈学习
lua 与c进行交互,或者lua函数执行,都需要用到栈,接下来就先了解下 lua 栈的内部实现。
了解栈的实现,可以从2个方面出发,栈结构在哪定义,栈存储的元素是什么。
lua 栈主要放在 lua_State 结构上,摘要 lua_State 一些和栈相关的字段:
/* ** 'per thread' state */ struct lua_State { StkId top; /* first free slot in the stack */ CallInfo *ci; /* call info for current function */ StkId stack_last; /* last free slot in the stack */ StkId stack; /* stack base */ CallInfo base_ci; /* CallInfo for first level (C calling Lua) */ int stacksize; ... }; typedef TValue *StkId; /* index to stack elements */ /* ** Union of all Lua values */ typedef union Value { GCObject *gc; /* collectable objects */ void *p; /* light userdata */ int b; /* booleans */ lua_CFunction f; /* light C functions */ lua_Integer i; /* integer numbers */ lua_Number n; /* float numbers */ } Value; #define TValuefields Value value_; int tt_ typedef struct lua_TValue { TValuefields; } TValue;
通过对 lua_State 分析,对栈的理解,可以分为数据栈,调用栈两部分。
数据栈:
- stack 指向栈的起始地址。
- top 指向栈顶(和我们平时学数据结构栈一样,都会有一个 top指针,指向入栈顶数据的下一个位置)。
- stack_last 指向最后可用的位置,但是会留空 EXTRA_STACK=5 个位置,用于元表调用或错误处理的栈操作。
- stacksize 栈大小,初始化时,大小为40
通过查看 stack 的类型,我们知道,栈中存储的每一个元素都是 TValue 类型,只不过给它起了个别名 StkId,目的是为了方便内部一些函数传参时,能一眼看出来,就是对栈中的数据进行操作,TValue 可不止在栈中使用,在其他地方,比如 table 的 key/value,Proto 函数原型的常量表k等地方也有使用到这个结构。TValue 它包含了一个类型标识字段 tt_(标识 Value 当前存的值是整型还是浮点型,又或者其他类型值),以及一个真正存储数据的 Value 字段。
lua 数据分为值类型和引用类型。值类型可以直接复制,比如整数,浮点数等。而引用类型共享同一份数据,由 GC 维护生命周期,比如,table,string 等。在 lua 中,用 TValue 保存数据。
调用栈:
- 用 CallInfo(简称ci)结构体来描述调用栈信息,我们在函数调用时,都需要通过 ci 来存储函数地址,以及记录函数在栈中使用到的栈顶位置。
- L->base_ci 指向第一个 ci。也就是所有 lua 函数调用的第一层,这个 base_ci 可以理解为指向 main 函数的 ci。
简单看下调用栈 ci 结构,摘要其中比较重要的字段分析:
typedef struct CallInfo { StkId func; /* function index in the stack */ StkId top; /* top for this function */ struct CallInfo *previous, *next; /* dynamic call link */ short nresults; /* expected number of results from this function */ ... } CallInfo;
- func 指向栈中存储函数的那个 TValue 位置。(我们前面说过,栈是由 TValue 构成的,TValue 可以存储函数)
- top 指向本函数在栈中可使用的最后一个元素的位置+1。
- previous, next 是把所有的 ci 串连起来,构成双向链表。就好比我们平时写的函数调用,A call B call C,那么调用栈顺序:ci(A) -> ci(B) -> ci(C),C执行完了,返回上一级函数B,B执行完了,返回A继续执行。函数能正确返回,都是因为 ci 记录了每个函数在栈中的位置。
- nresult 返回值个数,本次函数调用,调用方期待被调用函数能返回的值的个数。
初始化栈时,结构大致示意图如下:
下面举个例子,来说明下 c 函数是如何和 lua 栈进行交互的:
int f2(lua_State *L) { printf("f2() called\n"); return 0; } int f1(lua_State *L) { printf("f1() called start\n"); printf("args: %lld %s\n", lua_tointeger(L, 1), lua_tostring(L, -1)); lua_pushcfunction(L, f2); lua_pcall(L, 0, 0, 0); lua_pushstring(L, "a1"); lua_pushstring(L, "a2"); printf("f1() called end\n"); return 2; } int main(int argc, char const *argv[]) { lua_State *L = luaL_newstate(); lua_pushcfunction(L, f1); lua_pushinteger(L, 12); lua_pushstring(L, "hello world"); lua_call(L, 2, 2); printf("main() recv: %s %s\n", lua_tostring(L, 1), lua_tostring(L, 2)); lua_close(L); return 0; } /** 输出结果: f1() called start args: 12 hello world f2() called f1() called end main() recv: a1 a2 **/
在 main 函数中,先把 f1 压栈,接下来再压入一个整型12,以及一个字符串 "hello world",接下来会调用 lua_call,lua_call 会调用我们最先开始压入的函数f1,在函数 f1 中,打印栈中参数,然后接着通过 lua_call 调用 f2 函数,f2 执行完后,就退回到 f1,接着把字符串 "a1","a2" 压栈返回,最后 main 函数里打印 "a1","a2"。
如果忽略 lua_call API的包装,单纯从我们自己写的函数,调用顺序为:main() -> f1() -> f2()
,通过使用 lua_pushxxx(), lua_call,lua_pcall 等API,我们可以在函数之间传递多个参数,以及返回多个值。看起来,有点像 lua 代码中的可变参数使用。
接下来分析具体实现原理:
当我们在 main 函数中 push f1函数地址,12,"hello world" 三个值,栈的变化如下:
当我们调用 lua_call 时,会创建一个新的 CallInfo 对象,并且 L->ci 指向它。用它来记录 f1 在栈中的位置,以及在栈中默认可以使用 LUA_MINSTACK(20)个空位。此时,L->base_ci 的 next 会指向当前 L->ci,构成一个双向链表。
紧接着,lua_call 会通过 func 指向的 f1 函数地址,调用 f1函数。在 main 函数中,我们不仅 push 了 f1 函数地址, 还 push 了两个值:12,"hello world",那么我们是怎么在 f1 函数中获得这两个值的呢。
在代码中,我们是通过在 lua_tointeger(L, 1) 和 lua_tostring(L, 2) 来获取参数值的,还记得我们说过栈存储的是 TValue 吗,我们在 main 函数中 push 数据的时候,lua 就会根据不同的api,设置 TValue.tt_ 类型。在获取值的时候,我们也要自己知道参数的类型,根据不同的类型,来获取对应 TValue.value_ 的值。
#define lua_tointeger(L,i) lua_tointegerx(L,(i),NULL) LUA_API lua_Integer lua_tointegerx (lua_State *L, int idx, int *pisnum) { lua_Integer res; const TValue *o = index2addr(L, idx); int isnum = tointeger(o, &res); if (!isnum) res = 0; /* call to 'tointeger' may change 'n' even if it fails */ if (pisnum) *pisnum = isnum; return res; } static TValue *index2addr (lua_State *L, int idx) { CallInfo *ci = L->ci; if (idx > 0) { TValue *o = ci->func + idx; ... else return o; } else if (!ispseudo(idx)) { /* negative index */ ... return L->top + idx; } ... }
我们可以通过简单分析一下其中一个 lua_tointeger 的实现。lua_tointeger 是一个宏,它的原型是 lua_tointegerx 函数,在 lua_tointegerx 函数中,我们通过给定的 idx,先去 index2addr 函数中查找栈中对应位置的数据。
在 index2addr 函数中的查找方式主要有2种大的方式查找,一种是正数,一种是负数,正数是从 L->ci->func 开始偏移查找,负数是从 L->top 开始往下偏移。(当然,还有其他情况,比如上值,全局表等,这里先不讨论)。也就是说,获取参数值,可以通过基于 ci->func 当前函数正向偏移查找,也可以通过栈顶反方向偏移查找,具体看怎么方便怎么来。
在例子 f1 函数中,lua_tointeger(L, 1) 表示相对当前 L->ci->func(也就是 f1)在栈中的位置偏移1,指向栈中第2个位置,然后返回正数12。lua_tostring(L, -1) 表示相对于栈顶 L->top 向下偏移1,指向栈中的第3个位置,然后返回的是 "hello world"。
在例子11-12行中,我们再 push 一个函数 f2 进栈,并执行它。最终的效果如下
最终我们看到,所有的 ci 构成双向链表,base_ci 指向第一个起始 CallInfo(相对于 main 函数),L->ci 指向当前正在执行的 f2 函数。每个 CallInfo 都记录一个函数在栈中的起始地址,以及参数最大地址 top(当然上图没画 top指向)。再 f2 调用完后,继续 push 两个字符 "a1", "a2" 进栈。然后返回2,标识有两个返回值,这样我们就能在 main 函数中获取到 "a1","a2"。
我们再进一步分析,f1 函数 push 两个字符到栈上后,main 函数又是怎么拿到的,我们又应该偏移 L->base_ci->func 多少去获取呢。通过源码分析,lua_call -> lua_callk() -> luaD_precall() -> luaD_callnoyield() -> luaD_call() -> luaD_precall() -> luaD_poscall() -> moveresults()
最终在 moveresults() 函数中,根据 f1 的返回个数2,来移动栈顶的2个数据到 f1函数位置上,可以理解为: stack[1] = stack[4]; stack[2] = stack[5]; L->top = stack[3]
,具体可以看下 luaD_poscall() -> moveresults()
实现。在 f1 执行完后,最终效果如下:
函数最终返回几个值,主要和 lua_pcall 调用时,第3个参数(nresults)有关。 情况如下:
1. nresults 大于函数的返回值,例如:
int f1(lua_State *L) { lua_pushstring(L, "a1"); lua_pushstring(L, "a2"); return 2; }
此时,会从 a1位置开始移动两个元素到 f1处 ,不够时,补nil,L->top 指向 nresults (3)个元素的下一个位置,最终变成右图的栈。
2. nresults 小于或者等于函数的返回值,例如:
int f1(lua_State *L) { lua_pushstring(L, "a1"); lua_pushstring(L, "a2"); return 2; }
此时,会从 a1位置开始移动一个元素到 f1处 ,L->top 指向 nresults (1)个元素的下一个位置,最终变成右图的栈。
3. nresults 为 LUA_MULTRET(-1),表示函数返回多少就接收多少,例如:
int f1(lua_State *L) { lua_pushstring(L, "a1"); lua_pushstring(L, "a2"); return 2; }
此时,会从 a1位置开始移动两个元素到 f1处 ,L->top 指向 f1返回值(2)个元素的下一个位置,最终变成右图的栈。
最后,我们就可以在 main 函数中,调用 lua_tostring(L, 1) 和 lua_tostring(L, 2) 来相对 L->base_ci->func 偏移1位,获取字符串 "a1",偏移2位,获取字符串 "a2" 。
总结
总结下,我们的栈分为数据栈,和调用栈,数据栈stack 本质是一个可变长的数组(在堆中申请),数组中的每个数据存储的类型是 StkId,它的原型是 TValue,包括了一个标识当前值类型的 tt_ 字段,以及一个存储值的 value_ 字段。当我们调用函数层级越多时,需要手动调用 lua_checkstack(L, n),告诉栈,我们当前需要至少 n 个位置来存放数据,栈就会检测是否有 n 个多余空位,不足时,就会扩容。调用栈,即 CallInfo,记录了函数地址在栈中的位置,以及如何维护函数之间调用层级关系的,最后我们还介绍函数返回时,上一层函数是如何获取多个返回值。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!