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更加真實獲取實時路徑,但比較重,與用戶感知不完全一致,建議研發流程內開啓。

 

 

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章