深入理解Android NDK日誌符號化

賈志凱發表於2015-07-21

前言

為了進行程式碼及產品保護,幾乎所有的非開源App都會進行程式碼混淆。這樣,當收集到崩潰資訊後,就需要進行符號化來還原始碼資訊,以便開發者可以定 位Bug。基於使用SDK和NDK的不同,Android的崩潰分為兩類:Java崩潰和C/C++崩潰。Java崩潰通過mapping.txt檔案進 行符號化,比較簡單直觀。而C/C++崩潰的符號化則需要使用Google自帶的一些NDK工具,比如ndk-stack、addr2line、 objdump等。本文不去討論如何使用這些工具,有興趣的朋友可以參考之前尹春鵬寫的另一篇文章《 如何定位Android NDK開發中遇到的錯誤》,裡面做了詳細的描述。

基於NDK的Android開發都會生成一個動態連結庫(so),它是基於C/C++編譯生成的。動態連結庫在Linux系統下廣泛使用,而Android系統底層是基於Linux的,所以NDK so庫的編譯生成遵循相同的規則,只不過Google NDK把相關的交叉編譯工具都封裝了。

Ndk-build編譯時會生成的兩個同名的so庫,位於不同的目錄/project path/libs/armeabi/xxx.so和/project path/obj/local/armeabi/xxx.so,比較兩個so檔案會發現體積相差很大。前者會跟隨App一起釋出,所以儘可能地小,而後者包含了很多除錯資訊,主要為了gdb除錯的時候使用,當然,NDK的日誌符號化資訊也包含其中。

深入理解Android NDK日誌符號化

深入分析so動態庫組成結構

本文主要針對這個包含除錯資訊的so動態庫,深入分析它的組成結構。在開始之前,先來說說這樣做的目的或者好處。現在的App基本都會採集 上報崩潰時的日誌資訊,無論是採用第三方雲平臺,還是自己搭建雲服務,都要將含除錯資訊的so動態庫上傳,實現雲端日誌符號化以及雲端視覺化管理。

移動App的快速迭代,使得我們必須儲存管理每一個版本的debug so庫,而其包含了很多與符號化無關的資訊。如果我們只提取出符號化需要的資訊,那麼符號化檔案的體積將會呈現數量級的減少。同時可以在自定義的符號化文 件中新增App的版本號等定製化資訊,實現符號化提取、上傳到雲端、雲端解析及視覺化等自動化部署。另外,從技術角度講,開發者將不再害怕看到 “unresolved symbol” linking errors,可以更從容地debugging C/C++ crash或進行一些hacking操作。

首先通過readelf來看看兩個不同目錄下的so庫有什麼不同。

深入理解Android NDK日誌符號化

從中可以清楚看到,包含除錯資訊的so庫多了8個.debug_開頭的條目以及.symtab和.strtab條目。符號化的本質,是通過堆疊中的地址資訊,還原始碼本來的語句以及相應的行號,所以這裡只需解析.debug_ line和.symtab,最終獲取到如下的資訊就可以實現符號化了。

c85    c8b     willCrash       jni/hello-jni.c:27-29
c8b    c8d     willCrash       jni/hello-jni.c:32
c8d    c8f     JNI_OnLoad      jni/hello-jni.c:34
c8f    c93     JNI_OnLoad      jni/hello-jni.c:35
c93    c9d     JNI_OnLoad      jni/hello-jni.c:37

通常,目標檔案分為三類:relocatable檔案、executable檔案和shared object檔案,它們格式稱為ELF(Executable and Linking Format),so動態庫屬於第三類shared object,它的整體組織結構如下:

深入理解Android NDK日誌符號化

ELF Header

ELF Header檔案頭的結構如下,記錄了檔案其他內容在檔案中的偏移以及大小資訊。這裡以32bit為例:

typedef struct {
        unsigned char   e_ident[EI_NIDENT];
        Elf32_Half      e_type;          // 目標檔案型別,如relocatable、executable和shared object
        Elf32_Half      e_machine;   // 指定需要的特定架構,如Intel 80386,Motorola 68000
        Elf32_Word      e_version;   // 目標檔案版本,通e_ident中的EI_VERSION
        Elf32_Addr      e_entry;       // 指定入口點地址,如C可執行檔案的入口是_start(),而不是main()
        Elf32_Off       e_phoff;   // program header table 的偏移量
        Elf32_Off       e_shoff;   // section header table的偏移量
        Elf32_Word      e_flags;  // 處理器相關的標誌
        Elf32_Half      e_ehsize;  // 代表ELF Header部分的大小
        Elf32_Half      e_phentsize; // program header table中每一項的大小
        Elf32_Half      e_phnum;   // program header table包含多少項
        Elf32_Half      e_shentsize;  // section header table中每一項的大小
        Elf32_Half      e_shnum;  // section header table包含多少項
        Elf32_Half      e_shstrndx;  //section header table中某一子項的index,該子項包含了所有section的字串名稱
} Elf32_Ehdr;

其中e_ident為固定16個位元組大小的陣列,稱為ELF Identification,包含了處理器型別、檔案編碼格式、機器型別等,具體結構如下:

深入理解Android NDK日誌符號化

Sections

該部分包含了除ELF Header、program header table以及section header table之外的所有資訊。通過section header table可以找到每一個section的基本資訊,如名稱、型別、偏移量等。

先來看看Section Header的內容,仍以32-bit為例:

typedef struct {
	Elf32_Word	sh_name;  // 指定section的名稱,該值為String Table字串表中的索引
	Elf32_Word	sh_type; // 指定section的分類
	Elf32_Word	sh_flags; // 該欄位的bit代表不同的section屬性
	Elf32_Addr	sh_addr; // 如果section出現在記憶體映象中,該欄位表示section第一個位元組的地址
	Elf32_Off	sh_offset; // 指定section在檔案中的偏移量
	Elf32_Word	sh_size; // 指定section佔用的位元組大小
	Elf32_Word	sh_link; // 相關聯的section header table的index
	Elf32_Word	sh_info; // 附加資訊,意義依賴於section的型別
	Elf32_Word	sh_addralign; // 指定地址對其約束
	Elf32_Word	sh_entsize;   // 如果section包含一個table,該值指定table中每一個子項的大小
} Elf32_Shdr;

通過Section Header的sh_name可以找到指定的section,比如.debug_line、.symbol、.strtab。

String Table

String Table包含一系列以/0結束的字元序列,最後一個位元組設定為/0,表明所有字元序列的結束,比如:

深入理解Android NDK日誌符號化

String Table也屬於section,只不過它的偏移量直接在ELF Header中的e_shstrndx欄位指定。String Table的讀取方法是,從指定的index開始,直到遇到休止符。比如要section header中sh_name獲取section的名稱,假設sh_name = 7, 則從string table位元組流的第7個index開始(注意這裡從0開始),一直讀到第一個休止符(index=18),讀取到的名稱為.debug_line。

Symbol Table

該部分包含了程式符號化的定義相關資訊,比如函式定義、變數定義等,每一項的定義如下:

# Symbol Table Entry
typedef struct {
	Elf32_Word	st_name;   //symbol字串表的索引
	Elf32_Addr	st_value;  //symbol相關的值,依賴於symbol的型別
	Elf32_Word	st_size;   //symbol內容的大小
	unsigned char	st_info;   //symbol的型別及其屬性
	unsigned char	st_other;  //symbol的可見性,比如類的public等屬性
	Elf32_Half	st_shndx;  //與此symbol相關的section header的索引
} Elf32_Sym;

Symbol的型別包含以下幾種:

深入理解Android NDK日誌符號化

其中STT_FUNC就是我們要找的函式symbol。然後通過st_name從symbol字串表中獲取到相應的函式名(如 JNI_OnLoad)。當symbol型別為STT_FUNC時,st_value代表該symbol的起始地址,而 (st_value+st_size)代表該symbol的結束地址。

回顧之前提到的.symtab和.strtab兩個部分,對應的便是Symbol Section和Symbol String Section。

DWARF(Debugging With Attributed Record Formats)

DWARF是一種除錯檔案格式,很多編譯器和偵錯程式都通過它進行原始碼除錯(gdb等)。儘管它是一種獨立的目標檔案格式,但往往嵌入在ELF檔案中。前面通過readelf看到的8個.debug_* Section全部都屬於DWARF格式。本文將只討論與符號化相關的.debug_line部分,更多的DWARF資訊請檢視參考文獻的內容。

.debug_line部分包含了行號資訊,通過它可以將程式碼語句和機器指令地址對應,從而進行原始碼除錯。.debug_line由很多子項組成,每個子項都包含類似資料塊頭的描述,稱為Statement Program Prologue。Prologue提供瞭解碼程式指令和跳轉到其他語句的資訊,它包含如下欄位,這些欄位是以二進位制格式順序存在的:

深入理解Android NDK日誌符號化

這裡用到的機器指令可以分為三類:

深入理解Android NDK日誌符號化

這裡不做機器指令的解析說明,感興趣的,可以檢視參考文獻的內容。

通過.debug_line,我們最終可以獲得如下資訊:檔案路徑、檔名、行號以及起始地址。

最後,我們彙總一下整個符號化提取的過程:

  1. 從ELF Header中獲知32bit或者64bit,以及大端還是小端,基於此讀取後面的內容;
  2. 從ELF Header中獲得Section Header Table在檔案中的位置;
  3. 讀取Section Header Table,從中獲得.debug_line、.symtab以及.strtab三個section在文中的位置;
  4. 讀取.symtab和.strtab兩個section,最後獲得所有function symbol的名稱、起始地址以及結束地址;
  5. 讀取.debug_line,按照DWARF格式解析獲取檔名稱、路徑、行號以及起始地址;
  6. 對比步驟4和5中獲取的結果,進行對比合並,形成最終的結果。

參考文獻

作者簡介:

賈志凱 Testin技術總監,主要負責崩潰分析專案Android平臺架構設計、效能及穩定性優化。

相關文章