天天看點

深入iOS系統底層之crash解決方法

一個應用程式并不總會一直運作的很好,它總會有出現crash崩潰的情況。如果在應用程式中接入了一些第三方的crash收集工具或者自建crash收集報告平台的話将會很好的幫助開發者去分析和解決應用程式線上上運作的問題,當出現的崩潰問題能得到及時的解決和快速的修複時必将會大大的提升應用程式的使用者體驗。

目前比較流行的crash收集分析工具很多都是基于開源的KSCrash代碼來進行封裝和改進的。蘋果自身也建構了一套crash采集和分析的機制,你可以從真機的聯機日志或者從開發者賬号中去檢視對應的crash資訊。網絡上也有很多關于crash分析的文章,以及crash堆棧符号化處理的文章。這裡假定你已經了解了一些檢視crash報告的方法和技巧以及一些簡單的crash分析技巧,因為這些是作為開發者需要具備的技能之一。

應用程式出現的crash崩潰異常有一些能夠簡單的被分析和解決,往往這些crash崩潰異常都會帶有明确的上下文資訊和函數調用層級堆棧。但并不是所有的crash崩潰異常都能被簡單的解決,尤其是那些沒有明确上下文資訊的函數調用堆棧或者那些調用堆棧中沒有一個函數或者方法能夠被直接定位到源代碼的場景,就如下面這個崩潰的函數調用棧(部分資訊):

這是一個在iOS10.3.3版本的64位裝置上的一條crash異常報告的片段資訊,要記住這些資訊,它對定位crash崩潰異常有很大的幫助。從崩潰的函數調用棧中可以看出異常是出現在最頂層的函數調用objc_msgSend+16處,也就是在objc_msgSend函數的第5條指令處**(通常情況下arm體系結構中每條指令占用4個位元組,上述的資訊表明是崩潰在函數的第16個位元組的偏移位址處,也就是函數的第5條指令處)。崩潰異常類型顯示為EXC_BAD_ACCESS**表明是産生了無效的位址的讀寫通路,整個崩潰函數調用棧中沒應用程式中的任何上下文資訊。objc_msgSend函數是runtime方法執行的核心引擎而且調用如此的頻繁,函數内部是不可能有BUG的。 那麼為什麼會崩潰在這呢?

當異常出現在沒有源代碼的函數内部時,唯一的方法就是去看它内部的“源代碼”實作

既然出現問題是在objc_msgSend函數的第5條指令處,可以來看看這個函數實作的彙編代碼指令開頭片段:

無論是真機還是模拟器,XCODE都支援在運作時來檢視任何調用的函數的彙編代碼實作,你可以通過設定符号斷點或者進入彙編調試模式以及單指令跳轉的方式來檢視函數的彙編代碼實作。

從代碼中可以看出是在讀取對象的Class對象指針的資料成員cache時出現了無效的位址通路異常。但是對象的Class對象這部分定義資料是存儲在程序記憶體的資料區段中,并且伴随着整個應用的生命周期而存在,是不可能被釋放和銷毀的,是以正常情況下是不可能存在非法記憶體位址通路異常的。會出現這種問題的原因就是調用方法的OC對象被銷毀了,再說具體一點就是對一個已經被釋放掉的OC對象繼續調用了執行個體方法而導緻的。是以當出現這種類型的崩潰時,不管是否有明确上下文,其原因都是一緻的。下面這張圖就能很清楚的說明其中的原因了:

深入iOS系統底層之crash解決方法
實際上在arm64位系統中isa中儲存的并不是對象的Class對象位址,上面的圖目的是為了更加直覺的顯示問題原因。

一個OC對象obj在被銷毀前,其中的isa指針會指向正确的Class對象所在的記憶體位址。是以調用objc_msgSend方法将會正常的運作,而一旦obj對象被銷毀後,為其配置設定的堆記憶體将被回收用作其他用途,是以有可能這部分記憶體區域的資料會被覆寫。當對一個已經釋放了的OC對象繼續調用執行個體方法時,在objc_msgSend函數内部讀取到obj的isa指針得到的将是一個未知或者有可能無效的指針值。是以當對這個未知位址指向的記憶體進行通路時就出現了上面的EXC_BAD_ACCESS的異常崩潰了。

CPU指令中操作寄存器和常數的指令一般不會産生崩潰異常,比如上面的第1,2,4,6條指令;而一般産生通路異常的指令是發生在那些通路記憶體位址的指令當中,比如第3條和5條。

也許你會好奇既然obj對象已經被釋放了,為什麼崩潰會出現在objc_msgSend函數的第5條指令,其中的第3條指令是通路對象的isa資料的,為什麼不崩潰在這呢? 其實答案很簡單,因為幾乎所有的OC對象都是從堆記憶體區域中配置設定記憶體的,是以當某個OC對象被銷毀後,其所占用的記憶體仍然會放回堆記憶體區域中進行管理,而堆記憶體區域的位址是可以進行任意的讀寫通路的,是以即使對象被銷毀釋放,仍然是可以通路對象所指向的記憶體區域的資料的。

應用程式出現崩潰異常時除了函數調用棧可提供分析參考外,還可以從寄存器中的值來進行一步分析。根據上述的函數指令實作中可以看出:

x0 寄存器中的儲存的就是那個被銷毀了的對象指針。 x1 寄存器中儲存的就是産生崩潰的對象的方法名稱的位址。 x13 寄存器中儲存的就是對象的isa指針值。 x16 寄存器中儲存的就是對象的Class指針對象。

函數崩潰處指令為:

這時候因為x16中其實儲存的是一個非法的Class對象指針位址了,是以當執行ldp指令來從x16所指向位址的偏移0x10處讀取記憶體資料時就産生了崩潰,而崩潰的異常代碼:

中的位址值也剛好和x16寄存器中的值是一緻的。也就是表明x16中所儲存的Class對象指針就是一個非法和無效的記憶體位址。

在所有的OC方法中如果你設定了符号斷點那麼在方法開始執行時x0中儲存的總是執行方法的對象,也是第一個方法的參數;x1中總是儲存的執行的方法的名稱字元串,也是第二個方法的參數;然後x2到x15有可能依次是方法的其他參數。是以通常情況下你可以在調試控制台中輸入: <code>po $x0</code> 來顯示對象資訊, <code>p (char*)$x1</code> 來顯示方法名稱。 具體的詳細介紹可以參考我的另外一篇文章:寄存器介紹

上面的崩潰調用棧中,所有的函數和方法都是系統函數并沒有程式自身的源代碼,是以很難跟蹤或者發現問題産生的原因,因為此時是無法知道是哪個類的對象執行方法調用而産生的crash了,唯一的線索就是x1寄存器中的值了。這個寄存器中的值儲存的是調用的方法名, 它是一個SEL類型的資料,是以可以根據x1中儲存的方法名來進行反推,也就是從方法名來反推出産生崩潰的對象的類名。

x1寄存器中儲存的方法的記憶體位址是存在于某個加載的庫Image的代碼段中,是以可以在崩潰日志的Binary Images清單中找到定義方法名的庫Image資訊,Binary Images清單中的每個庫Image都有這個庫加載的開始和結束位址以及路徑名稱,可以很容易就從這些區間清單中找到x1寄存器所指的方法名到底屬于哪個庫。就上面的例子來說可以很明确的看到方法位址0x18eb89b7b是屬于:

也就是UIKit庫中定義的某個對象在執行x1所指的方法而産生了崩潰。有了這個更進一步的資訊後就可以在源代碼中進行檢檢視看哪部分代碼調用到了産生崩潰的庫中所定義的對象了(當然UIKit這裡不具備代表性,實際中崩潰時方法名也許會在其他的庫中)。這樣就從一定程度上能夠縮小排查問題的範圍。

當出現了沒有上下文的崩潰異常調用棧時,并不是對它束手無策。除了可以根據異常類型(signal的類型)分析外,還可以借助搜尋引擎以及一些常見的問題解答站點來尋找答案,當然還可以借助下面列出幾種定位和分析的方法:

1.開源代碼法

這個方法其實很簡單,蘋果其實開源了非常多的基礎庫的源代碼,是以當程式崩潰在這些開源的基礎庫上時就可以去下載下傳對應的基礎庫的源代碼進行閱讀。然後從源代碼上進行問題的分析,進而找到産生異常崩潰的原因。你可以從https://opensource.apple.com處去下載下傳開源的最新的源代碼。這種方法的缺點是并不是所有的代碼都是開源的,而且開源的代碼并不一定是你真機裝置上運作的iOS版本。是以這種方法隻能是一種輔助方法。

2.方法符号斷點法

采用這種方法時,確定你手頭上要有一台和産生崩潰異常問題的作業系統版本相同的真機裝置,以友善聯機調試和運作。你可以在崩潰異常報告的:

部分看到産生異常的作業系統版本号,就如本文的例子裡面産生異常的作業系統版本号為iOS 10.3.3。因為相同的作業系統版本号中所有庫中代碼實作的都是一樣的。如果實在沒有對應的版本号的裝置則可以試圖找一台版本号最相近的裝置。明确了作業系統版本和真機裝置後再從代碼倉庫中檢出和你線上相同版本的應用程式的源代碼(假如崩潰調用棧中沒有任何我們編寫的函數代碼則這個條件要求不必那麼嚴格)。并打開項目工程,然後為産生崩潰的函數調用棧的棧頂函數或者方法名添加一個符号斷點。如果你不知道如何添加符号斷點請參考文章:blog.csdn.net/xuhen/artic… 或者查找關鍵字:“XCODE 符号斷點"。

設定符号斷點的方法或者函數名時可以有如下的選擇:

如果産生崩潰的棧頂是一個OC對象的方法則可以直接用這個類名和方法名來設定符号斷點。

如果産生崩潰的棧頂是一個通用的C函數比如objc_msgSend、free、objc_release則考慮用函數調用棧的第二層函數和方法名來設定符号斷點。比如文本例子中的-[UIWebDocumentView _updateSubviewCaches]方法。

如果産生崩潰的函數調用棧頂是一個沒有對外暴露的C函數,因為這種函數設定符号斷點的難度比交大,是以往往考慮采用函數調用棧的第二層函數或者方法名來做為符号斷點。

設定符号斷點的目的是為了在崩潰函數調用堆棧重制時,能在運作時的斷點處進行動态分析。當你設定了符号斷點後,如果程式邏輯運作到這個函數或者方法時,系統就會在設定的方法或者函數的第一條指令處停止下來。這時候就可以檢視此時的函數調用棧是否和産生崩潰時的調用棧相符,如果相符合那麼表明能夠重制可能發生問題的邏輯了,如果斷點處的調用棧和産生崩潰的調用棧不相同,則可能需要讓程式繼續運作,以便下次在同樣斷點處時進行調用棧的比較,因為設定斷點的方法名并不一定隻在一處被調用。

當程式停在了設定符号斷點的函數或者方法的開始位址後,接下來就需要在這個方法内進行第二個斷點的設定,設定的地方就是崩潰函數調用棧中函數調用上層函數的偏移處,這個可以在崩潰的報告中看到:

也就是需要在_updateSubviewCaches函數的第11條指令或者函數的第40個偏移位元組附近處添加一個斷點。這樣當程式運動到斷點處時就可以在函數調用上層函數前檢視各寄存器的值進而進行問題的定位和分析。

一般情況下崩潰函數棧報告中除棧頂函數外的每一層函數名後 + 的數字表明是在目前函數的對應的位址偏移處附近進行了上層函數的調用,也就是對應的位址偏移附近一般都會存在一條bl指令或者blr這兩條指令,這兩條指令的作用就是執行函數的調用。

通過二次斷點的設定,程式運作到斷點時的指令是:

本例子的異常崩潰的原因是對一個已經釋放的對象繼續調用方法而産生的崩潰。是以當斷點停在指令處時,我們可以在右下角的lldb控制台中列印指令:

可以看出x0是一個數組對象,而x1中則是release方法。這樣就進一步明确了是對一個已經釋放了的數組對象調用了release方法而導緻異常崩潰了。至于x0是一個什麼數組以及儲存在哪裡,則可以通過彙編指令中的x0寄存器的使用進行回溯往上查找指令來進一步分析了。其實這個問題如果進一步觀察就可以看出:崩潰的線程并不是出現在主線程,而是在一個工作線程中。而視圖的操作基本都應該放在主線程進行,是以當主線程的某些子視圖數組對象被釋放後,這裡又在輔助線程中進行讀取通路,就出現了上面的異常崩潰問題了。

在函數調用bl或者blr指令處設定斷點後,因為根據ABI規則所有非浮點數的參數分别依次儲存在x0,x1,....這些寄存器中。是以可以在斷點處分别列印出這些寄存器的值就可以知道函數調用前所傳遞的參數值了。這個方法非常有助于進行問題的定位和分析。

3.手動重制法

有時候即使你設定了符号斷點,場景依然無法重制,這時候就需要采用一些特殊的手段,那就是手動的執行方法調用。實作方式很簡單就是在某個示範代碼中人為的進行崩潰棧頂函數的調用。就比如上面的例子當<code>[UIWebDocumentView _updateSubviewCaches]</code>方法一直不被執行時,就可以自己手動的去建立一個UIWebDocumentView對象,并手動的調用對應的方法_updateSubviewCaches即可。這裡存在的兩個問題是有可能這個類并沒有對外進行聲明,或者我們并不知道方法的參數類型或者需要傳遞的值。對于第一個問題解決的方法可以采用NSClassFromString來得到類資訊并進行對象建立。而第二個問題則可以借助一些工具比如class-dump或者一些其他的手段來确認方法的參數個數和參數類型。總之,目的就是為了能夠進入函數的斷點,甚至都可以在不知道如何傳遞參數時将所有的參數都傳值為0或者nil來臨時解決問題。下面就是模拟崩潰函數的調用實作代碼:

測試代碼可以寫在任何一個地方,這裡為了友善就在程式啟動處加上測試代碼。等代碼編寫完畢後,就可以為方法設定符号斷點。這樣當程式一運作時就一定能夠進入到這個函數的内部去。一旦函數被執行後出現了斷點,就可以按照第2種方法中的介紹進行崩潰分析了。

其實第3種方法的原則就是隻要能讓産生崩潰異常的方法被調用,這其中可以嘗試着采用各種手段将對象和方法run起來。

4.第三方工具靜态分析法

前面兩種介紹的都是動态分析法, 有時候還可以借助一些反編譯的工具來對程式代碼進行靜态分析。比如像Hopper或者IDA之類的工具。缺點就是這些工具是收費的,而且效果沒有動态分析那麼的好。在使用上個人覺得IDA分析工具更加友好和強大一些。

采用第三方工具時需要找到産生崩潰的函數所在的庫,函數所在的庫在崩潰的函數調用棧清單中就能找到了。如果崩潰函數是在應用程式本身中被定義,那麼需要将上傳到appstore的ipa檔案解壓縮并提取出其中的可執行程式用工具打開即可。如果崩潰函數是在某個系統庫中被定義,那麼可從如下的路徑:

~/Library/Developer/Xcode/iOS DeviceSupport/

iOS DeviceSupport這個檔案夾下的内容将展示你所有曾經聯機調試過的各種作業系統版本的庫的一份拷貝,如果你沒有真機調試過出現崩潰的作業系統版本,請找一個安裝了這個作業系統版本的真機裝置,并聯機,這樣你的檔案夾中就會有對應的作業系統版本下的系統庫的拷貝資訊了。

中找到對應的産生崩潰的手機作業系統版本号的庫檔案:10.3.3(14G60)/Symbols/System/Library/Frameworks/UIKit.framework/UIKit

當用IDA工具打開對應的庫檔案或者可執行檔案時你看到的将是這個庫檔案的所有彙編形式的代碼和資料。是以你可以通過搜尋菜單來查找産生崩潰的函數或者方法名。這時候你就可以進一步對産生問題的函數的彙編代碼進行分析了。采用IDA工具進行彙編代碼分析的缺點是靜态分析無法看到運作時的各個寄存器的真實的值,是以采用這種方法可能更需要考慮你對彙編代碼的了解能力。下面就是本文例子中的[UIWebDocumentView _updateSubviewCaches]方法的實作彙編代碼:

采用IDA工具進行分析時,需要了解一些比如庫基位址和代碼資料偏移位址以及位址重定向相關的知識。蘋果系統為安全對每個庫的加載都采用了ASLR的方式,也就是庫所加載的基位址每次運作時都是随機的,這樣當某次崩潰發生時需要将産生崩潰時的位址轉化為我們通過IDA工具打開的位址。 轉換公式為:

就以上面崩潰異常為例,當我們用IDA工具看看x1寄存器中的值到底是一個什麼方法名,那麼隻需要把x1的值(0x018eb89b7b),減去其所在的庫UIKit的基位址值(0x18e03d000),在加上IDA工具打開庫時的基位址(要想看基位址則滾動到IDA視圖的最開始部分,本次打開的基位址為:0x187769000)。是以x1寄存器中的位址值被轉化後應該為:

在IDA工具中将位址跳轉到0x1882B5B7B就可以看到本例子中産生崩潰的方法名是叫release:

當然IDA工具是可以手動進行基位址的自定義設定的,這樣就不需要進行計算以便和線上崩潰的基位址對齊。

如果你手頭上沒有第三方工具,其實系統内置的otools工具也可以幫我們進行問題的定位以及彙編代碼的檢視和分析了,具體的方法大家就去查找相關的對otools使用的教程即可,這裡就不展開了。

上面列出的所有分析方法中有靜态分析的也有動态分析。當出現了崩潰時除了從崩潰函數調用棧去分析問題,還可以從寄存器,以及加載的鏡像清單,以及崩潰棧頂部的函數的彙編代碼等等進行綜合的分析和判斷。當然即使這樣也不能保證所有問題就一定能夠得到解決,本文中列舉的例子隻是在實際中的一種非常常見的崩潰異常,希望通過這個示例來起到一個抛磚引玉的效果,畢竟不同的崩潰異常的差異是比較大的。遇到問題需要具體分析,走進函數的内部實作就一定能夠找到産生問題的根源。

繼續閱讀