CPU發生異常到生成Crash Log的過程

1、什麼是異常

不少介紹操做系統的書在講解操做系統的運行機制的時候都會提到「現代操做系統是靠中斷驅動的軟件」,這句話怎麼理解?html

中斷是指CPU對系統發生的某個事件作出的一種反應,CPU暫停正在執行的程序,保留現場後轉去執行相應的處理程序,處理完該事件後再返回斷點繼續執行被「打斷」的程序。git

而引入中斷技術的初衷是提升多道程序運行環境中CPU的利用率,好比CPU能夠在I/O的執行過程當中去執行其餘指令,不用空閒地去等待(或簡單輪詢) I/O設備的執行完成,I/O設備執行完成再經過中斷通知CPU,以提升CPU利用率。後來中斷技術逐步發展,成爲操做系統各項操做的基礎,好比進程調度,現代操做系統的進程調度通常都是採用基於時間片的優先級調度算法,把CPU的時間劃分爲很細粒度的時間片,執行一個任務的時間片用完了,時鐘經過時鐘中斷去通知CPU切換任務,再好比下面要討論到的CPU異常處理,也是基於中斷機制去完成的。github

中斷(interrupt)和異常(exception)在不一樣的CPU架構裏有不一樣的含義。算法

  • 好比在Intel架構中,中斷處理的入口由操做系統內核中的中斷分配表定義(interrupt dispatch table, IDT),IDT中有255箇中斷向量,其中前20個定義爲異常(exception)的處理入口,即中斷包含異常。
  • 而在ARM架構中,中斷處理的入口則是在異常向量(exception vector)中,8個異常向量裏邊有3個是中斷相關的,即異常包含中斷。

無論如何界定中斷和異常,CPU發生異常時,都會將控制權從異常前的程序交給異常處理程序,並且CPU將得到 不會更低 的執行權利,好比執行用戶態的應用程序發生異常,CPU將切換到內核態,並執行對應的異常處理程序。經典的CPU五級流水線中一條指令的生命週期爲[取指、譯碼、執行、訪存、寫回],每一個階段均可能出現CPU異常,好比在ARM架構下:數組

  • 在「執行」階段產生的「數據停止」異常:若處理器 數據訪問指令 的地址不存在,或該地址不容許當前指令訪問時,產生數據停止異常。
  • 在「取指」階段產生的」預取停止「異常:若處理器 預取指令 的地址不存在,或該地址不容許當前指令訪問,存儲器會向處理器發出停止信號,但當預取的指令被執行時,纔會產生指令預取停止異常。

這兩種異常對應的處理程序會直接或者間接調用 Mach 內核的 exception_triage() 函數,並將 EXC_BAD_ACCESS 做爲入參傳進去,exception_triage() 將會利用Mach消息傳遞機制投遞異常。儘管Intel架構和ARM架構的CPU異常處理有些不一樣,但異常處理程序都會直接或間接將異常類型(exception_type_t)傳給exception_triage()函數來處理異常,以此來屏蔽不一樣機器平臺異常處理的差別。緩存

異常類型(exception_type_t)在Mach層用int變量來存儲,在osfmk/mach/exception_types.h文件中能看到Mach層定義的十幾種異常,如常見的bash

#define EXC_BAD_ACCESS 1 /* Could not access memory */
        /* Code contains kern_return_t describing error. */
        /* Subcode contains bad memory address. */
#define EXC_CRASH 10 /* Abnormal process exit */
#define EXC_CORPSE_NOTIFY 13 /* Abnormal process exited to corpse state */
複製代碼
int main(int argc, const char * argv[]) {
    int *pi = (int*)0x00001111;
    *pi = 17;
    return 0;
}
複製代碼

上面這個程序中的非法內存訪問將會用到上面列舉三個異常類型,下面經過看源碼、看書、代碼調試來看下exception_triage() 函數都作了什麼。架構

2、調試跟蹤CPU異常

在《深刻解析Mac OS & iOS 操做系統》中有講解xnu異常處理的過程,但不是特別詳細,並且書的參考代碼與最新代碼也有出入,要把內核的異常處理流程弄清楚,須要看書、看源碼,固然少不了斷點調試。app

2.1 調試xnu

在MacOS上調試XNU要比在iOS上調試簡單,使用到的工具是:LLDB + VMware Fusion + Kernel Debug Kit ,調試環境的搭建只需簡單幾個步驟便可,可參考 《MacOS內核調試環境搭建》 ,iOS上的調試能夠參考lan beer 分享的 build your own iOS kernel debugger,連接裏有分享的PPT和PoC ,惋惜目前的Poc僅支持iOS 11.1.2框架

這裏記錄個在MacOS上調試XNU的坑,若是虛擬機到達「wait for the debugger」 階段,而且在主機經過「kdp-remote」 鏈接虛擬機成功,但虛擬機繼續啓動的過程當中一直卡在「Waiting for link to become available」,致使調試沒法繼續,就像這個帖子中描述的問題同樣

雖然我也沒找到問題的具體緣由,但摸出了個解決辦法,就是在虛擬機啓動時同時按下Option、Command、P 和 R,以reset NVRAM,將會進入到恢復模式,使用終端工具關閉虛擬機的SIP ,即輸入命令csrutil disable,而後重啓,啓動後再走一遍 「內核替換」-> "設置boot-args" -> "清除kext緩存" -> "重啓虛擬機" -> "主機鏈接虛擬機" 的流程,這時將會有百分之七十的機率能讓虛擬機正常啓動並可調試,若是不行就再試一次。

注:我使用的MacOs 版本是10.13.5,對應的XNU是4570.61.1,對應版本的源碼沒有放出,對比了前幾個版本,我須要參考的源碼都沒有變更,因此參考源碼是github上的xnu-4570.1.46

2.2 跟蹤CPU異常

int main(int argc, const char * argv[]) {
    char c = getchar();
    int *pi = (int*)0x00001111;
    *pi = 17;
    return 0;
}
複製代碼

首先使用gcc來把上面這個程序編譯成二進制可執行程序,而後運行。在程序等待鍵盤輸入的時候,能夠用ps命令查看進程PID是352。

在運行程序以前我在osfmk/kern/exception.cexception_triage_thread() 函數實現處打了三個斷點

breakpoint set --file exception.c --line 447
breakpoint set --file exception.c --line 459
breakpoint set --file exception.c --line 472
複製代碼

44七、45九、472 分別是往 thread 層、task 層、host 層的異常端口數組投遞異常,對應如下三行代碼

(447)kr = exception_deliver(thread, exception, code, codeCnt, thread->exc_actions, mutex);
(459)kr = exception_deliver(thread, exception, code, codeCnt, task->exc_actions, mutex);
(472)kr = exception_deliver(thread, exception, code, codeCnt, host_priv->exc_actions, mutex);
複製代碼

這三個斷點只有一個斷住了,那就是第472 行代碼,到這裏能夠驗證如下結論

首先經過lldb在終端輸出函數調用棧、線程狀態、進程PID

(lldb) bt
* thread #1, stop reason = breakpoint 4.1
  * frame #0: 0xffffff800f97f0c9 kernel.development`exception_triage_thread(exception=1, code=0xffffff8014debf50, codeCnt=2, thread=0xffffff801c7c2a10) at exception.c:472 [opt]
    frame #1: 0xffffff800fad71fb kernel.development`user_trap [inlined] exception_triage(code=0x0000000000000001) at exception.c:504 [opt]
    frame #2: 0xffffff800fad71df kernel.development`user_trap [inlined] i386_exception(exc=1, code=<unavailable>) at trap.c:1152 [opt]
    frame #3: 0xffffff800fad71d7 kernel.development`user_trap [inlined] user_page_fault_continue(kr=<unavailable>) at trap.c:232 [opt]
    frame #4: 0xffffff800fad71d1 kernel.development`user_trap(saved_state=0xffffff8017246b20) at trap.c:1093 [opt]
    frame #5: 0xffffff800f921102 kernel.development`hndl_alltraps + 226
(lldb) e struct proc *$p_proc = (struct proc *)thread->task->bsd_info
(lldb) po $p_proc->p_pid
352
(lldb) po thread->state
4

複製代碼
(注:線程狀態用int變量存儲,int state ,#define TH_SUSP 0x02 /*中止,或請求中止*/)
複製代碼

以上log結合源碼和《深刻解析Mac OS & iOS 操做系統》能夠得出結論:

在Intel架構上,CPU執行用戶態程序發生異常時會將對應進程掛起,並將CPU工做狀態設置爲內核態,還將執行XNU內核的異常處理程序。大多數操做系統都不會爲每個陷阱(異常)設置獨立的處理程序,而是爲全部的陷阱設置一個處理程序,而後這個處理程序經過switch()進行不一樣的處理,或者根據預約義的表跳轉到不一樣的函數。XNU的作法也是如此,hndl_alltraps是公共陷阱處理程序,user_trap負責處理實際的陷阱,hndl_alltraps是用匯編語言寫的,而user_trap 是用C語言寫的,在user_trap 的實現裏會調用i386_exception函數 ,i386_exception函數會調用exception_triage將陷阱轉換爲Mach 異常,在上面的程序中Mach 異常是 EXC_BAD_ACCESS

exception_triage()函數的實現只有兩行代碼

kern_return_t
exception_triage(
    exception_type_t    exception,
    mach_exception_data_t   code,
    mach_msg_type_number_t  codeCnt)
{
    thread_t thread = current_thread();
    return exception_triage_thread(exception, code, codeCnt, thread);
}
複製代碼

第一行獲取當前線程,這是由於第二行調用 exception_triage_thread 把異常投遞到異常端口時須要用到current thread,thread、task的異常端口數組都須要經過 thread 獲取到:

thread->exc_actions;
task = thread->task;
task->exc_actions;

host_priv = host_priv_self();
host_priv->exc_actions;
複製代碼

而thread、task的異常端口默認是NULL,host的異常端口是第一個用戶態進程 launchd(PID 1)初始化的時候就設置好的了,並且內核初始化成功後全部的用戶態進程都是launchd 的子進程,子進程經過父進程fork繼承了父進程的異常端口,所以全部的用戶態進程出現異常時,異常都能在host層獲得統一處理。

launchd 進程是如何設置host的異常端口的?接受到異常消息如何處理?

內核初始化的過程當中,第一個用戶態進程launchd 是在bsdinit_task()函數裏啓動的,在啓動launchd 進程前經過調用host_set_exception_ports() 函數,把全部的Mach 異常消息都定向到端口ux_exception_port,這個端口由一個內核線程持有,這個內核線程裏執行的ux_handle()函數,這個函數裏會在一個死循環裏調用mach_msg_receive()來接受ux_exception_port端口上的消息,並且mach_msg_receive() 會阻塞線程。

ux_handle()函數裏接受到Mach消息後,會調用mach_exc_server(),而mach_exc_server 會調用下面的handlers ,具體調用哪一個由參數 exception_behavior_t behavior決定,該參數是設置異常端口時調用host_set_exception_ports()傳入的

catch_mach_exception_raise() 對應 EXCEPTION_DEFAULT  1  ,表示 xx
catch_mach_exception_raise_state() 對應 define EXCEPTION_STATE  2 ,表示
catch_mach_exception_raise_state_identity() 對應 define EXCEPTION_STATE_IDENTITY  3,表示
複製代碼

catch_mach_exception_raise()這些handle 會調用 ux_exception()將Mach異常轉換成Unix信號,好比 EXC_BAD_ACCESS將會轉換成 SIGSEGVSIGBUS ,如代碼所示

static
void ux_exception(
        int         exception,
        mach_exception_code_t   code,
        mach_exception_subcode_t subcode,
        int         *ux_signal,
        mach_exception_code_t   *ux_code)
{
    switch(exception) {

    case EXC_BAD_ACCESS:
        if (code == KERN_INVALID_ADDRESS)
            *ux_signal = SIGSEGV;
        else
            *ux_signal = SIGBUS;
        break;
    ....
    }
    ....
}

複製代碼

catch_mach_exception_raise() 裏拿到Mach異常對應的Unix信號後會再調用 threadsignal()投遞Unix信號,在threadsignal 的實現裏經過幾層函數調用,最後會調用到act_set_astbsd() ,在該函數裏設置了AST(異步軟件中斷)信號

void
act_set_astbsd(
    thread_t    thread)
{
    act_set_ast( thread, AST_BSD );
}
複製代碼

AST 是人工引起的非硬件觸發的陷阱,AST 是內核操做的關鍵部分,並且是調度事件的底層機制,也是BSD信號(Unix信號)投遞的實現基礎。當系統從一個陷阱返回時(return_from_trap),系統不會當即返回用戶態,而是要檢查線程的ast字段以判斷是否存在AST 須要處理。如代碼所示,此時AST的標誌位是 AST_BSD,此標誌位對應的handler 是bsd_ast() 函數。這時若是在exception_triage()下了斷點,斷點將會被斷住,此時能夠經過lldb在終端輸出 函數調用棧、進程PID、線程狀態

(lldb) bt
* thread #1, stop reason = breakpoint 1.17
  * frame #0: 0xffffff800fe75fc9 kernel.development`proc_prepareexit [inlined] exception_triage(exception=10, code=0x000000000b100001, codeCnt=2) at exception.c:504 [opt]
    frame #1: 0xffffff800fe75fbc kernel.development`proc_prepareexit [inlined] task_exception_notify(exception=10, exccode=185597953, excsubcode=4369) at exception.c:547 [opt]
    frame #2: 0xffffff800fe75f96 kernel.development`proc_prepareexit(p=0xffffff8018d90b60, rv=<unavailable>, perf_notify=1) at kern_exit.c:889 [opt]
    frame #3: 0xffffff800fe75d86 kernel.development`exit_with_reason(p=0xffffff8018d90b60, rv=11, retval=<unavailable>, thread_can_terminate=1, perf_notify=1, jetsam_flags=<unavailable>, exit_reason=<unavailable>) at kern_exit.c:830 [opt]
    frame #4: 0xffffff800fe90675 kernel.development`postsig_locked(signum=11) at kern_sig.c:3140 [opt]
    frame #5: 0xffffff800fe90b07 kernel.development`bsd_ast(thread=<unavailable>) at kern_sig.c:3420 [opt]
    frame #6: 0xffffff800f973e44 kernel.development`ast_taken_user at ast.c:207 [opt]
    frame #7: 0xffffff800f9211bc kernel.development`return_from_trap + 172
(lldb) e struct proc *$proc_1 = (struct proc *)thread->task->bsd_info
(lldb) po $proc_1->p_pid
478
(lldb) po thread->state
4
複製代碼

能夠看到bsd_ast() 將會調用postsig_locked()數,從/bsd/kern/kern_sig.c postsig_locked()的實現可知,若是當前進程沒有設置 sigaction 捕獲Unix信號的話,默認處理是調用 exit_with_reason()exit_with_reason()間接調用task_exception_notify()task_exception_notify()的做用是通知launchd 去啓動ReportCrash 生成CrashLog,通知的方式也是經過Mach消息傳遞機制,因此斷點會在exception_triage()斷住。

launchd 在初始化的過程當中設置了異常端口,而且將 MachExceptionHandler 設置爲/System/Library/CoreServices/ReportCrash (iOS中的路徑),ReportCrash將會生成Crash Log。前面說了 exception_triage調用 exception_triage_thread()投遞異常,而exception_triage_thread()函數裏執行異常投遞的函數是exception_deliver(),查看上面log中的frame #0能夠看到函數入參exception=10 (EXC_CRASH),這是斷點第二次在這斷住,第一次斷住是CPU異常轉成Mach異常的時候,當時的exception=1 (EXC_BAD_ACCESS)exception_deliver()函數將會利用入參 exception 從異常數組中取出具體的異常端口,因此第一次投遞異常(CPU異常轉Mach異常)和第二次投遞異常給ReportCrash不會衝突。

此時再斷點放掉,在 exception_triage_thread()處將會再出現一次斷點

(lldb) bt
* thread #1, stop reason = breakpoint 2.1
  * frame #0: 0xffffff800f97ef47 kernel.development`exception_triage_thread(exception=13, code=0xffffff806fce3e40, codeCnt=2, thread=0xffffff801cded250) at exception.c:445 [opt]
    frame #1: 0xffffff800f9acffe kernel.development`task_deliver_crash_notification(task=0xffffff801d9af000, thread=0xffffff801cded250, etype=<unavailable>, subcode=<unavailable>) at task.c:1798 [opt]
    frame #2: 0xffffff800f9b6537 kernel.development`thread_terminate_self at thread.c:594 [opt]
    frame #3: 0xffffff800f9bab30 kernel.development`thread_apc_ast(thread=0xffffff801cded250) at thread_act.c:934 [opt]
    frame #4: 0xffffff800f973e6b kernel.development`ast_taken_user at ast.c:220 [opt]
    frame #5: 0xffffff800f9211bc kernel.development`return_from_trap + 172
複製代碼

能夠從函數調用棧看出,這也是設置AST 致使的,此時的exception=13(EXC_CORPSE_NOTIFY),表示進程狀態是殭屍狀態,也就至關於死了。

經過打斷點能夠看出一個用戶態應用程序非法訪問內存致使的CPU異常,將會依次用到 EXC_BAD_ACCESS、EXC_CRASH、EXC_CORPSE_NOTIFY這三個Mach異常類型。

2.3 小結

CPU異常 -> Mach異常 -> BSD層的Unix信號 -> 用戶態App Handler / 系統生成Crash Log 的流程能夠簡單粗略地畫一個圖

s

3、異常收集

雖然iOS \ macOS 都提供了 ReportCrash用來收集Crash 信息,Debug模式下也提供了 lldb 的debugserver 捕獲程序異常,但App 發版上架後出現Crash 不方便開發者收集,好比在iOS上須要用戶容許與開發者共享分析數據,開發者才能夠從 iTunes Connect 查看到Crash 上報信息,否則則要拿到發生Crash的設備才能查看到Crash信息。

爲了方便快速定位、解決Crash,能夠借鑑 ReportCrash 或 debugserver 捕獲異常的思路來作一個三方的Crash 收集的框架,收集思路主要有三種:

  • 捕獲 Mach 異常
  • 捕獲 Unix 信號
  • NSSetUncaughtExceptionHandler

3.1 捕獲Mach 異常

Mach 雖然很是底層,但也提供了API給用戶態應用程序使用,捕獲Mach異常可使用如下幾個API

  • 調用 mach_port_allocate 建立異常處理端口
  • 調用 mach_port_insert_right 獲取端口的權限
  • 調用 xxx_set_exception_ports 設置異常端口
  • 調用 mach_msg 等待異常端口上的消息

// 這裏有兩個須要注意的點:

3.2 TODO

由於異常收集已經有成熟的三方框架了,KSCrash、PLCrashReport 等,後面參考開源框架,再結合個人RDA來搞點事情,有足夠多的實踐經驗了再來這繼續分享

相關文章
相關標籤/搜索