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