简介

调试(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
//test.c
int add(int a,int b) {
return a+b;
}

//hook.c
#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
--main.lua
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 -- if
until not name
print(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) { /* turn off hooks? */
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) { /* Lua function? prepare its call */
***
if (L->hookmask & LUA_MASKCALL) {
L->savedpc++; /* hooks assume 'pc' is already incremented */
luaD_callhook(L, LUA_HOOKCALL, -1);
L->savedpc--; /* correct 'pc' */
}
***
}
else { /* if is a C function, call it */
***
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
//lvm.c
case OP_RETURN: {
***
b = luaD_poscall(L, ra);
***
}
}

//ldo.c
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)) { /* Lua function? */
while ((L->hookmask & LUA_MASKRET) && L->ci->tailcalls--) /* tail calls */
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) {
***
/* main loop of interpreter */
for (;;) {
***
if ((L->hookmask & (LUA_MASKLINE | LUA_MASKCOUNT)) &&
(--L->hookcount == 0 || L->hookmask & LUA_MASKLINE)) {
traceexec(L, pc);
if (L->status == LUA_YIELD) { /* did hook 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; /* map from opcodes to source lines */
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; /* tail call; no debug information about it */
else
ar.i_ci = cast_int(L->ci - L->base_ci);
luaD_checkstack(L, LUA_MINSTACK); /* ensure minimum stack size */
L->ci->top = L->top + LUA_MINSTACK;
lua_assert(L->ci->top <= L->stack_last);
L->allowhook = 0; /* cannot call hooks inside a hook */
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 = eventar.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) /* external hook? */
lua_pushliteral(L, "external hook");
else {
gethooktable(L);
lua_pushlightuserdata(L, L1);
lua_rawget(L, -2); /* get hook */
lua_remove(L, -2); /* remove hook table */
}
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); /* remove eventual returns */
}
}

通过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; /* cannot touch C upvalues from Lua */
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_getupvaluelua_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); /* level out of range */
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; /* return table */
}

看起来似乎很复杂,但实际上就是创建一个表,并不断设置表的一些项,这就是我们调用后此函数后返回的表,而表的内容实际是lua_Debug结构体的内容,它的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct lua_Debug {
int event;
const char *name; /* (n) */
const char *namewhat; /* (n) `global', `local', `field', `method' */
const char *what; /* (S) `Lua', `C', `main', `tail' */
const char *source; /* (S) */
int currentline; /* (l) */
int nups; /* (u) number of upvalues */
int linedefined; /* (S) */
int lastlinedefined; /* (S) */
char short_src[LUA_IDSIZE]; /* (S) */
/* private part */
int i_ci; /* active function */
};

注意这个结构体主要用于包装数据并传递,虽然如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终于快接近尾声了,再接再厉吧。