EmmyLua Attach Debugger浅析

导语
最近一段时间做的工作主要是为引擎提供Lua的IDE,包括编辑智能提示和调试部分。
一开始想的方案是用类似BabeLua的方式, 基于VSSDK去构建IDE。
这么选择的原因主要是基于两个:
1. 业余自己尝试基于Scintilla.Net, 和CodeProject上开源的一个C# 版的LuaInterpreter搭建过一个简单的LuaIDE, 花的时间很长, 效果么...问题比较多就是了, 图找不到了,黑历史之一...翻到一张存货, 贴一下


2. 我们目前的编辑器部分主要是用C#搭建, 选基于C#的BabeLua不会引入新的语言。
3. BabeLua本身是基于VS的, 很多体验可以跟原来的VS C++编码调试保持一致。
提到了BabeLua, 就顺带简单介绍一下BabeLua的构成了:
BabeLua本身使用Irony作为LUA->AST的生成工具, Irony本身是一个泛用途的语法分析工具, 所以其实原来BabeLua提取的Lua信息并不多, 针对编辑来说, 能够提供的辅助信息和检查相对来说也就会比较弱了。 我们最初的想法是先用MoonSharp替换掉Irony, 提供更好的AST生成, 有更多的信息, 这样就可以做更复杂的Checker和相关的操作了。
这部分是组里孙总做的,孙总的动作还是相当快的(^_^), 大概花了一天时间就做完了,其实也能说明, VS + VSSDK, 确实是一种不错的选择, IDE整体成型还是很快的。 实际尝试结果晒图一张:

当然,因为通过并不复杂的尝试,EmmyLua挂我们自己引擎的脚本比预想中简单, 在修了一轮Bug之后, 就可以有模有样的跑起来了, 加上EmmyLua本身功能完备非常多, 这条VSSDK的线就这样暂停了。。。
所以其实入坑EmmyLua, 纯属偶然, 也能说明几个Lua IDE对比, EmmyLua的开发完成度应该是最高的, 代码组织也比较良好, 上手也很快。 下文回归正题介绍EmmyLua Attach Debugger的实现。
EmmyLua Attach Debugger概述
EmmyLua的Attach Debugger部分脱胎于Decoda, 不过作者阿唐本身也对原来的代码做了大量的调整, 比如原来的Decoda其实是只支持32bit程序的, 也只兼容当时的lua和luajit. EmmyLua整合了libpe, 可以很好的提取32位和64位应用程序下需要hook 的lua api的地址. 针对Lua5.2和5.3也做了很多改动(存在一些小问题, 处理起来并不麻烦, 后文会作简单介绍的).
EmmyLua的Attach Debugger由两部分组成, 一部分是Java和Kotlin代码, 另外一部分是C++代码.
AttachDebugger Java和Kotlin代码
代码结构如下图所示:

value文件夹: 是用来描述Hook的App中发送过来的各种Lua数据类型信息的.
vfs: 是Attach模式额外多出来的功能, hook后可以监控到不在Source文件夹中的脚本(比如直接用loadstring方式加载的脚本等), 所以会存在一个vfs用来表达仅在内存中的那部分脚本. 其他像 LuaAttachDebugProcess: 是用来实现IDEA的Lua调试功能的, 与Debug Session交互相关的逻辑入口基本都在此处.
其他: 本文重点讲述C++部分, 所以此处不详细展开了, 有兴趣的可以自行翻阅EmmyLua的源码.
AttachDebugger C++部分代码
C++部分代码的工程位于: IntelliJ-EmmyLua\debugger\attach\windows\attach.sln.
工程结构如下图所示:

先分别简单介绍一下各工程的作用:
1. EasyHookDll.dll : 用于对Windows应用程序进行Hook的库.
2. emmy.arch.exe : 主要是两个功能, 进程architecture检测和系统进程列表获取.
3. emmy.backend.dll : Attach Debugger的主体部分, 真正加载到调试的目标程序进程空间进行调试交互的Dll.
4. emmy.tool.exe : 这个EXE主要的作用是拿到进程ID后, 根据进程的architecture对要调试的目标应用注入对应32bit/64bit的emmy.backend.dll.
5. Lua.exe : 这个应该是个废弃掉的lua.exe, 目前EmmyLua插件侧有比较完备的工作于JVM上的Lua VM, 很多功能直接在JVM上那个Lua虚拟机上实现即可, 不需要再绕到C++这边跑一遍再传回结果.
6. Shared.lib : 一些工具类的封装, 比如封装系统临界区的CriticalSection类, 封装命名管道的Channel类, 以及真正用来获取Windows系统中进程的 GetProcesses()函数等.
以上就是Attach Debugger的C++组成部分概述. (EmmyLua 目前的Remote Debugger使用的是MobDebug, 被调试的程序运行MobDebug, 然后将结果序列化为可执行的Lua字符串, 再回到EmmyLua本身的虚拟机上执行提取结果, 所以完全不依赖C++这部分的所有代码).
感觉EasyHookDll.dll, Shared.lib, Lua.exe, emmy.arch.exe功能相对单一, 结构也比较简单, 就不详细展开了, 重点聊一下emmy.backend.dll, emmy.tool.exe这两个, 对于一个典型的Attach Debugger的实现, 具备一定的参考性.
大致的工作流程

7. IDEA Plugin创建 emmy.tool.exe的进程, 并以命令行的方式传入目标调试程序的进程ID等必要信息
8. 在emmy.tool.exe执行过程中尝试根据传入的进程ID打开对应目标调试进程.
9. 尝试为目标调试进程加载emmy.backend.dll
10. 尝试在目标调试进程中开启一个独立的线程执行Backend的初始化
11. 在Backend初始化线程中尝试根据进程信息查找所有需要的Lua Api函数
12. 查找Lua Api函数成功后尝试Hook需要的Lua Api(像lua_load()等函数都需要hook)
13. 通知emmy.tool.exe Backend初始化成功
14. 通知EmmyLua Plugin调试环境初始化成功
15. 向目标调试进程发送调试命令
16. Backend处理调试命令后返回执行结果到EmmyLua Plugin
还有一种直接启动EXE附加调试的方式, 流程基本一致, 除了最开始的地方是直接发送目标EXE路径, 工作目录, 命令行参数到emmy.tool.exe, emmy.tool.exe创建目标进程后直接执行后续的从3开始的步骤, 以及当EmmyLua的DebugSession结束时, 会一起结束目标进程外(此时的emmy.tool.exe不会在附加成功后退出, 而是会执行一个loop, 等待EmmyLua的退出通知, 收到退出通知后会直接结束创建的目标进程并结束自己)
Backend代码浅析
具体的代码我就不展开了, 重点通过自己挂接我们自己的客户端程序和编辑器的过程碰到的问题以及解决问题的方法简单说一下Backend的实现.
刚开始的时候我看了一下官方Git仓库上的Issues, 阿唐有说Attach Debugger这部分是从Decoda迭代过来的, 存在一些问题, 已经是打算重构的状态了.
翻开源码工程看了一下, 代码结构还挺清晰的, 当时同事正在尝试替换BabeLua的AST生成模块, 反正调试这块不管用什么方式, 总是有需要的. 所以就开始尝试用EmmyLua去挂接我们自己的客户端了. 然后发现能改得动, 就一直尝试往下推进了. 中间EmmyLua的作者阿唐也提供了很多信息, 有效的加速了我挂接我们自己的EXE正常调试Lua的过程.
问题1: 尝试用EmmyLua直接启动客户端, 然后就没有然后了(没有收到检测Lua Api成功的消息)
通过跟踪定位, 发现LuaDll.cpp中的 LoadSymbolsRecursively()没有正常的工作, 一开始我以为我们引擎的lua api没有正确导出, 后面通过检测引擎本身的代码, 以及使用dependency.exe进行查看, 确认我们的Core.dll中有导出所有的lua5.3的api符号. 最后通过跟踪调试, 大概定位到了是libpe这一块工作异常, 具体原因不明, 只知道并没有能正确搜索到Core.dll中导出的lua api接口. 先列出大概的坐标代码:

进而发现libpe也是一个Git上开源的库, 就直接到对应的Git仓库拉取了它的最新代码, 通过BeyondCompare对比, 发现EmmyLua中用的libpe只是简单处理了64位兼容, 并没有作出其他修改. 这部分因为基本能定位是libpe没有正确解析出对应的导出函数, 所以就转而直接拿libpe源码直接去分析我们的Core.dll了, 然后发现libpe的示例也不能正确输出Core.dll的函数,

定位后发现, 是peParseExportTable()的时候, 指定的maximum 过小导致的, 直接改成如下图所示:

所有函数都正常输出到命令行了, 总共有5000多个导出函数, lua api位于尾部, 重新Check EmmyLua的代码, 发现在EmmyLua的LoadSymbolsRecursively()函数中, peParseExportTable()指定的最大导出符号数过小(1000), 所以肯定是没有办法检查到Core.dll中位于5000多序号的Lua Api的:

修改上图中的导出符号个数到0xff, 重新挂接客户端, 发现已经能正确检测到Lua并输出了相应的日志, 但是, 程序马上就崩溃了(黑人问号脸)...
简单调试后发现依然是崩在前一个Bug出现的地方, 然后导致崩溃的dll是 avcodec-57.dll(ffmpeg的dll), 直接用libpe的preview.exe运行avcodec-57.dll, 分析了老半天后, 发现问题是出在libpe.cpp中, peParseExportTable()的时候, 实际上应用程序输出的是导出函数的个数, 但连带着导出的字符串一起并入了最大导出个数中, 导致一些dll会直接发生崩溃(实际处理的函数个数超出EXE包含的):

作如下修改后, 代码合并到emmy.backend.dll, 挂调试启动EXE, 经过漫长的loading, 断点成功了!
问题2: 挂断点进入调试状态后, 监控栈上的变量, 弹出如下对话框, 程序又崩了...

定位后发现是emmylua中对5.2+的Lua版本, 依然在使用lua_upvalueindex(4)的方式在尝试获取global表的索引, 这种方式lua5.2+已经不在支持, 所以会出现push到栈顶进行操作的global表为nil, 后续stack的操作不是正常进行的, 外层的stack top 验证失败, 直接贴出修改的部分如下:


问题3: 直接调试启动客户端是能够正常挂上去的, 然后改成启动客户端后再Attach, 各种报错或者直接崩溃
其中有个错误刚好群友贴了, 借图用一下, 报错如下图所示:

这个问题查的时间相对久, 有点莫名奇妙, 最后还是从直接调试启动和附加启动的差异找到了一点线索: 直接调试启动, 速度比较慢, 客户端加载资源的时候其实Lua虚拟机基本是不工作的, 而Attach的情况, 客户端已经进到Login界面, Lua虚拟机是一直在持续工作的. 对照代码仔细分析了一下, 发现EmmyLua的Lua Api查找, Hook Lua Api的时候, 并没有提供任何保护, 也就是Hook注册之后, 如果目标应用程序的Lua虚拟机正在工作, 那么马上就会触发emmylua本身注册过去的lua hook, 但问题是这个时候下框所示的那部分状态重置的代码可能还没有被执行, 那么Hook那边执行的时候所有状态都还是没有初始化的, 也就导致了各种奇怪的崩溃, 先放图:

标1的地方是后面添加的保护, 与下图所示Hook处的保护是对应的, 防止Hook过程正在进行的时候(Decoda原来的设计应该是允许单一App同时存在多个版本的Lua的, 所以扫描到一种Lua Api之后并不会马上停止整个扫描过程, 而是继续扫描剩余的Dll和EXE中是否存在其他版本的Lua):

至此客户端部分的Lua 直接启动或者Attach调试均能正常工作, Detach后再重新附加目标进程, 也可以正确的触发调试.
问题4: 编辑器的Lua 调试支持
原本以为编辑器应该是顺理成章的支持了, 结果内网试了一下, 挂上去就崩溃, 最后发现是基于.net framework的EXE按照目前emmylua提供的机制, 不能正确的检测应用程序的architecture, 比如我们的情况是64位的应用, 检测出来却是32位的, 所以尝试给64位的应用附加了32位的emmy.backend.dll, 这肯定是会导致报错退出的. 用了一个相对临时的方案Fix了. 这里不再细说了.
问题5: 如果当前EmmyLua的IDE是自动断点到运行报错的脚本处, Detach目标程序, 会导致目标程序崩溃
这个问题是因为EmmyLua调试退出的时候破坏了Lua Stack上的内容, Hook的错误处理函数退出后, 调用原来的Lua错误处理函数, 一般会将栈顶(-1位置)的变量当成字符串来处理, 因为栈顶并不是字符串, 会直接触发崩溃, 简单把报错的message push回栈顶, 程序即可正常工作了.
总结
这篇文章是在尝试完EmmyLua的RemoteDebug后写的, 由于各方面的原因, 最后选择了用C++重新实现MobDebug的App端, 这部分内容会在下一篇<<EmmyLua Mobdebug浅析>>中再去展开, 实现RemoteDebug的C++版加深了我对Attach版细节的理解, 这两个调试器EmmyLua的作者阿唐应该已经打算重构了, 对于我而言, 在处理问题的过程中熟悉了一个Lua Attach调试器以及Remote调试器工作的方方面面, 还是有所收获的, 一个好的Attach调试器对于客户端工作的开展还进比较有助益的, 也希望EmmyLua的调试可以越做越好, 抛出整个处理过程, 就当是抛砖引玉了.
PS: 以上修改源码已经PR到EmmyLua官方Git上, 参考链接:
- 导语
- EmmyLua Attach Debugger概述
- AttachDebugger Java和Kotlin代码
- AttachDebugger C++部分代码
- 大致的工作流程
-
Backend代码浅析
-
问题1: 尝试用EmmyLua直接启动客户端, 然后就没有然后了(没有收到检测Lua Api成功的消息)
-
问题2: 挂断点进入调试状态后, 监控栈上的变量, 弹出如下对话框, 程序又崩了...
-
问题3: 直接调试启动客户端是能够正常挂上去的, 然后改成启动客户端后再Attach, 各种报错或者直接崩溃
-
问题4: 编辑器的Lua 调试支持
-
问题5: 如果当前EmmyLua的IDE是自动断点到运行报错的脚本处, Detach目标程序, 会导致目标程序崩溃
- 总结
-
问题5: 如果当前EmmyLua的IDE是自动断点到运行报错的脚本处, Detach目标程序, 会导致目标程序崩溃
-
问题4: 编辑器的Lua 调试支持
-
问题3: 直接调试启动客户端是能够正常挂上去的, 然后改成启动客户端后再Attach, 各种报错或者直接崩溃
-
问题2: 挂断点进入调试状态后, 监控栈上的变量, 弹出如下对话框, 程序又崩了...
-
问题1: 尝试用EmmyLua直接启动客户端, 然后就没有然后了(没有收到检测Lua Api成功的消息)