你真的懂iOS的異常捕獲嗎?
序
在開發的日常中,經常會遇到一些極其偶現的Bug,有些Bug很難以復現,所以一般的解決方案是接入PLCrashReporter這些第三方的崩潰統計工具,從保存的崩潰文件中讀取相應的崩潰信息。那麼這些崩潰統計工具又是基於什麼原理運作的呢?我對此產生了很大的興趣,所以對此做了一些調研,以下是我的成果:
Task & Thread & Process
在談到應用崩潰之前,首先需要知道的是,iOS操作系統的內核是XNU,它是一個混合內核,而這個混合內核的核心就是Mach這個微內核。
Process
操作系統被設計作為一個平台,而應用運行在這個平台之上。每一個運行中的應用的實例都是一個進程(process)。當然,一般情況下我們描述的是用户角度的進程。和很多任務的系統一樣,一個可執行程序的一個實例就是一個進程,UNIX也是基於這個概念創建的。而每一個實例都通過一個獨有的Process ID來標識(PID),即使是同一個可執行程序的不同實例,也是有不同的PID的。而許多進程進一步可能成為進程組,通常通過向一個Group發送信息,用户可以控制多個進程。一個進程可以通過調用setpgrp(2)
來加入進程組。
而在BSD這一層,BSD Process則更為具體一些,包含了內部的多個線程,以及對應的Mach Task等等。
Task
首先要提到的就是Mach中的Task這個概念,Mach Task是系統資源的集合,每一個Task都包含了一個虛擬的地址空間(分配內存),一個端口權限名稱空間,還有一個或者幾個線程。在Mach內核中,Task是系統分配資源的基本單位。它和我們熟悉的進程的概念是非常相識的,但是Mach Task和Process是有區別的,相比而言Mach Task要提供更少的功能。在Process中,有信號、組、文件描述符等等。而Mach Task用於資源的分配和共享,它是資源的容器。
因為Mach是XNU這個混合內核中的微內核,所以Mach中的Mach Task是無法提供其他操作系統中的“進程”中的邏輯的,Mach Task僅僅提供了最重要的一些基礎的實現,作為資源的容器。
而在BSD層中,BSD的process(其實也就是iOS的進程)和Mach Task是一一對應的。
Thread
理論上,Thread是CPU調度的基本單位。iOS中的進程和POSIX 線程(pthread)是分別基於Mach task和Mach thread的頂層實現。一個線程是相當輕量級的實體,創建一個新線程和操作一個線程的開銷是非常低的。
Mach threads是在內核中被實現的,Mach thread是最基本的計算實體,它屬於且僅屬於一個Mach task,這個Mach task定義了線程的虛擬地址內存空間。值得一提的是POSIX線程模型是除Windows之外,所有的操作系統都支持的一套標準的線程API,而iOS和OS X比其他系統都要更加支持pthread
。
Mach Task是沒有自己的生命週期的,因為它並不會去執行任務,只有線程才會執行指令。當它説“task Y does X”的時候,這其實意味着“包含在task Y中的一個線程執行了X操作”。
疑問
因為Task是XNU的微內核Mach獨有的,這個就和我們熟知的進程,線程等等會有一些差異,所以這裏就提出了幾個問題
1、Task和進程到底是什麼關係?
首先要明確的是task和進程是一一對應的關係,從springborad打開的每一個進程,其實在內核裏都有一個task與之對應。Task只是進程資源的容器,並不具備一般進程應該擁有的功能。
2、進程和線程到底是什麼區別?
線程是資源調度的最小單位。
進程是資源分配的最小單位,而在OS X以及iOS系統中,每一個進程對應的唯一資源容器就是Task。
異常的簡述
應用通常運行在用户態的,但是當應用需要去主動使用系統調用,或者説在被動遇到一些異常或者中斷的時候,應用都會有用户態進入到內核態,這個時候相當於系統收回了應用的運行權限,它要在內核態中去做一些特殊的處理。(system calls, exceptions, and interrupts)
而接下來我們要説的異常(Exception),它就會應用由用户態進入到內核態。這裏就借鑑了騰訊Bugly的一張圖來表示這種關係:
但是在iOS中所有的異常都會使得應用從用户態進入到內核態嗎?
異常的分類
在所遇到的場景中,異常基本只有一種產生的原因,那就是工程師寫的代碼出現了問題,從而導致了異常的發生,引起了程序的崩潰。而產生的異常結果可以分類為兩類:一種是硬件異常,一種是軟件異常。
比如我們做了一個除0操作,這在CPU執行指令的時候出現指令異常,這就是一個hardware-generated 異常,再比如我們寫Objective-C業務的過程中,給一個不存在的對象發送了消息,在Runtime時會拋出異常,這就是software-generated 異常。當然瞭如果不做處理他們都會導致程序的崩潰,而如果要做處理,那就需要知道如何去捕獲這些異常。
這裏再重複一下:雖然都是我們寫的軟件錯誤,但是造成的異常結果卻可能是硬件異常,亦或是軟件異常,而只有硬件異常才會發生上述的用户態到內核態的轉化。
Mach Exception
Mach Exception的傳遞
在上面我們提到了硬件異常,硬件異常會產生用户態→內核態的轉化,那麼有哪些異常屬於硬件異常呢?
- 試圖訪問不存在的內存
- 試圖訪問違反地址空間保護的內存
- 由於非法或未定義的操作代碼或操作數而無法執行指令
- 產生算術錯誤,例如被零除、上溢、或者下溢
- ……
以上這些都屬於硬件異常,但是這些硬件異常和我們提到的Mach Exception有什麼關係呢?
Mach內核提供了一個基於IPC的異常處理工具,其中異常被轉化為message。當異常發生的時候,一條包含異常的mach message,例如異常類型、發生異常的線程等等,都會被髮送到一個異常端口。而線程(thread),任務(task),主機(host)都會維護一組異常端口,當Mach Exception機制傳遞異常消息的時候,它會按照thread → task → host
的順序傳遞異常消息(這三者就是線程,進程,和系統的遞進關係),如果這三個級別都沒有處理異常成功,也就是收到KERN_SUCCESS
結果,那麼內核就會終止該進程。在/osfmk/kern/exception.c
的源碼中會通過exception_trige()
方法來進行上述消息傳遞的流程,此方法內部調用exception_deliver()
往對應級別的異常端口發送信息:
```c // 源碼地址:http://opensource.apple.com/source/xnu/xnu-2050.24.15/osfmk/kern/exception.c void exception_trige( exception_type_t exception, mach_excpetion_data_t code, mach_msg_type_number_t codeCnt) { ... kern_return_t kr; ... // 1、Try to raise the exception at the activation level. // 線程級別 thread = current_thread() mutex = &thread->mutex; excp = &thread->exc_actions[exception]; kr = exception_deliver(thread, esception, code, codeCnt, excp, mutex); if (kr == KERN_SUCCESS || kr == MACH_RCV_PORT_DIED) { goto out; } .... // 2、Maybe the task level will handle it. // 進程級別 task = current_task(); mutex = &task->lock; excp = &task->exc_actions[exception]; kr = exception_deliver(thread, exception, code, codeCnt, excp, mutex); if (kr == KERN_SUCCESS || kr == MACH_RCV_PORT_DIED) { goto out; } ... // 3、How about at the host level? // 主機級別 host_priv = host_priv_self(); mutex = &host_priv->lock; excp = &host_priv->exc_actions[exception]; kr = exception_deliver(thread, exception, code, codeCnt, excp, mutex); if (kr == KERN_SUCCESS || kr == MACH_RCV_PORT_DIED) { goto out; }
// 在MAC中還有一步,那就是如果這裏啟動了KDB,那麼就使用KDB調試異常。
/*
* 4、Nobody handled it, terminate the task.
*/
(void) task_terminate(task);
.....
out: if ((exception != EXC_CRASH) && (exception != EXC_RESOURCE)) thread_exception_return(); return; } ```
如何處理Mach Exception?
既然異常發生了,那麼異常就需要得到處理。異常處理程序是異常消息的接受者,它運行在自己的線程,雖然説它可以和發生異常的線程在同一個task中(也就是同一個進程中),但是它通常運行在其他的task中,比如説一個debugger。如果一個線程想處理這個task的異常消息,那麼就需要調用task_set_exception_ports()
來註冊這個task的異常端口。這樣的話,只要這個進程出現了硬件異常最後都會轉化為Mach Exception Mesaage並傳遞給註冊的端口,從而被異常處理程序接受到,處理接收到的異常消息。以下是異常code對應具體的原因:
| Exception | Notes | | --- | --- | | EXC_BAD_ACCESS | 無法訪問內存 | | EXC_BAD_INSTRUCTION | 非法或者未定義的指令或者操作數 | | EXC_ARITHMETIC | 算術異常(例如被零除) | | EXC_EMULATION | 遇到仿真支持指令 | | EXC_SOFTWARE | 軟件生成的異常(比如浮點數計算的異常) | | EXC_BREAKPOINT | 跟蹤或者斷點(比如Xcode的斷點,就會產生異常) | | EXC_SYSCALL | Unix系統調用 | | EXC_MACH_SYSCALL | Mach系統調用 | | EXC_RPC_ALERT | RPC警告 |
當然,並不是所有的異常引發的Exception都是我們所説的異常,這其中有的是系統調用,或者斷點如EXC_SYSCALL
,所以設置異常端口的時候,就需要去考慮到這一點,如下方的myExceptionMask
局部變量存儲了需要捕獲的幾種異常類型:
```c exception_mask_t myExceptionMask; myExceptionMask = EXC_MASK_BAD_ACCESS | / Memory access fail / EXC_MASK_BAD_INSTRUCTION | / Illegal instruction / EXC_MASK_ARITHMETIC | / Arithmetic exception (eg, divide by zero) / EXC_MASK_SOFTWARE | / Software exception (eg, as triggered by x86's bound instruction) / EXC_MASK_BREAKPOINT | / Trace or breakpoint / EXC_MASK_CRASH;
// 注意:這裏必須要使用THREAD_STATE_NONE和plcrash框架中使用的保持一致 // rc = task_set_exception_ports(mach_task_self(), myExceptionMask, myexceptionPort, (EXCEPTION_DEFAULT | MACH_EXCEPTION_CODES), THREAD_STATE_NONE); ```
這裏得着重強調一下端口設置方法的參數:
c
kern_return_t task_set_exception_ports
(
task_t task,
exception_mask_t exception_mask,
mach_port_t new_port,
exception_behavior_t behavior,
thread_state_flavor_t new_flavor
);
在這之中xx_set_exception_ports()
的behavior
參數指定來發生異常時發送的異常消息的類型。
| behavior | Notes | | --- | --- | | EXCEPTION_DEFAULT | catch_exception_raise消息:包含線程標識 | | EXCEPTION_STATE | catch_exception_raise_state: 包含線程狀態 | | EXCEPTION_STATE_IDENTITY | catch_exception_raise_state_identity: 包含線程標識和狀態 |
flavour
參數指定要與異常消息一起發送的線程狀態的類型,如果不需要,可以使用THREAD_STATE_NONE
。但是要注意的是,無論線程狀態是否在異常消息中被髮送,異常處理程序都可以使用thread_get_state()
和thread_set_state()
分別查詢和設置出錯線程的狀態。
而默認情況下,線程級別的異常端口都被設置為null端口,而task級別的異常端口,會在fork()
期間被繼承,通常也是null 端口(fock其實指的是從內核fock出一個進程)。所以這個時候,壓力就來到了Host的異常端口(也就是機器級的異常端口),這裏發生了什麼呢?
接下來,我們具體看一看如果一款Mac應用當線程中發生異常時,如果我們不做任何處理,會發生什麼?(Apple自己的exception handler的處理流程)
1、內核會將錯誤線程掛起,並且發送一條消息給適合的異常端口。
2、錯誤線程保持掛起狀態,等待消息回覆。
3、exception_deliver()
方法向線程的異常端口發送消息,未得到成功回覆。
4、exception_deliver()
方法向task的異常端口發送消息,未得到成功回覆。
5、exception_deliver()
方法向host的異常端口發送消息。
3、具備接收異常端口權限的任意task中的異常處理線程將取出該消息(在Mac上一般是KDB調試程序)
4、異常處理程序調用exc_server
方法來處理該消息。
5、exc_server
根據端口設置的 behavior
參數來選擇調用什麼方法來獲取相應的線程信息:catch_exception_raise()、catch_exception_raise_state()、catch_exception_raise_state_identity()
,就是三個函數之一
6、如果上述函數處理後返回KERN_SUCCESS
,那麼exc_server()
準備返回消息發送到內核,使得線程從異常點繼續執行。如果異常不是致命的,並且通過該函數修復了問題,那麼修復線程的狀態可以使得線程繼續。
7、如果上述函數處理後返回的不是KERN_SUCCESS
,那麼內核將終止該task。
這也就是為什麼在Mac上如果Xcode崩潰之後,Mac上會出現Xcode崩潰的報告界面,同時系統會將Xcode關閉。
如果我們自己捕獲處理之後,能否直接將調用方法
exc_server
將消息繼續往後轉發呢?答案是否定的,因為在iOS中exc_server
並不是一個public的API,所以根本無法使用。那麼我們捕獲異常之後如何轉發給其他的端口呢?這個後面進行描述。
上述過程的具體處理流程如下圖:
實際上在系統啟動的時候,Host異常端口對應的異常處理程序就已經初始化好了,同時,Unix的異常處理也是在這裏初始化,它會將Mach異常轉化為Unix signals。在系統啟動時,內核的BSD層通過bsdinit_task()
方法[源碼在:bsd/kern/bsd_ init.c中]
來進行初始化的:
```c //源碼地址:http://opensource.apple.com/source/xnu/xnu-7195.81.3/bsd/kern/bsd_init.c.auto.html void bsdinit_task(void) { proc_t p = current_proc();
process_name("init", p);
/* Set up exception-to-signal reflection */
ux_handler_setup();
······
} ```
然後bsdinit_task()
它會調用ux_handler_init
(在最新的xnu-7195.81.3中為ux_handler_setup
)方法來進行設置異常監聽端口:
```c /// 源碼地址:http://opensource.apple.com/source/xnu/xnu-7195.81.3/osfmk/kern/ux_handler.c.auto.html / * setup is called late in BSD initialization from initproc's context * so the MAC hook goo inside host_set_exception_ports will be able to * set up labels without falling over. / void ux_handler_setup(void) { ipc_port_t ux_handler_send_right = ipc_port_make_send(ux_handler_port);
if (!IP_VALID(ux_handler_send_right)) {
panic("Couldn't allocate send right for ux_handler_port!\n");
}
kern_return_t kr = KERN_SUCCESS;
/*
* Consumes 1 send right.
*
* Instruments uses the RPC_ALERT port, so don't register for that.
*/
kr = host_set_exception_ports(host_priv_self(),
EXC_MASK_ALL & ~(EXC_MASK_RPC_ALERT),
ux_handler_send_right,
EXCEPTION_DEFAULT | MACH_EXCEPTION_CODES,
0);
if (kr != KERN_SUCCESS) {
panic("host_set_exception_ports failed to set ux_handler! %d", kr);
}
} ```
這裏host_set_exception_ports
方法註冊host級別的ux_exception_port
異常端口,當這個端口接受到異常信息之後,異常處理線程會調用handle_ux_exception
方法,這個方法會調用ux_exception
將mach信息轉化為signal信號,隨後會將轉化的unix signal投遞到錯誤線程:threadsignal(thread, ux_signal, code, TRUE);
具體的轉化方法如下:
```c / * Translate Mach exceptions to UNIX signals. * * ux_exception translates a mach exception, code and subcode to * a signal. Calls machine_exception (machine dependent) * to attempt translation first. / static int ux_exception(int exception, mach_exception_code_t code, mach_exception_subcode_t subcode) { int machine_signal = 0;
/* Try machine-dependent translation first. */
if ((machine_signal = machine_exception(exception, code, subcode)) != 0) {
return machine_signal;
}
switch (exception) {
case EXC_BAD_ACCESS:
if (code == KERN_INVALID_ADDRESS) {
return SIGSEGV;
} else {
return SIGBUS;
}
case EXC_BAD_INSTRUCTION:
return SIGILL;
case EXC_ARITHMETIC:
return SIGFPE;
case EXC_EMULATION:
return SIGEMT;
case EXC_SOFTWARE:
switch (code) {
case EXC_UNIX_BAD_SYSCALL:
return SIGSYS;
case EXC_UNIX_BAD_PIPE:
return SIGPIPE;
case EXC_UNIX_ABORT:
return SIGABRT;
case EXC_SOFT_SIGNAL:
return SIGKILL;
}
break;
case EXC_BREAKPOINT:
return SIGTRAP;
}
return 0;
} ```
Unix Signal
Mach已經提供了底層的異常機制,但是基於Mach exception,Apple在內核的BSD層上也建立了一套信號處理系統。這是為什麼呢?原因很簡單,其實就是為了兼容Unix系統。而基於Linux的安卓也是兼容Unix的,所以安卓的異常也是拋出的Signal。當然這裏得説明,在現代的Unix系統中,Mach異常只是導致信號生成的一類事件,還有很多其他的事件可能也會導致信號的生成,比如:顯式的調用kill(2)或者killpg(2)、子線程的狀態變化等等。
信號機制的實現只要是兩個重要的階段:信號生成和信號傳遞。信號生成是確保信號被生成的事件,而信號傳遞是對信號處理的調用,即相關信號動作的執行。而每一個信號都有一個默認動作,在Mac OS X上可以是以下事件:
1、終止異常進程
2、Dump core終止異常進程
3、暫停進程
4、如果進程停止,繼續進程;否則忽略
5、忽略信號
當然這些都是信號的默認處理方法,我們可以使用自定義的處理程序來重寫信號的默認處理方法,具體來説可以使用sigaction
來自定義,詳細的代碼實例我們在後續的捕獲信號的demo中有描述。
Mach Exception轉化為Signal
Mach異常如果沒有在其他地方(thread,task)得到處理,那麼它會在ux_exception()
中將其轉化為對應的Unix Signal信號,以下是兩者之間的轉化:
| Mach Exception | Unix Signal | 原因 | | --- | --- | --- | | EXC_BAD_INSTRUCTION | SIGILL | 非法指令,比如除0操作,數組越界,強制解包可選形等等 | | EXC_BAD_ACCESS | SIGSEVG、SIGBUS | SIGSEVG、SIGBUS兩者都是錯誤內存訪問,但是兩者之間是有區別的:SIGBUS(總線錯誤)是內存映射有效,但是不允許被訪問; SIGSEVG(段地址錯誤)是內存地址映射都失效 | | EXC_ARIHMETIC | SIGFPE | 運算錯誤,比如浮點數運算異常 | | EXC_EMULATION | SIGEMT | hardware emulation 硬件仿真指令 | | EXC_BREAKPOINT | SIGTRAP | trace、breakpoint等等,比如説使用Xcode的斷點 | | EXC_SOFTWARE | SIGABRT、SIGPIPE、SIGSYS、SIGKILL | 軟件錯誤,其中SIGABRT最為常見。 |
Mach異常轉化為了Signal信號並不代表Mach異常沒有被處理過。有可能存在線程級或者task級的異常處理程序,它將接受異常消息並處理,處理完畢之後將異常消息轉發給ux_exception()
這也將導致最終異常轉化為Signal。
軟件異常轉化為Signal
除了上述引發CPU Trap的異常之外,還有一類異常是軟件異常,這一類異常並不會讓進程進入內核態,所以它也並不會轉化為Mach Exception,而是會直接轉化為Unix Signal。而由Objective-C產生的異常就是軟件異常這一類,它將直接轉換為Signal信號,比如給對象發送未實現的消息,數組索引越界直接引發SIGABRT信號,作為對比Swift的數組異常會導致CPU Trap,轉化為EXC_BAD_INSTRUCTION異常消息。
那為什麼Objective-C異常只是軟件異常,而不會觸發CPU Trap?
因為Objective-C寫的代碼都是基於Runtime運行的,所以異常發生之後,直接會被Runtime處理轉化為Unix Signal,同時,對於這類異常,我們可以直接使用NSSetUncaughtExceptionHandler
設置處理方法,即使我們設置了處理方法,OC異常依舊會被轉發為信號,同時值得説明的是註冊Signal的處理程序運行於的線程,以及NSSetUncaughtExceptionHandler
的處理程序運行於的線程,就是異常發生的線程,也就是哪個線程出錯了,由哪個線程來處理。
Mach Exception和Unix Signal的區別
Mach Exception的處理機制中異常處理程序可以在自己創建的處理線程中運行,而該線程和出錯的線程甚至可以不在一個task中,即可以不在一個進程中,因此異常處理不需要錯誤線程的資源來運行,這樣可以在需要的時候直接獲得錯誤線程的異常上下文,而Unix Signal的處理無法運行在其他的線程,只能在錯誤線程上處理,所以Mach異常處理機制的優勢是很明顯的,比如説debugging場景,我們平時打斷點的時候,其實程序運行到這裏的時候會給Xcode這個task中的註冊異常端口發EXC_BREAKPOINT消息,而Xcode收到之後,就會暫停在斷點處,在處理完之後(比如點擊跳過斷點),將發送消息返回到Xcode,Xcode也將繼續跑下去。
這也是Mach Exception處理機制的優勢,它可以在多線程的環境中很好的運行,而信號機制只能在出錯線程中運行。而其實Mach異常處理程序可以以更細粒度的方式來運行,因為每一種Mach異常消息都可以有自己的處理程序,甚至是每一個線程,每一個Task單獨處理,但是要説明的是,線程級的異常處理程序通常適用於錯誤處理,而Task級的異常處理程序通常適用於調試。
那麼Unix Signal的優勢是什麼呢?就是全!無論是硬件異常還是軟件異常都會被轉化為Signal。
在《Mac OS X and iOS Internals To the Apple Core》這本書中提到:為了統一異常處理機制,所有的用户自身產生的異常並不會直接轉化為Unix信號,而是會先下沉到內核中轉化為Mach Exception,然後再走Mach異常的處理流程,最後在host層轉化為UnixSignal信號。
但是我是不同意這個觀點的,因為在我註冊的Task級別的異常處理程序中並不會捕獲Objective-C產生的異常(如數組越界),它是直接轉化為SIGABRT的。而軟件異常產生的Signal,實際上都是由以下兩個API:kill(2)或者pthread_kill(2)之一生成的異常信號,而我這兩個方法的源碼中並沒有看到下沉到內核中的代碼,而是直接轉化為Signal並投遞異常信號。流程如下圖所示,其中psignal()
方法以及psignal_internal()
方法的源碼都在[/bsd/kern/kern_sig.c]文件中。
異常的捕獲
捕獲異常的方式
説了這麼多異常是什麼,異常怎麼分類,那麼接下來我們具體來説説我們如何捕獲異常,但是再聊如何捕獲之前,且思考一下,我們應該採用哪種方式來捕獲呢?從上述可知Mach Exception異常處理機制只能捕獲硬件異常,而Unix異常處理機制都能捕獲,所以大抵有兩種方式可以選擇:
1、Unix Signal
2、Mach Exception and Unix Signal
微軟有一個非常著名的崩潰統計框架PLCrashReport ,這個框架也是提供了兩種統計崩潰的方案:
objectivec
typedef NS_ENUM(NSUInteger, PLCrashReporterSignalHandlerType) {
PLCrashReporterSignalHandlerTypeBSD = 0, /// 一種是BSD層,也就是Unix Signal方式
PLCrashReporterSignalHandlerTypeMach = 1 /// 一種是Mach層,也就是Mach Exception方式
}
對於第二種方案,如果看網上很多文章,都説提到到PLCrashReport這個庫中説:
We still need to use signal handlers to catch SIGABRT in-process. The kernel sends an EXC_CRASH mach exception to denote SIGABRT termination. In that case, catching the Mach exception in-process leads to process deadlock in an uninterruptable wait. Thus, we fall back on BSD signal handlers for SIGABRT, and do not register for EXC_CRASH.
意思就是説,如果不捕獲SIGABRT 信號,那麼Mach Exception接到EXC_CRASH消息會發生進程的死鎖,但是我不認可這個觀點,原因如下:
1、在我自己測試Demo的過程中,發現需要捕獲SIGABRT 信號的原因是軟件異常並不會下沉到Mach內核轉化為Signal,而是會直接發出SIGABRT 信號,所以需要捕獲。
2、即使我在task的task_set_exception_ports
方法中設置了需要捕獲EXC_CRASH異常,當異常發生時也不會出現死鎖的情況。
3、如果看BSD層中將Mach異常轉化為Signal的源碼中ux_exception
方法的具體實現,會發現根本就不會處理EXC_CRASH的情況,正如上述列表中的Mach Exception和Unix Signal的對應關係
所以我的結論是捕獲SIGABRT信號,只是因為軟件異常並不會造成Mach Exception,而是直接會被轉化SIGABRT信號,並向錯誤線程投遞。也就是説:只採用Mach Exception無法捕獲軟件異常,所以需要額外捕獲SIGABRT信號。 那麼具體來説如何捕獲呢?
捕獲異常的實踐——Unix Signal
```objectivec // 1、首先是確定註冊哪些信號 + (void)signalRegister { ryRegisterSignal(SIGABRT); ryRegisterSignal(SIGBUS); ryRegisterSignal(SIGFPE); ryRegisterSignal(SIGILL); ryRegisterSignal(SIGPIPE); ryRegisterSignal(SIGSEGV); ryRegisterSignal(SIGSYS); ryRegisterSignal(SIGTRAP); }
// 2、實際的註冊方法:將信號和action關聯,此處我的處理方法為rySignalHandler static void ryRegisterSignal(int signal) { struct sigaction action; action.sa_sigaction = rySignalHandler; action.sa_flags = SA_NODEFER | SA_SIGINFO; sigemptyset(&action.sa_mask); sigaction(signal, &action, 0); }
// 3、實現具體的異常處理程序 static void rySignalHandler(int signal, siginfo_t info, void context) { NSMutableString *mstr = [[NSMutableString alloc] init]; [mstr appendString:@"Signal Exception:\n"]; [mstr appendString:[NSString stringWithFormat:@"Signal %@ was raised. \n", signalName(signal)]];
// 因為註冊了信號崩潰回調方法,系統回來調用
for (NSUInteger index = 0; index < NSThread.callStackSymbols.count; index ++) {
NSString *str = [NSThread.callStackSymbols objectAtIndex:index];
[mstr appendString:[str stringByAppendingString:@"\n"]];
}
[mstr appendString:@"threadInfo: \n"];
[mstr appendString:[[NSThread currentThread] description]];
NSString *path = [NSString stringWithFormat:@"%@/Library/signal.txt",NSHomeDirectory()];
[mstr writeToFile:path atomically:true encoding:NSUTF8StringEncoding error:nil];
exit(-1);
} ```
上面的流程很簡單,我會在收到Signal信號之後,由錯誤線程來執行異常處理程序,執行完畢之後,使用exit(-1)
強制退出。
問題一:如果只是執行一個寫入文件的操作之後不退出即不執行exit(-1)
會發生什麼?
它將會導致該出錯線程執行完寫入文件的操作之後,繼續執行的時候依然出現異常,依然會拋出信號,然後又會拋給該線程處理異常,於是變成了一個死循環,導致一直在將錯誤信息寫入文件。
問題二:如果不想使用exit(-1)
又想正常工作,應該如何做呢?
```objectivec // 1、首先取消掉所有綁定的action // 2、然後處理完之後使用raise(signal) 將信號發給進程做默認處理 static void rySignalHandler(int signal, siginfo_t info, void context) { [Signal unRegisterSignal];
...
raise(signal);
}
static int monitored_signals[] = {SIGABRT, SIGBUS, SIGFPE, SIGILL, SIGPIPE, SIGSEGV, SIGSYS, SIGTRAP}; static int monitored_signals_count = (sizeof(monitored_signals) / sizeof(monitored_signals[0]));
- (void)unRegisterSignal {
for (int i = 0; i < monitored_signals_count; i++) {
struct sigaction sa;
memset(&sa, 0, sizeof(sa)); sa.sa_handler = SIG_DFL; sigemptyset(&sa.sa_mask); sigaction(monitored_signals[i], &sa, NULL);
} } ```
上述方案其實是模仿的PLCrashReport
框架中的寫法,建議閲讀相關源碼。
問題三:如果錯誤線程是子線程,然後Signal投遞到子線程處理,這個時候影響主線程嗎?
不影響,因為Signal異常處理程序在錯誤線程運行,這個和主線程無關,當然,如果錯誤線程是主線程,那就另當別論了。
捕獲異常的實踐——Mach Exception + Unix Signal
相對而言使用Mach Exception的異常處理機制要稍微複雜一些,Unix Signal的捕獲上述已經提到了,接下來就是Mach Exception異常的捕獲了。
```objectivec + (void)setupMachHandler { kern_return_t rc;
// 1、分配端口
rc = mach_port_allocate(mach_task_self(), MACH_PORT_RIGHT_RECEIVE, &myexceptionPort);
if (rc != KERN_SUCCESS) {
NSLog(@"聲明異常端口沒有成功");
}
// 2、添加mach_send的權限
rc = mach_port_insert_right(mach_task_self(), myexceptionPort, myexceptionPort, MACH_MSG_TYPE_MAKE_SEND);
if (rc != KERN_SUCCESS) {
NSLog(@"添加權限失敗");
}
exception_mask_t myExceptionMask;
// 3、設置需要接受哪些異常信息
myExceptionMask = EXC_MASK_BAD_ACCESS | /* Memory access fail */
EXC_MASK_BAD_INSTRUCTION | /* Illegal instruction */
EXC_MASK_ARITHMETIC | /* Arithmetic exception (eg, divide by zero) */
EXC_MASK_SOFTWARE | /* Software exception (eg, as triggered by x86's bound instruction) */
EXC_MASK_BREAKPOINT | /* Trace or breakpoint */
EXC_MASK_CRASH;
// 4、task_set_exception_ports設置task級別的異常端口
rc = task_set_exception_ports(mach_task_self(),
myExceptionMask,
myexceptionPort,
(EXCEPTION_DEFAULT | MACH_EXCEPTION_CODES),
THREAD_STATE_NONE);
// 5、初始化異常處理線程,並設置異常處理方法。
pthread_t thread;
pthread_create(&thread, NULL, exc_handler, NULL);
}
// 6、異常處理程序 // 類似RunLoop的思路,使用一個while-true循環來保證線程不會退出,同時使用mach_msg來一直接收消息 static void exc_handler(void ignored) { mach_msg_return_t rc;
// 自定義一個消息體
typedef struct {
mach_msg_header_t Head; /* start of the kernel processed data */
mach_msg_body_t msgh_body;
mach_msg_port_descriptor_t thread;
mach_msg_port_descriptor_t task; /* end of the kernel processed data */
NDR_record_t NDR;
exception_type_t exception;
mach_msg_type_number_t codeCnt;
integer_t code[2];
int flavor;
mach_msg_type_number_t old_stateCnt;
natural_t old_state[144];
kern_return_t retcode;
} Request;
Request exc;
exc.Head.msgh_size = 1024;
exc.Head.msgh_local_port = myexceptionPort;
while (true) {
rc = mach_msg(&exc.Head,
MACH_RCV_MSG | MACH_RCV_LARGE,
0,
exc.Head.msgh_size,
exc.Head.msgh_local_port, // 這是一個全局的變量
MACH_MSG_TIMEOUT_NONE,
MACH_PORT_NULL);
if (rc != MACH_MSG_SUCCESS) {
NSLog(@"沒有成功接受到崩潰信息");
break;
}
// 將異常寫入文件(當然, 你也可以做自己的自定義操作)
break;
}
exit(-1);
} ```
代碼很容易理解,收到異常之後就會執行相應的處理代碼,處理完異常之後執行exit(-1)
退出應用。依然是問自己幾個問題:
問題一:不做exit(-1)操作會發生什麼,異常會不停寫入嗎?
不然,因為這裏接收到異常消息之後,就沒有對外轉發了,只會停留在task這一級,但是由於異常線程沒有得到恢復,所以表現出來的狀態就是異常線程阻塞。
問題二:不做exit(-1),異常線程是子線程,會對主線程有影響嗎?
不會,它只會阻塞異常線程,對主線程沒有影響。換言之,UI事件正常響應。
問題三:Mach Exception收到消息處理之後就不會向外轉發了,那如果想轉發呢?
可以向原端口回覆你的處理結果,這就會由系統默認向上轉發,最終轉化為Unix信號。
```objectivec static void exc_handler(void ignored) { mach_msg_return_t rc;
// 自定義一個消息體
typedef struct {
mach_msg_header_t Head; /* start of the kernel processed data */
mach_msg_body_t msgh_body;
mach_msg_port_descriptor_t thread;
mach_msg_port_descriptor_t task; /* end of the kernel processed data */
NDR_record_t NDR;
exception_type_t exception;
mach_msg_type_number_t codeCnt;
integer_t code[2];
int flavor;
mach_msg_type_number_t old_stateCnt;
natural_t old_state[144];
kern_return_t retcode;
} Request;
....
// 處理完消息之後,我們回覆處理結果
Request reply;
memset(&reply, 0, sizeof(reply));
reply.Head.msgh_bits = MACH_MSGH_BITS(MACH_MSGH_BITS_REMOTE(exc.Head.msgh_bits), 0);
reply.Head.msgh_local_port = MACH_PORT_NULL;
reply.Head.msgh_remote_port = exc.Head.msgh_remote_port;
reply.Head.msgh_size = sizeof(reply);
reply.NDR = NDR_record;
reply.retcode = KERN_SUCCESS;
/*
* Mach uses reply id offsets of 100. This is rather arbitrary, and in theory could be changed
* in a future iOS release (although, it has stayed constant for nearly 24 years, so it seems unlikely
* to change now). See the top-level file warning regarding use on iOS.
*
* On Mac OS X, the reply_id offset may be considered implicitly defined due to mach_exc.defs and
* exc.defs being public.
*/
reply.Head.msgh_id = exc.Head.msgh_id + 100;
mach_msg(&reply.Head,
MACH_SEND_MSG,
reply.Head.msgh_size,
0,
MACH_PORT_NULL,
MACH_MSG_TIMEOUT_NONE,
MACH_PORT_NULL);
return NULL;
} ```
參考
- 《Mac OS X and iOS Internals To the Apple Core》
- Mac OS X Internals: A Systems Approach 第九章
- kernel源碼
- Android 平台 Native 代碼的崩潰捕獲機制及實現
- PLCrashReporter