简介 调试(Debug)对于大部分程序员来说并不陌生,我们接下来主要要讲的是动态调试。当然我们大多时候,使用的调试工具都是基于我们图形界面的IED,但实际上调试的一些关键的东西也是可以在命令行下完成,这就是我们要讲的lua调试机制。
lua的基本调试api lua有关调试的接口都在debug库里,但我们不会全都讲,因为有些特性繁琐又用处不大,比如debug.getfenv,所谓的函数环境。
断点功能 lua的原生环境没有图形界面,所以我们不可能直接在脚本的某行直接打断点。但我们应该了解断点的本质是什么,实际上是hook,俗称钩子。我们从C语言的动态库链接简单地来理解一下。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 int add (int a,int b) { return a+b; } #include <stdio.h> int add (int ,int ) ; int main () { int res = add(1 ,3 ); printf ("%d\n" ,res); }
对于上述两个文件,我们用不同的编译参数,我们在Termux下运行,如下:
注意我们使用了export LD_LIBRARY_PATH=./
,这是因为调用动态库时并不会查找当前路径。接下来,我们修改test.c文件,如下
1 2 3 4 5 #include <stdio.h> int add (int a,int b) { printf ("I am a hook." ); return a+b; }
我们加了一句打印来说明hook思想的源头。
我们发现虽然我们没有改变主程序hook,但前后两次的打印结构却不同了,知道程序的运行机制的话是很好理解的。这有许多方面的应用,比如安卓的xposed框架和root权限管理,这都是在linux内核下的系统。实际上,对window来说也是类似的,比如烂大街的破解软件本质都是反编译dll文件并修改来实现的。对于运行在虚拟机上的软件也有例子,java版minecraft的forge框架。当然,实际的hook工作是很复杂的,现在有许多的反反编译的技术,比如代码混淆。所以,我们要考虑的是本身就提供hook功能的软件,这样也没有违法,比如我们的lua和IDE的debug。 lua对hook提供了两个函数,debug.gethook ([thread])
和debug.sethook ([thread,] hook, mask [, count])
。thread表示需要hook的协程,默认是我们当前运行的协程;hook表示一个函数,表示hook到节点后执行的函数;mask是一个字符串,表示hook的时机,如“c”表示每次调用一个函数时,“r”表示从一个函数返回时,“l”表示每次进入新的一行时;“count”表示执行hook的最小代码量间隔,防止过于频繁的hook。一个简单的例子如下:
1 2 3 4 5 local function printLine (event, line) print ("Line:" ..line) end debug .sethook (printLine, "l" )debug .gethook ()
hook函数有两个参数,第一个是事件(字符串类型),对于“line”事件则还有line参数表示行号,具体看官方文档。不过有一点要注意,有关line的hook只能在运行脚本的时候有用,交互环境下是没用的。
逐行运行 实际上对于交互式语言来说,本身就是逐行运行的,将脚本一行行复制进去即可。意义不大,但是对于脚本文件而言lua提供了debug.debug()
,当然交互环境也可以有但意义不大。但文件运行到这里时,会触发断点进入交互模式,此时我们可以执行相应的lua语句,比如查看变量之类的,这好像和hook是一回事,的却如此因为debug的各项操作都是离不开hook的,或者说它是debug之源,我们写个简单的例子体会一下。
1 2 3 4 a = 3 debug .debug ()print (a)
效果如下:
有几点值得我们注意,这里的交互环境不能直接输出变量a
,而要通过可执行语句print(a)
来查看,这是因为底层的原理与我们原生的交互环境有所不同,其次我们可以通过执行cont
结束交互环境,继续执行语句,由两次结果的不同我们可以看到,这与hook实际有些类似,但我们可以在脚本中实现。实际上依据这个功能,我们可以类似实现一个对脚本逐行执行的功能,例如运行前使用其它脚本对每行插入debug.debug()语句。已经有许多相关的工具了,我就不过多说明了,可以自己去探究一下源码。
变量跟踪 这部分对于lua虽然占了debug大部分函数,但实际上没有太多可说的东西,通过debug.get*
系列函数获取需要的参数,通过debug.set*
系列调整相应参数,比较特别的有:debug.traceback
可以打印调用栈信息,debug.getregistry()
获取注册表,在很久以前,我们讲过下面这个
1 2 3 #define LUA_REGISTRYINDEX (-10000 ) #define LUA_ENVIRONINDEX (-10001 ) #define LUA_GLOBALSINDEX (-10002 )
我们获取的就是其中的第一个,最后一个可以通过_G
获得,第二个通过getfenv
获得,最后一个比较特别的是debug.getinfo
,返回一个函数信息表,参数可以表示调用级别,如0是当前函数,1是调用此函数的函数,以此类推。最后我们摘录一个烂大街的例子结尾吧。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 function newCounter () local n = 0 local k = 0 return function () k = n n = n + 1 return n end end counter = newCounter () print (counter())print (counter())local i = 1 repeat name, val = debug .getupvalue (counter, i) if name then print ("index" , i, name, "=" , val) if (name == "n" ) then debug .setupvalue (counter,2 ,10 ) end i = i + 1 end until not nameprint (counter())
输出结果如下:
1 2 3 4 5 1 2 index 1 k = 1 index 2 n = 2 11
这是一个与upval有关的例子,因为upval本身就是函数的局部变量,即栈上的变量,对debug而言这就称为upval,而第二个参数本质就是栈上的索引,接下来从源码我们就能看到这点。
源码浏览 最后我们来稍微浏览一下lua中与debug相关的源码,与之前类似我们从注册部分开始。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 static const luaL_Reg dblib[] = { {"debug" , db_debug}, {"getfenv" , db_getfenv}, {"gethook" , db_gethook}, {"getinfo" , db_getinfo}, {"getlocal" , db_getlocal}, {"getregistry" , db_getregistry}, {"getmetatable" , db_getmetatable}, {"getupvalue" , db_getupvalue}, {"setfenv" , db_setfenv}, {"sethook" , db_sethook}, {"setlocal" , db_setlocal}, {"setmetatable" , db_setmetatable}, {"setupvalue" , db_setupvalue}, {"traceback" , db_errorfb}, {NULL , NULL } }; LUALIB_API int luaopen_debug (lua_State *L) { luaL_register(L, LUA_DBLIBNAME, dblib); return 1 ; }
由于相似的部分比较多,我们接下来就选取几个比较有代表的来看看。同时对于lua函数在执行时都有对lua虚拟栈的操作,大多比较重复,我们就直接跳过,转而看它的核心函数。
hook系列函数 首先是我们的debug.sethook函数
1 2 3 4 5 6 7 8 9 10 11 LUA_API int lua_sethook (lua_State *L, lua_Hook func, int mask, int count) { if (func == NULL || mask == 0 ) { mask = 0 ; func = NULL ; } L->hook = func; L->basehookcount = count; resethookcount(L); L->hookmask = cast_byte(mask); return 1 ; }
该函数的参数与我们调用时是一样的,仔细一看该函数就是改变了协程上的4个参数而已。如果就这样结束,我们显然是不满足的,我们想要更加深入,通过函数的作用,我们可以猜想在某些字节码执行上应该有相关函数,实际上之前我们就看到了,不过被我们跳过了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 int luaD_precall (lua_State *L, StkId func, int nresults) { *** if (!cl->isC) { *** if (L->hookmask & LUA_MASKCALL) { L->savedpc++; luaD_callhook(L, LUA_HOOKCALL, -1 ); L->savedpc--; } *** } else { *** if (L->hookmask & LUA_MASKCALL) luaD_callhook(L, LUA_HOOKCALL, -1 ); *** } } }
这是调用前,对应“c”参数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 case OP_RETURN: { *** b = luaD_poscall(L, ra); *** } } int luaD_poscall (lua_State *L, StkId firstResult) { *** if (L->hookmask & LUA_MASKRET) firstResult = callrethooks(L, firstResult); *** } static StkId callrethooks (lua_State *L, StkId firstResult) { *** luaD_callhook(L, LUA_HOOKRET, -1 ); if (f_isLua(L->ci)) { while ((L->hookmask & LUA_MASKRET) && L->ci->tailcalls--) luaD_callhook(L, LUA_HOOKTAILRET, -1 ); } *** }
这个是函数返回时,藏得稍微深了点,对应“r”。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 void luaV_execute (lua_State *L, int nexeccalls) { *** for (;;) { *** if ((L->hookmask & (LUA_MASKLINE | LUA_MASKCOUNT)) && (--L->hookcount == 0 || L->hookmask & LUA_MASKLINE)) { traceexec(L, pc); if (L->status == LUA_YIELD) { L->savedpc = pc - 1 ; return ; } *** } static void traceexec (lua_State *L, const Instruction *pc) { *** if ((mask & LUA_MASKCOUNT) && L->hookcount == 0 ) { resethookcount(L); luaD_callhook(L, LUA_HOOKCOUNT, -1 ); } if (mask & LUA_MASKLINE) { Proto *p = ci_func(L->ci)->l.p; int npc = pcRel(pc, p); int newline = getline(p, npc); if (npc == 0 || pc <= oldpc || newline != getline(p, pcRel(oldpc, p))) luaD_callhook(L, LUA_HOOKLINE, newline); } }
对于有关行的处理是稍微有些复杂的,首先每个字节码并不对应于一行,所以虽然每个字节码执行前虽然都设置了断点,但并没有直接执行hook,实际上我们的proto存储了有关的行号信息
1 2 3 4 5 6 7 typedef struct Proto { *** int *lineinfo; int linedefined; int lastlinedefined; *** } Proto;
我们的hook就是依据此来决定是否执行hook的,上面我保留了这个关键部分,可以看看。到此我们发现了执行hook的关键函数是luaD_callhook
我们来看看吧。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 void luaD_callhook (lua_State *L, int event, int line) { lua_Hook hook = L->hook; if (hook && L->allowhook) { ptrdiff_t top = savestack(L, L->top); ptrdiff_t ci_top = savestack(L, L->ci->top); lua_Debug ar; ar.event = event; ar.currentline = line; if (event == LUA_HOOKTAILRET) ar.i_ci = 0 ; else ar.i_ci = cast_int(L->ci - L->base_ci); luaD_checkstack(L, LUA_MINSTACK); L->ci->top = L->top + LUA_MINSTACK; lua_assert(L->ci->top <= L->stack_last); L->allowhook = 0 ; lua_unlock(L); (*hook)(L, &ar); lua_lock(L); lua_assert(!L->allowhook); L->allowhook = 1 ; L->ci->top = restorestack(L, ci_top); L->top = restorestack(L, top); } }
lua_Hook是一个函数指针,即我们在lua里传入的函数,lua_Debug是一个用来保存相关调试信息的结构体,如上面的ar.event = event
和ar.currentline = line
就是传给我们函数的两个参数。其它就没啥好说了,就是准备调用函数前的一些基本操作罢了,(*hook)(L, &ar);
用来执行我们的hook函数。 然后是我们的debug.gethook
函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 static int db_gethook (lua_State *L) { int arg; lua_State *L1 = getthread(L, &arg); char buff[5 ]; int mask = lua_gethookmask(L1); lua_Hook hook = lua_gethook(L1); if (hook != NULL && hook != hookf) lua_pushliteral(L, "external hook" ); else { gethooktable(L); lua_pushlightuserdata(L, L1); lua_rawget(L, -2 ); lua_remove(L, -2 ); } lua_pushstring(L, unmakemask(mask, buff)); lua_pushinteger(L, lua_gethookcount(L1)); return 3 ; }
我们没有看lua_gethook(L1);
函数,是因为它属于一般的变量返回函数,就一句return返回L1的hook参数,在面向对象里封装私有变量的基本方法,没什么好讲的。gethooktable函数也比较重要,代码如下:
1 2 3 4 5 6 7 8 9 10 11 static void gethooktable (lua_State *L) { lua_pushlightuserdata(L, (void *)&KEY_HOOK); lua_rawget(L, LUA_REGISTRYINDEX); if (!lua_istable(L, -1 )) { lua_pop(L, 1 ); lua_createtable(L, 0 , 1 ); lua_pushlightuserdata(L, (void *)&KEY_HOOK); lua_pushvalue(L, -2 ); lua_rawset(L, LUA_REGISTRYINDEX); } }
整体都是通过lua的CAPI实现的,我们稍做总结就是,先构造一个表存储有关hook的信息,放在栈上,再从栈上获取hook的名字和总个数,放到栈上。总之就是输出hook的名字([crl])和个数([0-3])。
debug函数 其实它的原理很简单,如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 static int db_debug (lua_State *L) { for (;;) { char buffer[250 ]; fputs ("lua_debug> " , stderr ); if (fgets(buffer, sizeof (buffer), stdin ) == 0 || strcmp (buffer, "cont\n" ) == 0 ) return 0 ; if (luaL_loadbuffer(L, buffer, strlen (buffer), "=(debug command)" ) || lua_pcall(L, 0 , 0 , 0 )) { fputs (lua_tostring(L, -1 ), stderr ); fputs ("\n" , stderr ); } lua_settop(L, 0 ); } }
通过fgets(buffer, sizeof(buffer), stdin) == 0
获取一行字符到buffer,由strcmp(buffer, "cont\n") == 0
检测输入是否为cont,是则进入return 0
结束函数,否则通过luaL_loadbuffer
编译源码,lua_pcall(L, 0, 0, 0)来执行,出问题则调用fputs(lua_tostring(L, -1), stderr);
输出报错信息,否则lua_settop(L, 0);
后进入下一个循环。比较神器的或许是它将获取和比较、加载和执行都放到一个if的条件里面去,这样的句子实际见的也比较多,比如读取文件的while循环里有while((line=fread(f))!=NULL)
这样的句子,少见多怪了。
变量相关 这里我们就选取upval的部分来看看,以便完结我们之前的坑。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 static int auxupvalue (lua_State *L, int get) { const char *name; int n = luaL_checkint(L, 2 ); luaL_checktype(L, 1 , LUA_TFUNCTION); if (lua_iscfunction(L, 1 )) return 0 ; name = get ? lua_getupvalue(L, 1 , n) : lua_setupvalue(L, 1 , n); if (name == NULL ) return 0 ; lua_pushstring(L, name); lua_insert(L, -(get+1 )); return get + 1 ; } static int db_getupvalue (lua_State *L) { return auxupvalue(L, 1 ); } static int db_setupvalue (lua_State *L) { luaL_checkany(L, 3 ); return auxupvalue(L, 0 ); }
容易看出,本质是通过调用lua的CAPI中的lua_getupvalue
和lua_setupvalue
实现的,而这个函数的索引则是当前运行函数闭包的upval栈上的索引,注意我们之前有从栈上取出函数,这正是我们调用时传入的参数,这也提醒我们upval是相对于函数而言的。
getinfo函数 最后我们再来看一下这个函数吧
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 static int db_getinfo (lua_State *L) { lua_Debug ar; int arg; lua_State *L1 = getthread(L, &arg); const char *options = luaL_optstring(L, arg+2 , "flnSu" ); if (lua_isnumber(L, arg+1 )) { if (!lua_getstack(L1, (int )lua_tointeger(L, arg+1 ), &ar)) { lua_pushnil(L); return 1 ; } } else if (lua_isfunction(L, arg+1 )) { lua_pushfstring(L, ">%s" , options); options = lua_tostring(L, -1 ); lua_pushvalue(L, arg+1 ); lua_xmove(L, L1, 1 ); } else return luaL_argerror(L, arg+1 , "function or level expected" ); if (!lua_getinfo(L1, options, &ar)) return luaL_argerror(L, arg+2 , "invalid option" ); lua_createtable(L, 0 , 2 ); if (strchr (options, 'S' )) { settabss(L, "source" , ar.source); settabss(L, "short_src" , ar.short_src); settabsi(L, "linedefined" , ar.linedefined); settabsi(L, "lastlinedefined" , ar.lastlinedefined); settabss(L, "what" , ar.what); } if (strchr (options, 'l' )) settabsi(L, "currentline" , ar.currentline); if (strchr (options, 'u' )) settabsi(L, "nups" , ar.nups); if (strchr (options, 'n' )) { settabss(L, "name" , ar.name); settabss(L, "namewhat" , ar.namewhat); } if (strchr (options, 'L' )) treatstackoption(L, L1, "activelines" ); if (strchr (options, 'f' )) treatstackoption(L, L1, "func" ); return 1 ; }
看起来似乎很复杂,但实际上就是创建一个表,并不断设置表的一些项,这就是我们调用后此函数后返回的表,而表的内容实际是lua_Debug结构体的内容,它的定义如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 struct lua_Debug { int event; const char *name; const char *namewhat; const char *what; const char *source; int currentline; int nups; int linedefined; int lastlinedefined; char short_src[LUA_IDSIZE]; int i_ci; };
注意这个结构体主要用于包装数据并传递,虽然如Proto之类的结构体有存储debug信息,但都是分散存储的,需要的时候再取出来就行了。
结尾 最后,我想再提一件事,lua虽然有调试机制,但没有异常处理机制,除了最基本的assert外,lua既没有exception
也没有try catch
,这与C语言有些类似,但有本质的不同,lua只存在编译错误,并不存在运行时错误,一切错误通过返回值的不同来表现,比如之前协程的resume,这主要得益于lua的返回值没有类型限制,C语言的话则与CPU有关,错误状态是保留在某个固定的寄存器里,我们的CPU要是出错停止那不就完蛋,当然我们平常的程序都是运行在用户空间,没必要当心就是了。至于如何处理有些异常,实际与C是类似的,比如文件读取的基本步骤如下:
1 2 3 4 5 6 f = io.open("a.txt", "r") if f==nil then print("文件不存在") return nil end return f:read()
这里就对于文件可能不存在的相关情况做了处理。在实际开发过程中,我比较喜欢的是先加#TODO
标签再使用assert
直接处理,主要是保持开发过程中的简洁性,属于个人习惯而已,不必在意,反正最后还是要补充回去的。 好了,好了,lua终于快接近尾声了,再接再厉吧。