iOS系統崩潰的捕獲 相信大家在開發iOS程式的時候肯定寫過各種Bug,而其中最為嚴重的Bug就是會導致崩潰的Bug(一般來說妥妥的P1級)。在應用軟體大大小小的各種異常中,崩潰確實是最讓人難以接受的行為。畢竟崩潰意味著用戶將丟失應用程式運行中的所有上下文環境,丟失其所有未保存的數據,會帶給用戶最糟 ...
iOS系統崩潰的捕獲
相信大家在開發iOS程式的時候肯定寫過各種Bug,而其中最為嚴重的Bug就是會導致崩潰的Bug(一般來說妥妥的P1級)。在應用軟體大大小小的各種異常中,崩潰確實是最讓人難以接受的行為。畢竟崩潰意味著用戶將丟失應用程式運行中的所有上下文環境,丟失其所有未保存的數據,會帶給用戶最糟糕的使用體驗。
所以在應用的開發階段,我們一定要杜絕此類可能造成應用程式無法使用的崩潰。但是很多崩潰並不是自己在開發階段就能預料到的,此時就需要一種能夠線上獲取崩潰日誌並且上報的機制,這就是所謂的崩潰捕獲和上報體系。
今天我們不研究SuperApp中的崩潰上報,主要研究一下崩潰捕獲是如何實現的。
iOS系統中如何捕獲崩潰
首先,iOS系統中,並沒有通用的能夠捕獲所有崩潰的處理函數。捕獲崩潰主要有以下三種方式:
- NSSetUncaughtExceptionHandler
- Unix Signal捕獲函數
- Mach(讀音為[mʌk])異常捕獲函數
關於如何用上述的方式捕獲崩潰,不是本次分享的重點,大家可以自行查閱博客中的代碼。我們主要需要理解的是這三者各自的原理和應用場景。
NSSetUncaughtExceptionHandler
首先我們寫一個會導致崩潰的Objective-C代碼片段:
NSDictionary *userinfo = @{
@"username": @"TP-LINK",
@"email": @"[email protected]",
@"tel": @"15015001500"
};
NSMutableArray<NSDictionary *> *memberarray = [NSMutableArray arrayWithArray:@[userinfo]];
for (NSDictionary *dic in memberarray) {
if ([[dic valueForKey:@"username"] isEqualToString:@"TP-LINK"]) {
[memberarray removeObject:dic];
}
}
運行程式,不出意外的話,程式在執行到片段的時候就會立刻崩潰,然後我們會在控制台裡面看到如下列印:
*** Terminating app due to uncaught exception 'NSGenericException', reason: '*** Collection <__NSArrayM: 0xb550c30> was mutated while being enumerated.'
相信不少同學都會這串提示很熟悉,從字面上來看,程式崩潰是因為有個異常沒有被捕獲到,異常的類型是NSGenericException
,導致異常的原因則是因為在遍歷集合的時候嘗試去修改裡面的元素。
NSGenericException
是一個繼承自NSException
的類,表示觸發的是一種通用的異常,除了這個類,還有很多其他的子類,像是NSRangException
、NSInvalidArgumentException
等,基本上只要看到名稱就知道異常大致是什麼原因導致的。
NSException
是可以被我們手動捕獲的,例如:
@try {
for (NSDictionary *dic in memberarray) {
if ([[dic valueForKey:@"username"] isEqualToString:@"TP-LINK"]) {
[memberarray removeObject:dic];
}
}
@catch (NSException *exception) {
NSLog(@"Caught %@: %@", [exception name], [exception reason]);
}
但是在寫實際項目的時候,我們通常不會手動寫這類處理異常的代碼,Objective-C也並沒有強制要求我們寫此類的異常處理程式。
其實,這主要是因為異常代表的通常是我們編寫的程式存在邏輯錯誤,通常不可恢復,需要我們在發佈給用戶使用之前由開發者進行處理,所以NSException又被稱為應用級異常。NSSetUncaughtExceptionHandler實際上是給我們提供了一個手段,對這些我們未捕獲的異常進行一個最終的處理,但如果這些錯誤是在用戶使用的時候發生的,我們也無法立刻進行處理。
或許也是因為這個原因,Swift語言拋棄了NSException,而只保留了Error。
由於NSSetUncaughtExceptionHandler不是萬能的,比如我們寫一段Swift的強制解包代碼:
var userName= fetchUserName()
printUserName(userName!)
上述代碼假設fetchUserName()函數返回nil,並且printUserName()函數只接受非空參數,那麼在程式運行時,由於強制解包失敗,應用程式會崩潰並且NSSetUncaughtExceptionHandler也無法捕獲此類崩潰,這時候就需要其他的機制來捕獲此類異常。
Mach異常
要瞭解Mach異常,首先要瞭解什麼是Mach!首先上一張mac OS X的架構圖:
mac OS X的核心操作系統被稱為“Darwin”,其由系統組件和內核構成。其中內核被稱為"xnu",他是一個混合型的內核,包括了Mach和BSD兩個部分,其中BSD實現了文件系統、網路、NKE(Network Kernel Extension,實現註入通信加密、虛擬網路介面等網路方面的擴展功能)、POSIX介面等功能,而Mach則實現了I/O組件和驅動程式。xnu內核是開源的。
從圖裡面可以看到,內核的下麵就是硬體,所以由Mach內核拋出的異常也被稱為是最底層的異常,造成異常的原因通常是硬體導致的異常,比如:
- 試圖訪問不存在的記憶體
- 試圖訪問違反地址空間保護的記憶體
- 由於非法或未定義的操作代碼或操作數而無法執行指令
- 產生算術錯誤,例如被零除、上溢、或者下溢
- ……
關於Mach拋出異常的流程,我們可以結合以下圖來理解:
如果出錯的線程觸發了一個硬體級別的錯誤,處於內核的陷阱處理程式就會調用exception_deliver()函數依次嘗試將異常投遞到thread、task和host。
這裡插入一個小話題,在Mach內核中,為了和thread、task和host打交道,或者他們互相之間打交道,提供了一種基於埠的IPC手段,這個手段在Cocoa上層也有對應的抽象,就是NSMachPort。這個mach port大家可能聽說過,不知大家是否有印象?
當異常發生的時候,一條包含異常的mach message,例如異常類型、發生異常的線程等等,都會被髮送到一個異常埠。而thread、task、host都會維護一組異常埠,當Mach Exception機制傳遞異常消息的時候,它會按照thread → task → host
的順序傳遞異常消息。這是通過上面的mach_exc_raise()類函數來實現的。
如果thread、task都沒有處理異常,那麼就會由host也就是操作系統內核來處理異常,操作系統處理異常的方式就是上圖Exception Handler中的流程,可以看到,handler是一個迴圈處理消息的機制,mach_msg_receive()函數負責接受消息;mach_exc_server()函數內有catch_mach_exception_raise()函數,這個函數通過ux_exception()將mach異常轉換為Unix的Signal,並通過threadsignal()將其發送到對應的線程上去。
這一系列過程中,我們可控的部分是thread,我們可以新建一條thread並且通過mach port監聽異常埠來實現崩潰的捕獲。
有時候,Debugger會在程式崩潰的時候,給出Mach異常的類型:
上述代碼試圖給一個assign類型的property賦值,由於引用計數為0,對象在賦值之後就被立刻釋放了,所以這行代碼就崩潰了
Debugger給出的標紅信息,可以這麼理解:
一些其他常見的Mach異常類型及其對應的原因如下表:
Exception | Notes |
---|---|
EXC_BAD_ACCESS | 訪問了不該訪問的記憶體 |
EXC_BAD_INSTRUCTION | 線程執行非法指令 |
EXC_ARITHMETIC | 算術異常 |
EXC_SOFTWARE | 軟體生成的異常 |
EXC_BREAKPOINT | 跟蹤或者斷點 |
關於code大家可能會存在疑惑,它代表的其實是內核函數的返回值,其中,code=1代表的是地址不可用,其定義如下:
#define KERN_INVALID_ADDRESS 1
由於code的種類有很多,其他code對應的含義,可以翻閱kern_return.h
頭文件進行查閱
以下為蘋果的崩潰日誌,裡面也包含類似信息:
Exception Type: EXC_BAD_ACCESS (SIGSEGV)
Exception Subtype: KERN_INVALID_ADDRESS at 0x00000000000000b8
將兩者進行結合,一般就可以判斷崩潰的原因究竟是什麼。瞭解以上知識相信會對大家日後解決Bug帶來一定的幫助。
Unix Signal
Signal是Unix、類Unix以及其他POSIX相容的操作系統中進程間通訊的一種有限制的方式。它是一種非同步的通知機制,用來提醒進程一個事件已經發生。信號的作用有很多,比如可以用來進程間通信(IPC)、用於Debugger調試等,當然也可以用來報告異常。
既然Mach已經實現了硬體導致的異常,為什麼還需要將其轉化為Unix Signal,繼續報告一次呢?
原因很簡單,因為xnu包含了BSD和Mach,為了實現POSIX相容,讓用戶可以使用BSD提供的POSIX API,就需要做這樣一層轉換。
Mach異常和Unix Signal兩者的轉換關係如下表:
Mach 異常 | Unix Signal | 原因 |
---|---|---|
EXC_BAD_INSTRUCTION | SIGILL | 非法指令,比如數組越界,強制解包可選形等等 |
EXC_BAD_ACCESS | SIGSEVG、SIGBUS | SIGSEVG、SIGBUS兩者都是錯誤記憶體訪問,但是兩者之間是有區別的:SIGBUS(匯流排錯誤)是記憶體映射有效,但是不允許被訪問,比如訪問一個結構體但是起始地址有誤; SIGSEVG(段地址錯誤)是記憶體地址映射都失效,比如野指針 |
EXC_ARIHMETIC | SIGFPE | 運算錯誤,比如浮點數運算異常 |
EXC_BREAKPOINT | SIGTRAP | trace、breakpoint等等,比如說使用Xcode的斷點 |
EXC_SOFTWARE | SIGABRT、SIGPIPE、SIGSYS、SIGKILL | 軟體錯誤,其中SIGABRT最為常見。 |
問1:既然Mach異常可以轉換為unix異常,而signal也是可以由我們自由處理的,那是否可以不處理Mach異常,只處理unix的signal就可以了?
答案是不行,因為某些異常,比如EXC_GUARD 異常(這是一種違反了受保護資源的防護而導致的異常,比如訪問SQLite文件的時候關閉了它的文件描述符),是沒有映射到Unix Signal的,這種異常就沒法通過signal處理。
問2:那是不是處理了Mach異常,就不需要處理signal異常了呢?
答案是也不行,因為如果底層有些異常類型只能通過signal處理,比如直接調用了 __pthread_kill
函數直接向某條線程發送了SIGABRT
這個signal,這類異常不能被Mach所捕獲
為什麼沒有通用的異常處理函數
現在我們可以回答這個問題了。總結一下,iOS系統中,崩潰有可能是以下兩種方式產生的:
- 應用級異常,比如NSException
- 硬體級異常,比如野指針訪問
對於前者,我們只能使用NSSetUncaughtExceptionHandler進行捕獲,對於後者,我們需要使用以下機制:
- Mach異常處理機制
- Unix Signal異常處理機制
因為以上兩者作用域也無法互相覆蓋,所以以上兩者也需要結合使用。
正是因為這三種處理機制覆蓋了不同的領域,並且處理機制也不盡相同,因此iOS中沒有通用的異常處理函數。
然而,事情沒有那麼簡單
上述三個函數的功能十分強大,但是實際上設計一個崩潰捕獲系統沒有那麼容易。一般來說,捕獲系統除了捕獲崩潰,還需要記錄崩潰時的現場信息,比如崩潰時的iOS系統版本、應用版本、崩潰時間、異常信息、程式堆棧等等:
{"app_name":"TP-LINK物聯","timestamp":"2023-02-16 15:40:40.00 +0800","app_version":"4.12.1","slice_uuid":"d146125f-f904-3e39-940a-0f7dd32d6071","adam_id":0,"build_version":"41201","platform":2,"bundleID":"net.tplink.surveillancesystem","share_with_app_devs":0,"is_first_party":0,"bug_type":"109","os_version":"iPhone OS 14.0.1 (18A393)","incident_id":"70E8ABFF-6F0F-4094-BF31-EE929EFA78DD","name":"TP-LINK物聯"}
Incident Identifier: 70E8ABFF-6F0F-4094-BF31-EE929EFA78DD
CrashReporter Key: 8c905de38d4cd4ff6ad692cc4ca4f6b1f41a50af
Hardware Model: iPhone12,8
Process: TP-LINK物聯 [2002]
Path: /private/var/containers/Bundle/Application/F17C1188-8ED4-4C72-8E46-FE7ABE28DDA1/TP-LINK物聯.app/TP-LINK物聯
Identifier: net.tplink.surveillancesystem
Version: 41201 (4.12.1)
Code Type: ARM-64 (Native)
Role: Foreground
Parent Process: launchd [1]
Coalition: net.tplink.surveillancesystem [564]
Date/Time: 2023-02-16 15:40:39.7011 +0800
Launch Time: 2023-02-16 15:37:15.6262 +0800
OS Version: iPhone OS 14.0.1 (18A393)
Release Type: User
Baseband Version: 2.00.01
Report Version: 104
Exception Type: EXC_BAD_ACCESS (SIGSEGV)
Exception Subtype: KERN_INVALID_ADDRESS at 0x00000000000000b8
VM Region Info: 0xb8 is not in any region. Bytes before following region: 4375183176
REGION TYPE START - END [ VSIZE] PRT/MAX SHRMOD REGION DETAIL
UNUSED SPACE AT START
--->
__TEXT 104c80000-1077ac000 [ 43.2M] r-x/r-x SM=COW ...app/TP-LINK物聯
Termination Signal: Segmentation fault: 11
Termination Reason: Namespace SIGNAL, Code 0xb
Terminating Process: exc handler [2002]
Triggered by Thread: 15
Thread 0 name: Dispatch queue: com.apple.main-thread
Thread 0:
0 libsystem_kernel.dylib 0x00000001d0bbfdd0 0x1d0bbc000 + 15824
1 libsystem_kernel.dylib 0x00000001d0bbf184 0x1d0bbc000 + 12676
2 CoreFoundation 0x00000001a4bb6cf8 0x1a4b19000 + 646392
3 CoreFoundation 0x00000001a4bb0ea8 0x1a4b19000 + 622248
4 CoreFoundation 0x00000001a4bb04bc 0x1a4b19000 + 619708
5 GraphicsServices 0x00000001bb635820 0x1bb632000 + 14368
6 UIKitCore 0x00000001a7554734 0x1a69d7000 + 12048180
7 UIKitCore 0x00000001a7559e10 0x1a69d7000 + 12070416
8 TP-LINK物聯 0x0000000104c89ff0 0x104c80000 + 40944
9 libdyld.dylib 0x00000001a4877e60 0x1a4877000 + 3680
……
在iOS系統中,如果直接在上述的崩潰處理函數中進行這些信息的記錄,並不安全,這主要是因為iOS中App被限制在一個進程中運行,如果應用崩潰,那崩潰的線程將會立刻暫停執行,那就會導致如下問題:
- 記憶體可能被破壞(比如某些數值溢出導致的崩潰,記憶體會被溢出的數據覆蓋)
- 鎖可能正在被暫停執行的線程持有著
- 數據結構可能只更新一半
這樣的不穩定環境,大部分函數都不能保證能夠正確運行,導致崩潰處理程式能夠調用的庫函數非常有限,你將無法做到:
- 通過malloc等函數分配堆記憶體
- 通過backtrace函數獲取調用棧信息
如果破解這些限制?我們不妨研究下SuperApp中集成的Breakpad是怎麼操作的。
Breakpad的整體構成
如上圖所示,Breakpad主要由三部分構成:
- symbol dumper:符號提取器。應用程式在構建的時候會包含debug相關的信息,它能夠提取這些信息並生成專屬的符號文件。
- client:客戶端是一種包含在你應用程式裡面的第三方庫,它能夠捕獲當前各線程的狀態以及當前載入的共用庫等信息,將其寫入minidump文件中。
- processor:處理器主要用來讀取minidump文件和符號文件,將其翻譯為人類可讀的格式
符號文件是程式編譯的產物,裡面會包含函數或數據的名稱、地址、大小、類型等。由於Breakpad是一個跨平臺的方案,因此沒有採用XCode編譯產生的符號表文件,而是使用了自定義的格式。minidump則是一種微軟開發的文件格式,它被用在微軟的崩潰上傳體系中,包含了可執行文件和共用庫的列表、進程中的各線程列表信息、調用棧信息等。
Breakpad如何解決上述問題
1.如何安全分配記憶體
以下為Breakpad啟動代碼:
ProtectedMemoryAllocator* gMasterAllocator = NULL;
ProtectedMemoryAllocator* gKeyValueAllocator = NULL;
ProtectedMemoryAllocator* gBreakpadAllocator = NULL;
BreakpadRef BreakpadCreate(NSDictionary* parameters) {
try {
gMasterAllocator =
new ProtectedMemoryAllocator(sizeof(ProtectedMemoryAllocator) * 2);
gKeyValueAllocator =
new (gMasterAllocator->Allocate(sizeof(ProtectedMemoryAllocator)))
ProtectedMemoryAllocator(sizeof(LongStringDictionary));
int mutexResult = pthread_mutex_init(&gDictionaryMutex, NULL);
if (mutexResult == 0) {
int breakpad_pool_size = 4096;
gBreakpadAllocator =
new (gMasterAllocator->Allocate(sizeof(ProtectedMemoryAllocator)))
ProtectedMemoryAllocator(breakpad_pool_size);
NSAutoreleasePool* pool = [[NSAutoreleasePool alloc] init];
Breakpad* breakpad = Breakpad::Create(parameters);
if (breakpad) {
gMasterAllocator->Protect();
gKeyValueAllocator->Protect();
gBreakpadAllocator->Protect();
[pool release];
return (BreakpadRef)breakpad;
}
[pool release];
}
} catch(...) {
fprintf(stderr, "BreakpadCreate() : error\n");
}
...
}
上述代碼片段已經包含了對記憶體分配問題的解決,其 核心思路是:既然崩潰時無法分配記憶體,那麼只要在崩潰前提前分配好崩潰處理程式所需的記憶體並將其保護起來避免被崩潰所破壞即可
。
ProtectedMemoryAllocator
這個類相當於一個記憶體池,它允許分配記憶體,但是分配記憶體無法被回收。此外,它還提供了一個Protect()
方法用於將記憶體池設置為只讀,這樣一來這塊記憶體就不會在崩潰發生的時候被各種原因覆蓋。
通過源碼,我們可以一窺其實現的原理,首先是構造函數:
ProtectedMemoryAllocator::ProtectedMemoryAllocator(vm_size_t pool_size)
: pool_size_(pool_size),
next_alloc_offset_(0),
valid_(false) {
kern_return_t result = vm_allocate(mach_task_self(),
&base_address_,
pool_size,
TRUE
);
valid_ = (result == KERN_SUCCESS);
assert(valid_);
}
vm_allocate
是一個內核函數,用於申請虛擬記憶體,由於Breakpad需要直接申請一塊較大的記憶體,用於整個模塊的記憶體使用,因此它直接使用了該函數,而不是malloc。該類申請的記憶體大小是由參數pool_size
決定的,記憶體分配之後,base_address_
指向記憶體池的起始地址。
再看看Protect()
方法的實現:
kern_return_t ProtectedMemoryAllocator::Protect() {
kern_return_t result = vm_protect(mach_task_self(),
base_address_,
pool_size_,
FALSE,
VM_PROT_READ);
return result;
}
其同樣調用了內核函數vm_protect
,將申請的虛擬記憶體設置為只讀,這樣就實現了記憶體的保護。
2.如何獲取調用棧信息
Breakpad內部有一個MinidumpGenerator
類專門用於寫入minidump,其中包括了我們關心的線程調用棧信息。由於涉及到minidump格式問題,我們不深入分析這個類,只是簡單介紹下原理。
首先,我們需要理解線程調用棧的結構:
線程的調用棧分為若幹棧幀(stack frame
),每個棧幀對應一個函數調用。上圖包含了兩個棧幀,DrawLine和DrawSquare。
棧幀主要由三部分組成:函數參數、返回地址、幀內的本地變數。上述DrawSquare函數調用DrawLine函數的時候,首先函數的參數入棧,然後把返回地址入棧,最後是函數內部本地變數。
這裡要註意的是,有兩個特殊的指針:Stack Pointer指向了調用棧的棧頂,Frame Pointer則指向了當前棧幀。
此外,我們還需要瞭解一下iOS系統中虛擬記憶體的相關知識:
我們知道,操作系統會對虛擬記憶體進行分頁。在iOS系統中,為了更好的管理記憶體頁,系統會將一組連續的記憶體頁關聯到一個VMObject上,也稱為VM Region。我們可以通過XCode的Instruments工具,查看當前App的虛擬記憶體分配情況,其中就包含了VM Region的相關信息:
可以看到,VM Region被分為不同的Category,其中有一種Category叫做VM Stack,其包含的就是線程調用棧的信息。
為了獲取VM Stack中的信息,Breakpad大致做了以下操作:
-
通過內核函數
task_threads
獲取當前進程的所有線程 -
通過內核函數
thread_get_state
獲取目標線程的thread_state_t
,這個結構中包含了該線程調用棧的棧頂指針Stack Point_STRUCT_ARM_THREAD_STATE64 { __uint64_t __x[29]; /* General purpose registers x0-x28 */ __uint64_t __fp; /* Frame pointer x29 */ __uint64_t __lr; /* Link register x30 */ __uint64_t __sp; /* Stack pointer x31 */ __uint64_t __pc; /* Program counter */ __uint32_t __cpsr; /* Current program status register */ __uint32_t __pad; /* Same size for 32-bit or 64-bit clients */ };
-
由Stack Pointer的地址作為起始地址,獲取下一個VM Region,如果其Category為VM Stack,將此塊記憶體的信息記錄下來,寫入minidump