Android远程调试的探索与实现
作為移動開發者,最頭疼的莫過于遇到產品上線以后出現了Bug,但是本地開發環境又無法復現的情況。常見的調查線上棘手問題方式大概如下:
| 聯系用戶安裝已添加測試日志的APK | 方便定位問題 | 需要用戶積極配合,如果日志添加不全面還需要反復重試 |
| 提前在一些關鍵路徑設置埋點,在用戶出現問題以后上報日志進而定位問題 | 不需要用戶深度配合 | 關鍵路徑不好預測 |
以上兩種方法在之前調查線上問題時都有使用,但因為二者都有明顯的缺點,所以效果不是特別理想。
能否開發一種工具,既不需要用戶深度配合也不需要提前埋點就能方便、快速地定位線上問題?
作為程序員,查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 ----( |--------------| <------- JVM TI - Java VM Tool Interface\ | back-end |\ |--------------|/ |comm channel -( | <--------------- JDWP - Java Debug Wire Protocol\ ||--------------|| front-end ||--------------| <------- JDI - Java Debug Interface| UI ||--------------|下面重點介紹一下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:
| dlopen | 使對象文件可被程序訪問 |
| dlsym | 獲取執行了 dlopen 函數的對象文件中的符號的地址 |
| dlerror | 返回上一次出現錯誤的字符串 |
| dlclose | 關閉目標文件 |
在介紹如何調用動態加載功能之前,先介紹一下C/C++編譯器在編譯目標文件時所進行的名字修飾(符號化)。
符號化
上文提到要想自定義JDWP-Thread,首先需要修改gJdwpOptions的值,該值是在debugger.cc中通過Dbg::ParseJdwpOptions方法來設置的,所以只要用新的配置重新調用一次ParseJdwpOptions即可。
如何找到Dbg::ParseJdwpOptions這個函數地址呢?為了保證每個函數、變量名都有唯一的標識,編譯器在將源代碼編譯成目標文件時會對變量名或函數名進行名字修飾。
先看一個例子,下面的C++程序中兩個f()的定義:
int f (void) { return 1; } int f (int) { return 0; } void g (void) { int i = f(), j = f(0); }這些是不同的函數,除了函數名相同以外沒有任何關系。如果不做任何改變直接把它們當成C代碼,結果將導致一個錯誤:C語言不允許兩個函數同名。所以,C++編譯器將會把它們的類型信息編碼成符號名,結果類似下面的代碼:
int __f_v (void) { return 1; } int __f_i (int) { return 0; } void __g_v (void) { int i = __f_v(), j = __f_i(0); }可以通過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("/system/lib/libart.so", RTLD_NOW);if(handler == NULL){LOGD(LOG_TAG,env->NewStringUTF(dlerror()));}//對于debuggable false的配置,重新設置為可調試void (*allowJdwp)(bool);allowJdwp = (void (*)(bool)) dlsym(handler, "_ZN3art3Dbg14SetJdwpAllowedEb");allowJdwp(true);void (*pfun)();//關閉之前啟動的jdwp-threadpfun = (void (*)()) dlsym(handler, "_ZN3art3Dbg8StopJdwpEv");pfun();//重新配置gJdwpOptionsbool (*parseJdwpOptions)(const std::string&);parseJdwpOptions = (bool (*)(const std::string&)) dlsym(handler,"_ZN3art3Dbg16ParseJdwpOptionsERKNSt3__112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEE");std::string options = "transport=dt_socket,address=8000,server=y,suspend=n";parseJdwpOptions(options);//重新startJdwppfun = (void (*)()) dlsym(handler, "_ZN3art3Dbg9StartJdwpEv");pfun();以上代碼關閉了之前可能存在的JDWP-Thread,同時開啟一個本地的Socket通道來進行通信,這樣就能通過本地的Socket通道來進行JDWP消息的傳遞。
突破7.0動態鏈接的限制
通過上面代碼可知,實現自定義的JDWP通道主要是采用動態調用libart.so/libdvm.so中的函數實現。但從 Android 7.0 開始,系統將阻止應用動態鏈接非公開 NDK庫,詳情請參考《Android 7.0行為變更》,強制調用會產生如下Crash:
java.lang.UnsatisfiedLinkError: dlopen failed: library "/system/lib/libart.so" needed or dlopened by "/system/lib/libnativeloader.so" is not accessible for the namespace "classloader-namespace"如何繞過這個限制來動態調用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:
以上邏輯的部分代碼片段如下:
fd = open(libpath, O_RDONLY);size = lseek(fd, 0, SEEK_END);if(size <= 0) fatal("lseek() failed for %s", libpath);elf = (Elf_Ehdr *) mmap(0, size, PROT_READ, MAP_SHARED, fd, 0);close(fd);fd = -1;if(elf == MAP_FAILED) fatal("mmap() failed for %s", libpath);ctx = (struct ctx *) calloc(1, sizeof(struct ctx));if(!ctx) fatal("no memory for %s", libpath);//通過/proc/self/proc 找到的libart.so的起始地址ctx->load_addr = (void *) load_addr;shoff = ((char *) elf) + elf->e_shoff;for(k = 0; k < elf->e_shnum; k++) {shoff = (char *)shoff + elf->e_shentsize;Elf_Shdr *sh = (Elf_Shdr *) shoff;log_dbg("%s: k=%d shdr=%p type=%x", __func__, k, sh, sh->sh_type);switch(sh->sh_type) {case SHT_DYNSYM:if(ctx->dynsym) fatal("%s: duplicate DYNSYM sections", libpath); /* .dynsym */ctx->dynsym = malloc(sh->sh_size);if(!ctx->dynsym) fatal("%s: no memory for .dynsym", libpath);memcpy(ctx->dynsym, ((char *) elf) + sh->sh_offset, sh->sh_size);//ctx->nsyms 動態符號表的個數ctx->nsyms = (sh->sh_size/sizeof(Elf_Sym)) ;break;case SHT_STRTAB:if(ctx->dynstr) break; /* .dynstr is guaranteed to be the first STRTAB */ctx->dynstr = malloc(sh->sh_size);if(!ctx->dynstr) fatal("%s: no memory for .dynstr", libpath);memcpy(ctx->dynstr, ((char *) elf) + sh->sh_offset, sh->sh_size);break;//當前段內容為program defined information:程序定義區case SHT_PROGBITS:if(!ctx->dynstr || !ctx->dynsym) break;//得到偏移地址ctx->bias = (off_t) sh->sh_addr - (off_t) sh->sh_offset;break;}}//關閉內存映射munmap(elf, size);接下來就可以根據要找的符號名在SHT_DYNSYM中對應的位置得到具體的函數指針,部分代碼如下:
void *fake_dlsym(void *handle, const char *name) {int k;struct ctx *ctx = (struct ctx *) handle;Elf_Sym *sym = (Elf_Sym *) ctx->dynsym;char *strings = (char *) ctx->dynstr;for(k = 0; k < ctx->nsyms; k++, sym++)if(strcmp(strings + sym->st_name, name) == 0) {//動態庫的基地址 + 當前符號section地址 - 偏移地址return (char *)ctx->load_addr + sym->st_value - ctx->bias;}return 0; }通過以上模擬dlopen和dlsym的邏輯,我們成功繞過了系統將阻止應用動態鏈接非公開 NDK庫的限制。
消息轉發
完成上面邏輯以后就可以通過本地Socket在虛擬機和用戶進程之間傳遞JDWP消息。但是要實現遠程調試,還需要遠程下發虛擬機的調試指令并回傳執行結果。我們通過App原有Push通道加上線上消息轉發服務,實現了整個調試工具的消息轉發功能:
Proguard對調試的影響
正常發布到市場的項目都會通過Proguad進行混淆,不同力度的混淆配置會生成不同的字節碼文件。對調試功能影響比較大的配置有兩個:
- LineNumberTable
- LocalVariableTable
如果Proguard中沒有對這兩個屬性進行Keep,那經過Proguard處理的方法字節碼中會缺失這兩個模塊,對調試的影響分別是無法在方法的某一行設置斷點和無法獲取當前本地變量的值(但能獲取到方法參數變量和類成員變量)。一般為了在應用發生崩潰時能獲取到調用棧中每個函數對應的行號,需要保留LineNumberTable,同時為了減少包體積會放棄LocalVariableTable。在沒有LocalVariableTable的情況下,可以通過調用Execute命令得到一些運行時結果間接得獲取到本地變量。
JDI的實現
整個消息交互流程跑通以后,接下來要做的就是根據JDI規范作進一步的封裝。為了方便快速調試,目前調試工具的前端實現主要參考了LLDB的調試流程,通過設置命令的方式進行調試,整體樣式如下圖所示:
本文從調查線上問題的常見手段入手,介紹了到店餐飲移動團隊在實現遠程調試過程中的嘗試和探索。通過遠程調試可以方便快捷地獲取用戶當前App運行時的狀態,助力開發者快速定位線上問題。
武智,Android高級開發工程師,2013年7月校招加入美團,目前負責維護大眾點評App的美食頻道。
瑩瑩,2015年校招加入美團,主要參與大眾點評美食頻道的日常開發工作,專注于通過工具自動化地提高開發效率和質量。
周佳、盧晟、永鋒,2016年校招加入美團,主要參與大眾點評美食頻道的日常開發工作。
到店餐飲技術部交易與信息技術中心,負責美團美食用戶端業務,服務于數以億計用戶,通過更好的榜單、真實的評價和完善的信息為用戶提供更好的決策支持,致力于提升用戶體驗。我們同時承載所有餐飲商戶端線上流量,為餐飲商戶提供多種營銷工具,提升餐飲商戶營銷效率,最終達到讓國人“Eat Better、Live Better”的美好愿景!我們的團隊包含且不限于Android、iOS、FE、Java、PHP等技術方向,已完備覆蓋前后端技術棧。只要你來,就能點亮全棧開發技能樹。誠摯歡迎投遞簡歷至wangying49#meituan.com。
總結
以上是生活随笔為你收集整理的Android远程调试的探索与实现的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 为Spring Cloud Ribbon
- 下一篇: Java多线程系列(二):线程的五大状态