简介
lua在游戏服务器中,用的越来越多,作为一门嵌入式语言,lua一直没有一个好用的调试器。于是花了点时间做了一个gdb风格的lua调试器dlua,用来解决到处打log定位问题的烦恼。本文简单讲解一下内部实现的原理。
原理
其实原理很简单,lua官方虽然没有内置调试功能,但是提供了调试接口,可以在lua设置hook,每执行一行代码的时候,调用回调函数,在回调函数里,可以拿到堆栈的相关信息,比如运行到什么函数,local变量是什么值等等。 那么我们就利用这些接口,来实现即可。
实现
下面是具体实现的细节,既然期望是gdb风格,那么很显然,我们不能修改源程序,不管是c部分还是lua部分。所以我们需要一个第三方的程序来附加到目标进程上,然后开始调试。
模块划分与通信
既然需要附加,那么直接使用so注入工具来做这个事情,具体参考linux的so注入与热更新原理。 所以我们把整个程序分成了两个部分,一个是启动器dlua,负责注入so、命令输入与结果显示。一个动态链接库dluaagent so,注入到目标进程中运行,负责实际的调试操作。 两部分是运行在两个进程中,所以需要建立一种通信方式来耦合。这里直接采用的pipe通信,简单也足够适用于场景。
获得lua_State
注入so,其实很简单,但是要想开启lua hook,需要lua_State的指针才行
代码语言:javascript复制LUA_API void (lua_sethook) (lua_State *L, lua_Hook func, int mask, int count);
而对于一个陌生的目标进程,我们如何简单有效的拿到lua_State呢?
这里采用取巧的办法,我们先列出lua执行会用到的函数,比如
代码语言:javascript复制const std::vector g_lua_hook_func = {"luaV_execute", "luaD_call", "luaD_precall"};
然后我们使用gdb,去获得每个函数对应的内存地址,例如:
代码语言:javascript复制gdb -p PID -ex "p (long)luaV_execute" --batch
最后,在这个地址,使用hookso的argp功能,即在这个地址加一个断点,等执行到了触发,输出第一个参数,这个参数,就是lua_State的值了。当然,可能这个函数目标进程根本执行不到,所以需要加个超时。
代码语言:javascript复制timeout 1 ./hookso argp PID 0xXXXXXX 1
循环几个函数尝试,最后我们拿到了lua_State的指针
这里可能有人要问,为什么不自己去解析,要使用gdb呢?首先gdb很常见,大部分机器都装好了,其次自己去解析的话,费时费力,还需要考虑各种平台之类的兼容问题,代码也很臃肿。
初始化
拿到lua_State之后,调用hookso的call方法,让目标进程执行dluaagent.so的start_agent函数,开启调试功能。 而start_agent只是设置一下lua_sethook,和一个标志位,如下:
代码语言:javascript复制extern "C" int start_agent(lua_State *L, int pid) {
if (g_opening != RUNNING_STATE_STOP) {
return -1;
}
g_opening = RUNNING_STATE_BEGIN;
g_pid = pid;
g_L = L;
g_old_hook = lua_gethook(L);
g_old_hook_mask = lua_gethookcount(L);
g_old_hook_count = lua_gethookmask(L);
lua_sethook(L, hook_handler, LUA_MASKLINE, 0);
return 0;
}
因为start_agent是相当于在signal中调用的,所以不能做太多事情。初始化的事情,放在了hook_handler中执行,也就是当lua完全执行完一行的时候,由lua的回调函数来驱动逻辑。 同理,当退出调试,调用stop_agent,也是这样的处理机制。 当dluaagent.so完全初始化好之后,会从pipe发送消息到dlua,dlua这时候收到,判断初始化成功,开始正式的命令行调试,否则一直处于等待。
命令输入
c标准输入,是无法处理回退、方向键的,比较难用,所以这里使用readline库来处理输入问题,使用的方式比较简单:
代码语言:javascript复制char *command = readline("n(dlua) ");
dlua获取到命令后,直接将原文发给dluaagent.so处理,这样dlua只是一个壳,不用频繁的改动。同理,结果的显示,也是dluaagent.so预先处理好,直接发给dlua显示。
命令处理
刚才我们提到,dluaagent.so的逻辑都是在hook_handler内驱动的,收到的命令同样是在这里处理。 为什么不新加一个线程来处理?新加线程会极大的增加复杂度,造成两个线程同时操作lua_State,得不偿失。 一些简单的命令,如h(帮助)、bt(看堆栈)、l(查看源码),实现很简单,不再多讲。这里讲一讲以下几个命令
b(断点)
断点的原理,其实就是记录一个断点列表,每个断点有文件名、行号、有效性、条件等信息。当hook_handler在触发的时候,检查是否命中列表的某一个,命中则进入了step模式(名字随便取的,step模式后面会讲到)。
常规的打断点方法,比如 b test.lua:123
,很简单,不再赘述。
这里讲一讲另外几种需要额外处理的:
比如b _G.Test.my_test_func
,这是一个定义在嵌套table中的函数,我们期望可以直接打断点在这个函数的第一行,那么就需要遍历的去解析,找到这个函数所在的文件和行号。
还有条件断点其实也是很有用的,在gdb中,比如b my_test_func if a == 1
,只有当a == 1
,才会断住。
那么dlua如何简单的支持下呢,首先这个a == 1
其实是一个表达式,那么就需要对其做语法解析,并且分析出哪些是需要传值,比较复杂。
这里依然采取一个取巧的方式,我们让用户指定哪些是需要传值的,那么语法改为b my_test_func if [a] a == 1
,这里的[a]也就是需要传值的变量名。然后我们定义一个临时函数,将参数和语句都拼进去:
function dlua_debug_if1(a)
return a == 1
end
这样每次执行到断点所在的语句时,只需要塞入a对应的值,然后判断这个函数的返回值即可
step模式
前面提到,当触发断点后,进入了step模式。在这个模式里,程序暂停了运行,直到用户输入了n(下一步)、s(下一步入)、f(结束函数)、c(继续)、q(退出)。 实现的方式,就是当进入了step模式,hook_handler进入了一个死循环中,直到用户输入了n、s、f、c、q才跳出。
代码语言:javascript复制while (1) {
if (loop_recv(L) != 0) {
DERR("loop_recv fail");
stop_agent();
return -1;
}
if (g_step == 0 || g_step_next != 0 || g_step_next_in != 0 || g_step_next_out != 0 ||
g_opening != RUNNING_STATE_RUNNING) {
break;
}
usleep(100);
}
而当输入了n,会记录当前的文件名、行号,以及调用栈深度,然后让程序继续跑起来,hook_handler会继续触发回调,当发现当前执行的文件名、行号发生改变,且调用栈深度小于等于之前的,则停住。也就是实现了按n执行下一行的逻辑。代码如下:
代码语言:javascript复制if (curlevel > g_step_last_level || (curfile == g_step_last_file && curline == g_step_last_line)) {
} else {
need_stop = true;
}
s、f的原理同理,只是调用栈的判断不同。 c的原理,即把所有标志清空,退出step模式即可。
p(查看变量)
p可以查看变量,或者查看表达式的结果。本质上和条件断点的逻辑类似,即需要一个机制来知道表达式的结果。
所以我们做了一个兼容,对于简单的变量,可以直接p a
来查看,而对于复杂的,可以p [t] t.a
,等价于:
function dlua_p_val(a)
return t.a
end
剩下的问题是如何输出结果,我们知道lua里的变量可以是整数、字符串、表,我们需要把这些类型输出成string显示。如果用C来写,费时费力还容易有bug,这里也取巧,直接调用一个lua函数来把变量转换成string。 需要注意的是,lua的表可能会相互引用嵌套,所以需要处理下,不然会死循环或者栈溢出。 同时,一些很大的表,比如_G,如果直接print出来,程序直接gc卡死,所以需要做一个最大打印的限制。 最后的打印函数如下:
代码语言:javascript复制function dlua_tprint (tbl, indent, visit, path, max)
if not indent then
indent = 0
end
local ret = ""
for k, v in pairs(tbl) do
if type(v) ~= "function" and type(v) ~= "userdata" and type(v) ~= "thread" then
local formatting = string.rep(" ", indent) .. k .. ": "
if type(v) == "table" then
if visit[v] then
ret = ret .. formatting .. "*Recursion at " .. visit[v] .. "*n"
else
visit[v] = path .. "/" .. tostring(k)
ret = ret .. formatting .. "n" .. dlua_tprint(v, indent 1, visit, path .. "/" .. tostring(k), max)
end
elseif type(v) == 'boolean' or type(v) == 'number' then
ret = ret .. formatting .. tostring(v) .. "n"
elseif type(v) == 'string' then
ret = ret .. formatting .. "'" .. tostring(v) .. "'" .. "n"
end
if #ret > max then
break
end
end
end
return ret
end
function dlua_pprint (tbl)
local path = ""
local visit = {}
if type(tbl) ~= "table" then
return tostring(tbl)
end
local max = 1024 * 1024
local ret = dlua_tprint(tbl, 0, visit, path, max)
if #ret > max then
ret = ret .. "..."
end
return ret
end
结语
这个调试小工具,其实开发并不复杂,只是需要一些取巧的方法。通过这个工具,可以快速定位复杂lua业务逻辑的bug,避免log大法,提高开发效率。