最初,我并没有打算为Silly提供一个lua调试器,因为我本人不是一个重度调试器使用者。在开发期间出bug,看一下代码,打几条log可能比使用调试器会更快的找到问题,尤其是服务端程序,在很多时候其实只有log这一原始工具可以使用。但就我周围的人来推断,应该还是有很多重度调试器使用者。
因此,最终还是计划为Silly增加一个lua调试器作为基本组件存在。但是这个调试器到底以哪种方式提供调试功能,我一直在纠结中。
就我个人而言,我用过至少两种完全不同的调试器,gdb和dtrace(严格来讲dtrace可能不算是调试器)。
gdb主要用于开发期间进行阻塞式调试,虽然也可以写调试脚本用在生产环境中调试,但是在gdb加载符号文件时,延迟比较明显。
dtrace提供的是动态跟踪手段,在程序中放置相应的探针,当程序执行到探针的位置时,就执行这个探针上的函数。在放置探针时几乎没有卡顿延迟的现象,而且在放置探针后其对程序的整体执行性能影响甚小。因此在特殊时期可以用于生产环境调试使用。
最开始我觉得也许应该为Silly增加一个类似dtrace的调试器。这个调试器只提供一个probe接口,probe有两个参数,参数1是代码位置(文件名与行号),参数2是一个探针函数。当调用probe注册一个探针之后,代码执行到指定行时,就会执行调用probe时注册的探针函数。这样当线上出问题时,就可以像dtrace一样编写一段代码注入到程序中,将我们所关心的信息全部dump出来,而不会影响程序的正常执行。
但是,最近重新仔细思考发现,dtrace之所以可以如此高效,是因为他可以有各种加速手段。比如在增加探针时,可以通过调试信息快速定位到要增加的探针在CPU指令的哪个位置,还可以通过int 3中断被动触发探针函数等(这些只是大概的猜测,具体实现也许不同,但是至少可以证明的是,他可以非常高效的确定探针函数是否应该被执行)。而在lua中,即使仅仅确定什么时间探针函数需要被执行,也至少需要在全局hook事件”call”,在每次触发”call”事件时,遍历检测是否有探针触发。这会极大的拖慢程序的整体性能。因此即使实现了探针机制也不适合在生产环境做调试使用。
既然不可以在生产环境使用,那么做成类dtrace的方式也就没有必要了。如果是在开发期间使用,还是Gdb那种阻塞交互式调试最方便易用。
这不是第一次实现lua调试器了,两年前我就实现了一个非常简易并且低效的阻塞式调试器。
当时我们的客户端代码采用lua编写,偶尔会出现一些偶发性bug,并且出错的地方总是一些没有提前预料到的地方,因此没有任何log。而当我们在出错的地方加上详细的log再重启客户端后,问题又不能被重现了。
这个调试器就是这种情况下产生的,他的作用仅仅是当出现偶发性bug时,能够在不重启客户端的情况下,打印出我们想要的上下文。因此在实现时一切从简,只有s(单步步入)、c(继续运行)、 b(设置断点)和 d(删除断点功能)的功能,并且没有考虑整个调试器的效率。
这次实现,希望除了要具备上一个调试器所有功能以外,还要支持gdb中命令n(单步步过)的功能。此外,它还应该是高效的。
在Silly中,所有的代码都是被包在某一个coroutine中被执行的,整个程序运行期间,可能会有数十或上百个coroutine并发执行。因此在调试某一个coroutine中运行的代码时,应该尽可能的保证其他coroutine应该可以不受调试器的干扰而正常执行。
这样就不能使用之前调试器中的方式(直接阻塞等待命令输入)来实现阻塞式调试器。因为那样会塞住整个Silly进程,所有的coroutine都会被卡住。因此,整个lua调试器的命令输入均来源于socket, 这样可以充分利用Silly自身的高并发能力而减少阻塞。
我把整个调试器实现成了一个调试状态机,共分为三个状态,分别是:checkcall, checkline、checkbreak。
程序运行时,整个调试器处于checkcall状态。在checkcall状态,调试器会检测所有的coroutine的”call”事件。只检测“call”事件可以最大程度保证程序的运行效率,而之所以会检测所有的coroutine,是因为这时还不知道被断点的代码将会在哪个coroutine上被执行。
每次触发”call”事件后,都会检测此函数中是否被设置断点。如果被设置断点,则进入checkline状态。
进入checkline后,将程序中所有的coroutine的事件检测取消,只保留当前coroutine的”call/return/line”事件,并且此后,所有针对事件检测范围的修改均只影响此coroutine。
在每个line事件被触发后,都要检测此行代码是否被设置断点,如果有则进入checkbreak状态。
需要说明的这里有一个可能存在的优化,比如下面代码。
function foo(x) ... end function bar() local x = 3 ... foo(x) local y = 10 ... end
假设在checkcall状态下触发”call”事件的是bar函数,并且有断点设置在bar函数内。这时调试状态机会进入checkline状态,如果foo函数中没有断点的存在,理论上我们只需要检测bar函数中所有行是否触发断点即可,也就是在执行foo函数时,不必触发”line”事件。这也是在checkline状态,我们需要检测”call”和”return”事件的原因。
checkline状态中,如果触发了断点,则挂起当前coroutine并进入checkbreak状态。
checkbreak状态主要处理用户命令输入比如b(设置断点)、d(删除断点)、s(单步步入),n(单步步过)、c(继续运行)等命令。
b/d/c命令都没什么好说的,无非就是向断点列表中增加删除断点信息,及将因为触发断点而被挂起的coroutine唤醒。
命令s的实现是参照Intel x86 CPU架构中的TF标志的思路,即如果单步标志位被置起,则每执行一行都会有触发断点的效果(当前coroutine被挂起等待,用户下一次命令输入)。
命令n在实现时,其实费了颇多周折。如果这一行的代码是调用一个函数,命令s会直接进入到这个被调用的函数的上下文并暂停执行并等待用户输入命令。而命令n则是直到这个被调用的函数返回才会暂停执行。
最开始是通过设置临时断点的方式来实现的,每执行一行,就为下一行设置一个临时断点,后来发现当函数执行到最后一行时,下一行的临时断点就失效了。
经过多次分析总结之后,发现命令n和s惟一的差别仅在于函数调用这里,其余情况下,n与s的作用一模一样。
有了这个关系,问题就简单多了。引入一个变量calllevel代表调用层数,每当call事件触发时,calllevel加1,每当return事件触发时,calllevel则减1。这样calllevel为0时,命令n就可以和命令s的处理方式相同。
这里同样有优化的空间,在“call”事件响应中,如果calllevel大于1,说明我只关心”return”事件,就可以把“line”事件的检测取消。
虽然我前面说要做一个高效的调试器,然而这个版本其实还是有很多可以优化的空间,比如可以重新设置断点列表的数据结构,以使在checkcall状态时可以尽快检测当前函数中是否有断点等。不过,至少这个版本,在使用lua debug hook上,我已经做到了尽可能的高效。