Lua异常处理
Lua代码运行过程中,可能会出现异常状态,比如非法地址访问、遇到未定义符号、或者断言失败等,由于异常出现的地方不定,所以我们需要用一些方法来获取异常信息,找到出现异常的原因。
在C语言代码中处理Lua脚本运行产生的异常:
要能在发生异常后可以在C语言代码中获取到异常信息,就必须通过lua_pcall,或者lua_pcallk函数来运行Lua代码,如果直接使用lua_call函数来运行Lua代码,当发生异常后,Lua解析器不会保留异常信息,而是会调用系统函数abort导致整个程序运行中止。
对于lua_pcall、lua_pcallk这两个函数,前者实际上是一个宏,是对后者做了一下简单封装后的结果,后者用得不多,这里不再做介绍。故名思意,lua_pcall函数是以保护模式运行Lua代码,当Lua代码中遇到异常时,lua_pcall函数不会直接调用abort函数导致程序卡死,而是会进行异常处理,然后恢复运行Lua代码之前的状态,然后返回。
在调用lua_pcall函数前,可以通过lua_pushcfunction函数将自己的异常处理函数压栈,然后通过lua_pcall函数的参数指定异常处理函数在栈中所在的位置,然后当Lua脚本运行出错时,这个自定义的异常处理函数会被调用,这时候可以继续在自定义异常处理函数中调用luaL_traceback函数,这个函数会获取到更多的错误信息,然后将错误信息以一个字符串的形式压栈,然后打印栈中的信息就可以得到详细的错误信息了。
当然,这个自定义异常处理函数不是必须的,如果没有自定义异常处理函数,那么解析器会将一个稍微简单一点的错误信息以字符串的形式压栈,当lua_pcall函数返回时,判断它的返回值,如果不为LUA_OK,就可以通过以字符串的形式将栈底打印出来获取错误信息。但需要注意的是,这两种方法不可以同时使用,换一种说法,如果使用了自定义异常处理函数,那么当lua_pcall函数返回后,栈顶的值就不会包含错误信息,因为在调用自定义异常处理函数的过程中,栈顶的错误信息会被覆盖掉。
说那么多,还是得来点代码。
Main.c: |
int main(void) { ????lua_State* L = NULL; ????int status; ????L = luaL_newstate(); ????luaL_openlibs(L); ????luaL_loadfile(L, "test_01.lua"); ????status=lua_pcall(L, 0, LUA_MULTRET,0); lua_remove(L, 1);/*将ExceptionHandle从栈中删掉,注意这里不用lua_pop,而是lua_remove是因为此时ExceptionHandle不一定在栈顶*/ ????if (status != LUA_OK) ????{ ????????/*将错误代码和栈顶的字符串打印出来*/ ????????printf("error code:%d,msg:%s\r\n",status,lua_tostring(L,-1)); ????} ????lua_close(L); ????return 0; } |
?
Test_01.lua |
Hello() |
?
运行结果: |
?
上面的代码中,main函数通过lua_pcall函数运行Lua脚本,Lua脚本中调用Hello函数,但是Hello函数并没有被定义,所以在运行时必定会产生异常,这里没有使用自定义异常处理函数,而是等lua_pcall返回后再打印栈顶获取异常信息。(和C语言不同,这种符号未定义的情况在Lua编译器编译的时候并不会报错,但运行的时候要还是找不到这个函数,那就不得不报错了。)
再试一下使用自定义异常处理函数的情况,这里Main.c修改如下,Lua代码不修改。
Main.c: |
int ExceptionHandle(lua_State* L) { ????const char* msg = lua_tostring(L, -1); ????luaL_traceback(L, L, msg, 1); ????printf("%s\r\n", lua_tostring(L, -1)); ????return 0; } int main(void) { ????lua_State* L = NULL; ????int status; ????L = luaL_newstate(); ????luaL_openlibs(L); ????lua_pushcfunction(L, ExceptionHandle); ????luaL_loadfile(L, "test_01.lua"); ????status=lua_pcall(L, 0, LUA_MULTRET,1); lua_remove(L, 1);/*将ExceptionHandle从栈中删掉,注意这里不用lua_pop,而是lua_remove是因为此时ExceptionHandle不一定在栈顶*/ ????if (status != LUA_OK) ????{ ????????/*将错误代码和栈顶的字符串打印出来*/ ????????printf("error code:%d,msg:%s\r\n",status,lua_tostring(L,-1)); ????} ????lua_close(L); ????return 0; } |
?
运行结果: |
?
这里首先调用lua_pushcfunction(L, ExceptionHandle),将自定义异常处理函数压栈,由于是第一次压栈,所以ExceptionHandle函数在栈中的位置为1,然后加载Lua文件,调用lua_pcall函数运行Lua脚本,注意这里调用lua_pcall函数时的第四个参数为1,这个1,就是自定义异常处理函数在栈中的位置。
看结果,在自定义异常处理函数中,首先获取栈顶的值,并将其转换成字符串,这个字符串就是前面说的,Lua解析器压入的简单错误信息,获取到这个字符串后,再调用luaL_traceback函数获取相信错误信息,最后打印栈顶字符串,将详细错误信息打印出来,到这里,运行结果中的第1、2、3行便是运行的结果,然后lua_pcall函数退出后,主函数试图再次打印栈顶字符串,但打印出来的确实null(不应该是烫烫烫吗。。。),为啥?因为这时候栈顶已经不包含错误信息了,其实,如果细心看,这里运行结果的第一行和上面打印的错误信息是一样的,因为luaL_traceback函数将简单错误信息和详细错误信息合在一起了。
问题:
为啥用了自定义异常处理函数后,Lua解析器存放在栈中的简单错误信息会被覆盖掉,按道理,调用一个函数,被调用的函数不应该影响调用它的函数的栈才对,并且调用完函数后,栈的状态应该和调用前完全一样才对,这个让我纠结了很久,因为一开始按照这个思路,定义了自定义异常处理函数,却没有在异常处理函数中将栈中的信息打印出来,而是等lua_pcall退出后才打印,总是出不来结果,后来还是找到了问题。
首先当异常发生后,Lua解析器会做什么?要知道发生异常后Lua解析器会做啥,就得知道异常会在哪里产生,这个问题简单,当然是在Lua代码执行的某条字节码时产生,Lua解释器会通过void luaV_execute (lua_State *L)函数执行Lua字节码。
上图是前面Lua代码的汇编代码,只有3条,第一条找到Hello函数,第二条执行它,第三条指令返回。经过测试,异常在第二条指令执行的时候产生,执行第二条指令时,会调用luaD_precall来执行对应的函数(只有C函数是在这个函数里被直接调用的),luaD_precall代码如下(删除了部分代码):
int luaD_precall (lua_State *L, StkId func, int nresults) { /* do something */ switch (ttype(func)) {/* 判断函数类型,是C函数还是Lua函数 */ case LUA_TCCL: /* C closure */ /* do something */ goto Cfunc; case LUA_TLCF: /* light C function */ /* do something */ return 1; default: { /* 既不是C函数,也不是Lua函数 */ /* do something */ tryfuncTM(L, func); /* try to get ‘__call‘ metamethod */ return luaD_precall(L, func, nresults); /* 如果发生异常,则不会执行到这里 */ } } } |
我们要看的就是default分支,它会调用tryfuncTM函数去继续寻找元方法(这里存疑),在寻找元方法之前,该函数首先会检测func是不是一个函数,这里当然不是,当确定func不是函数后,tryfuncTM函数便会抛出异常,代码如下:
static void tryfuncTM (lua_State *L, StkId func) { const TValue *tm = luaT_gettmbyobj(L, func, TM_CALL); /*do something*/ if (!ttisfunction(tm)) luaG_typeerror(L, func, "call");/*抛出异常*/ /*do something*/ } |
接下来luaG_typeerror函数会函数会给出错误原因,然后将错误原因传给luaG_runerror函数,luaG_runerror函数调用luaG_addinfo函数添加文件名和行号,然后将这些信息以一个字符串的形式压栈,这个字符串就是前面说的简单错误信息,最后调用luaG_errormsg函数,而在luaG_errormsg函数中,我们可以找到简单错误信息被覆盖的原因。
三个变量:文件名、行号、错误信息,组成了简单错误信息。
接下来看luaG_errormsg函数,这个函数解释了为啥简单错误信息会被自定义异常处理函数覆盖。
l_noret luaG_errormsg (lua_State *L) { if (L->errfunc != 0) { /* is there an error handling function? */ StkId errfunc = restorestack(L, L->errfunc); setobjs2s(L, L->top, L->top - 1); /* move argument */ setobjs2s(L, L->top - 1, errfunc); /* push function */ L->top++; /* assume EXTRA_STACK */ luaD_callnoyield(L, L->top - 2, 1); /* call it */ } luaD_throw(L, LUA_ERRRUN); } |
首先这个函数会判断L->errfunc是否为0,这这个errfunc和lua_pcall的第4个参数有关,具体关系为L->errfunc=16*arg;(16为栈中一个元素的大小,arg为第四个参数值),如果arg不为0,则L->errfunc也不为0,这表示设置了自定义异常处理函数,进入if后,会调用restorestack找到压倒栈中的异常处理函数,然后调用setobjs2s调整栈数据,setobjs2s(L,A,B)就是将L的栈中,B地址指向的变量中数据给A地址指向的变量,注意,这里L->top-1地址指向的数据包含了简单错误信息,调整完后会调用luaD_callnoyield函数来掉用异常处理函数,画个图吧。
从上图可以看出,当执行完调整栈数据的三行代码后,简单错误信息被往上移了一格,而当 luaD_callnoyield(L, L->top - 2, 1);执行完后,栈指针会还原到调整栈数据之前的状态,这就回到了上图中的第二步的那种状态,由于Lua中的栈是空递增的栈,所以这时候简单错误信息无法被访问到,这里做一个小测试,将栈指针自己强行加1,看能不能打印出简单错误信息。
出来了,和预想的一样,但在写文档的时候又发现一个问题,在luaG_errormsg函数中对L->top强行加一可以得到预想中的结果,但在main函数中,等lua_pcall函数退出后再将L->top加一却出不来预想的结果,一顿跟踪,发现了问题的真相,lua_pcall函数经过层层调用,会调用到luaD_rawrunprotected函数,这个函数会继续调用其它函数来运行Lua脚本,函数的大致结构如下:
int luaD_pcall (lua_State *L, Pfunc func, void *u,ptrdiff_t old_top, ptrdiff_t ef) { int status; /* do something:保存调用前的状态 */ L->errfunc = ef; status = luaD_rawrunprotected(L, func, u);/*执行Lua脚本 */ if (status != LUA_OK) { /* 如果执行得有问题 */ /* do something:获取调用前的栈状态 */ seterrorobj(L, status, oldtop); /* do something:还原调用前的状态 */ } L->errfunc = old_errfunc; return status; } |
当错误发生后,luaD_rawrunprotected函数的返回值是不等于LUA_OK的,也就是运行Lua脚本失败了,它会调用seterrorobj函数将错误信息移到调用前的栈指针所指向的那个个位置,然后将L->top强制设置为调用前栈指针oldtop+1,seterrorobj函数代码如下:
static void seterrorobj (lua_State *L, int errcode, StkId oldtop) { switch (errcode) { case LUA_ERRMEM: { /* do something */ break; } case LUA_ERRERR: { /* do something */ break; } default: { setobjs2s(L, oldtop, L->top - 1); /* 将错误信息压倒旧栈指针指向的位置 */ break; } } L->top = oldtop + 1;/* 强制恢复栈顶指针,并将其加一 */ } |
?
可以看到,seterrorobj函数中有3个分支,分别是LUA_ERRMEM、LUA_ERRERR和default,这个分支代表Lua5.3参考手册中的5中错误代码:
- LUA_OK (0): 成功。
- LUA_ERRRUN: 运行时错误。
- LUA_ERRMEM: 内存分配错误。对于这种错,Lua 不会调用错误处理函数。
- LUA_ERRERR: 在运行错误处理函数时发生的错误。
- LUA_ERRGCMM: 在运行__gc 元方法时发生的错误。这个错误和被调用的函数无关。
画个图吧:
这是在luaG_errormsg函数中对L->top强制加一的情况。
这是在main函数中等lua_pcall函数退出后再将L->top加一的情况。
LUA_ERRMEM、LUA_ERRERR有单独的分支,其它的错误代码都会进入default分支,调用seterrorobj函数。脚本中调用未定义函数属于运行时出错,所以也会进入default分支。这就解释了为啥在luaG_errormsg函数中对L->top强行加一可以得到预想中的结果,但在main函数中,等lua_pcall函数退出后再将L->top加一却出不来预想的结果,因为当luaG_errormsg函数退出后会做恢复原状态的工作,所以到main函数中的栈指针已经不是luaG_errormsg函数中的栈指针了,所以在main函数中对栈指针加一是不可行的。想想也应该,保护调用按道理也应该分三段:保存调用前状态、调用需要调用的函数、如果出错恢复出错前状态。
至此,这个问题就理清了。