当前位置: 首页 > news >正文

游戏性能优化与逆向分析技术

【USparkle专栏】如果你深怀绝技,爱“搞点研究”,乐于分享也博采众长,我们期待你的加入,让智慧的火花碰撞交织,让知识的传递生生不息!


一、前言

一直以来性能优化的工作,非常依赖于工具,从结果反推过程,采集产品运行时信息,反推生产环节中的问题,性能问题的定位其实就是在做各种逆向。

不同的工具有不同的检测面,一般会按照由粗及细的顺序使用,直到找到问题的答案。

  • 粗粒度的工具,可大致定位到问题是出在哪个硬件上,比如发热问题,可能的负载点在于CPU、GPU、其它硬件(屏幕、传感器、网络),一般应该是系统级的工具,常用的有Perfetto、Xcode、GamePerf、PerfDog。
  • 细粒度的工具,检测面较窄,但能提供更深入的信息,比如:定位到是CPU的问题时,可使用Unity Profiler、Simpleperf看问题堆栈;当定位到是GPU的问题时,则使用RenderDoc、SnapdragonProfiler、Arm Graphics Analyzer截帧。

打个比喻,粗粒度的工具好比地铁,能带你到大致的区域范围,更细粒度的工具帮你解决最后一公里路,在实际情况中,“打通”一公里的问题往往是卡点,通用性质的工具可能满足不了需求,常常做一些定制化的东西,通过一定积累,形成强大的工具链以应对各种突发问题,本文主要对于这些底层的技术栈做一些总结。

二、动态库注入

Android系统的数据基本都能通过读各种文件实现(统计线程,读取CPU利用率/频率),但有严格的权限限制,非root环境下,只能读取自己进程相关的文件、内存信息。

我们注入到目标进程的动态库,就好像我们派出的“间谍”一样,利用目标进程的身份执行我们自己的代码。

使用JDWP Shellifier是最常用的方式,我们用C++在NDK环境下编写一个动态库so文件,这个脚本利用Java调试服务加载我们自己的库。这也是RenderDoc、‌LoliProfiler、Matrix用的方式,需要应用Debug权限,或者root开全局调试,或者使用APKTool,解包修改AndroidManifest文件的Debug权限。

https://github.com/IOActive/jdwp-shellifier

这个脚本用Python封装了注入过程,在onCreate函数触发时,加载我们的库。

jdwp_start("127.0.0.1", 500, "android.app.Activity.onCreate", None, libname)

 


控制台输出显示注入成功

 

当动态库注入成功时,C++侧入口函数JNI_OnLoad会被执行,我们就可以干自己想干的事情了,这只是打开大门的第一步。

JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved)
{(void)reserved;LOGI("JNI_OnLoad");JNIEnv *env;LOGI("------------------ 4000 : %d", (int)JNI_VERSION_1_6);if (vm->GetEnv((void **)&env, JNI_VERSION_1_6) != JNI_OK){LOGI("JNI version not supported");return JNI_ERR; // JNI version not supported.
    }else{LOGI("JNI init complete");}
}

 

下一步介绍Hook技术,俗称钩子,能对特定函数劫持,两种常见Hook手段为PLT Hook、Inline Hook。

三、PLT Hook

先大概讲一下程序调用动态链接库中函数的流程,以libunity.so中调用libc.so的Open函数为例子:会先访问PLT(Procedure Linkage Table),第一次访问它会使用动态连接器查找libc.so中Open函数的地址,然后地址保存到GOT(Global Offset Table)地址表,之后的调用就直接查GOT表了,如下:

 

 

所谓的PLT Hook就是在这个过程做文章、钻空子,比如xHook就是修改GOT表的函数地址为我们的自定义函数实现拦截,xHook是一个常用的库,较多运用于各种工具底层实现,我们可以直接使用它,同时它也是开源的,我们可以参考它里面的很多代码。

https://github.com/iqiyi/xHookgithub.com/iqiyi/xHook

PLT Hook比较适合去Hook一些公用库的调用,不管上层怎么变,IO的行为最终落地到对Open、Close、Read、Wirte的调用,实际项目中主要用于IO、内存分配、线程、网络等行为的监控,但它的局限性在于不能Hook内部函数,比如引擎内部的函数调用。

四、实战:打印引擎启动时的IO调用

随便创建一个空的Demo,打包APK,将下面C++代码通过NDK编译成动态库后,使用JDWP注入运行。

这里在JNI_OnLoad函数创建一个新的线程,延迟3秒后再执行Hook的动作,是因为时机太早libunity.so未加载会导致失败(据说xHook的作者后续开发了一个新的库叫bHook,改进了这一点)。

#include <jni.h>
#include <dlfcn.h>
#include "xhook/xhook.h"
#include <thread>
#include <unistd.h>
#include <fcntl.h>
#include <android/log.h>int MyOpen(const char *pathname, int flags, mode_t mode)
{int ret = open(pathname, flags, mode);__android_log_print(ANDROID_LOG_INFO, "TestHook", "unity open %s %d", pathname, ret);return ret;
}void TestHook()
{// 延迟3秒,等待Unity加载完成std::this_thread::sleep_for(std::chrono::seconds(3));// 对Open函数Hook注册xhook_register("libunity.so", "open", (void *)MyOpen, nullptr);// 执行Hookxhook_refresh(0);
}JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved)
{JNIEnv *env;if (vm->GetEnv((void **)&env, JNI_VERSION_1_6) != JNI_OK){return JNI_ERR; // JNI version not supported.
    }std::thread(TestHook).detach();return JNI_VERSION_1_6;
}

 

这样我们可以观察到Unity启动时加载的一些东西:


正在加载obb文件

 


正在加载il2cpp.so

 

五、Inline Hook

前面提到,PLT Hook不能Hook到库内部的函数调用,这个时候就应该轮到Inline Hook出场,它是通过对目标函数地址插入跳转指令实现,理论上可以Hook住任意内部函数,功能更为强大,由于涉及到在不同CPU架构上的运行状态机器码修改,看起来很复杂,其实一点也不简单,虽兼容性不如PLT Hook,不推荐在生产环境使用,但作为测试环境中的性能工具还是很强的。

ShadowHook是我常用的库,可以将它的C++源码下载下来,和自己库一起编译。

https://github.com/bytedance/android-inline-hook

如果Hook的目标库是带符号表的,可以通过函数名hook,像这样:

stub = shadowhook_hook_sym_name("libart.so","_ZN3art9ArtMethod6InvokeEPNS_6ThreadEPjjPNS_6JValueEPKc",(void *)proxy,(void **)&orig);

 

但是我们常见的libunity.so、libil2cpp.so的符号表是分离的,可以尝试用llvm-objcopy合并回去,这里更推荐另一种做法,ShadowHook也可以直接通过函数地址进行Hook

void *shadowhook_hook_func_addr(void *func_addr,void *new_addr,void **orig_addr);

 

这里的func_addr函数地址是绝对地址,为动态库基地址、函数偏移地址之和,找到这两个地址加起来就行。

动态库基地址每次进程启动都不一样,需要我们在程序中动态获取,可以通过dl_iterate_phdr(Android 5.0以上)获取,也可以读/proc/self/maps实现(Android 4.0版本以上),之前介绍的xHook有源码可以抄一下。

 


/proc/self/maps能查询到动态库基地址

 

而函数的偏移地址可以使用NDK下llvm-readelf -s指令,读取符号表获取到:

 


readelf读取出的引擎内部函数地址

 

接下来,对函数Hook后,需要对参数进行内存分析提取里面的有用信息,如果有源码,就是开卷考试,按照其内存布局定义出来;没源码,我们也可以通过一些技巧把信息提取出来,下面以实战说明一下。

六、实战:统计引擎内部调用

我曾经在《使用Simpleperf+Timeline诊断游戏卡顿》[1]这一篇文章中提到过,一些常见的卡顿归因,能通过Simpleperf识别,但我们只知道触发堆栈,今天我们更进一步。

这里以AddComponent函数为例,做一个Demo,然后尝试使用Hook把触发的GameObject、组件名字都打印出来,C# 测试代码如下:

// New Game Object节点添加一些Unity内置组件
var go = newGameObject();
go.AddComponent<MeshFilter>();
go.AddComponent<MeshRenderer>();
go.AddComponent<MeshCollider>();
// 相机节点添加一个自定义脚本组件
gameObjet.AddComponent<TestCom>();

 

通过Simpleperf锁定我们的目标函数为AddComponent(GameObject&, Unity::Type const*, ScriptingClassPtr, core::basic_string<char, core::StringStorageDefault >*)

 


Simpleperf-Timeline查看命中的native函数

 

接下来通过llvm-readelf -s指令,查询函数在符号表中的位置,名字稍微和Simpleperf中的显示形式有点区别,但是我们还是能认出它,它的地址就是0x5126a4。

 


搜索符号表内AddComponent函数地址

 

接下来,我们需要在代理函数里面,对函数参数做一些解析,从函数签名可以看到,参数有4个:void *go、void *unitytype、void *scriptclassptr和void *error。

我们的目标是获取节点名和组件名,解析前3个就行,主要有两种方案:

1. 在符号表里多收集一些工具函数地址,比如获取GameObject名字的方法0x435010,这个方法传入GameObject对象指针作为参数,返回名字字符串,所以可以把这个函数地址存起来,直接调用,我管这叫“他山之石,可以攻玉”。

 


获取GameObject名字的方法地址能轻易搜索到

 

2. 针对另外两个参数,可以将结构直接定义出来使用,比如ScriptClass前两个参数是指针,第三个就是C字符串。这些工作,在有相关源码的情况下会容易很多,如果没有的话,只能通过LLDB无源码动态调试之类的手段来获取其内存布局,会涉及到一些二进制分析手段、工具。

有了这些准备工作,就可以开始编码了:

#include <jni.h>
#include <dlfcn.h>
#include "shadowhook.h"
#include <thread>
#include <unistd.h>
#include <fcntl.h>
#include <android/log.h>
#include <link.h>classScriptclass
{public:void *placeholder1;void *placeholder2;constchar *name;
};classUnityType
{public:void *placeholder1;void *placeholder2;constchar *name;
};uintptr_t baseaddr = 0;
int callback(struct dl_phdr_info *info, size_t size, void *data)
{constchar *target = (constchar *)data;// Check if the current shared library is the target libraryif (strstr(info->dlpi_name, target)){__android_log_print(ANDROID_LOG_INFO, "TestHook", "Base address of %s: 0x%lx\n", target, (unsigned long)info->dlpi_addr);baseaddr = info->dlpi_addr;return1; // Return 1 to stop further iteration
    }return0; // Continue iteration
}void *old_AddComponent = nullptr;
typedef void *(*AddComponentFunc)(void *go, void *unitytype, void *scriptclassptr, void *error);
typedef constchar*(*GameObjectGetNameFunc)(void *ptr);void *MyAddComponent(void *go, void *unitytype, void *scriptclassptr, void *error)
{constchar *goName = nullptr;constchar *typeName = nullptr;if(go != nullptr){// 计算GameObjectGetName的地址uintptr_t addr = baseaddr + 0x435010; // 调用GameObjectGetName获取名称GameObjectGetNameFunc func = (GameObjectGetNameFunc)(addr);goName = func(go);}if (scriptclassptr != nullptr){Scriptclass *t = (Scriptclass *)scriptclassptr;typeName = t->name;}elseif (unitytype != nullptr){UnityType *t = (UnityType *)unitytype;typeName = t->name;}if(goName == nullptr)goName = "null";if(typeName == nullptr)typeName = "null";__android_log_print(ANDROID_LOG_INFO, "TestHook", "UnityAddComponent: %s %s\n", goName, typeName);return ((AddComponentFunc)old_AddComponent)(go, unitytype, scriptclassptr, error);
}void TestHook()
{// 延迟3秒,等待Unity加载完成std::this_thread::sleep_for(std::chrono::seconds(3));// 查询libunity的基地址constchar *library_name = "libunity.so";dl_iterate_phdr(callback, (void *)library_name);// 计算AddComponent的函数地址uintptr_t addr = baseaddr + 0x5126a4;// 执行Hook并保存原函数地址到old_AddComponentvoid *stub = shadowhook_hook_func_addr((void *)addr, (void *)MyAddComponent, (void **)&old_AddComponent);if (stub == nullptr){int err_num = shadowhook_get_errno();constchar *err_msg = shadowhook_to_errmsg(err_num);__android_log_print(ANDROID_LOG_INFO, "TestHook", "hook error %d - %s\n", err_num, err_msg);}else{__android_log_print(ANDROID_LOG_INFO, "TestHook", "hook success\n");}
}JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved)
{JNIEnv *env;if (vm->GetEnv((void **)&env, JNI_VERSION_1_6) != JNI_OK){return JNI_ERR; // JNI version not supported.
    }// 初始化Shadowhookint ret = shadowhook_init(SHADOWHOOK_MODE_UNIQUE, true);if (ret != 0){constchar *err_msg = shadowhook_to_errmsg(shadowhook_get_init_errno());__android_log_print(ANDROID_LOG_INFO, "TestHook", "init error %d - %s\n", shadowhook_get_init_errno(), err_msg);}else{__android_log_print(ANDROID_LOG_INFO, "TestHook", "init success\n");}std::thread(TestHook).detach();return JNI_VERSION_1_6;
}

 

和前面PLT Hook的例子一样,使用JDWP注入执行,最终可以输出Demo中调用AddComponet的参数详情,利用这些信息,接下来就可以做很多事情了,我们现在可以几乎Hook任意函数!

 


控制台最终能正常输出节点、组件名

 

七、栈回溯

在栈上每个函数都有自己的储存空间,被称之为栈帧(Frame),上面保存了部分参数、局部变量。当调用其它函数时,会将这个函数返回后的下一行指令地址也保存在栈帧,栈回溯就是分析这些栈上面函数地址,还原函数运行轨迹的过程。

 


函数A调用函数B,0x40056a是函数B结束后返回的地址

 

栈回溯经常和Hook一起配合,当Hook住某个函数后,输出它的调用栈,能更进一步分析问题归因,如果对性能要求不高,可以直接使用libunwind库,它在不需要开-fno-omit-frame-pointer编译选项、dwarf调试信息的情况下,也能输出函数地址,然后我们通过符号表将函数名解析出来。

#include <unwind.h>
#include <android/log.h>// 栈回溯上下文结构
struct BacktraceState
{void **current;void **end;
};static _Unwind_Reason_Code UnwindCallback(struct _Unwind_Context *context, void *arg)
{BacktraceState *state = static_cast<BacktraceState *>(arg);uintptr_t pc = _Unwind_GetIP(context);if (pc){if (state->current == state->end){return _URC_END_OF_STACK;}else{*state->current++ = reinterpret_cast<void *>(pc);}}return _URC_NO_REASON;
}size_t CaptureBacktrace(void **buffer, size_t max)
{BacktraceState state = {buffer, buffer + max};_Unwind_Backtrace(UnwindCallback, &state);return state.current - buffer;
}void DumpBacktrace(std::ostream &os, void **buffer, size_t count)
{for (size_t idx = 0; idx < count; ++idx){constvoid *addr = buffer[idx];constchar *symbol = "";Dl_info info;if (dladdr(addr, &info) && info.dli_sname){symbol = info.dli_sname;}// 这里将函数的绝对地址转换为相对地址uintptr_t relative = (uintptr_t)addr - (uintptr_t)info.dli_fbase;os << "  #" << std::setw(2) << idx << ": " << info.dli_fname << " " << (void *)relative << "\n";}
}// 经封装后的打印函数
void PrintStacktrace(const size_t count)
{void* buffer[count];std::ostringstream oss;DumpBacktrace(oss, buffer, CaptureBacktrace(buffer, count));__android_log_print(ANDROID_LOG_INFO, "TestHook", oss.str().c_str());
}

 

栈回溯的步骤虽然看起来繁琐,但只要经过封装后,使用起来其实和在C# 里面一样方便,下一步我们来试一下。

八、实战:为IO调用加入栈统计

沿用之前的PLT Hook的例子,这次我们将调用堆栈打印出来:

 


调用封装好的PrintStacktrace

 

 


现在打印日志里多了调用栈函数地址

 

使用NDK目录下的addr2line.exe对这些地址进行解析,最终得到我们想要的结果。

LocalFileSystemPosix::Open(FileEntryData&, FilePermission, FileAutoBehavior)
zip::CentralDirectory::Enumerate(bool (*)(FileSystemEntry const&, FileAccessor&, char const*, zip::CDFD const&, void*), void*)
VerifyAndMountObb(char const*)
MountObbs()
UnityPause(int)
UnityPlayerLoop()
nativeRender(_JNIEnv*, _jobject*)

 

九、结语

本文从以性能优化分析目的入手,介绍了常用的逆向分析手段 —— 注入、Hook、堆栈回溯,这里只是浅显地聊了一下运用场景,事实上每一个坑都能挖到很深,比如注入与反注入,如何对竞品进行注入,Hook的相关调试方法、内存分析、更高性能的栈回溯、聚合显示(火焰图)等等。

之所以总结此文,是因为我在近期的工作中感觉到,了解一点逆向分析的知识,对性能优化、程序调试方面很有好处,也不局限于游戏开发领域,技多不压身。

参考
[1] 使用Simpleperf+Timeline诊断游戏卡顿
https://zhuanlan.zhihu.com/p/666443120


这是侑虎科技第1878篇文章,感谢作者其乐陶陶供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)

作者主页:https://www.zhihu.com/people/jun-yan-76-80

再次感谢其乐陶陶的分享,如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)

http://www.hskmm.com/?act=detail&tid=14532

相关文章:

  • 使用 feign 调用时对微服务实例进行选择
  • EI目录今年第3次更新!55本中国期刊被收录,附完整版下载
  • 程序员的未来:从技术岗位到全栈思维的进化之路 - 实践
  • envoy和nginx的区别
  • 基于自适应差分进化算法的MATLAB实现
  • 【SPIE出版、主题宽泛、快速检索】2025年可持续发展与数字化转型国际学术会议(SDDT 2025)
  • langfuse使用的postgresql异机备份和恢复(docker)并进行langfuse版本升级
  • 国产化Excel处理组件Spire.XLS教程:Java在 Excel 表格中轻松添加下标
  • tips图解复杂数组、指针声明
  • 通过perl或awk实现剪切功能
  • java列队多种实现方式,
  • Ashampoo Music Studio 12.0.3 音频编辑处理
  • Gitee:本土化代码托管平台如何重塑中国开发者协作生态
  • WEB项目引入druid监控配置
  • Computer Graphics Tutorial
  • CF1874(CF Round 901) 总结
  • 2. Spring AI 快速入门使用 - Rainbow
  • PyCharm 2025.1安装包下载与安装教程
  • 阿里将发布多模态模型 Qwen3-Omni,主打多语言与复杂推理;DeepvBrowser 上线 AI 语音浏览器丨日报
  • Word文档内容批量替换脚本 - wanghongwei
  • VMware ESXi 磁盘置备类型详解
  • EF 数据迁移生成sql脚本
  • HWiNFO 硬件信息检测工具下载与安装教程
  • 第七章 手写数字识别V1
  • 西电PCB设计指南1~2章学习笔记
  • 1. 大模型的选择详细分析 - Rainbow
  • 云计算实践部署笔记
  • [eJOI 2024] 奶酪交易 / Cheese
  • 逆向分析之switch语句
  • 批量查询设计桩号方法及文件格式