iOS 堆栈获取异常分析

最近遇到偶然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获取的判断,当获取不到,即停止获取

比如BSBacktraceLogger

 比如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中。创建一个RunloopObserverRunloop观察者),将RunloopObserver添加到主线程runloopcommonModes下观察。同时,子线程的runloop开始监听,每当主线程runloop的状态发生变化时,就会通知该RunloopObserver,如果耗时严重则获取堆栈分析。

https://www.jianshu.com/p/632d7a1526e9

一个是通过向主线程添加CADisplayLink我们可以接收到每次屏幕刷新的回调,如果调帧严重,则获取堆栈分析。

https://www.jianshu.com/p/4151e4def785

哪个思路好?

参考答案:CADisplayLink更轻量,但需要在cpu稍微清闲时才能够回调,严重卡顿的堆栈获取不一定及时,但用户感知卡和检测卡相一致,适合外网监控;RunloopObserver更加真实获取实时路径,但比较重,与用户感知不完全一致,建议研发流程内开启。

 

 

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值