OpenGL Leak学习记录
介绍
OpenGL Leak是腾讯开源框架Matrix中的一个组件,官方并没有为这个组件提供介绍文档,也并没有给这个组件提供Demo。因此这篇文章中对这个组件的分析都是基于阅读源码+手动搭建Demo实现的。大体上讲,这个组件的作用是用来检测安卓系统中OpenGL存在的内存泄露行为;具体的细节上还存在诸多疑惑的点,后续文章中会提到。
代码架构
OpenGL Leak组件的核心技术是TLS Hook。组件将OpenGL ES的API进行了Hook,收集每个API的调用状态,然后生成事件供上层处理。整体的代码分为以下几个模块,我们逐一讲述。
入口
入口模块的主要工作是提供组件工作需要的Context,事件处理回调等参数,并且提供组件启动和结束的方法。这一部分需要关注的点是:组件本身提供了一个OpenglIndexDetectorService
;入口完成初始化以后,就会启动这个Service并开始寻找OpenGL api在内存中的位置,为后续做hook做准备。不过为什么这个操作要通过IPC的方式在另外一个Service完成呢?截止目前我还没搞懂。
API Hook
Hook部分算是这个组件中比较核心的部分。他的原理是:由于OpenGL ES的实现是由不同的驱动厂商提供的,因此Android需要从厂商提供的so库中加载OpenGL ES api的函数地址并存放到特定的寄存器中。那么我们的程序便可以从这个寄存器中获取到api的函数地址,并替换成Hook以后的函数地址,即可以完成Hook操作
这里有一篇文章可以当做参考:Android Hook OpenGL ES (TLSHook)
配合源码理解一下:
gl_hooks_t *get_gl_hooks() {
// 获取 TLS,即从寄存器中获取指针数组
volatile void *tls_base = __get_tls();
// 强制类型转换,将Void*类型的指针转化为函数指针结构体
gl_hooks_t *volatile *tls_hooks =
reinterpret_cast<gl_hooks_t *volatile *>(tls_base);
// 声明一个用来指向api函数的指针
gl_hooks_t *hooks = NULL;
// android >= 10 TLS 位置有变化
char sdk[128] = "0";
__system_property_get("ro.build.version.sdk", sdk);
int sdk_version = atoi(sdk);
// 为api函数指针赋值,指向api函数结构体的起始位置
if (sdk_version >= 29) {
// android 10
hooks = tls_hooks[4];
} else {
hooks = tls_hooks[3];
}
return hooks;
}
上面这个函数,只是获取到了OpenGL ES api函数指针的起始位置,至于每个api函数的具体位置,可以按照AOSP中规定的来,也可以自行寻找。OpenGL Leak中采用的是自行寻找的方式,这里也结合源码看一下:
extern "C" JNIEXPORT jint JNICALL
Java_com_tencent_matrix_openglleak_detector_FuncSeeker_getTargetFuncIndex(JNIEnv *env, jclass, jstring target_func_name) {
// 首先,拿到OpenGL ES api对应的函数指针结构体
gl_hooks_t *hooks = get_gl_hooks();
if (NULL == hooks) {
return 0;
}
// 获取到函数名对应的原函数的指针
System_GlNormal_TYPE target_func = get_target_func_ptr(
env->GetStringUTFChars(target_func_name, JNI_FALSE));
if (NULL == target_func) {
return 0;
}
for (i_glGenNormal = 0; i_glGenNormal < 500; i_glGenNormal++) {
// has_hook_glGenNormal是一个标志位,_my_glNormal中会将这个标志位置为true;
if (has_hook_glGenNormal) {
// 由于_my_glNormal执行完后index会+1,所以这里判断标志位为true以后,要将index减去1得到的才是函数真正的位置
i_glGenNormal = i_glGenNormal - 1;
// 将原函数放回原来的位置
void **method = (void **) (&hooks->gl.foo1 + i_glGenNormal);
*method = (void *) _system_glGenNormal;
break;
}
// 第一次遍历的时候,_system_glGenNormal为NULL,所以不用担心索引为-1
if (_system_glGenNormal != NULL) {
void **method = (void **) (&hooks->gl.foo1 + (i_glGenNormal - 1));
*method = (void *) _system_glGenNormal;
}
// 这里比较关键:将原函数的位置替换成_my_glNormal,然后后续执行一下原函数,如果能顺利执行,也就意味着_my_glNormal被执行了一遍,那么has_hook_glGenNormal标志位就会被设置为true,就可以跳出循环并返回函数的位置了
void **replaceMethod = (void **) (&hooks->gl.foo1 + i_glGenNormal);
_system_glGenNormal = (System_GlNormal_TYPE) *replaceMethod;
*replaceMethod = (void *) _my_glNormal;
// 执行一下原函数,验证是否已经拿到偏移值
HOOK_O_FUNC(target_func, 0, 0);
}
// 遍历到头都没有找到对应的函数,认为函数不存在
if (i_glGenNormal == 500) {
i_glGenNormal = 0;
}
// release
_system_glGenNormal = NULL;
has_hook_glGenNormal = false;
int result = i_glGenNormal;
i_glGenNormal = 0;
return result;
}
总结一下这个查找函数位置的逻辑就是:根据要查找的目标函数名称,找到目标函数在内存中的位置;然后将目标函数的位置替换成自定义的函数,并执行一下目标函数;如果执行成功,也就意味着自定义函数执行成功,就可以返回目标函数的位置了;如果没有找到目标函数,则说明内存中没有目标函数
如此一来,就不用依赖AOSP中定义的函数位置了,组件可以在初始化的时候自行寻找每个函数的位置,为后续的Hook做准备。有了函数在结构体中的Index以后,Hook函数就变得比较简单了,直接将结构体的地址加上Index的地址替换成Hook以后的函数即可:
extern "C" JNIEXPORT jboolean JNICALL
Java_com_tencent_matrix_openglleak_hook_OpenGLHook_hookGlGenRenderbuffers
(JNIEnv *, jclass, jint index) {
gl_hooks_t *hooks = get_gl_hooks();
if (NULL == hooks) {
return false;
}
void **origFunPtr = NULL;
origFunPtr = (void **) (&hooks->gl.foo1 + index);
system_glGenRenderbuffers = (System_GlNormal_TYPE) *origFunPtr;
*origFunPtr = (void *) my_glGenRenderbuffers;
return true;
}
这一步结束,Hook操作就算完成了。自此每一个在Java层指定的OpenGL ES api函数都被替换成了Hook以后的函数。
Hook函数逻辑
成功Hook api以后,接下来就要关注Hook以后的函数做了哪些自定义的操作。所有Hook函数的逻辑基本相似,拿一个来举例:
GL_APICALL void GL_APIENTRY my_glGenTextures(GLsizei n, GLuint *textures) {
if (NULL != system_glGenTextures) {
// 首先执行原函数
system_glGenTextures(n, textures);
// 判断是否在RenderThread中,如果是的话则不做后续的逻辑了(等于没Hook)
if (is_render_thread()) {
return;
}
// 后续操作:将函数生成的纹理,native堆栈,tid,context等等的很多统计数据,然后将这些统计数据回调到Java层对应的Callback中
GLuint *copy_textures = new GLuint[n];
memcpy(copy_textures, textures, n * sizeof(GLuint));
wechat_backtrace::Backtrace *backtracePrt = get_native_backtrace();
int throwable = get_java_throwable();
pid_t tid = pthread_gettid_np(pthread_self());
EGLContext egl_context = eglGetCurrentContext();
EGLSurface egl_draw_surface = eglGetCurrentSurface(EGL_DRAW);
EGLSurface egl_read_surface = eglGetCurrentSurface(EGL_READ);
char *activity_info = static_cast<char *>(malloc(BUF_SIZE));
if (curr_activity_info != nullptr) {
strcpy(activity_info, curr_activity_info);
} else {
strcpy(activity_info, "null");
}
messages_containers->
enqueue_message((uintptr_t) egl_context,
[n, copy_textures, throwable, tid, backtracePrt, egl_context, egl_read_surface, egl_draw_surface, activity_info]() {
gen_jni_callback(n, copy_textures, throwable, tid,
backtracePrt, egl_context, egl_draw_surface,
egl_read_surface,
activity_info, method_onGlGenTextures);
});
}
}
Hook函数的逻辑就两个:
- 先执行一遍原函数,确保功能正常
- 再判断是否是在渲染线程,如果不在,就收集统计数据并回调给Java层处理
这里令我十分不理解的是为什么要跳过渲染线程中的操作。我尝试了一下在默认的开启硬件加速的情况下,原生组件、MediaPlayer和ExoPlayer视频播放都是在渲染线程中进行的。只有Webview会在Chromium的线程中渲染。
💡1月7日更新:咨询了一下代码Owner,跳过渲染线程的原因是:1.性能考虑;2.渲染线程的操作是完全由系统管理的,应用层就算监控到了异常也做不了什么,所以就直接屏蔽掉监控了。按照这个逻辑,针对Chromium线程的渲染监控其实也没什么用,因为我们也没办法修改Chromium的代码。所以这里监控的对象主要就是自定义线程中,绑定GL环境的操作。
Leak判断
针对Leak的判断,组件的逻辑是这样的:对于每一种资源(Texture、Buffer、Context等),在申请资源的时候将它放到一个表中。后续释放资源的时候从表中取出资源。然后当一个Activity被销毁的时候,判断表中是否还存在没有被取出的资源,如果有,则判断可能发生了Leak;等待五秒后二次判断,如果资源还存在,则确定发生了Leak。这块的逻辑不复杂,和Leak Canary有点相似。
待搞懂的问题
整理一下截止目前没有搞懂的问题:
- 为什么查找函数要放到单独的Service中进行?
- 我搜索了一些关于OpenGL内存泄露的问题,发现有人提到OpenGL的API本身并不约束内存操作。比如当我先调用glGenTextures再调用glDeleteTextures并不意味着我先申请了一块内存再释放了一块内存。因为OpenGL的API都是由厂商的驱动实现的,所以他们具体如何管理内存我们是不知道的。这也就意味着,监听资源的申请和回收并不能代表实际内存的变化。所以这个组件的原理和实际表现,还有待进一步考证。