你真的懂iOS的異常捕獲嗎?

語言: CN / TW / HK

在開發的日常中,經常會遇到一些極其偶現的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 TaskProcess是有區別的,相比而言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操作”。

singhfig7-1.jpeg

疑問

因為Task是XNU的微內核Mach獨有的,這個就和我們熟知的進程,線程等等會有一些差異,所以這裏就提出了幾個問題

1、Task和進程到底是什麼關係?

首先要明確的是task和進程是一一對應的關係,從springborad打開的每一個進程,其實在內核裏都有一個task與之對應。Task只是進程資源的容器,並不具備一般進程應該擁有的功能。

2、進程和線程到底是什麼區別?

線程是資源調度的最小單位。

進程是資源分配的最小單位,而在OS X以及iOS系統中,每一個進程對應的唯一資源容器就是Task。

異常的簡述

應用通常運行在用户態的,但是當應用需要去主動使用系統調用,或者説在被動遇到一些異常或者中斷的時候,應用都會有用户態進入到內核態,這個時候相當於系統收回了應用的運行權限,它要在內核態中去做一些特殊的處理。(system calls, exceptions, and interrupts)

接下來我們要説的異常(Exception),它就會應用由用户態進入到內核態。這裏就借鑑了騰訊Bugly的一張圖來表示這種關係:

異常信號機制.jpg

但是在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,所以根本無法使用。那麼我們捕獲異常之後如何轉發給其他的端口呢?這個後面進行描述。

上述過程的具體處理流程如下圖:

截屏2022-06-06_15.25.48.png

實際上在系統啟動的時候,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]文件中。

截屏2022-07-07_11.39.59.png

異常的捕獲

捕獲異常的方式

説了這麼多異常是什麼,異常怎麼分類,那麼接下來我們具體來説説我們如何捕獲異常,但是再聊如何捕獲之前,且思考一下,我們應該採用哪種方式來捕獲呢?從上述可知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;

} ```

參考

  1. 《Mac OS X and iOS Internals To the Apple Core》
  2. Mac OS X Internals: A Systems Approach 第九章
  3. kernel源碼
  4. Android 平台 Native 代碼的崩潰捕獲機制及實現
  5. PLCrashReporter