異常處理 - Native 層的崩潰捕獲機制及實現

在 Android 平臺,native crash 我們可能關注得比較少,記得在長沙做開發那會,基本不會用到自己寫的 so 庫,集成第三方功能像地圖也就會拷貝幾個 so 到目錄下,當時連 so 是什麼都不知道。後來漸漸的由於項目的特殊性,不能直接集成 bugly 和 qapm 這些,因此後面就被逼着學會了 Native 層的崩潰捕獲。雖然實現起來相對要比 java 層更難一些,但也並不是很複雜,我們可以查一些資料或者借鑑一些第三方的開源庫,總結起來只需要從以下幾個方面入手即可:

  • 瞭解 native 層的崩潰處理機制
  • 捕捉到 native crash 信號
  • 處理各種特殊情況
  • 解析 native 層的 crash 堆棧

1. 瞭解 native 層的崩潰處理機制

開源庫有 coffeecatchbreakpad 等,普通項目中我們可以直接集成 bugly ,由於 bugly 不開源所以借鑑的意義並不大。breakpad 是 google 開源的比較權威但是代碼體積量大,coffeecatch 實現簡潔但存在兼容性問題。其實無論是 coffeecatch 還是 bugly 又或是我們自己寫,其內部的實現原理肯定都是一致的, 只要我們瞭解 native 層的崩潰處理機制,一切便能迎刃而解。

在 Unix-like 系統中,所有的崩潰都是編程錯誤或者硬件錯誤相關的,系統遇到不可恢復的錯誤時會觸發崩潰機制讓程序退出,如除零、段地址錯誤等。異常發生時,CPU 通過異常中斷的方式,觸發異常處理流程。不同的處理器,有不同的異常中斷類型和中斷處理方式。linux 把這些中斷處理,統一爲信號量,可以註冊信號量向量進行處理。信號機制是進程之間相互傳遞消息的一種方法,信號全稱爲軟中斷信號。

函數運行在用戶態,當遇到系統調用、中斷或是異常的情況時,程序會進入內核態。信號涉及到了這兩種狀態之間的轉換。

接收信號的任務是由內核代理的,當內核接收到信號後,會將其放到對應進程的信號隊列中,同時向進程發送一箇中斷,使其陷入內核態。注意,此時信號還只是在隊列中,對進程來說暫時是不知道有信號到來的。進程陷入內核態後,有兩種場景會對信號進行檢測:

  • 進程從內核態返回到用戶態前進行信號檢測
  • 進程在內核態中,從睡眠狀態被喚醒的時候進行信號檢測

當發現有新信號時,便會進入信號的處理。信號處理函數是運行在用戶態的,調用處理函數前,內核會將當前內核棧的內容備份拷貝到用戶棧上,並且修改指令寄存器(eip)將其指向信號處理函數。接下來進程返回到用戶態中,執行相應的信號處理函數。信號處理函數執行完成後,還需要返回內核態,檢查是否還有其它信號未處理。如果所有信號都處理完成,就會將內核棧恢復(從用戶棧的備份拷貝回來),同時恢復指令寄存器(eip)將其指向中斷前的運行位置,最後回到用戶態繼續執行進程。至此,一個完整的信號處理流程便結束了,如果同時有多個信號到達,會不斷的檢測和處理信號。

2. 捕捉到 native crash 信號

瞭解 native 層的崩潰處理機制,那麼我們的實現方案便是註冊信號處理函數,在 native 層可以用 sigaction():

#include <signal.h> 

// signum:代表信號編碼,可以是除SIGKILL及SIGSTOP外的任何一個特定有效的信號,如果爲這兩個信號定義自己的處理函數,將導致信號安裝錯誤。
// act:指向結構體sigaction的一個實例的指針,該實例指定了對特定信號的處理,如果設置爲空,進程會執行默認處理。
// oldact:和參數act類似,只不過保存的是原來對相應信號的處理,也可設置爲NULL。
// int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact));

void signal_pass(int code, siginfo_t *si, void *sc) {
    LOGD("捕捉到了 native crash 信號.");
}

bool installHandlersLocked() {
    if (handlers_installed)
        return false;

    // Fail if unable to store all the old handlers.
    for (int i = 0; i < kNumHandledSignals; ++i) {
        if (sigaction(kExceptionSignals[i], NULL, &old_handlers[i]) == -1) {
            return false;
        } else {
            handlerMaps->insert(
                    std::pair<int, struct sigaction *>(kExceptionSignals[i], &old_handlers[i]));
        }
    }

    struct sigaction sa;
    memset(&sa, 0, sizeof(sa));
    sigemptyset(&sa.sa_mask);

    // Mask all exception signals when we're handling one of them.
    for (int i = 0; i < kNumHandledSignals; ++i)
        sigaddset(&sa.sa_mask, kExceptionSignals[i]);

    sa.sa_sigaction = signal_pass;
    sa.sa_flags = SA_ONSTACK | SA_SIGINFO;

    for (int i = 0; i < kNumHandledSignals; ++i) {
        if (sigaction(kExceptionSignals[i], &sa, NULL) == -1) {
            // At this point it is impractical to back out changes, and so failure to
            // install a signal is intentionally ignored.
        }
    }
    handlers_installed = true;
    return true;
}

3. 處理各種特殊情況

Native 層的崩潰捕獲複雜就複雜在需要處理各種特殊情況,雖然一個函數就能監聽到崩潰信號回調,但是需要預防各種其他異常情況的出現,我們一一來看下:

3.1 設置額外棧空間

SIGSEGV 很有可能是棧溢出引起的,如果在默認的棧上運行很有可能會破壞程序運行的現場,無法獲取到正確的上下文。而且當棧滿了(太多次遞歸,棧上太多對象),系統會在同一個已經滿了的棧上調用 SIGSEGV 的信號處理函數,又再一次引起同樣的信號。我們應該開闢一塊新的空間作爲運行信號處理函數的棧。可以使用 sigaltstack 在任意線程註冊一個可選的棧,保留一下在緊急情況下使用的空間。(系統會在危險情況下把棧指針指向這個地方,使得可以在一個新的棧上運行信號處理函數)

/**
 * 先創建一塊 sigaltstack ,因爲有可能是由堆棧溢出發出的信號
 */
static void installAlternateStackLocked() {
    if (stack_installed)
        return;

    memset(&old_stack, 0, sizeof(old_stack));
    memset(&new_stack, 0, sizeof(new_stack));

    // SIGSTKSZ may be too small to prevent the signal handlers from overrunning
    // the alternative stack. Ensure that the size of the alternative stack is
    // large enough.
    static const unsigned kSigStackSize = std::max(16384, SIGSTKSZ);

    // Only set an alternative stack if there isn't already one, or if the current
    // one is too small.
    if (sigaltstack(NULL, &old_stack) == -1 || !old_stack.ss_sp ||
        old_stack.ss_size < kSigStackSize) {
        new_stack.ss_sp = calloc(1, kSigStackSize);
        new_stack.ss_size = kSigStackSize;

        if (sigaltstack(&new_stack, NULL) == -1) {
            free(new_stack.ss_sp);
            return;
        }
        stack_installed = true;
    }
}
3.2 兼容其他 signal 處理

某些信號可能在之前已經被安裝過信號處理函數,而 sigaction 一個信號量只能註冊一個處理函數,這意味着我們的處理函數會覆蓋其他人的處理信號。保存舊的處理函數,在處理完我們的信號處理函數後,在重新運行老的處理函數就能完成兼容。

/* Call the old handler. */
void call_old_signal_handler(const int sig, siginfo_t *const info, void *const sc) {
    // 恢復默認應該也行吧
    LOGD("sig -> %d", sig);
    handlerMaps->at(sig)->sa_sigaction(sig, info, sc);
}
3.3 防止死鎖或者死循環
void signal_pass(int code, siginfo_t *si, void *sc) {
    /* Ensure we do not deadlock. Default of ALRM is to die.
    * (signal() and alarm() are signal-safe) */
    // 這裏要考慮用非信號方式防止死鎖
    signal(code, SIG_DFL);
    signal(SIGALRM, SIG_DFL);
    /* Ensure we do not deadlock. Default of ALRM is to die.
     * (signal() and alarm() are signal-safe) */
    (void) alarm(8);

    /* Available context ? */
    notifyCaughtSignal();

    call_old_signal_handler(code, si, sc);

    LOGD("at the end of signal_pass");
}

4. 解析 native 層的 crash 堆棧

關於解析 native 層的 crash 堆棧解析,並不是一兩句話能說清楚的,因此我們打算單獨拿一次課來跟大家講。視頻鏈接地址無法發出來希望大家能夠諒解,因爲一粘貼視頻地址文章就會被簡書鎖定。大家感興趣的話,可以去我的 csdn 或者掘金找。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章