1. 程式人生 > >如何定位Android NDK開發中遇到的錯誤

如何定位Android NDK開發中遇到的錯誤

應部分同學要求,把之前的幾篇文章合成這個一篇大笑

正式開始這個話題之前,先簡單介紹一下什麼是NDK和JNI,部分內容來自網路

Android NDK是什麼,為什麼我們要用NDK

Android NDK 是在SDK前面又加上了“原生”二字,即Native Development Kit,因此又被Google稱為“NDK”。眾所周知,Android程式執行在Dalvik虛擬機器中,NDK允許使用者使用類似C / C++之類的原生程式碼語言執行部分程式。NDK包括了:

  • C / C++生成原生程式碼庫所需要的工具和build files
  • 將一致的原生庫嵌入可以在Android裝置上部署的應用程式包檔案(
    application packages files,即.apk檔案)中。
  • 支援所有未來Android平臺的一些列原生系統標頭檔案和庫

為何要用到NDK?概括來說主要分為以下幾種情況:

  • 程式碼的保護,由於apkjava層程式碼很容易被反編譯,而C/C++庫反匯難度較大。
  • NDK中呼叫第三方C/C++庫,因為大部分的開源庫都是用C/C++程式碼編寫的。
  • 便於移植,用C/C++寫的庫可以方便在其他的嵌入式平臺上再次使用。

Android JNI是什麼?和NDK是什麼關係?

Java Native Interface(JNI)標準是java平臺的一部分,它允許Java程式碼和其他語言寫的程式碼進行互動。JNI

是本地程式設計介面,它使得在 Java 虛擬機器(VM) 內部執行的 Java 程式碼能夠與用其它程式語言( CC++和組合語言)編寫的應用程式和庫進行互動操作。

簡單來說,可以認為NDK就是能夠方便快捷開發.so檔案的工具。JNI的過程比較複雜,生成.so需要大量操作,而NDK就是簡化了這個過程。

NDK的異常會不會導致程式CrashNDK的常見的有哪些型別異常?

NDK編譯生成的.so檔案作為程式的一部分,在執行發生異常時同樣會造成程式崩潰。不同於Java程式碼異常造成的程式崩潰,在NDK的異常發生時,程式在Android裝置上都會立即退出,即通常所說的閃退,而不會彈出“程式xxx

無響應,是否立即關閉”之類的提示框。

NDK是使用C/C++來進行開發的,熟悉C/C++的程式設計師都知道,指標和記憶體管理是最重要也是最容易出問題的地方,稍有不慎就會遇到諸如記憶體無效訪問、無效物件、記憶體洩露、堆疊溢位等常見的問題,最後都是同一個結果:程式崩潰。例如我們常說的空指標錯誤,就是當一個記憶體指標被置為空(NULL)之後再次對其進行訪問;另外一個經常出現的錯誤是,在程式的某個位置釋放了某個記憶體空間,而後在程式的其他位置試圖訪問該記憶體地址,這就會產生一個無效地址錯誤。常見的錯誤型別如下:

  • 初始化錯誤
  • 訪問錯誤
    • 陣列索引訪問越界
    • 指標物件訪問越界
    • 訪問空指標物件
    • 訪問無效指標物件
    • 迭代器訪問越界
  • 記憶體洩露
  • 引數錯誤
  • 堆疊溢位
  • 型別轉換錯誤
  • 數字除0錯誤

NDK錯誤發生時,我們能拿到什麼資訊?

利用Android NDK開發本地應用的時候,幾乎所有的程式設計師都遇到過程式崩潰的問題,但它的崩潰會在logcat中列印一堆看起來類似天書的堆疊資訊,讓人舉足無措。單靠新增一行行的列印資訊來定位錯誤程式碼做在的行數,無疑是一件令人崩潰的事情。在網上搜索“Android NDK崩潰”,可以搜尋到很多文章來介紹如何通過Android提供的工具來查詢和定位NDK的錯誤,但大都晦澀難懂。下面以一個實際的例子來說明,首先生成一個錯誤,然後演示如何通過兩種不同的方法,來定位錯誤的函式名和程式碼行。

首先,看我們在hello-jni程式的程式碼中做了什麼(有關如何建立或匯入工程,此處略),看下圖:在JNI_OnLoad()的函式中,即so載入時,呼叫willCrash()函式,而在willCrash()函式中, std::string的這種賦值方法會產生一個空指標錯誤。這樣,在hello-jni程式載入時就會閃退。我們記一下這兩個行數:在61行呼叫了willCrash()函式;在69行發生了崩潰。


下面來看看發生崩潰(閃退)時系統列印的logcat日誌:

*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
 Build fingerprint: 'vivo/bbk89_cmcc_jb2/bbk89_cmcc_jb2:4.2.1/JOP40D/1372668680:user/test-keys'
 pid: 32607, tid: 32607, name: xample.hellojni  >>> com.example.hellojni <<<
 signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 00000000
     r0 00000000  r1 beb123a8  r2 80808080  r3 00000000
     r4 5d635f68  r5 5cdc3198  r6 41efcb18  r7 5d62df44
     r8 4121b0c0  r9 00000001  sl 00000000  fp beb1238c
     ip 5d635f7c  sp beb12380  lr 5d62ddec  pc 400e7438  cpsr 60000010
 
 backtrace:
     #00  pc 00023438  /system/lib/libc.so 
     #01  pc 00004de8  /data/app-lib/com.example.hellojni-2/libhello-jni.so
     #02  pc 000056c8  /data/app-lib/com.example.hellojni-2/libhello-jni.so
     #03  pc 00004fb4  /data/app-lib/com.example.hellojni-2/libhello-jni.so
     #04  pc 00004f58  /data/app-lib/com.example.hellojni-2/libhello-jni.so
     #05  pc 000505b9  /system/lib/libdvm.so
     #06  pc 00068005  /system/lib/libdvm.so
     #07  pc 000278a0  /system/lib/libdvm.so
     #08  pc 0002b7fc  /system/lib/libdvm.so
     #09  pc 00060fe1  /system/lib/libdvm.so
     #10  pc 0006100b  /system/lib/libdvm.so
     #11  pc 0006c6eb  /system/lib/libdvm.so
     #12  pc 00067a1f  /system/lib/libdvm.so
     #13  pc 000278a0  /system/lib/libdvm.so
     #14  pc 0002b7fc  /system/lib/libdvm.so
     #15  pc 00061307  /system/lib/libdvm.so
     #16  pc 0006912d  /system/lib/libdvm.so
     #17  pc 000278a0  /system/lib/libdvm.so
     #18  pc 0002b7fc  /system/lib/libdvm.so
     #19  pc 00060fe1  /system/lib/libdvm.so
     #20  pc 00049ff9  /system/lib/libdvm.so
     #21  pc 0004d419  /system/lib/libandroid_runtime.so
     #22  pc 0004e1bd  /system/lib/libandroid_runtime.so
     #23  pc 00001d37  /system/bin/app_process
     #24  pc 0001bd98  /system/lib/libc.so
     #25  pc 00001904  /system/bin/app_process
 
 stack:
          beb12340  012153f8  
          beb12344  00054290  
          beb12348  00000035  
          beb1234c  beb123c0  [stack]
     
……

如果你看過logcat列印的NDK錯誤時的日誌就會知道,我省略了後面很多的內容,很多人看到這麼多密密麻麻的日誌就已經頭暈腦脹了,即使是很多資深的Android開發者,在面對NDK日誌時也大都默默的選擇了無視。


“符號化”NDK錯誤資訊的方法

其實,只要你細心的檢視,再配合Google 提供的工具,完全可以快速的準確定位出錯的程式碼位置,這個工作我們稱之為“符號化”。需要注意的是,如果要對NDK錯誤進行符號化的工作,需要保留編譯過程中產生的包含符號表的so檔案,這些檔案一般儲存在$PROJECT_PATH/obj/local/目錄下。

第一種方法:ndk-stack

這個命令列工具包含在NDK工具的安裝目錄,和ndk-build和其他一些常用的NDK命令放在一起,比如在我的電腦上,其位置是/android-ndk-r9d/ndk-stack。根據Google官方文件,NDKr6版本開始提供ndk-stack命令,如果你用的之前的版本,建議還是儘快升級至最新的版本。使用ndk –stack命令也有兩種方式

使用ndk-stack實時分析日誌

在執行程式的同時,使用adb獲取logcat日誌,並通過管道符輸出給ndk-stack,同時需要指定包含符號表的so檔案位置;如果你的程式包含了多種CPU架構,在這裡需求根據錯誤發生時的手機CPU型別,選擇不同的CPU架構目錄,如:

adb shell logcat | ndk-stack -sym $PROJECT_PATH/obj/local/armeabi

當崩潰發生時,會得到如下的資訊:

********** Crash dump: **********
Build fingerprint: 'vivo/bbk89_cmcc_jb2/bbk89_cmcc_jb2:4.2.1/JOP40D/1372668680:user/test-keys'
pid: 32607, tid: 32607, name: xample.hellojni  >>> com.example.hellojni <<<
signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 00000000
Stack frame #00  pc 00023438  /system/lib/libc.so (strlen+72)
Stack frame #01  pc 00004de8  /data/app-lib/com.example.hellojni-2/libhello-jni.so (std::char_traits<char>::length(char const*)+20): Routine std::char_traits<char>::length(char const*) at /android-ndk-r9d/sources/cxx-stl/stlport/stlport/stl/char_traits.h:229
Stack frame #02  pc 000056c8  /data/app-lib/com.example.hellojni-2/libhello-jni.so (std::basic_string<char, std::char_traits<char>, std::allocator<char> >::basic_string(char const*, std::allocator<char> const&)+44): Routine basic_string at /android-ndk-r9d/sources/cxx-stl/stlport/stlport/stl/_string.c:639
Stack frame #03  pc 00004fb4  /data/app-lib/com.example.hellojni-2/libhello-jni.so (willCrash()+68): Routine willCrash() at /home/testin/hello-jni/jni/hello-jni.cpp:69
Stack frame #04  pc 00004f58  /data/app-lib/com.example.hellojni-2/libhello-jni.so (JNI_OnLoad+20): Routine JNI_OnLoad at /home/testin/hello-jni/jni/hello-jni.cpp:61
Stack frame #05  pc 000505b9  /system/lib/libdvm.so (dvmLoadNativeCode(char const*, Object*, char**)+516)
Stack frame #06  pc 00068005  /system/lib/libdvm.so
Stack frame #07  pc 000278a0  /system/lib/libdvm.so
Stack frame #08  pc 0002b7fc  /system/lib/libdvm.so (dvmInterpret(Thread*, Method const*, JValue*)+180)
Stack frame #09  pc 00060fe1  /system/lib/libdvm.so (dvmCallMethodV(Thread*, Method const*, Object*, bool, JValue*, std::__va_list)+272)
……(後面略)

我們重點看一下#03#04,這兩行都是在我們自己生成的libhello-jni.so中的報錯資訊,那麼會發現如下關鍵資訊:

#03 (willCrash()+68): Routine willCrash() at /home/testin/hello-jni/jni/hello-jni.cpp:69
#04 (JNI_OnLoad+20): Routine JNI_OnLoad at /home/testin/hello-jni/jni/hello-jni.cpp:61

回想一下我們的程式碼,在JNI_OnLoad()函式中(第61行),我們呼叫了willCrash()函式;在willCrash()函式中(第69行),我們製造了一個錯誤。這些資訊都被準確無誤的提取了出來!是不是非常簡單?

先獲取日誌,再使用ndk-stack分析

這種方法其實和上面的方法沒有什麼大的區別,僅僅是logcat日誌獲取的方式不同。可以在程式執行的過程中將logcat日誌儲存到一個檔案,甚至可以在崩潰發生時,快速的將logcat日誌儲存起來,然後再進行分析,比上面的方法稍微靈活一點,而且日誌可以留待以後繼續分析。

adb shell logcat > 1.log
ndk-stack -sym $PROJECT_PATH/obj/local/armeabi –dump 1.log

第二種方法:使用addr2line和objdump命令

這個方法適用於那些,不滿足於上述ndk-stack的簡單用法,而喜歡刨根問底的程式設計師們,這兩個方法可以揭示ndk-stack命令的工作原理是什麼,儘管用起來稍微麻煩一點,但是可以滿足一下程式設計師的好奇心。

先簡單說一下這兩個命令,在絕大部分的linux發行版本中都能找到他們,如果你的作業系統是linux,而你測試手機使用的是Intel x86系列,那麼你使用系統中自帶的命令就可以了。然而,如果僅僅是這樣,那麼絕大多數人要絕望了,因為恰恰大部分開發者使用的是Windows,而手機很有可能是armeabi系列。

別急,在NDK中自帶了適用於各個作業系統和CPU架構的工具鏈,其中就包含了這兩個命令,只不過名字稍有變化,你可以在NDK目錄的toolchains目錄下找到他們。以我的Mac電腦為例,如果我要找的是適用於armeabi架構的工具,那麼他們分別為arm-linux-androideabi-addr2linearm-linux-androideabi-objdump;位置在下面目錄中,後續介紹中將省略此位置:

/Developer/android_sdk/android-ndk-r9d/toolchains/arm-linux-androideabi-4.8/prebuilt/darwin-x86_64/bin/

假設你的電腦是windows CPU架構為mips,那麼你要的工具可能包含在這個目錄中:

D:\ android-ndk-r9d\toolchains\mipsel-linux-android-4.8\prebuilt\windows-x86_64\bin\

好了言歸正傳,如何使用這兩個工具,下面具體介紹:

1. 找到日誌中的關鍵函式指標

其實很簡單,就是找到backtrace資訊中,屬於我們自己的so檔案報錯的行。

首先要找到backtrace資訊,有的手機會明確列印一行backtrace(比如我們這次使用的手機),那麼這一行下面的一系列以“#兩位數字 pc”開頭的行就是backtrace資訊了。有時可能有的手機並不會列印一行backtrace,那麼只要找到一段以“#兩位數字 pc ”開頭的行,就可以了。


其次要找到屬於自己的so檔案報錯的行,這就比較簡單了。找到這些行之後,記下這些行中的函式地址


2. 使用addr2line查詢程式碼位置

執行如下的命令,多個指標地址可以在一個命令中帶入,以空格隔開即可

arm-linux-androideabi-addr2line –e obj/local/armeabi/libhello-jni.so 00004de8 000056c8 00004fb4 00004f58

結果如下
/android-ndk-r9d/sources/cxx-stl/stlport/stlport/stl/char_traits.h:229
/android-ndk-r9d/sources/cxx-stl/stlport/stlport/stl/_string.c:639
/WordSpaces/hello-jni/jni/hello-jni.cpp:69
/WordSpaces hello-jni/jni/hello-jni.cpp:6

addr2line的結果就能看到,我們拿到了我們自己的錯誤程式碼的呼叫關係和行數,在hello-jni.cpp69行和61行(另外兩行因為使用的是標準函式,可以忽略掉),結果和ndk-stack是一致的,說明ndk-stack也是通過addr2line來獲取程式碼位置的。

補充資訊:在本文完成後,有同學提供了下列方法,在使用addr2line查詢程式碼位置時增加 -f 引數,就可以可以獲取函式資訊,這樣可以忽略下面一小節objdump的使用

arm-linux-androideabi-addr2line -f –e obj/local/armeabi/libhello-jni.so 00004fb4

得到的結果如下
willCrash
/WordSpaces/hello-jni/jni/hello-jni.cpp:69


3. 使用objdump獲取函式資訊

通過addr2line命令,其實我們已經找到了我們程式碼中出錯的位置,已經可以幫助程式設計師定位問題所在了。但是,這個方法只能獲取程式碼行數,並沒有顯示函式資訊,顯得不那麼“完美”,對於追求極致的程式設計師來說,這當然是不夠的。下面我們就演示怎麼來定位函式資訊。

使用如下命令匯出函式表:

arm-linux-androideabi-objdump –S obj/local/armeabi/libhello-jni.so > hello.asm

在生成的asm檔案中查詢剛剛我們定位的兩個關鍵指標00004fb400004f58



從這兩張圖可以清楚的看到(要注意的是,在不同的NDK版本和不同的作業系統中,asm檔案的格式不是完全相同,但都大同小異,請大家仔細比對),這兩個指標分別屬於willCrash()JNI_OnLoad()函式,再結合剛才addr2line的結果,那麼這兩個地址分別對應的資訊就是:

00004fb4: willCrash() /WordSpaces/hello-jni/jni/hello-jni.cpp:69
00004f58: JNI_OnLoad()/WordSpaces/hello-jni/jni/hello-jni.cpp:61

相當完美,和ndk-stack得到的資訊完全一致!

使用Testin崩潰分析服務定位NDK錯誤

以上提到的方法,只適合在開發測試期間,如果你的應用或者遊戲已經發布上線,而使用者經常反饋說崩潰、閃退,指望使用者幫你收集資訊定位問題,幾乎是不可能的。這個時候,我們就需要用其他的手段來捕獲崩潰資訊。

目前業界已經有一些公司推出了崩潰資訊收集的服務,通過嵌入SDK,在程式發生崩潰時收集堆疊資訊,傳送到雲服務平臺,從而幫助開發者定位錯誤資訊。在這方面,處於領先地位的是國內的Testin和國外的crittercism,其中crittercism需要付費,而且沒有專門的中國開發者支援,我們更推薦Testin,其崩潰分析服務是完全免費的。

Testin1.4版本開始支援NDK的崩潰分析,其最新版本已經升級到1.7。當程式發生NDK錯誤時,其內嵌的SDK會收集程式在使用者手機上發生崩潰時的堆疊資訊(主要就是上面我們通過logcat日誌獲取到的函式指標)、裝置資訊、執行緒資訊等等,SDK將這些資訊上報至Testin雲服務平臺,只要登陸到Testin平臺,就可以看到所有使用者上報的崩潰資訊,包括NDK;並且這些崩潰做過歸一化的處理,在不同系統和ROM的版本上列印的資訊會略有不同,但是在Testin的網站上這些都做了很好的處理,避免了我們一些重複勞動。

上圖的紅框部分,就是從使用者手機上報的,我們自己的so中報錯的函式指標地址堆疊資訊,就和我們開發時從logcat讀到的日誌一樣,是一些晦澀難懂的指標地址,TestinNDK崩潰提供了符號化的功能,只要將我們編譯過程中產生的包含符號表的so檔案上傳(上文我們提到過的obj/local/目錄下的適用於各個CPU架構的so),就可以自動將函式指標地址定位到函式名稱和程式碼行數。符號化之後,看起來就和我們前面在本地測試的結果是一樣的了,一目瞭然。

而且使用這個功能還有一個好處:這些包含符號表的so檔案,在每次我們自己編譯之後都會改變,很有可能我們剛剛釋出一個新版本,這些目錄下的so就已經變了,因為開發者會程式的修改程式;在這樣的情況下,即使我們拿到了崩潰時的堆疊資訊,那也無法再進行符號化了。所以我們在編譯打包完成後記得備份我們的so檔案。這時我們可以將這些檔案上傳到Testin進行符號化的工作,Testin會為我們儲存和管理不同版本的so檔案,確保資訊不會丟失。來看一下符號化之後的顯示: