Linker加载so失败问题分析

做者:段聪,腾讯社交平台部高级工程师html

商业转载请联系腾讯WeTest得到受权,非商业转载请注明出处。node

原文连接:wetest.qq.com/lab/view/42…linux




WeTest 导读

近期测试反馈一个问题,在旧版本微视基础上覆盖安装新版本的微视APP,首次打开拍摄页录制视频合成时高几率出现crash。nginx


那么咱们直奔主题,看看日志:shell


另外复现的日志中还出现以下信息:
bash

'/data/data/com.tencent.weishi/appresArchiveExtra/res1bodydetect/bodydetect/libxnet.so: strtab out of bounds errorapp



后通过测试,发现覆盖安装后首次使用美体功能也会出现crash,日志以下:async



因为出现问题的场景都是覆盖安装首次使用,而且涉及到人体检测相关的so,彷佛存在某种共同的缘由。函数


所以Abort异常比起fault addr类问题更容易分析,先从前面Linker出现Abort异常的位置开始着手。性能


Linker是so连接和加载的关键,属于系统可执行文件,所以分析起来比较棘手。好在手上正好有一台刚刷完本身编译的Android AOSP的Pixel,作一些实验变得更轻松了。

出现异常的Linker代码linker_soinfo.cpp以下:


const char* soinfo::get_string(ElfW(Word) index) const { if (has_min_version(1) && (index >= strtab_size_)) {   async_safe_fatal("%s: strtab out of bounds error; STRSZ=%zd, name=%d",       get_realpath(), strtab_size_, index); } return strtab_ + index;}bool soinfo::elf_lookup(SymbolName& symbol_name,                       const version_info* vi,                       uint32_t* symbol_index) const { uint32_t hash = symbol_name.elf_hash(); TRACE_TYPE(LOOKUP, "SEARCH %s in %s@%p h=%x(elf) %zd",            symbol_name.get_name(), get_realpath(),            reinterpret_cast<void*>(base), hash, hash % nbucket_); ElfW(Versym) verneed = 0; if (!find_verdef_version_index(this, vi, &verneed)) {   return false; } for (uint32_t n = bucket_[hash % nbucket_]; n != 0; n = chain_[n]) {   ElfW(Sym)* s = symtab_ + n;   const ElfW(Versym)* verdef = get_versym(n);   // skip hidden versions when verneed == 0   if (verneed == kVersymNotNeeded && is_versym_hidden(verdef)) {       continue;   }   if (check_symbol_version(verneed, verdef) &&       strcmp(get_string(s->st_name), symbol_name.get_name()) == 0 &&       is_symbol_global_and_defined(this, s)) {     TRACE_TYPE(LOOKUP, "FOUND %s in %s (%p) %zd",                symbol_name.get_name(), get_realpath(),                reinterpret_cast<void*>(s->st_value),                static_cast<size_t>(s->st_size));     *symbol_index = n;     return true;   } } TRACE_TYPE(LOOKUP, "NOT FOUND %s in %s@%p %x %zd",            symbol_name.get_name(), get_realpath(),            reinterpret_cast<void*>(base), hash, hash % nbucket_); *symbol_index = 0; return true;}复制代码


从代码上看,是在so的symtab中查找某个符号时ElfW(Sym)* s的地址出现异常,致使s->st_name获取到错误的数据。


经过复现问题,能够抓到更完整的 /data/tombstone日志,获得以下完整的信息:



尽管从tombstone中咱们能够看到一些寄存器数据及寄存处地址附近内存数据,同时也能够看到crash时的虚拟内存映射表,仍然没法获取有价值的信息。另外经过几回复现,发现并非每次Crash都是SIGABRT,也出现很多SIGSEGV信号,而调用栈和以前都是同样的,好比这个:



这基本上能够说明,并非so自己的代码存在异常,只多是加载的so出现了文件异常。


另外经过在linker中增长日志,并从新编译linker替换到/system/lib/linker中:




能够获取到以下的地址信息:



经过根据tombstone中的/proc/<poc>/maps的虚拟内存地址与日志打印的地址进行对比,能够发现最为符号表地址的s并无指向so文件在虚拟内存中的地址段,所以能够怀疑,so加载确实出现了异常。


由于手机root,能够直接获取到crash时的so文件(adb pull /data/data/com.tencent.weishi/appresArchiveExtra/res1bodydetect/bodydetect/libxnet.so),导出来对比md5,然而发现与正常状况下的so是如出一辙的:



既然前面的这些实验都没有得出什么有意义的结论,那么我回过头来分析一下,与问题关联的so加载到底有什么特殊性。


实际上,微视为了减包,将一部分so文件进行下发,因为so也处于不断迭代的过程当中,新版本的微视可能会在后台更新so文件,那么客户端一旦发现新的版本有新的so,就会去下载so并进行本地替换。


那么这个过程有什么问题呢?惟一可能的问题,就是先加载了旧的so,以后下载新的so进行了热更新。


咱们先看下微视中是否有这种现象。要观察这种现象,咱们能够打开linker自身的调试开关,开启so加载的日志。经过设置系统属性,咱们能够很容易地进行开启LD_LOG日志:

adb shell setprop debug.ld.all dlerror,dlopen



固然咱们也能够只针对某个应用开启这个日志(设置系统属性debug.ld.app.)。另外,为了开启linker中更多的日志,好比DEBUG打印的信息等,咱们只须要在adb shell中设置环境变量:

export LD_DEBUG=10



那么,咱们从新复现问题,能够看到以下so加载过程:



这个过程代表:旧的so先被加载了,而后下载了新版本的so,并进行了替换。


这个过程有什么问题呢?根据《理解inode》一文咱们能够得知,linux的文件系统使用的inode机制支持了so文件的热更新(动态更新),即每一个文件都有一个惟一的inode号,打开文件后使用inode号区分文件而不是文件名:


8、inode的特殊做用

因为inode号码与文件名分离,这种机制致使了一些Unix/Linux系统特有的现象。


1. 有时,文件名包含特殊字符,没法正常删除。这时,直接删除inode节点,就能起到删除文件的做用。

2. 移动文件或重命名文件,只是改变文件名,不影响inode号码。

3. 打开一个文件之后,系统就以inode号码来识别这个文件,再也不考虑文件名。所以,一般来讲,系统没法从inode号码得知文件名。


第3点使得软件更新变得简单,能够在不关闭软件的状况下进行更新,不须要重启。由于系统经过inode号码,识别运行中的文件,不经过文件名。更新的时候,新版文件以一样的文件名,生成一个新的inode,不会影响到运行中的文件。等到下一次运行这个软件的时候,文件名就自动指向新版文件,旧版文件的inode则被回收。


可是问题就出在这里,若是替换文件使用的是cp这样的操做,会致使原来的so文件截断,而后从新写入数据,可是inode并无更新号,磁盘与内存中的信息出现不一致,这种状况在linux中很常见,好比这篇文章就进行了分析:


1. cp new.so old.so,文件的inode号没有改变,dentry找到是新的so,可是cp过程当中会把老的so截断为0,这时程序再次进行加载的时候,若是须要的文件偏移大于新的so的地址范围会生成buserror致使程序core掉,或者因为全局符号表没有更新,动态库依赖的外部函数没法解析,会产生sigsegv从而致使程序core掉,固然也有必定的可能性程序继续执行,可是十分危险。


2. mv new.so old.so,文件的inode号会发生改变,但老的so的inode号依旧存在,这时程序必须中止重启服务才能继续使用新的so,不然程序继续执行,使用的仍是老的so,因此程序不会core掉,就像咱们在第二部分删除掉log文件,而依然能用lsof命令看到同样。


还有更深刻的解释:


Linux因为Demand Paging机制的关系,必须确保正在运行中的程序镜像(注意,并不是文件自己)不被意外修改,所以内核在启动程序后会绑定 内存页 到这个so的inode,而一旦此inode文件被open函数O_TRUNC掉,则kernel会把so文件对应在虚存的页清空,这样当运行到so里面的代码时,由于物理内存中再也不有实际的数据(仅存在于虚存空间内),会产生一次缺页中断。Kernel从so文件中copy一份到内存中去,a)可是这时的全局符号表并无通过解析,当调用到时就产生segment fault , b)若是须要的文件偏移大于新的so的地址范围,就会产生bus error。


那么问题基本清晰了。咱们在回去看看微视的代码,这里下载了so以后直接unzip到原来的路径,并无先进行rm操做。


更近一步,咱们本身写个demo测试下刚才的问题(2个按钮,一个加载指定so,一个调用so中的native方法):



代码不能再简单了:



正常加载so而后执行native方法都是ok的,使用rm+mv替换或者adb push替换也都是ok的,最后再按照错误的方法操做,步骤为:


1. 启动app,点击加载so;

2. 经过cp命令替换so;

3. 点击执行native方法;


结果确实是crash了:


日志以下,是否是很最开始的日志信息同样呢:



到此,咱们有两种解决办法:

1. 若是so有升级,先不加载旧的so,等新的so下载完成以后再加载;

2. 能够先加载旧的so,可是下载了新的so以后,要删除旧的so,再进行替换。

引文参考:

www.cnblogs.com/cnland/arch…

www.cnblogs.com/cnland/arch…

www.nginx.cn/1329.html

www.ruanyifeng.com/blog/2011/1…

www.bo56.com/linux%E4%B8…


目前,“自动化兼容测试” 提供云端自动化兼容服务,提交云端百台真机,并行测试。快速发现游戏/应用兼容性和性能问题,覆盖安卓主流机型。


点击:wetest.qq.com/product/aut… 便可体验。


若是使用当中有任何疑问,欢迎联系腾讯WeTest企业QQ:2852350015

相关文章
相关标签/搜索