你真的懂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