你真的懂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 // 原始碼地址:https://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 //原始碼地址:https://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 /// 原始碼地址:https://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