最近遇到偶然Bug,ios获取堆栈时偶尔会出现无法识别的栈帧,且对应的地址与macho文件内存的image无法对应,🧐看看到底是什么原因:
首先看现象:
可以看到栈底和中间的栈帧均出现了unkonwn,且栈帧的地址明显与其他长度不一致
查找关键字“unkonwn”
代码中只有一处出现了此关键字🤓,代码定位get(注意区分unknown和unkonwn区别,这里写代码的同学“手误”(腹黑)写了两个方式来区分不同问题)
代码比较简单,大概的意思是,通过读取machO文件,获取所有的代码镜像,然后拿当前的栈帧地址与所有代码镜像比对,找到对应的代码块,然后进行打印。
而一旦出现unkonwn,就意味着,在所有的代码块中并没有该栈帧的位置。
没有错,这个栈帧不存在😂
为什么会出现这样的情况,由于问题是偶现的,没有必现路径,无法单步调试,只能先在代码上下功夫。
首先,由于栈帧的地址明显与其他长度不一致,怀疑是栈帧地址获取出错,所以将栈帧地址获取这块代码进行review
这里有个知识点,如何获取某个线程的堆栈(一个线程对应一个堆栈),也就是获取它包含所有的栈帧地址,很多同学说用backtrace就可以了,其实backtrace有局限性,一是backtrace只能获取当前线程的堆栈,如果我们需要监控主线程状态时,需要用一个子线程进行堆栈获取的操作(比如主线程卡顿、卡死、阻塞),二是,很多时候为了能够定位问题,我们需要获取所有线程的情况。
具体需要3个知识点
知识点1,machO文件结构
这里需要一步步细说,ipa打开后,我们会发现可执行文件,即machO文件,该文件包含了所有的可执行代码和数据等,我们获取的内容无非就对该文件的读取
machO文件的讲解参考:
https://blog.csdn.net/weixin_33859844/article/details/88031654
https://www.jianshu.com/p/4ab0e06c5ec9
https://www.jianshu.com/p/8f3d3f6b6af8
更加直观的方式用machOview打开一个machO文件即可,
知识点2,栈的结构
栈用来存储方法的调用关系,以及方法本身的相关数据和代码,关于栈的文章很多,需要慢慢的看,看懂了对于系统运行会有比较深刻的理解,特别是pc,lr,sp,fp
关键的点其实两条:
当前栈帧中fp指向该栈帧的起始位置,该起始位置存储的是上一个栈帧的fp——这样通过栈顶的fp,可以逐层获得上个栈帧,从而获取该栈的所有栈帧
当前栈帧中fp指向该栈帧的起始位置,该位置+1(栈是高位地址向地位地址延伸),即为上一个栈帧的lr,lr存储的是上一个需要返回的方法地址——这样不仅可以获得上一个栈帧的位置,还可以知道上一个栈帧运行完,返回的地址,依次类推,就可知道所有栈帧运行完返回的方法地址,即我们要的所谓的“方法调用链”,即我们需要的“堆栈”
参考文章:
https://blog.csdn.net/jasonblog/article/details/49909163
https://www.cnblogs.com/qcloud1001/p/10268298.html
https://juejin.im/post/5d81fac66fb9a06af7126a44
比较推荐第2篇,里面用汇编逐步讲解了彼此的关系,是良心之作,其实讲栈的文章比较多,但讲的通透的比较少,还需要自己多分析,不然看“海量”的文章还是不能明白,笔者建议只读这三篇,然后“想”明白。
知识点3,如何获取某个线程,如果获取某个线程对应的栈
即建立,获取线程——获取堆栈——获取堆栈里面所有的方法的地址(即我们关心代码关系)
这里有两篇十分经典的文章,笔者获益匪浅
https://www.jianshu.com/p/df5b08330afd,这里前半段讲如何获取线程对应的堆栈,后半段讲如何翻译该堆栈,堪称手把手教学
https://www.jianshu.com/p/7cbfd8aa4a3c则是用类似BSBacktraceLogger的方法代码级进行了示例说明,并提供demo,是良心之作
https://blog.csdn.net/abc649395594/article/details/52350426是对BSBacktraceLogger的分析,重点讲解了NSThread 转内核 thread的内容,建议配着源码https://github.com/bestswifter/BSBacktraceLogger看
这里有个拓展知识点
根据早期facebook出的经典的fishhook代码,业界终于明白如何读取machO文件,从而获取堆栈后,讲不可读的内存转变成“源代码”,从而导致堆栈获取和翻译的框架如雨后春笋般出现,包括后来的比较有名的BSBacktraceLogger,kscrash等
通过三个知识点,现在,我们可以做到:获取某个线程——获取堆栈——获取堆栈里面所有的方法的地址——翻译所有地址——展示出翻译后的堆栈
(翻译堆栈时注意:Xcode 的调试输出不稳定,有时候存在调用 NSLog()
但没有输出结果的情况,建议前往 控制台 中根据设备的 UUID 查看完整输出。真机调试和使用 Release 模式时,为了优化,某些符号表并不在内存中,而是存储在磁盘上的 dSYM 文件中,无法在运行时解析,因此符号名称显示为 <redacted>
)
道理都懂了,看业务代码,这里重点看了获取堆栈的边界,栈顶的pc,lr是否越界,以及每种架构header的长度是否区分,以及每种架构的偏移是否准确,并没有发现问题,但通过对比BSBacktraceLogger,kscrash,发现大家都做了image获取的判断,当获取不到,即停止获取
比如kscrash
这几个业界常用的方式,都没有处理这个异常,是不是说明这个问题不影响核心问题的发现?
而且从图1来看,某个栈帧出现问题,不一定影响后面的栈帧,与偶现问题的同学沟通,发现,以前也有靠着“部分”堆栈解决问题的案例。
这样看,业界普遍不处理这个异常,又可以靠着“部分”堆栈解决问题,似乎这个bug不用解,或者说并不是一个bug?
问题到这里似乎结束了,但并没有根本解决,因为,出现异常栈帧的原因并没有找到,
是不是我们获取堆栈的方式还是有死角?
本着这个思路,需要从两个方面分析,一是系统是否“优化”了堆栈,二是某些堆栈是否“已经”修改
优化这块,比较经典的是尾调用优化(只能release)
参考资料:https://www.jianshu.com/p/3c7a8f6fe451
笔者本地测试了下,发现尾调用优化,只能“吃掉中间的栈帧,不会出现address不能与image匹配
换思路二,比较经典的是内联方法inline,总结起来重点是以下四点:
inline为了解决一些频繁调用的小函数大量消耗栈空间(栈内存)的问题,
inline 的使用是有所限制的,inline 只适合涵数体内代码简单的涵数使用,不能包含复杂的结构控制语句例如 while、switch,并且不能内联函数本身不能是直接递归函数(即,自己内部还调用自己的函数)。
inline对于编译器而言,意味着“在编译阶段,将调用动作以被调用函数的本体替换之”
不要获取inline函数的地址。如果要取得一个inline函数的地址,编译器就必须为此函数产生一个函数实体,无论如何,编译器无法交出一个“不存在函数”的指针。注意,有些编译器可能会使用类的constructors和destructors的函数指针,用以构造和析构一个class对象的数组。另外类的constructors和destructors可能简单,但是其父类的类的constructors和destructors可能是复杂的,所以类的constructors和destructors往往不是inline函数的最佳选择!
作者:HelloGeekBand
链接:https://www.jianshu.com/p/2342ab5a5962
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
参考资料:
https://www.jianshu.com/p/2342ab5a5962
https://www.runoob.com/w3cnote/cpp-inline-usage.html
https://www.jianshu.com/p/f96d68eba946
https://www.geeksforgeeks.org/inline-functions-cpp/
第三点意味着,动态获取堆栈时,已经本体替换,我们获取不到内联方法的地址
第一点和第二点意味着,虽然获取不到,但内联方法消耗不大,如果为了解决性能问题,并不是重点
第四点意味着,如果获取内联方法的地址,有可能会有错误风险
上手,试demo
重现了!!!
而且是偶现的!!!
到此,找到了问题所在!
事实上关于inline方法与堆栈的关系,以及inline方法的生命周期,一直以来是大家讨论的热点,比如
https://stackoverflow.com/questions/50462042/inline-and-stack-frame-control?r=SearchResults
https://stackoverflow.com/questions/3318322/do-inline-functions-have-addresses
有兴趣的同学可以研究一下,希望回复你的看法
拓展知识:
ios卡顿监控有两个思路,
一个是开启一个子线程,并打开子线程的runloop
,让该子线程常驻在App
中。创建一个RunloopObserver
(Runloop
观察者),将RunloopObserver
添加到主线程runloop
的commonModes
下观察。同时,子线程的runloop
开始监听,每当主线程runloop
的状态发生变化时,就会通知该RunloopObserver,如果耗时严重则获取堆栈分析。
https://www.jianshu.com/p/632d7a1526e9
一个是
通过向主线程添加CADisplayLink我们可以接收到每次屏幕刷新的回调,如果调帧严重,则获取堆栈分析。
https://www.jianshu.com/p/4151e4def785
哪个思路好?
参考答案:CADisplayLink更轻量,但需要在cpu稍微清闲时才能够回调,严重卡顿的堆栈获取不一定及时,但用户感知卡和检测卡相一致,适合外网监控;RunloopObserver更加真实获取实时路径,但比较重,与用户感知不完全一致,建议研发流程内开启。