本
文
摘
要
英雄大会是倩女一月一度的活动,参与人数很多,参与时间是每个月第四个周末,奖励也比较丰厚,历来是倩女玩家参与十分积极的活动之一。然而,自从次世代在外网投放以来,使用次世代的很多玩家都反馈崩溃,严重影响了他们拿到更高奖励的可能性。由于英雄大会是在周末,每次只有一个小时,开发组的测试人员也不能重现这个崩溃。因此虽然开发组也在根据mini dump积极查找原因,并且在查找的过程中也发现了很多其他问题,甚至一度跟英雄大会的崩溃有着千丝万缕的联系。但在经历了两次玩家的大面积崩溃和和玩家的吐槽后,开发组技术人员仍然不敢掉以轻心。
这一次,又快到了下一次比武大会的日子了,为了能在第三次英雄大会期间找到崩溃原因,开发组做了下面的预案:
一、提前预案
1.崩溃的当时一定要在现场外网账号在线,最好能搞到一个完整的dump。
2.鉴于之前的崩溃大部分都崩溃到同一个叫 batch_command_list的全局变量,这个全局变量从初始化以后,按着正常的流程从未被修改过。为了能够准确定位,因此研究了下如何让程序自己在指定线程用SetThreadContext 设置硬件断点,并且形成了一套启动客户端 就保护关键全局变量的流程,只要这个全局变量被修改立刻中断打印出被修改的call stack,从而抓到凶手。现在回想起来这套流程应该是很有用了,但最终对问题的解决并未发挥较大作用(甚至还因为蜜汁自信起到了一些反作用)。棋差一招的地方在于,这段时间的大量修改导致内存布局变化了,崩溃的地方已经由这里转移到了一个叫 gTaskMgr的全局变量,如果当时能在gTaskMgr附近故意设置一些从不使用的全局变量,对他们用硬件断点加以保护,可能在第三次玩家崩溃现场就能找到问题。
3.在第三次英雄大会当周周五的下午,突然灵机一动,重温了一下windbg的使用方法,并且对本地客户端启动自动启动windbg进行调试做了支持,事实证明这个决策对周天比赛时间能抓到full dump起到了关键作用。
二、windbg基本使用方法概述
windbg算是一个老牌的内核和用户层调试工具了,现在网上能找到的6.x版本,功能非常强大,然而却不太容易使用。可喜的是,微软爸爸对windbg进行了升级,10.x版本增加了dx命令
The dx command displays a C++ expression using the NatVis extension model. For more information about NatVis, see Create custom views of native objects in the debugger.
可以实现像vs一样打印出变量的详细的结构体成员信息
此外,借助dt 指令可以在符号文件内用通配符搜索 指定的符号地址,进而进行调试。
但是这些命令看起来还是很复杂 还是不会用没有具体的例子,怎么办?实际上windbg 10.x版本 还对每次执行的结果增加了可以点击更加详细信息的快捷方式和命令写法,这些做法大大降低了windbg的学习成本。但即使如此看一看windbg的帮助文档也是很必要的。
下面用一个具体例子演示下:
1.打开崩溃文件:直接把崩溃文件拖动到windbg的窗口里
2.设置符号路径 和 源码路径,设置以后windbg就能正常进行调试了。
3.然后就可以用 k 指令去看当时具体的栈了,如果想看到每一层传的参数也可以用kp
4.为了方便 view菜单下有很多页面可以显示,显示出来可以看的更清楚,离vs又更近了一步
三、第三次崩溃当日实况
2020年8月23日13:30 英雄大会快开始了,开着保护 batch_command_list 全局变量的客户端在比武大会npc门口等着,果然5分钟后,命中了一个像硬件断点的 single step (唯一的问题是他好像没有写断点号,估计不是硬件断点,虽然当时被兴奋的心情忽略了),兴奋之情溢于言表,然后猛然想到之前一位同事也命中过类似的断点,于是非常开心的“找到”了是反外挂模块的问题,同时让相关同事先外网关掉,进行测试。2020年8月23日14.50 快进场了,十分开心高枕无忧的进入了比赛场地,看着别人打。2020年8月23日15.10 有玩家崩溃了 ,觉得应该修好了,怀疑是未重启客户端原因,通知玩家重启客户端。2020年8月23日15.20 有玩家重启后依然崩溃了,感觉可能并未修好。2020年8月23日15.30 我的客户端在人很多的时候也崩溃了,立即转储了个full dump。6.2020年8月23日15.31 怀疑是 batch_command_list 写坏之前 别的变量也写坏了,立即把当时batch_command_list的内容回退
7.2020年8月23日15.45 回退完毕 启动最新客户端等崩溃
8.2020年8月23日16.00 英雄大会结束,也没崩
最终就拿到了一份外网崩溃的full dump 别的一无所获,当时心理也没底是不是这次能把这个问题解决。
当日,初步分析了下full dump 发现崩溃当时对象很多大约3000+ ,同一帧最多更新 1000+ ,因此怀疑之前的复现测试还没达到这个数量级。
四、找出崩溃元凶过程
既然只得到了一个full dump,作为一个仅有的线索,只能从这里找突破口,做了下面几个尝试
从dump里打印出了所有当时在更新的模型,希望看看那里面有没有什么特殊的,里面的信息很多由于windbg限制一次最多只能打印100个,因此搞了个批处理分34次打印for /l %%i in (0,100,3300) do (cdb -y J:\engine_update\program\bin\Release64\wdg -i J:\engine_update\program\bin\Release64\wdg -z D:\Netease\dump1234.dmp -c "dx -r6 -c %%i (*((GacRunner!std::map<unsigned int,ArkCore::CCharacterRenderObj *,std::less<unsigned int>,std::allocator<std::pair<unsigned int const ,ArkCore::CCharacterRenderObj *> > > *)0x22529603040))";q >>d:\res.dlog),
将最终结果提供给了qa 希望他在测试的时候能借鉴
2.从dump里 看到了headinfo里面有1000个引擎id ,咨询了下相关同事,发现这个引擎id没什么价值
3.从dump里看到了当时被写坏的数据
从上图中可以发现被写坏了的数据非常有规律,仿佛就像某种结构体,但这个结构体究竟是什么呢?
尝试用格式模式去解释(比如考虑到2字节对齐,用short做了解释,如下图)跟别的同学讨论,仍旧一筹莫展
经过各种尝试,最终发现了一个重要线索
左边是被写坏的内存,右边是外网一个正常运行的游戏客户端做了强制 dump,按照写坏内存的模式找到的,简直一模一样,惊人的相似。这条线索引起了我极大的重视,至少可以基本确定的是被写坏的内容,是被右图的数据写坏了,因为他们都有一个共同的特点 ,即大量的0000803fffffffff0000??00??3f???3f???3f 重复范式,因此接下来的重点就是看看到底这个数据是什么在哪里。
要搞清楚这个问题,需要弄清楚,那段内存在实际运行内存中的虚拟地址是多少。这个问题确实困扰了我比较久,开始打算通过dump文件结构自己去解析,最终还是发现最快的方法还是用windbg去搜。然而,windbg的s指令并不支持搜索大块内存,如果强制指定一个大的范围会直接报range error。这个问题最终还是在windbg的!address命令的文档中找到了答案
If you are starting with an address and trying to determine information about it, the usage information is frequently the most valuable. After you know the usage, you can use additional extensions to learn more about this memory. For example, if the usage is Heap, you can use the !heap extension to learn more.
The following example uses the s (Search Memory) command to search each memory region of type Image for the wide-character string "Note".
!address /f:Image /c:"s -u %1 %2 \"Note\"" *** Executing: s -u 0xab0000 0xab1000 "Note" *** Executing: s -u 0xab1000 0xabc000 "Note" 00ab2936 004e 006f 0074 0065 0070 0061 0064 0000 N.o.t.e.p.a.d... 00ab2f86 004e 006f 0074 0065 0070 0061 0064 005c N.o.t.e.p.a.d.\. 00ab32e4 004e 006f 0074 0065 0070 0061 0064 0000 N.o.t.e.p.a.d... *** Executing: s -u 0xabc000 0xabd000 "Note" . . .
!address 这条伪指令可以用来给s指令提供可供搜索的内存块,一般我们在这里选择 /f:MEM_COMMIT 就可以,因为我们并不清楚我们要搜索的内存块的类型,我们唯一知道的就是这块内存一定是被commit了。如果找到内存块的地址,我们就可以通过硬件断点找到访问这块内存的调用栈,大概也就能猜到问题所在了。既然如此,我们还是说干就干吧。首先运行起来游戏,搜索内存,找到可疑的地址,继续执行的同时精确的筛选下以防止掉线,下硬件断点,一气呵成,然后,就没有然后了,硬件断点并没有命中。
又回忆了下刚才的流程,为什么找到地址以后继续让程序跑呢?这段时间很可能作案现场已经被毁尸灭迹了。
于是,再试一次,运行起来游戏,搜索内存,找到可疑的地址,不继续游戏
很快就搜到了一大批,因为s不支持通配符,眼疾手快的筛一下,随便搞了俩硬件断点,硬件断点的选择也很有讲究,选了一个看地址在堆里面的,又选了一个地址在全局变量里面的,刚继续运行游戏,断点就命中了
确认一下命中的地址确实被修改了,已经不是刚才的范式了。
这次断点的命中,确实增强了查找问题的信心,唯一的遗憾是,命中的位置在堆里,距离写坏的位置非常远。
没关系,我们再试一次,
同样的操作,这次的结果就要好很多
更神似了连写坏的地址 都更像了,
这个特别神似的中断,让我仔细研究了下这个堆栈,最终发现,
被写坏的全局变量是个函数内静态变量,写的时候没有加保护,导致头顶窗口需要画的东西较多时溢出了。
这里确实是有问题的,但仍然由两个疑问没有解答清楚
1、那段神秘的范式数字到底是什么:
后面弄清楚了
ui用的顶点格式是这种,一般而言,由于是二维坐标,p.z一般是1该浮点数的刚好是
后面的 diffuse 刚好是
表示 diffuse 是不透明的纯白色
2、如何确定这个崩溃跟英雄大会崩溃一定一致:
将这个值改为16,进游戏会立即崩溃,并且崩溃的堆栈跟英雄大会玩家大量反馈的堆栈一致
taskmgr被写坏。
测试人员那边也通过试验,发现头顶窗口过多(比如300个玩家,一个玩家6个异人宝宝),会崩溃,也是崩溃在ElementVB写坏了别人。
五、结论
从拿到完整的full dump到最终找到问题仅用了两天,因此成功避免了下个月英雄大会的再一次崩溃。
新倩女幽魂这个游戏已经成功上线10多年了,为了给玩家最好的体验,开发组初心不改,最近又对游戏进行了次世代迭代。在运营过程中难免会遇到一些线上问题,上述崩溃查找的过程是时有发生的,开发组总是第一时间尝试加以解决,同时也在其中积累了大量的经验去应付线上问题。