作為移動開發者,最頭疼的莫過于遇到産品上線以後出現了bug,但是本地開發環境又無法複現的情況。常見的調查線上棘手問題方式大概如下:
以上兩種方法在之前調查線上問題時都有使用,但因為二者都有明顯的缺點,是以效果不是特别理想。
能否開發一種工具,既不需要使用者深度配合也不需要提前埋點就能友善、快速地定位線上問題?
作為程式員,查bug一般使用下面幾種方式:閱讀源碼、記錄日志或調試程式。一般本地無法複現的問題通過閱讀源碼很難找到原因,而且大多數情況都和使用者本地環境有關。記錄日志的缺點之前講過了,同樣不予考慮,那能否像調試本地程式一樣調試已經釋出出去的程式呢?我們對此做了一些嘗試和探索。
調試原理
先看下調試原理,這裡以Java為例(通過IDE調試Android程式也基于此原理)。Java(Android)程式都是運作在Java(Dalvik\ART)虛拟機上的,要調試Java程式,就需要向Java虛拟機請求目前程式運作狀态,并對虛拟機發送一定的指令,設定一些回調等等。Java的調試體系,就是虛拟機的一套用于調試的工具和接口。Java SE從1.2.2版本以後推出了JPDA架構(Java Platform Debugger Architecture,Java平台調試體系結構)。
JPDA架構
JPDA定義了一套獨立且完整的調試體系,它由三個相對獨立的子產品組成,分别為:
- JVM TI:Java虛拟機工具接口(被調試者)。
- JDWP:Java Debug Wire Protocol,Java調試協定(通道)。
- JDI:Java Debug Interface,Java調試接口(調試者)。
這三個子產品把調試過程分解成了三個自然的概念:
- 被調試者運作在我們想要調試的虛拟機上,它可以通過JVM TI這個标準接口監控目前虛拟機的資訊。
- 調試者定義了使用者可以使用的調試接口,使用者可以通過這些接口對被調試虛拟機發送調試指令,同時顯示調試結果。
- 在調試者和被調試者之間,通過JDWP傳輸層傳輸消息。
整個過程如下:
Components Debugger Interfaces
/ |--------------|
/ | VM |
debuggee ----( |--------------|
下面重點介紹一下JDWP協定。
JDWP協定
JDWP協定是用于調試器與目标虛拟機之間進行調試互動的通信協定,它的通信會話主要包含兩類資料包:
- Command Packet:指令包。調試器發送給虛拟機Command,用于擷取程式狀态或控制程式執行;虛拟機發送Command給調試器,用于通知事件觸發消息。
- Reply Packet:回複包,虛拟機發送給調試者回複指令的請求或者執行結果。
JDWP的資料包主要包含標頭和資料兩部分,標頭字段含義如下:
資料包部分JDWP協定按照功能分為18組指令(以Java 7為例),包含了虛拟機、引用類型、對象、線程、方法、堆棧、事件等不同類型的操作指令。
Dalvik虛拟機/ART虛拟機對JDWP協定的支援并不完整,但是大部分關鍵指令都是支援的,具體資訊可以參考Dalvik-JDWP和ART-JDWP中所支援的消息。
Android調試原理
Android調試模型可以看作JPDA架構的具體實作。其中變化比較大的一個是JVM TI适配了Android裝置特有的Dalvik虛拟機/ART虛拟機,另一個是JDWP的實作支援ADB和Socket兩種通信方式(ADB全稱為Android Debug Bridge,是Android系統的一個很重要的調試工具)。整體的調試模型如下:
ADB Server (host) |
| |
Debugger LocalSocket RemoteSocket |
| || |
|___________________________||_______|
||
Transport || (TCP for emulator - USB for device) ||
||
___________________________||_______
| || |
| ADBD (device) || |
| || |
Android-VM | || |
JDWP-thread <====> LocalSocket RemoteSocket |
| |
|____________________________________|
運作在PC上的ADB Server和運作在Android裝置上的ADBD守護程序之間通過USB或者無線網絡建立連接配接,分别負責Debugger和Android裝置的虛拟機進行通信。一旦連接配接建立起來,Debugger和Android VM通過“橋梁”進行資料的交換,ADB Server和ADBD對它們來說是透明的。
遠端調試
綜上,要實作遠端調試,關鍵需要實作兩部分功能:
- 能夠自定義JDWP通道。
- 能模拟ADB和ADBD實作消息的轉發。
先看下如何實作自定義JDWP通道。
JDWP啟動過程
我們看下Android 5.0系統在啟動一個應用時是如何啟動JDWP Thread的。
通過上圖可以看到,Android在建立虛拟機的同時會建立一個JDWP-Thread,JDWP預設有ADB和Socket兩種通信方式。要實作遠端調試,ADB這種方式肯定不适用,是以能否實作一個自定義的Socket通道來實作JDWP的消息轉發成了問題的關鍵。
Hack-Native-JDWP
通過閱讀JDWP啟動源碼(Android-API-21)發現,要想讓JDWP通過自定義的Socket通道進行通信,需要滿足兩個條件:
- 能夠修改全局變量gJdwpOptions的值,使其配置為Socket模式,并指明對應的端口号。
- 使用新的gJdwpOptions參數重新啟動JDWP-Thread。
在Android中,JDWP相關代碼分别被編譯成libart.so(Art)和libdvm.so(Dalvik)。修改或調用其他so庫中的代碼需要用到動态加載,使用動态加載,應用程式需要先指定要加載的庫,然後将該庫作為一個可執行程式來使用(即調用其中的函數)。動态加載API 就是為了動态加載而存在的,它允許共享庫對使用者空間程式可用。下面表格展示了這個完整的 API:
在介紹如何調用動态加載功能之前,先介紹一下C/C++編譯器在編譯目标檔案時所進行的名字修飾(符号化)。
符号化
上文提到要想自定義JDWP-Thread,首先需要修改gJdwpOptions的值,該值是在debugger.cc中通過Dbg::ParseJdwpOptions方法來設定的,是以隻要用新的配置重新調用一次ParseJdwpOptions即可。
如何找到Dbg::ParseJdwpOptions這個函數位址呢?為了保證每個函數、變量名都有唯一的辨別,編譯器在将源代碼編譯成目标檔案時會對變量名或函數名進行名字修飾。
先看一個例子,下面的C++程式中兩個f()的定義:
int f (void) {
這些是不同的函數,除了函數名相同以外沒有任何關系。如果不做任何改變直接把它們當成C代碼,結果将導緻一個錯誤:C語言不允許兩個函數同名。是以,C++編譯器将會把它們的類型資訊編碼成符号名,結果類似下面的代碼:
int __f_v (
可以通過nm指令檢視so檔案中的符号資訊。
nm -D libart.so | grep ParseJdwpOptions
001778d0 T _ZN3art3Dbg16ParseJdwpOptionsERKNSt3__112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEE
這樣就得到了ParseJdwpOptions函數在動态連結庫檔案中符号化以後的函數名。
找到符号化了的函數名後,就可以通過調用動态連結庫中的函數重新啟動JDWP-Thread。部分代碼如下(以下代碼隻針對Android-API-21和Android-API-22版本有效):
void *handler = dlopen(
以上代碼關閉了之前可能存在的JDWP-Thread,同時開啟一個本地的Socket通道來進行通信,這樣就能通過本地的Socket通道來進行JDWP消息的傳遞。
突破7.0動态連結的限制
通過上面代碼可知,實作自定義的JDWP通道主要是采用動态調用libart.so/libdvm.so中的函數實作。但從 Android 7.0 開始,系統将阻止應用動态連結非公開 NDK庫,詳情請參考《Android 7.0行為變更》,強制調用會産生如下Crash:
"/system/lib/libart.so" needed or dlopened by
如何繞過這個限制來動态調用libart.so中的方法?既然直接調用dlopen會失敗,那是不是可以模拟dlopen和dlsym的實作來繞過這個限制?
dlopen和dlsym分别傳回動态連結庫在記憶體中的句柄和某個符号的位址,是以隻要能找到dlopen傳回的句柄并通過句柄找到dlsym符号對應的位址,就相當于實作了這兩個函數的功能。libart.so會在程式啟動之後就被加載到記憶體中,可以在/proc/self/maps找到目前程序中libart.so在記憶體中映射的位址:
vbox86p:/ # cat /proc/1665/maps | grep libart.so
e2d50000-e3473000 r-xp 00000000 08:06 1087 /system/lib/libart.so
e3474000-e347c000 r--p 00723000 08:06 1087 /system/lib/libart.so
e347c000-e347e000 rw-p 0072b000 08:06 1087 /system/lib/libart.so
這裡libart.so被分成了三個連續子空間,從e2d50000開始。
如何才能在記憶體中找到想要打開的函數位址?我們先看下ELF檔案結構:
要實作dlsym,首先要保證查找的符号在動态符号表中能找到,在ELF檔案中,SHT_DYNSYM對應的Section定義了目前檔案中的動态符号;SHT_STRTAB定義了動态庫中所有字元串;SHT_PROGBITS則定義了動态庫中定義的資訊。如何找到這些Section:
- 通過記憶體映射的方式把libart.so映射到記憶體中;
- 按照ELF檔案結構解析映射到記憶體中的libart.so;
- 解析SHT_DYNSYM,并把目前section複制到記憶體中;
- 解析SHT_STRTAB,并把目前section複制到記憶體中(後面需要根據SHT_STRTAB來找到特定的符号);
- 解析SHT_PROGBITS,得到目前記憶體映射的偏移位址,這裡要注意:不同程序中相同動态庫的同一個函數的偏移位址是一樣的。
以上邏輯的部分代碼片段如下:
0, SEEK_END);
接下來就可以根據要找的符号名在SHT_DYNSYM中對應的位置得到具體的函數指針,部分代碼如下:
void *fake_dlsym(void *handle, const char *name){
通過以上模拟dlopen和dlsym的邏輯,我們成功繞過了系統将阻止應用動态連結非公開 NDK庫的限制。
消息轉發
完成上面邏輯以後就可以通過本地Socket在虛拟機和使用者程序之間傳遞JDWP消息。但是要實作遠端調試,還需要遠端下發虛拟機的調試指令并回傳執行結果。我們通過App原有Push通道加上線上消息轉發服務,實作了整個調試工具的消息轉發功能:
Proguard對調試的影響
正常釋出到市場的項目都會通過Proguad進行混淆,不同力度的混淆配置會生成不同的位元組碼檔案。對調試功能影響比較大的配置有兩個:
- LineNumberTable
- LocalVariableTable
如果Proguard中沒有對這兩個屬性進行Keep,那經過Proguard處理的方法位元組碼中會缺失這兩個子產品,對調試的影響分别是無法在方法的某一行設定斷點和無法擷取目前本地變量的值(但能擷取到方法參數變量和類成員變量)。一般為了在應用發生崩潰時能擷取到調用棧中每個函數對應的行号,需要保留LineNumberTable,同時為了減少包體積會放棄LocalVariableTable。在沒有LocalVariableTable的情況下,可以通過調用Execute指令得到一些運作時結果間接得擷取到本地變量。
JDI的實作
整個消息互動流程跑通以後,接下來要做的就是根據JDI規範作進一步的封裝。為了友善快速調試,目前調試工具的前端實作主要參考了LLDB的調試流程,通過設定指令的方式進行調試,整體樣式如下圖所示:
總結
本文從調查線上問題的常見手段入手,介紹了到店餐飲移動團隊在實作遠端調試過程中的嘗試和探索。通過遠端調試可以友善快捷地擷取使用者目前App運作時的狀态,助力開發者快速定位線上問題。
---------- END ----------
*本文轉載自美團技術團隊,感謝授權
本文觀點不代表我方觀點