如何实现一个Lua调试器

2020-12-11 16:22:44 浏览数 (1)

简介

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]也就是需要传值的变量名。然后我们定义一个临时函数,将参数和语句都拼进去:

代码语言:javascript复制
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,等价于:

代码语言:javascript复制
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大法,提高开发效率。

0 人点赞