最近一段时间在帮公司一个项目组的客户端 review 代码。
我们的所有项目,无论渲染底层是用的 ejoy2d 还是 Unity3d ,实际开发的时候都基本是使用 lua 。所以开发人员日常工作基本是在和 Lua 打交道。
虽然我个人挺反感围绕着调试的开发方式,也就是不断的在测试、试错,纠正的循环中奔波。我认为好的程序应该努力在编写的过程中,在头脑中排错;在预感到坏味道时,就赶快重写。而坏味道通常指代码陷入了复杂度太高的境地,无法一眼看出潜在的问题。对付复杂度最好的武器是简化代码,而非调试器。
在真正遇到 bug 时,应该仔细浏览代码,设想各种出错的可能。而不是将错误的代码运行起来,查看运行中的状态变化。
话说回来,看到项目组的同学真的碰到 bug 时,不断的启动 Unity 客户端,把时间浪费在等待那几行 debug log 上,我觉得效率还是很低。必要的调试工具应该能提升一些开发效率的。
lua 官方提供了完善的 debug api 可以查询所有的信息;但并没有一套官方的调试工具。我都不记得是第几次写调试工具了。至少在这个 blog 上就记录了好几次。最近的一次是 3 年前 。
每次做完送给人用了两天就扔掉了。这次一时兴起,周末又做了一个。当然每次都会有一些不一样的想法。
这次的版本只开了个头,把构想中的基础架构搭好了。那就是,我认为一个优雅的调试器不应该过多的干涉被调试的实体(比方说你想监控被调试的程序内存使用的情况,和 gc 的工作)。
过去的一些版本都是把调试器代码直接嵌入被调试的虚拟机的,调试器本身和被调试的代码并没有明显的界限。调试过程也会在同一个虚拟机中运行。
我这次想玩点不一样的,让调试器运行在一个独立的虚拟机内,它通过一组接口来观察被调试程序。这样,在这个基础上制作的调试器,可以更放心的添加一些花哨的功能了。比如启动一个图形界面、或是提供一个 web server ,调试者可以通过浏览器来监控内部状态,发送调试指令。
当然最简单的用法是非侵入式的输出 log 。不必在被调试代码中硬加上几句 print 输出 log (这是没有调试工具时,大家最常用的调试方法),而可以把 print 查看内部状态的代码写在独立的调试器模块中。我们可以用编程的方式来编写调试过程,而不局限于一个交互式调试工具提供的有限手段。
我这次设计的调试模块,只提供一个概念:探测点。
你可以在被调试代码中设置探测点,探测点并不是断点,而更像一个观测点。在这个点上,调试器并不会停下来等待调试者的指令,而是运行调试器里的一个函数。(这个函数是运行在独立的虚拟机里的,完全不用代码有什么副作用)
你可以在探测点函数中,访问被调试代码在该处的状态,做一些合适的事情。比如把状态输出到 log 文件中,比如根据状态条件来选择做些事情;当然也可以暂停下来,交互式等待调试命令。
探测点可以分为两种,一种是在调试前,硬编码在代码中的,只要运行到那里,就会触发一下探测行为;还有一种是利用 lua 的 Hook 透明添加的。
两种探测点可以混合使用。比如在游戏主循环中预先硬编码进一个探测点,平时不启动调试器,运行到探测点时就自动忽略;当需要的时候,让这个探测点起作用,然后在探测函数中,设置 hook 点,进一步的调试。
初步的代码放在 github 上,这段时间会慢慢完善。不过期待它短期内发展成为一个图形式的漂亮交互调试器可能有点不现实,除非做前端的朋友有兴趣来完善它。(比如增加一个 web server ,直接可以通过浏览器连接到程序里交互调试)
最后说点这个东西的实现中一个有趣的部分:
由于调试器和被调试程序处于两个不同的 VM 中,所以调试器代码并不能直接引用被调试代码环境中的 table 。这里是怎么做到的呢?
我设计了一个 C 结构(封装成 userdata),里面保存了一个无法被直接引用的 lua 对象的引用路径。
比如,从探测点出发,你想获得某个对象的状态,无非只有几个途径。获取某个栈帧的 local 变量、upvalue 、或是从全局表中检索到一个对象等等,如果这个对象是一个 table ,可以进一步的去取 table 里的子域。总之,你总是通过一层层的简洁途径获得最终想观察的变量的。
那么,在调试器中,只需要把这个过程记录下来、而不需要铆定一个特定的对象。这个过程封装成一个 userdata ,它的实际含义和最终对应的对象是一致的。