乾貨|安卓APP崩潰捕獲方案——xCrash

愛奇藝技術產品團隊發表於2022-12-05

導讀

2019 年,愛奇藝在 GitHub 上開源了 xCrash。這是一個比較完整的安卓 APP 崩潰捕獲 SDK,它能在 App 程式崩潰時,在你指定的目錄中生成 tombstone 檔案(格式與系統的 tombstone 檔案類似)。它支援捕獲 native 崩潰和 Java 崩潰;支援安卓 4.0 - 9.0;支援 armeabi,armeabi-v7a,arm64-v8a,x86 和 x86_64。

依託於愛奇藝安卓 APP 上億的日活使用者資料,xCrash 在相容性、穩定性、功能完整性等方面不斷地自我完善。目前 xCrash 已被應用於愛奇藝、愛奇藝極速版、愛奇藝動畫屋、奇秀、愛奇藝 VR 影院、叭噠漫畫等 20 餘款愛奇藝的安卓 APP 中。

問題概述

在移動端 APP 的各種質量問題中,最嚴重的可能就是 APP 崩潰閃退了。

從安卓 APP 開發的角度,Java 崩潰捕獲相對比較容易,JVM 給 Java 位元組碼提供了一個受控的執行環境,同時也提供了完善的 Java 崩潰捕獲機制。Native 崩潰的捕獲和處理相對比較困難,安卓系統的debuggerd 守護程式會為 native 崩潰自動生成詳細的崩潰描述檔案(tombstone)。

在開發除錯階段,可以透過系統提供的 bugreport 工具獲取 tombstone 檔案(或者將裝置 root 後也可以拿到)。但是對於釋出到線上的安卓 APP,如何獲取 tombstone 檔案,安卓作業系統本身並沒有提供這樣的功能。這個問題一直是安卓 native 崩潰分析和移動端 APM 系統的痛點之一。

Native 崩潰介紹

訊號

Native 崩潰發生在機器指令執行的層面。比如:APP 中的 so 庫、系統的 so 庫、JVM 本身等等。如果這部分程式做了 Linux kernel 認為不可接受的事情(比如:除數為零、讓 CPU 執行它無法識別的指令等),kernel 就會向 APP 中對應的執行緒傳送相應的訊號(signal),這些訊號的預設處理方式是殺死整個程式。使用者態程式也可以傳送 signal 終止其他程式或自身。這些致命的訊號分為 2 類,主要有:

1、kernel 發出的:

SIGFPE: 除數為零。

SIGILL: 無法識別的 CPU 指令。

SIGSYS: 無法識別的系統呼叫(system call)。

SIGSEGV: 錯誤的虛擬記憶體地址訪問。

SIGBUS: 錯誤的物理裝置地址訪問。

2、使用者態程式發出的:

SIGABRT: 呼叫 abort() / kill() / tkill() / tgkill() 自殺,或被其他程式透過 kill() / tkill() / tgkill() 他殺。

訊號處理函式

乾貨|安卓APP崩潰捕獲方案——xCrash

Naive 崩潰捕獲需要註冊這些訊號的處理函式(signal handler),然後在訊號處理函式中收集資料。

因為訊號是以“中斷”的方式出現的,可能中斷任何 CPU 指令序列的執行,所以在訊號處理函式中,只能呼叫“非同步訊號安全(async-signal-safe)”的函式。例如malloc()、calloc()、free()、snprintf()、gettimeofday() 等等都是不能使用的,C++ STL / boost 也是不能使用的。

所以,在訊號處理函式中我們只能不分配堆記憶體,需要使用堆記憶體只能在初始化時預分配。如果要使用不在非同步訊號安全白名單中的 libc / bionic 函式,只能直接呼叫 system call 或者自己實現。

程式崩潰前的極端情況

當崩潰捕獲邏輯開始執行時,會面對很多糟糕的情況,比如:棧溢位、堆記憶體不可用、虛擬記憶體地址耗盡、FD 耗盡、Flash 空間耗盡等。有時,這些極端情況的出現,本身就是導致程式崩潰的間接原因。

1、棧溢位

我們需要預先用 sigaltstack() 為 signal handler 分配專門的棧記憶體空間,否則當遇到棧溢位時,signal handler 將無法正常執行。

2、虛擬記憶體地址耗盡

記憶體洩露很容易導致虛擬記憶體地址耗盡,特別是在 32 位環境中。這意味著在 signal handler 中也不能使用類似 mmap() 的呼叫。

3、FD 耗盡

FD 洩露是常見的導致程式崩潰的間接原因。這意味著在 signal handler 中無法正常的使用依賴於 FD 的操作,比如無法 open() + read() 讀取/proc 中的各種資訊。為了不干擾 APP 的正常執行,我們僅僅預留了一個 FD,用於在崩潰時可靠的建立出“崩潰資訊記錄檔案”。

4、Flash 空間耗盡

在 16G / 32G 儲存空間的安卓裝置中,這種情況經常發生。這意味著 signal handler 無法把崩潰資訊記錄到本地檔案中。我們只能嘗試在初始化時預先建立一些“佔坑”檔案,然後一直迴圈使用這些“佔坑”檔案來記錄崩潰資訊。如果“佔坑”檔案也建立失敗,我們需要把最重要的一些崩潰資訊(比如 backtrace)儲存在記憶體中,然後立刻回撥和傳送這些資訊。

xCrash 架構與實現

訊號處理函式與子程式

在訊號處理函式(signal handler)程式碼執行的開始階段,我們只能“忍辱偷生”:

1、遵守它的各種限制。

2、不使用堆記憶體。

3、自己實現需要的呼叫的“非同步訊號安全版本”,比如:snprintf()、gettimeofday()。

4、必要時直接呼叫 system call。

但這並非長久之計,我們要儘快在訊號處理函式中執行“逃逸”,即使用clone() + execl() 建立新的子程式,然後在子程式中繼續收集崩潰資訊。這樣做的目的是:

1、避開 async-signal-safe 的限制。

2、避開虛擬記憶體地址耗盡的問題。

3、避開 FD 耗盡的問題。

4、使用 ptrace() suspend 崩潰程式中所有的執行緒。與 iOS 不同,Linux / Android 不支援 suspend 本程式內的執行緒。(如果不做 suspend,則其他未崩潰的執行緒還在繼續執行,還在繼續寫 logcat,當我們收集 logcat 時,崩潰時間點附近的 logcat 可能早已被淹沒。類似的,其他的業務 log buffers 也存在被淹沒的問題。)

5、除了崩潰執行緒本身的 registers、backtrace 等,還能用 ptrace()收集到程式中其他所有執行緒的 registers、backtrace 等資訊,這對於某些崩潰問題的分析是有意義的。

6、更安全的讀取記憶體資料。(ptrace 讀資料失敗會返回錯誤碼,但是在崩潰執行緒內直接讀記憶體資料,如果記憶體地址非法,會導致段錯誤)

由此可以看出“逃逸”是必然的選擇,整個過程如下圖所示:

乾貨|安卓APP崩潰捕獲方案——xCrash

整體架構

xCrash 整體分為兩部分:執行於崩潰的 APP 程式內的部分,和獨立程式的部分(我們稱為 dumper)。

乾貨|安卓APP崩潰捕獲方案——xCrash

1、APP 程式內

這部分可以再分為 Java 和 native 兩個部分。

(1)Java 部分:

①Java 崩潰捕獲。直接使用 JVM 提供的機制來完成,最後生成相容 tombstone 格式的 dump 檔案。

②Native 崩潰捕獲機制的註冊器。透過 JNI 啟用 native 層的對應機制。

③Tombstone 檔案解析器。可以將 tombstone 檔案解析成 json 格式。

④Tombstone 檔案管理器。可以檢索裝置上已經生成的 tombstone 檔案。

(2)Native 部分:

①JNI Bridge。負責與 Java 層的互動。(傳參與回撥)

②Signal handlers。負責訊號捕獲,以及啟動獨立程式 dumper。

③Fallback mode。負責當 dumper 捕獲崩潰資訊失敗時,嘗試在崩潰進行的 signal handler 中收集崩潰資訊。

2、Dumper 獨立程式

這部分是純 native 的實現:

①Process。負責崩潰程式中各個執行緒的控制(attach 和 detach),以及程式層面的資訊收集,比如 FD 列表、logcat 等等。

②Threads。負責崩潰程式中的執行緒相關資料的收集,比如 registers、backtrace、stack 等等。

③Memory Layout。負責 maps 和 smaps 的解析。

④Memory。負責各種記憶體資料的讀寫。比如來自本地 buffer、來自mmap() 的 ELF 檔案、或者透過 ptrace() 遠端訪問的崩潰程式的記憶體。

⑤Registers。負責各種處理機架構相關的資料處理。

⑥ELF。負責 ELF 資訊的解析。需要解析各種 unwind table 和 symbols 資訊,有時需要使用 LZMA 解壓 .gnu_debugdata 中的 mini debug info 資訊做進一步的處理。

獲取 backtrace

獲取 backtrace 是崩潰捕獲中比較複雜和重要的部分,這也恰恰是安卓 native 開發中最混亂和不一致的地方之一。

1、libc 對 backtrace 的支援

在 Linux 伺服器環境中,當那些致命的 signal 發生時,系統可以為我們產生標準的 core dump 檔案,之後我們可以用 gdb 除錯和恢復崩潰現場,我們俗稱“驗屍”。

在 Linux 嵌入式環境中,由於 flash 空間有限,我們一般可以註冊 signal handler,然後呼叫 libc 的 backtrace() 和backtrace_symbols_fd() 獲取 backtrace。

(注意:不能使用backtrace_symbols(),它不是非同步訊號安全的)

2、NDK 對 backtrace 的支援

NDK 中目前沒有提供可靠的 unwind API。

安卓使用 bionic 替代了 libc,bionic 中沒有 backtrace() 和backtrace_symbols_fd()。(它們不在 POSIX 標準中)

unwind.h 中的 _Unwind_Backtrace 系列函式對於高版本 Android 系統庫幾乎無效。(NDK 中的 unwind 實現,已經無法跟上 Android 系統快速的迭代最佳化)

3、Google AOSP 的 backtrace 實現

各版本的 AOSP 都有系統自用的 backtrace 庫,主要作用是配合系統 debuggerd 程式和偵錯程式的工作。

(1)libcorkscrew:只用於 Android 4.1 - 4.4W。

(2)libunwind:只用於 Android 5.0 - 7.1.1。

(3)libunwindstack:只用於 Android 8.0 及以上版本。

如果 APP 直接使用這些庫,會遇到以下的問題:

(1)系統 debuggerd 是以 root 許可權執行的,而我們的 APP 沒有 root 許可權,所以某些操作會受到限制。

(2)NDK 沒有暴露這些系統庫的對外呼叫介面。Android 7.0 以後 APP 無法直接 dlopen() 系統庫,所以其中的 libunwind 和 libunwindstack 只能自己編譯原始碼後放到 APP 中使用。

(3)使用這些庫的 local unwind 介面比較容易,但是使用 remote unwind 介面時適配比較複雜。(原因還是這些庫是為了 debuggerd 和偵錯程式設計的,不是為 APP 設計的)

(4)高版本系統的 backtrace 庫無法直接編譯用於低版本的系統,libunwind 和 libunwindstack 中使用了大量低版本系統所沒有的系統函式。所以作為 APP 只能分別編譯這些系統 backtrace 庫,然後在執行時根據系統 API level 動態判斷需要使用哪個庫。這顯著的增加了 APP 包體積。

4、xCrash 的 backtrace 實現

xCrash 參考了一部分 AOSP 和 BreakPad 的實現思路,在不需要 root 許可權和相容 Android 4.0 - 9.0 的前提下,自己實現了 unwind 邏輯。這樣做的好處是 unwind 過程不再是一個黑盒,細節完全可控,遇到問題完全可除錯。

Backtrace unwind 依賴於三部分資料:暫存器、棧記憶體、各 ELF 中的 unwind table。xCrash 目前能處理 Android 4.0 - 9.0 中可能出現的所有格式的 unwind table,它們來自於 ELF 中的以下 section:

(1).ARM.exidx(只存在於 32 位 ARM 架構)

(2).eh_frame 和 .eh_frame_hdr

(3).debug_frame

(4).gnu_debugdata(LZMA 壓縮的 mini debug info,其中可能包含其他的 unwind table,比如:.debug_frame)

xCrash 的其他功能

除了獲取常見的裝置資訊、registers、backtrace、stack、memory near、maps、logcat 等基本資訊,xCrash 還提供以下的功能:

1、完整的 FD 列表

讓你知道崩潰時程式中的每一個 FD 具體都用在了哪裡。

2、詳細的記憶體使用統計

獲取了作業系統全域性的實體記憶體使用統計、崩潰程式的虛擬記憶體使用統計、崩潰程式的記憶體詳細使用資訊(類似 dumpsys meminfo)。讓你對程式崩潰時的記憶體狀態有全面的瞭解。

3、用正則白名單設定需要獲取哪些執行緒的資訊

APP 的執行緒數超過 100 個是很常見的,如果像系統 tombstone 那樣總是獲取全部執行緒的 registers、backtrace 等資訊,在大多數情況下是沒有必要的;這也容易導致 unwind 時間過長,崩潰捕獲邏輯還沒有走完,APP 就被系統強殺了。xCrash 讓你能透過一組正規表示式白名單來設定需要獲取哪些執行緒的資訊。

4、零許可權需求

xCrash 不需要 root 許可權,也不需要任何的 APP 系統許可權,這讓使用 xCrash 的 APP 沒有任何許可權方面的負擔。

5、監測裝置是否已被 root

監測的過程是完全透明和無感知的。在後期分析資料時,如果發現某個崩潰只發生在已被 root 的裝置上,就有理由懷疑是否是一些特別的原因造成的。

6、極高的崩潰資訊捕獲成功率

xCrash 透過 FD 預留;Flash “佔坑”檔案;寫檔案失敗時透過預分配記憶體儲存 backtrace 等重要資訊做緊急回撥、clone() + execl() 失敗後進入 fallback 模式執行本地 unwind 等一系列保護措施,最大程度的保證了崩潰資訊捕獲的成功率。

7、擴充套件性支援

xCrash 支援崩潰後附加使用者自定義資訊。目前在愛奇藝 APP 中,已經透過 xCrash 的擴充套件能力,在崩潰時投遞了大播放日誌、彈幕日誌、NLE影片編輯日誌、APP Life Cycle Trace等資訊。為排查特定業務的崩潰問題提供支援。

xCrash 與 BreakPad 比較

BreakPad 是 Google 開發的跨平臺崩潰捕獲方案,目前主要用於 Chromium。安卓 APP 也可以使用 BreakPad 來捕獲異常。

BreakPad 是一種“以後期除錯為目的的崩潰捕獲方案”,BreakPad 的崩潰捕獲結果是一個二進位制的 minidump 檔案,需要後期拿到崩潰相關的所有 ELF 原始檔案(包括系統動態庫檔案),然後開始進行類似 gdb 的除錯過程,才能定位問題。

拿到每個崩潰機型上需要的系統庫檔案,這會是一個耗時的過程;再加上覆雜的 APP 自身可能包含數十個 native 庫,這些 native 庫由不同的業務團隊開發,並且在 APP 發版後還可能熱更新。如果要把這整個過程自動化的完成,需要一個非常複雜的系統來支援。

在我們開發移動端 APM 系統和 xCrash SDK 的初期,曾經短暫的試用過 BreakPad,最後覺得這種方式對於我們來說後期的維護成本太高了,而收益看起來比較有限。

1、BreakPad 的優勢

對於特定的疑難問題,可以透過除錯來獲取到更多的暫存器和記憶體資訊,也許有助於這些問題的解決。

2、BreakPad 的弱點

(1)後期的自動化處理比較複雜耗時,維護成本非常高。

(2)後期處理時如果遇到對應系統庫缺失、或者庫版本錯誤的情況,就會無法拿到正確的 backtrace。這會影響到突發線上崩潰的報警,以及對突發崩潰的及時熱修。

(3)BreakPad 自身的跨平臺屬性,以及較長的開發歷史,導致了它的程式碼結構比較龐大而複雜,維護和二次開發的難度較大。

3、相對於 BreakPad,xCrash 的優勢

(1)完全在裝置本地執行崩潰資訊提取,生成系統標準的 tombstone 文字格式的 dump 資訊。後期只要在服務端做簡單的文字解析和聚合,就能快速發現線上的突發崩潰。

(2)tombstone 文字格式是安卓系統 debuggerd 的標準崩潰資訊輸出格式,無需再向開發人員解釋該格式的具體含義。

(3)專為安卓 APP 量身定製,接入使用的過程已經做到極簡。

xCrash 的未來計劃

伴隨著安卓本身以及移動端各項技術的快速發展,xCrash 未來還有很多事情可以做,例如:

(1)ANR 監控。

(2)強化 fallback 模式。

(3)減少 dump 過程中崩潰程式的卡頓。

(4)崩潰次數和時間的本地記錄和統計。

(5)與 BreakPad 如何互補。

我們真誠的歡迎您和我們一起開發和維護 xCrash。

xCrash 在 GitHub 的專案地址:

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69945252/viewspace-2674668/,如需轉載,請註明出處,否則將追究法律責任。

相關文章