MemDumpView通过Hook实现的内存分析工具
本文首发于知乎专栏:MACK的游戏开发笔记,欢迎各位关注。
这是2011年开发的一个小工具。当时因为目标用户的原因,我们希望只有1G内存的玩家也能流畅的玩轩辕,而当时游戏启动就七八百兆峰值2G以上,32位机器会有闪退风险。工欲善其事必先利其器,为了优化内存首先我在游戏内加入了简单的实时内存显存消耗显示,并加入图形化分析器,自动报警(Log出内存占用)和自动化测试(一键启动自动跑游戏长时间挂机战斗活动)。但是在实际使用过程中,发现还有很多缺陷,例如不知道内存的分配情况,是否有内存泄漏等等。尝试过例如vtune等其他专业分析工具,但因为峰值时客户端已经很庞大了,使用内存分析器后导致游戏非常缓慢并且经常崩溃无法得到准确的测试结果。
痛定思痛之后开始开发了专属的内存分析工具。
原理很简单:
- 当客户端启动时使用微软的detours库hook住所有内存分配的底层API(例如HeapAlloc,
HeapFree和HeapReAlloc),然后修改API在分配的同时利用消息机制将内存分配信息和堆栈信息发给监听端;
- 枚举电脑所有进程,通过进程名比较的方式获得进程ID,然后在通过进程ID获得进程句柄;
HANDLE GetProcessHandle() { if (g_hProcess == NULL) { //-获得分析工具的根目录 TCHAR szPath[1024] = {0}; ::GetModuleFileName(NULL, szPath, 1024); *(_tcsrchr(szPath, '\\') + 1) = 0; CString sIniPath = szPath; sIniPath += "PdbPath.ini"; //-获得pdb文件路径 TCHAR ExeName[MAX_PATH]; DWORD ret = GetPrivateProfileString(_T("ExeName") , _T("Name") , NULL , ExeName , sizeof(ExeName)-1 , sIniPath); DWORD dPrecessID = GetProcessIdByName(ExeName); g_hProcess = ::OpenProcess(PROCESS_ALL_ACCESS, FALSE, dPrecessID); } return g_hProcess; }
- 监听端加载被监听进程的符号表,不断收集客户端的内存分配信息,通过dbghelp转化为堆栈信息。这里使用简单的方式监控端直接通过寻找进程句柄加载被分析端的符号表;BOOL InitStackWalking()
{ if (!g_bSymEngInitialized) { void* DllHandle = LoadLibraryA("PSAPI.DLL"); if( DllHandle == NULL ) { return FALSE; } MyEnumProcesses = (pfnEnumProcesses)(void*)GetProcAddress((HMODULE)DllHandle,"EnumProcesses"); MyEnumProcessModules = (pfnEnumProcessModules)(void*)GetProcAddress((HMODULE)DllHandle,"EnumProcessModules"); MyGetModuleFileNameEx = (pfnGetModuleFileNameEx)(void*)GetProcAddress((HMODULE)DllHandle,"GetModuleFileNameExA"); MyGetModuleBaseName = (pfnGetModuleBaseName)(void*)GetProcAddress((HMODULE)DllHandle,"GetModuleBaseNameA"); MyGetModuleInformation = (pfnGetModuleInformation)(void*)GetProcAddress((HMODULE)DllHandle,"GetModuleInformation"); if (!MyEnumProcesses || !MyEnumProcessModules || !MyGetModuleFileNameEx || !MyGetModuleBaseName || !MyGetModuleInformation) { return FALSE; } DWORD SymOpts = SymGetOptions(); //SymOpts |= SYMOPT_DEBUG; SymOpts |= SYMOPT_LOAD_LINES; SymOpts |= SYMOPT_FAIL_CRITICAL_ERRORS; SymSetOptions(SymOpts); //-获得分析工具的根目录 TCHAR szPath[1024] = {0}; ::GetModuleFileName(NULL, szPath, 1024); *(_tcsrchr(szPath, '\\') + 1) = 0; CString sIniPath = szPath; sIniPath += "PdbPath.ini"; //-获得pdb文件路径 TCHAR PdbPath[MAX_PATH]; DWORD ret = GetPrivateProfileString(_T("PdbPath"), _T("Path"), NULL, PdbPath, sizeof(PdbPath)-1, sIniPath); //-加载调试符号 bool success = SymInitialize(GetProcessHandle()//-被加载的进程句柄,当第三个参数是TRUE时必须指定 , PdbPath//-PDB的路径,如果传NULL回到调试进程工作路径下寻找,去到环境变量指定路径下寻找 , TRUE);//-指示是否加载进程所有模块的调试符号,如果该参数为FALSE,那么SymInitialize只是创建一个符号处理器,不加载任何模块的调试符号 assert(success); g_bSymEngInitialized = TRUE; } return g_bSymEngInitialized; } //************************************************************************* // Method : Load[public ] // Description : 从内存读 // Parameter : const char * szFileName // Returns : bool // Author : mack 2011/10/20 //************************************************************************* UINT CMemoryDump::LoadFromMemory(AllocMemInfoMap& _kAllocMemInfo) { //-统计总的内存大小 UINT nTotalSize = 0; //-临时变量 char callStackInfo[8192] = {0}; //-重置 Reset(); //-设置MemoryInfos的大小 UINT nMemoryInfoCount = _kAllocMemInfo.size(); m_vMemoryInfos.resize(nMemoryInfoCount); //-遍历每一条内存申请,找出所有堆栈的字符串名称,放入以函数地址为Key的数据中 MemoryInfosDef::iterator iterMemInfo = m_vMemoryInfos.begin(); for (AllocMemInfoMap::iterator it = _kAllocMemInfo.begin(); it != _kAllocMemInfo.end(); it++) { CAllocInfo* pAllocInfo = &it->second; SMemoryInfo& memInfo = *iterMemInfo++; //-遍历每个CallStack,获得堆栈的字符串名称 int CurrentDepth = 0; while(pAllocInfo->m_dCallStack[CurrentDepth] && (CurrentDepth < pAllocInfo->m_nCallStackCount)) { DWORD64 dwAddress = pAllocInfo->m_dCallStack[CurrentDepth]; MemoryAddressInfo::iterator iter = m_mapAddress.find(dwAddress); if (iter == m_mapAddress.end()) { GetMemoryAllocCallStackStringInfo(g_hProcess , pAllocInfo->m_dCallStack[CurrentDepth] , callStackInfo); m_mapAddress[dwAddress] = callStackInfo; } CurrentDepth++; } memInfo = pAllocInfo;//-这里没必要使用SMemoryInfo,因为就是CAllocInfo //-统计总大小 nTotalSize += pAllocInfo->m_uiSize; } return nTotalSize; }
- 当点击LoadFromMemory按钮时,启动分析功能时树状显示内存分配的调用堆栈和内存分配情况;
内存的分配信息一目了然,有如下优势:
- 速度非常快,不需要特殊编译客户端,任意版本都可分析包括外网上线版本;
- 分析工具和客户端分离,是两个独立进程几乎不会影响客户端的真实内存分配和运行速度;
- 可以看到内存分配的调用堆栈,精确定位到是哪个文件哪行代码哪个函数,配合GameBryo引擎的符号表可以显示GameBryo引擎层的内部调用;
- 支持内存类型的自定义分类,可以优先关注希望监控的类型;
- 可以将内存中的Memory数据Dump到文件,方便保存内存分配信息供以后查找;
- 可以分析任意一段时间内的内存增量进行对比找出内存泄漏;
- 可以通过工具拉起客户端获得最完整的内存信息,也支持在任意时刻连入客户端获得当前的内存分配信息;
遇到的一些问题:
- 多线程内存统计不准确,一开始没加锁会导致各种错误和死锁,加了之后因为加锁操作又会有new也会不准确需要过滤;
- 希望分析正常编译的外网版本以及其他游戏,加入了外挂常用的Dll注入的方式进行Hook;
void hookCreateProcess(void) { DetourTransactionBegin(); DetourUpdateThread(GetCurrentThread()); DetourAttach(&(PVOID&)gTrampolineCreateProcessA, Detour_CreateProcessA); DetourAttach(&(PVOID&)gTrampolineCreateProcessW, Detour_CreateProcessW); DetourTransactionCommit(); }
- 后来因为客户端集成了防外挂组件会对注入进行监控,无法使用注入dll的方式勾住CreateProcess。将内存分析模块做成一个dll,以插件的形式让客户端启动,没有插件的时候试正常版本,游戏目录放了插件之后开启内存分析功能;
- 使用查找句柄的方式内存统计不准确,因为FindWindow也会调用new需要过滤;
- 收集信息不卡但是分析器显示堆栈信息非常卡,加入优化可配置分配最小限制(100B),小于的操作不拦截;
- 无法分析到引擎层的内存分配堆栈,分析是因为堆栈信息不全,因为修改了GB的源码重新编译,并提供ini配置文件指定堆栈路径;
使用该工具获得的内存分配信息非常准确,并且连调用其他模块和DLL的内存分配信息也很完整。这个工具在后续的开发过程中查出了很多次内存泄漏并帮助我更有效的优化了内存,特别是有一次外网版本运行一个小时左右客户端必然闪退的问题,使用该工具快速定位到了问题,因为UI动态贴图不停创建导致的内存泄漏。后来公共组件TAG内存组件也加入了类似功能并且更强大完善许多。
最终当时的轩辕传奇客户端实现了启动时内存只占用300M,任何极限环境(最高配置400人同屏放技能)下内存峰值在1G内存以内,长时间(1天以上)挂机内存无膨胀无泄漏。
PS:
内存工具0.1:
使用方法,可以通过内存工具拉起需要监控的可执行文件。或者开启可执行文件然后运行内存工具,也会自动收集。PDB文件可以通过PdbPath.ini配置,如果不配置默认在被分析进程根目录。分析结果可以保存到磁盘,也可以从磁盘加载。注意,被分析的客户端需要开启_MEMORY_PROFILE_。
内存工具0.11:
加入支持0.2的数据格式的功能,通过宏在消息接收的地方切换。
内存工具0.2:
新增了内存种类分类。注意,被分析的客户端需要开启_MEMORY_PROFILE_和_NEW_MEMORY_PROFILE_。
内存工具0.3:
大幅重构加入注释,目前导出到文件还有问题,hook子进程还有问题,还存在同名堆栈的问题