Android远程调试的探索与实现
作為移動(dòng)開發(fā)者,最頭疼的莫過于遇到產(chǎn)品上線以后出現(xiàn)了Bug,但是本地開發(fā)環(huán)境又無法復(fù)現(xiàn)的情況。常見的調(diào)查線上棘手問題方式大概如下:
| 聯(lián)系用戶安裝已添加測(cè)試日志的APK | 方便定位問題 | 需要用戶積極配合,如果日志添加不全面還需要反復(fù)重試 |
| 提前在一些關(guān)鍵路徑設(shè)置埋點(diǎn),在用戶出現(xiàn)問題以后上報(bào)日志進(jìn)而定位問題 | 不需要用戶深度配合 | 關(guān)鍵路徑不好預(yù)測(cè) |
以上兩種方法在之前調(diào)查線上問題時(shí)都有使用,但因?yàn)槎叨加忻黠@的缺點(diǎn),所以效果不是特別理想。
能否開發(fā)一種工具,既不需要用戶深度配合也不需要提前埋點(diǎn)就能方便、快速地定位線上問題?
作為程序員,查bug一般使用下面幾種方式:閱讀源碼、記錄日志或調(diào)試程序。一般本地?zé)o法復(fù)現(xiàn)的問題通過閱讀源碼很難找到原因,而且大多數(shù)情況都和用戶本地環(huán)境有關(guān)。記錄日志的缺點(diǎn)之前講過了,同樣不予考慮,那能否像調(diào)試本地程序一樣調(diào)試已經(jīng)發(fā)布出去的程序呢?我們對(duì)此做了一些嘗試和探索。
先看下調(diào)試原理,這里以Java為例(通過IDE調(diào)試Android程序也基于此原理)。Java(Android)程序都是運(yùn)行在Java(Dalvik\ART)虛擬機(jī)上的,要調(diào)試Java程序,就需要向Java虛擬機(jī)請(qǐng)求當(dāng)前程序運(yùn)行狀態(tài),并對(duì)虛擬機(jī)發(fā)送一定的指令,設(shè)置一些回調(diào)等等。Java的調(diào)試體系,就是虛擬機(jī)的一套用于調(diào)試的工具和接口。Java SE從1.2.2版本以后推出了JPDA框架(Java Platform Debugger Architecture,Java平臺(tái)調(diào)試體系結(jié)構(gòu))。
JPDA框架
JPDA定義了一套獨(dú)立且完整的調(diào)試體系,它由三個(gè)相對(duì)獨(dú)立的模塊組成,分別為:
- JVM TI:Java虛擬機(jī)工具接口(被調(diào)試者)。
- JDWP:Java Debug Wire Protocol,Java調(diào)試協(xié)議(通道)。
- JDI:Java Debug Interface,Java調(diào)試接口(調(diào)試者)。
這三個(gè)模塊把調(diào)試過程分解成了三個(gè)自然的概念:
- 被調(diào)試者運(yùn)行在我們想要調(diào)試的虛擬機(jī)上,它可以通過JVM TI這個(gè)標(biāo)準(zhǔn)接口監(jiān)控當(dāng)前虛擬機(jī)的信息。
- 調(diào)試者定義了用戶可以使用的調(diào)試接口,用戶可以通過這些接口對(duì)被調(diào)試虛擬機(jī)發(fā)送調(diào)試命令,同時(shí)顯示調(diào)試結(jié)果。
- 在調(diào)試者和被調(diào)試者之間,通過JDWP傳輸層傳輸消息。
整個(gè)過程如下:
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 ||--------------|下面重點(diǎn)介紹一下JDWP協(xié)議。
JDWP協(xié)議
JDWP協(xié)議是用于調(diào)試器與目標(biāo)虛擬機(jī)之間進(jìn)行調(diào)試交互的通信協(xié)議,它的通信會(huì)話主要包含兩類數(shù)據(jù)包:
- Command Packet:命令包。調(diào)試器發(fā)送給虛擬機(jī)Command,用于獲取程序狀態(tài)或控制程序執(zhí)行;虛擬機(jī)發(fā)送Command給調(diào)試器,用于通知事件觸發(fā)消息。
- Reply Packet:回復(fù)包,虛擬機(jī)發(fā)送給調(diào)試者回復(fù)命令的請(qǐng)求或者執(zhí)行結(jié)果。
JDWP的數(shù)據(jù)包主要包含包頭和數(shù)據(jù)兩部分,包頭字段含義如下:
數(shù)據(jù)包部分JDWP協(xié)議按照功能分為18組命令(以Java 7為例),包含了虛擬機(jī)、引用類型、對(duì)象、線程、方法、堆棧、事件等不同類型的操作命令。
Dalvik虛擬機(jī)/ART虛擬機(jī)對(duì)JDWP協(xié)議的支持并不完整,但是大部分關(guān)鍵命令都是支持的,具體信息可以參考Dalvik-JDWP和ART-JDWP中所支持的消息。
Android調(diào)試原理
Android調(diào)試模型可以看作JPDA框架的具體實(shí)現(xiàn)。其中變化比較大的一個(gè)是JVM TI適配了Android設(shè)備特有的Dalvik虛擬機(jī)/ART虛擬機(jī),另一個(gè)是JDWP的實(shí)現(xiàn)支持ADB和Socket兩種通信方式(ADB全稱為Android Debug Bridge,是Android系統(tǒng)的一個(gè)很重要的調(diào)試工具)。整體的調(diào)試模型如下:
____________________________________| || ADB Server (host) || |Debugger <---> LocalSocket <----> RemoteSocket || || ||___________________________||_______|||Transport ||(TCP for emulator - USB for device) ||||___________________________||_______| || || ADBD (device) || || || | Android-VM | || | JDWP-thread <====> LocalSocket <-> RemoteSocket || ||____________________________________|運(yùn)行在PC上的ADB Server和運(yùn)行在Android設(shè)備上的ADBD守護(hù)進(jìn)程之間通過USB或者無線網(wǎng)絡(luò)建立連接,分別負(fù)責(zé)Debugger和Android設(shè)備的虛擬機(jī)進(jìn)行通信。一旦連接建立起來,Debugger和Android VM通過“橋梁”進(jìn)行數(shù)據(jù)的交換,ADB Server和ADBD對(duì)它們來說是透明的。
綜上,要實(shí)現(xiàn)遠(yuǎn)程調(diào)試,關(guān)鍵需要實(shí)現(xiàn)兩部分功能:
- 能夠自定義JDWP通道。
- 能模擬ADB和ADBD實(shí)現(xiàn)消息的轉(zhuǎn)發(fā)。
先看下如何實(shí)現(xiàn)自定義JDWP通道。
JDWP啟動(dòng)過程
我們看下Android 5.0系統(tǒng)在啟動(dòng)一個(gè)應(yīng)用時(shí)是如何啟動(dòng)JDWP Thread的。
通過上圖可以看到,Android在創(chuàng)建虛擬機(jī)的同時(shí)會(huì)創(chuàng)建一個(gè)JDWP-Thread,JDWP默認(rèn)有ADB和Socket兩種通信方式。要實(shí)現(xiàn)遠(yuǎn)程調(diào)試,ADB這種方式肯定不適用,所以能否實(shí)現(xiàn)一個(gè)自定義的Socket通道來實(shí)現(xiàn)JDWP的消息轉(zhuǎn)發(fā)成了問題的關(guān)鍵。
Hack-Native-JDWP
通過閱讀JDWP啟動(dòng)源碼(Android-API-21)發(fā)現(xiàn),要想讓JDWP通過自定義的Socket通道進(jìn)行通信,需要滿足兩個(gè)條件:
- 能夠修改全局變量gJdwpOptions的值,使其配置為Socket模式,并指明對(duì)應(yīng)的端口號(hào)。
- 使用新的gJdwpOptions參數(shù)重新啟動(dòng)JDWP-Thread。
在Android中,JDWP相關(guān)代碼分別被編譯成libart.so(Art)和libdvm.so(Dalvik)。修改或調(diào)用其他so庫中的代碼需要用到動(dòng)態(tài)加載,使用動(dòng)態(tài)加載,應(yīng)用程序需要先指定要加載的庫,然后將該庫作為一個(gè)可執(zhí)行程序來使用(即調(diào)用其中的函數(shù))。動(dòng)態(tài)加載API 就是為了動(dòng)態(tài)加載而存在的,它允許共享庫對(duì)用戶空間程序可用。下面表格展示了這個(gè)完整的 API:
| dlopen | 使對(duì)象文件可被程序訪問 |
| dlsym | 獲取執(zhí)行了 dlopen 函數(shù)的對(duì)象文件中的符號(hào)的地址 |
| dlerror | 返回上一次出現(xiàn)錯(cuò)誤的字符串 |
| dlclose | 關(guān)閉目標(biāo)文件 |
在介紹如何調(diào)用動(dòng)態(tài)加載功能之前,先介紹一下C/C++編譯器在編譯目標(biāo)文件時(shí)所進(jìn)行的名字修飾(符號(hào)化)。
符號(hào)化
上文提到要想自定義JDWP-Thread,首先需要修改gJdwpOptions的值,該值是在debugger.cc中通過Dbg::ParseJdwpOptions方法來設(shè)置的,所以只要用新的配置重新調(diào)用一次ParseJdwpOptions即可。
如何找到Dbg::ParseJdwpOptions這個(gè)函數(shù)地址呢?為了保證每個(gè)函數(shù)、變量名都有唯一的標(biāo)識(shí),編譯器在將源代碼編譯成目標(biāo)文件時(shí)會(huì)對(duì)變量名或函數(shù)名進(jìn)行名字修飾。
先看一個(gè)例子,下面的C++程序中兩個(gè)f()的定義:
int f (void) { return 1; } int f (int) { return 0; } void g (void) { int i = f(), j = f(0); }這些是不同的函數(shù),除了函數(shù)名相同以外沒有任何關(guān)系。如果不做任何改變直接把它們當(dāng)成C代碼,結(jié)果將導(dǎo)致一個(gè)錯(cuò)誤:C語言不允許兩個(gè)函數(shù)同名。所以,C++編譯器將會(huì)把它們的類型信息編碼成符號(hào)名,結(jié)果類似下面的代碼:
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文件中的符號(hào)信息。
nm -D libart.so | grep ParseJdwpOptions 001778d0 T _ZN3art3Dbg16ParseJdwpOptionsERKNSt3__112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEE這樣就得到了ParseJdwpOptions函數(shù)在動(dòng)態(tài)鏈接庫文件中符號(hào)化以后的函數(shù)名。
找到符號(hào)化了的函數(shù)名后,就可以通過調(diào)用動(dòng)態(tài)鏈接庫中的函數(shù)重新啟動(dòng)JDWP-Thread。部分代碼如下(以下代碼只針對(duì)Android-API-21和Android-API-22版本有效):
void *handler = dlopen("/system/lib/libart.so", RTLD_NOW);if(handler == NULL){LOGD(LOG_TAG,env->NewStringUTF(dlerror()));}//對(duì)于debuggable false的配置,重新設(shè)置為可調(diào)試void (*allowJdwp)(bool);allowJdwp = (void (*)(bool)) dlsym(handler, "_ZN3art3Dbg14SetJdwpAllowedEb");allowJdwp(true);void (*pfun)();//關(guān)閉之前啟動(dòng)的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();以上代碼關(guān)閉了之前可能存在的JDWP-Thread,同時(shí)開啟一個(gè)本地的Socket通道來進(jìn)行通信,這樣就能通過本地的Socket通道來進(jìn)行JDWP消息的傳遞。
突破7.0動(dòng)態(tài)鏈接的限制
通過上面代碼可知,實(shí)現(xiàn)自定義的JDWP通道主要是采用動(dòng)態(tài)調(diào)用libart.so/libdvm.so中的函數(shù)實(shí)現(xiàn)。但從 Android 7.0 開始,系統(tǒng)將阻止應(yīng)用動(dòng)態(tài)鏈接非公開 NDK庫,詳情請(qǐng)參考《Android 7.0行為變更》,強(qiáng)制調(diào)用會(huì)產(chǎn)生如下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"如何繞過這個(gè)限制來動(dòng)態(tài)調(diào)用libart.so中的方法?既然直接調(diào)用dlopen會(huì)失敗,那是不是可以模擬dlopen和dlsym的實(shí)現(xiàn)來繞過這個(gè)限制?
dlopen和dlsym分別返回動(dòng)態(tài)鏈接庫在內(nèi)存中的句柄和某個(gè)符號(hào)的地址,所以只要能找到dlopen返回的句柄并通過句柄找到dlsym符號(hào)對(duì)應(yīng)的地址,就相當(dāng)于實(shí)現(xiàn)了這兩個(gè)函數(shù)的功能。libart.so會(huì)在程序啟動(dòng)之后就被加載到內(nèi)存中,可以在/proc/self/maps找到當(dāng)前進(jìn)程中l(wèi)ibart.so在內(nèi)存中映射的地址:
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被分成了三個(gè)連續(xù)子空間,從e2d50000開始。
如何才能在內(nèi)存中找到想要打開的函數(shù)地址?我們先看下ELF文件結(jié)構(gòu):
要實(shí)現(xiàn)dlsym,首先要保證查找的符號(hào)在動(dòng)態(tài)符號(hào)表中能找到,在ELF文件中,SHT_DYNSYM對(duì)應(yīng)的Section定義了當(dāng)前文件中的動(dòng)態(tài)符號(hào);SHT_STRTAB定義了動(dòng)態(tài)庫中所有字符串;SHT_PROGBITS則定義了動(dòng)態(tài)庫中定義的信息。如何找到這些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 動(dòng)態(tài)符號(hào)表的個(gè)數(shù)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;//當(dāng)前段內(nèi)容為program defined information:程序定義區(qū)case SHT_PROGBITS:if(!ctx->dynstr || !ctx->dynsym) break;//得到偏移地址ctx->bias = (off_t) sh->sh_addr - (off_t) sh->sh_offset;break;}}//關(guān)閉內(nèi)存映射munmap(elf, size);接下來就可以根據(jù)要找的符號(hào)名在SHT_DYNSYM中對(duì)應(yīng)的位置得到具體的函數(shù)指針,部分代碼如下:
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) {//動(dòng)態(tài)庫的基地址 + 當(dāng)前符號(hào)section地址 - 偏移地址return (char *)ctx->load_addr + sym->st_value - ctx->bias;}return 0; }通過以上模擬dlopen和dlsym的邏輯,我們成功繞過了系統(tǒng)將阻止應(yīng)用動(dòng)態(tài)鏈接非公開 NDK庫的限制。
消息轉(zhuǎn)發(fā)
完成上面邏輯以后就可以通過本地Socket在虛擬機(jī)和用戶進(jìn)程之間傳遞JDWP消息。但是要實(shí)現(xiàn)遠(yuǎn)程調(diào)試,還需要遠(yuǎn)程下發(fā)虛擬機(jī)的調(diào)試指令并回傳執(zhí)行結(jié)果。我們通過App原有Push通道加上線上消息轉(zhuǎn)發(fā)服務(wù),實(shí)現(xiàn)了整個(gè)調(diào)試工具的消息轉(zhuǎn)發(fā)功能:
Proguard對(duì)調(diào)試的影響
正常發(fā)布到市場(chǎng)的項(xiàng)目都會(huì)通過Proguad進(jìn)行混淆,不同力度的混淆配置會(huì)生成不同的字節(jié)碼文件。對(duì)調(diào)試功能影響比較大的配置有兩個(gè):
- LineNumberTable
- LocalVariableTable
如果Proguard中沒有對(duì)這兩個(gè)屬性進(jìn)行Keep,那經(jīng)過Proguard處理的方法字節(jié)碼中會(huì)缺失這兩個(gè)模塊,對(duì)調(diào)試的影響分別是無法在方法的某一行設(shè)置斷點(diǎn)和無法獲取當(dāng)前本地變量的值(但能獲取到方法參數(shù)變量和類成員變量)。一般為了在應(yīng)用發(fā)生崩潰時(shí)能獲取到調(diào)用棧中每個(gè)函數(shù)對(duì)應(yīng)的行號(hào),需要保留LineNumberTable,同時(shí)為了減少包體積會(huì)放棄LocalVariableTable。在沒有LocalVariableTable的情況下,可以通過調(diào)用Execute命令得到一些運(yùn)行時(shí)結(jié)果間接得獲取到本地變量。
JDI的實(shí)現(xiàn)
整個(gè)消息交互流程跑通以后,接下來要做的就是根據(jù)JDI規(guī)范作進(jìn)一步的封裝。為了方便快速調(diào)試,目前調(diào)試工具的前端實(shí)現(xiàn)主要參考了LLDB的調(diào)試流程,通過設(shè)置命令的方式進(jìn)行調(diào)試,整體樣式如下圖所示:
本文從調(diào)查線上問題的常見手段入手,介紹了到店餐飲移動(dòng)團(tuán)隊(duì)在實(shí)現(xiàn)遠(yuǎn)程調(diào)試過程中的嘗試和探索。通過遠(yuǎn)程調(diào)試可以方便快捷地獲取用戶當(dāng)前App運(yùn)行時(shí)的狀態(tài),助力開發(fā)者快速定位線上問題。
武智,Android高級(jí)開發(fā)工程師,2013年7月校招加入美團(tuán),目前負(fù)責(zé)維護(hù)大眾點(diǎn)評(píng)App的美食頻道。
瑩瑩,2015年校招加入美團(tuán),主要參與大眾點(diǎn)評(píng)美食頻道的日常開發(fā)工作,專注于通過工具自動(dòng)化地提高開發(fā)效率和質(zhì)量。
周佳、盧晟、永鋒,2016年校招加入美團(tuán),主要參與大眾點(diǎn)評(píng)美食頻道的日常開發(fā)工作。
到店餐飲技術(shù)部交易與信息技術(shù)中心,負(fù)責(zé)美團(tuán)美食用戶端業(yè)務(wù),服務(wù)于數(shù)以億計(jì)用戶,通過更好的榜單、真實(shí)的評(píng)價(jià)和完善的信息為用戶提供更好的決策支持,致力于提升用戶體驗(yàn)。我們同時(shí)承載所有餐飲商戶端線上流量,為餐飲商戶提供多種營(yíng)銷工具,提升餐飲商戶營(yíng)銷效率,最終達(dá)到讓國(guó)人“Eat Better、Live Better”的美好愿景!我們的團(tuán)隊(duì)包含且不限于Android、iOS、FE、Java、PHP等技術(shù)方向,已完備覆蓋前后端技術(shù)棧。只要你來,就能點(diǎn)亮全棧開發(fā)技能樹。誠摯歡迎投遞簡(jiǎn)歷至wangying49#meituan.com。
總結(jié)
以上是生活随笔為你收集整理的Android远程调试的探索与实现的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 为Spring Cloud Ribbon
- 下一篇: Java多线程系列(二):线程的五大状态