EmmyLua的remote debug是通过 mobdebug来实现的, 本文先对Mobdebug本身的实现做一下介绍, 再展开后续的部分.
MobDebug的基本结构
mobdebug是一个纯lua实现的远程调试器, 依赖于luasocket, 基本的通信方式是使用字符串的方式在目标程序和IDE之间传输相应的控制指令和执行结果(应该是为了兼容Telnet, 直接Telnet到mobdebug开启的端口后就可以以命令行的方式来进行Lua相关的调试了). mobdebug与远端交互的数据是直接包装成Lua格式的字符串的, 这个地方用了一个Lua实现的序列化反序列化开源库 https://github.com/pkulchenko/serpent, 原理也是通过设置lua本身的debug hook函数来对Lua的执行进行干预, 利用lua debug库获取需要的信息发送至远端.
整体情况如下图所示:
MobDebug的交互协议
mobdebug使用的通信模式是应答式的, 也就是大部分时候都是远端的IDE向调试目标程序发送一条命令后, 就进入等待调试目标返回结果的状态了, 在EmmyLua源代码侧的体现就是维护了一个Command队列, 如果Command是需要应答的, 那只有当前Command被处理完后, 才会接着发送队列中剩余的Command, 具体可自行翻阅这部分代码, 组织结构还是很清晰的.
大部分情况都是由IDE发送控制指令到目标调试程序, 然后目标调试程序返回对应执行结果给IDE, 除了一处例外, 目标程序触发断点进入断点状态的情况, 下面简单描述过程:
1. 向IDE发送 "202 Paused [file] [line]n"
2. IDE接到该状态后会向目标调试程序发送"stack"指令获取当前的Lua执行栈
3. 接下来就是一个比较正常的发送各种控制指令查询目标调试程序状态的过程了
MobDebug的调试框架实现
接下来我们简单看一下mobdebug的关键代码, 通过关键代码熟悉一下MobDebug的实现思路. mobdebug的所有代码都集中在mobdebug.lua中, 这对于我们集成它是比较方便的(除了要先整合luasocket相对麻烦外).
头部主要是各Lua版本兼容相关的代码, 以及一些mobdebug使用的全局表的初始化. 我们节选部分重要的简单展开.
序列化相关的代码:
此处其实就是对前面介绍到的lua实现的序列化库的封装, 最后我们主要使用 mobdebug.line()和mobdebug.dump()来序列化我们需要发送给IDE的结果.
stack()函数:
上面介绍过的stack指令最后就会调用到这个函数, 函数本身的实现也比较简单, 枚举每一层call stack并记录相应stack的基本信息, local变量列表, upvalue列表, 函数本身利用lua debug库完成相应功能, 实现简单直白. 唯一需要注意的是我们注意到locals获取了两次, 第二次其实是针对variant arguments的, 也就是参数列表中有...的lua 函数.
断点相关的操作函数:
环境表提取函数 capture_vars()
这是mobdebug中很重要的一个函数, 如前面介绍的exec指令, 或者说当我们在某层Stack上展开它的本地变量的时候, 指令能正常执行依赖的就是capture_vars()这个函数, 它会将当前stack的_ENV完整提取, 当我们将该表用作exec查询的内容的环境表的时候, 对应的代码就能正确的执行了(注意红色箭头标注处的getfenv(), 在lua5.2 版本该函数已经取消, 有兴趣的可以自行查看mobdebug.lua, 看5.2 中getfenv()是如何实现的).
栈深获取:
调试启动start()函数:
开启debug session的入口函数, 我们重点关注红色箭头标的地方 :
1. 使用luasocket以传入的IP和端口连接IDE那边开启的server
2. 创建debug_loop协程
3. 设置lua hook函数
具体的调试流程我先简单给出时序图, 代码比较多, 这里就不一一展开详细说了, 有兴趣的读者可以自行去阅读源码深入了解各个细节:
上图是一个比较典型的mobdebug工作的流程, 也就是我们调用start()开启debug session后IDE与目标调试应用程序之间交互的过程, 我们简单过一下以上的每个步骤:
1. 建立与IDE之间的网络连接(接受指令, 以及返回结果)
2. EmmyLua会在最开始发送 "delb * 0" 命令清除目标调试程序的所有断点
3. 目标应用程序向IDE发送 "200 OKn" 表示指令成功执行
4. EmmyLua向应用程序发送 setb 指令设置所有存在于IDE中的断点
5. 每次操作IDE会接收到 "200 OKn" 指令, 重复4,5步骤, 直到所有断点设置完毕
6. 应用程序触发断点后主动向IDE发送 "202 Paused [file] [line]n"的指令
7. IDE收到指令后向应用程序发送"stack --{maxlevel=1}" 查询当前的Lua堆栈
8. 应用程序返回获取到的堆栈结果到IDE, 过程中就会用到上面介绍的stack()函数以及序列化相关的函数, 最后的结果以字符串的方式通过指令表中返回结果的格式发回到IDE(此时我们发现我们已经可以在IDE中看到当前的Lua Stack的情况, 以及每个Stack下的变量)(目前的emmylua版本中--{maxlevel=10}, 所以一开始断点的时候, 会有一个漫长的等待, 才会在IDE中看到堆栈信息)
9. 当我们监视某变量, 或者直接展开某层Stack的本地变量时, 就会向应用程序发送"exec return xxxn" 命令, 应用程序收到该命令后会用前面介绍的capture_vars()函数初始化执行环境表, 然后利用该执行环境表去执行发送过来的表达式(EmmyLua中一定是 retun xxx)
10. 应用程序返回获取到的执行结果到IDE.
11. IDE 向应用程序发送" step/overn"指令后, 应用程序会退出调试状态, 直到触发下一次断点, 再重复从6开始的整个调试过程.
12. IDE接收到 "200 OK/n"退出当前的断点调试状态.
总结
文章省略掉了协程yield和resume那部分的逻辑, 虽然协程帮mobdebug解决了不需要暂停主线程, 另外开一个线程去处理调试相关的逻辑, 直接在主线程中处理网络指令拆解, 执行, 结果返回, 但也会让所有相关执行过程耦合的比较严重, 另外序列化库serpent本身的实现会导致大量的Lua临时对象, 整个执行效率也不高(我用我们的客户端, 每次获取_G表, 内存会上涨几百M), 虽然可以通过强制GC移除这些临时对象, 但性能的问题依然不容易Fix, 所以也就有了后面C 版的RemoteDebug实现, 本来是打算在这篇文章中一并列出的, 但感觉篇幅过长了, 干脆这篇文章就集中讲MobDebug了.