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" 三个值,栈的变化如下:

图2

当我们调用 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,记录了函数地址在栈中的位置,以及如何维护函数之间调用层级关系的,最后我们还介绍函数返回时,上一层函数是如何获取多个返回值。

posted @   墨色山水  阅读(87)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
点击右上角即可分享
微信分享提示