為什麼說獲取堆疊從來就不是一件簡單的事情

語言: CN / TW / HK

碎碎談

為了不讓文章看上去過於枯燥,筆者考慮了一下,特意增加了碎碎談環節!自從上次這篇文章發出去後 黑科技!讓Native Crash 與ANR無處發洩!,就挺受讀者歡迎的呀,收藏數大於點贊數是什麼鬼,嘿嘿!從我的角度出發,本來Signal出發的目的就是想建造一個類似於安全氣囊的裝置,保證crash後第一時間重啟恢復,達到一個應用穩定的目的,但是慢慢寫著寫著,發現很多crash監控平臺的也是用了相同的核心原理(大部分還沒開源噢),只是作用的目標不同,那麼為什麼不把Signal打造成一個通用的基礎件呢!無論是安全氣囊還是監控,其實都是上層的應用不同罷了!嗯!有了這個想法之後,給Signal補充一些日誌監控邏輯,就更加完善了!所以就有了本篇文章!算是一個補充文!如果沒看過黑科技!讓Native Crash 與ANR無處發洩!這篇文章的新朋友,請先閱讀!(如果沒有ndk開發經驗也沒關係,裡面也不涉及很複雜的c知識)

獲取堆疊

獲取堆疊!可能很多新朋友看到這個就會想,這有什麼難的嘛!直接new 一個Throwable獲取不就可以了嘛,或者Thread.currentThread().stackTrace(kotlin)等等也可以呀!嗯!是的!我們在java層通常會有很固定的獲取堆疊方式,這得益於java虛擬機器的設計,也得益於java語言的設計,因為遮蔽了多平臺底層的差異,我們就可以用相對統一的api去獲取當前的堆疊。這個堆疊也特指java虛擬機器堆疊!

但是對於native的堆疊,問題就來了!我們知道native層通常跟很多因素有關,比如連結器,編譯器,還有各種庫的版本,各種abi等等影響,獲取一個堆疊訊息,可沒有那麼簡單,因為太多因素干擾了,這也是歷史的包袱!還有對於我們android來說,android官方在對堆疊獲取的方式,也是有歷史變化的

4.1.1以上,5.0以下,android native使用系統自帶的libcorkscrew.so,5.0開始,系統中沒有了libcorkscrew.so 高版本的安卓原始碼中就使用了他的優化版替換libunwind。同時對於ndk來說,編譯器的版本也在不斷變化,從預設的gcc變成clang(ndk >=13),可以看到,我們會在眾多版本,眾多因素下,找一個統一的方式,還真的不簡單!不過呀!在2022的今天,google早已推出了一個計劃統一庫 breakpad ,嗯!雖然能不能成為標準還未定,但是也是一個生態的進步

Signal的選擇

前面介紹了這麼多方案,breakpad是不是Signal的首選呢!雖然breakpad不錯,但是裡面覆蓋了太多其他系統的編譯,比如mac,window等等標準,還有就是作為一個開源庫,還是希望減少這些庫的匯入,所以跟大多數主流方案一直,我們選擇用unwind.h去實現堆疊列印,因為這個就直接內建在我們的預設編譯中了,而且這個在在android也能用!下面我們來看一下實現!即Signal專案的unwind-utils的實現。那麼我們要考慮一些什麼呢!

堆疊大小

日誌當然需要設定追溯的堆疊大小,內容太多不好(過於臃腫,排查困難),內容太少也不好(很有可能漏掉關鍵crash堆疊),所以Signal預設設定30條,可以根據實際專案修改 std::string backtraceToLogcat() { 預設30個 const size_t max = 30; void *buffer[max]; //ostringstream方便輸出string std::ostringstream oss; dumpBacktrace(oss, buffer, captureBacktrace(buffer, max)); return oss.str(); }

_Unwind_Backtrace

_Unwind_Backtrace是unwind提供給我們堆疊回溯函式 _Unwind_Reason_Code _Unwind_Backtrace(_Unwind_Trace_Fn, void *); 那麼這個_Unwind_Trace_Fn是個啥,其實點進去看 typedef _Unwind_Reason_Code (*_Unwind_Trace_Fn)(struct _Unwind_Context *, void *); 其實這就代表一個函式,對於我們常年寫java的朋友有點不友好對吧,以java的方式,其實意思就是傳xxx(隨便函式名)( _Unwind_Context ,void )這樣的結構的函式即可,這裡的意思就是一個callback函式,當我們獲取到地址資訊就會回撥該引數,第二個就是需要傳遞給引數一的引數,這裡有點繞對吧,我們怎麼理解呢!引數一其實就是一個函式的引用,那麼這個函式需要引數怎麼辦,就通過第二個引數傳遞!

我們看個例子:這個在Signal也有 ``` static _Unwind_Reason_Code unwindCallback(struct _Unwind_Context context, void args) { BacktraceState state = static_cast(args); uintptr_t pc = _Unwind_GetIP(context); if (pc) { if (state->current == state->end) { return _URC_END_OF_STACK; } else { state->current++ = reinterpret_cast(pc); } } return _URC_NO_REASON; }

size_t captureBacktrace(void **buffer, size_t max) { BacktraceState state = {buffer, buffer + max}; _Unwind_Backtrace(unwindCallback, &state); // 獲取大小 return state.current - buffer; } ```

struct BacktraceState { void **current; void **end; }; 我們定義了一個結構體BacktraceState,其實是為了後面記錄函式地址而用,這裡有兩個作用,end代表日誌限定的大小,current表示實際日誌條數大小(因為堆疊條數可能小於end)

_Unwind_GetIP

我們在unwindCallback這裡拿到了系統回撥給我們的引數,關鍵就是這個了 _Unwind_Context這個結構體引數了,這個引數的作用就是傳遞給_Unwind_GetIP這個函式,獲取我們當前的執行地址,即pc值!那麼這個pc值又有什麼用呢!這個就是我們獲取堆疊的關鍵!native堆疊的獲取需要地址去解析!(不同於java)我們先有這個概念,後面會繼續講解

dladdr

經過了_Unwind_GetIP我們獲取了pc值,這個時候就用上dladdr函式去解析了,這個是linux核心函式,專門用於地址符號解析

``` The function dladdr() determines whether the address specified in addr is located in one of the shared objects loaded by the calling application. If it is, then dladdr() returns information about the shared object and symbol that overlaps addr. This information is returned in a Dl_info structure:

       typedef struct {
           const char *dli_fname;  /* Pathname of shared object that
                                      contains address */
           void       *dli_fbase;  /* Base address at which shared
                                      object is loaded */
           const char *dli_sname;  /* Name of symbol whose definition
                                      overlaps addr */
           void       *dli_saddr;  /* Exact address of symbol named
                                      in dli_sname */
       } Dl_info;

   If no symbol matching addr could be found, then dli_sname and
   dli_saddr are set to NULL.

可以看到,每個地址會的解析資訊會儲存在Dl_info中,如果有執行符號滿足,dli_sname和dli_saddr就會被設定為相應的so名稱跟地址,dli_fbase是基址資訊,因為我們的so庫被載入到程式的位置是不固定的!所以一般採用地址偏移的方式去在執行時尋找真正的so庫,所以就有這個dli_fbase資訊。 Dl_info info; if (dladdr(addr, &info) && info.dli_sname) { symbol = info.dli_sname;

} os << " #" << idx << ": " << addr << " " <<" "<<symbol <<"\n" ; ``` 最終我們可以通過dladdr,一一把儲存的地址資訊解析出來,列印到native日誌中比如Signal中demo crash資訊(如果需要列印so名稱,也可以通過dli_fname去獲取,這裡不舉例)

image.png

native堆疊產生過程

通過上面的日誌分析(最好看下demo中的app演示crash),我們其實在MainActivity中設定了一個crash函式 private external fun throwNativeCrash() 按照堆疊日誌分析來看,只有在第16條才出現了呼叫符號,這跟我們在日常java開發中是不是很不一樣!因為java層的堆疊一般都是最近的堆疊訊息代表著錯誤訊息,比如應該是第0條才導致的crash,但是演示中真正的堆疊crash卻隱藏在了日誌海里面!相信有不少朋友在看native crash日誌也是,是不是也感到無從下手,因為首條日誌往往並不是真正crash的主因!我們來看一下真正的過程:我們程式從正常態到crash,究竟發生了什麼!

image.png

可以看到,我們真正dump_stack前,是有很多前置的步驟,為什麼會有這麼多呢!其實這就涉及到linux核心中斷的原理,這裡給一張粗略圖

image.png crash產生後,一般會在使用者態階段呼叫中斷進入核心態,把自己的中斷訊號(這裡區分一下,不是我們signal.h裡面的訊號)放在eax暫存器中(大部分,也有其他的暫存器,這裡僅舉例)

然後核心層通過傳來的中斷訊號,找到訊號表,然後根據對應的處理程式,再拋回給使用者態,這個時候才進行sigaction的邏輯

所以說,crash產生到真正dump日誌,其實會有一個過程,這裡面根據sigaction的設定也會有多個變化,我們要了解的一點是,真正的crash資訊,往往藏在堆疊海中,需要我們一步步去解析,比如通過addr2line等工具去分析地址,才能得到真正的原因,而且一般的android專案,都是依賴於第三方的so,這也給我們的排查帶來難度,不過只要我們能識別出特定的so(dli_fname資訊就有),是不是就可以把鍋甩出去了呢,對吧!

最後

看到這裡,讀者朋友應該有一個對native堆疊的大概模型了,當然也不用怕!Signal專案中就包含了相關的unwind-utils工具類,直接用也是可以的,不過目前列印的資訊比較簡單,後續可以根據大家的實際,去新增引數!程式碼都在裡面,求star求pr !Signal,當然,看完了本文,別忘了留下你的贊跟評論呀!

往期推薦

聽說Compose與RecyclerView結合會有水土不服?

Android gradle遷移至kts

我正在參與掘金技術社群創作者簽約計劃招募活動,點選連結報名投稿