文章來源:美團點評技術(shù)團隊
作為移動開發(fā)者泼差,最頭疼的莫過于遇到產(chǎn)品上線以后出現(xiàn)了bug躯概,但是本地開發(fā)環(huán)境又無法復(fù)現(xiàn)的情況年栓。常見的調(diào)查線上棘手問題方式大概如下:
方法優(yōu)點缺點
聯(lián)系用戶安裝已添加測試日志的APK方便定位問題需要用戶積極配合流纹,如果日志添加不全面還需要反復(fù)重試
提前在一些關(guān)鍵路徑設(shè)置埋點糜烹,在用戶出現(xiàn)問題以后上報日志進而定位問題不需要用戶深度配合關(guān)鍵路徑不好預(yù)測
以上兩種方法在之前調(diào)查線上問題時都有使用,但因為二者都有明顯的缺點漱凝,所以效果不是特別理想疮蹦。
能否開發(fā)一種工具,既不需要用戶深度配合也不需要提前埋點就能方便茸炒、快速地定位線上問題愕乎?
作為程序員,查bug一般使用下面幾種方式:閱讀源碼壁公、記錄日志或調(diào)試程序感论。一般本地無法復(fù)現(xiàn)的問題通過閱讀源碼很難找到原因,而且大多數(shù)情況都和用戶本地環(huán)境有關(guān)紊册。記錄日志的缺點之前講過了比肄,同樣不予考慮,那能否像調(diào)試本地程序一樣調(diào)試已經(jīng)發(fā)布出去的程序呢囊陡?我們對此做了一些嘗試和探索芳绩。
調(diào)試原理
先看下調(diào)試原理,這里以Java為例(通過IDE調(diào)試Android程序也基于此原理)撞反。Java(Android)程序都是運行在Java(Dalvik\ART)虛擬機上的妥色,要調(diào)試Java程序,就需要向Java虛擬機請求當前程序運行狀態(tài)遏片,并對虛擬機發(fā)送一定的指令嘹害,設(shè)置一些回調(diào)等等。Java的調(diào)試體系吮便,就是虛擬機的一套用于調(diào)試的工具和接口笔呀。Java SE從1.2.2版本以后推出了JPDA框架(Java Platform Debugger Architecture,Java平臺調(diào)試體系結(jié)構(gòu))线衫。
JPDA框架
JPDA定義了一套獨立且完整的調(diào)試體系凿可,它由三個相對獨立的模塊組成惑折,分別為:
JVM TI:Java虛擬機工具接口(被調(diào)試者)授账。
JDWP:Java Debug Wire Protocol枯跑,Java調(diào)試協(xié)議(通道)。
JDI:Java Debug Interface白热,Java調(diào)試接口(調(diào)試者)敛助。
這三個模塊把調(diào)試過程分解成了三個自然的概念:
被調(diào)試者運行在我們想要調(diào)試的虛擬機上,它可以通過JVM TI這個標準接口監(jiān)控當前虛擬機的信息屋确。
調(diào)試者定義了用戶可以使用的調(diào)試接口纳击,用戶可以通過這些接口對被調(diào)試虛擬機發(fā)送調(diào)試命令,同時顯示調(diào)試結(jié)果攻臀。
在調(diào)試者和被調(diào)試者之間焕数,通過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協(xié)議刨啸。
JDWP協(xié)議
JDWP協(xié)議是用于調(diào)試器與目標虛擬機之間進行調(diào)試交互的通信協(xié)議堡赔,它的通信會話主要包含兩類數(shù)據(jù)包:
Command Packet:命令包。調(diào)試器發(fā)送給虛擬機Command设联,用于獲取程序狀態(tài)或控制程序執(zhí)行善已;虛擬機發(fā)送Command給調(diào)試器,用于通知事件觸發(fā)消息离例。
Reply Packet:回復(fù)包换团,虛擬機發(fā)送給調(diào)試者回復(fù)命令的請求或者執(zhí)行結(jié)果。
JDWP的數(shù)據(jù)包主要包含包頭和數(shù)據(jù)兩部分宫蛆,包頭字段含義如下:
數(shù)據(jù)包部分JDWP協(xié)議按照功能分為18組命令(以Java 7為例)艘包,包含了虛擬機、引用類型洒扎、對象辑甜、線程、方法袍冷、堆棧磷醋、事件等不同類型的操作命令。
Dalvik虛擬機/ART虛擬機對JDWP協(xié)議的支持并不完整胡诗,但是大部分關(guān)鍵命令都是支持的邓线,具體信息可以參考Dalvik-JDWP和ART-JDWP中所支持的消息。
Android調(diào)試原理
Android調(diào)試模型可以看作JPDA框架的具體實現(xiàn)煌恢。其中變化比較大的一個是JVM TI適配了Android設(shè)備特有的Dalvik虛擬機/ART虛擬機骇陈,另一個是JDWP的實現(xiàn)支持ADB和Socket兩種通信方式(ADB全稱為Android Debug Bridge,是Android系統(tǒng)的一個很重要的調(diào)試工具)瑰抵。整體的調(diào)試模型如下:
____________________________________? ? ? ? ? ? |? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? |? ? ? ? ? ? |ADBServer(host)|? ? ? ? ? ? |? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? | Debugger <---> LocalSocket <----> RemoteSocket? |? ? ? ? ? ? |? ? ? ? ? ? ? ? ? ? ? ? ? ||? ? ? |? ? ? ? ? ? |___________________________||_______|? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ||? ? ? ? ? ? ? ? ? ? ? ? ? ? ? Transport ||(TCPforemulator - USBfordevice)||? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ||? ? ? ? ? ? ___________________________||_______? ? ? ? ? ? |? ? ? ? ? ? ? ? ? ? ? ? ? ||? ? ? |? ? ? ? ? ? |ADBD(device)||? ? ? |? ? ? ? ? ? |? ? ? ? ? ? ? ? ? ? ? ? ? ||? ? ? |Android-VM? |? ? ? ? ? ? ? ? ? ? ? ? ? ||? ? ? |JDWP-thread <====> LocalSocket <-> RemoteSocket? |? ? ? ? ? ? |? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? |? ? ? ? ? ? |____________________________________|
運行在PC上的ADB Server和運行在Android設(shè)備上的ADBD守護進程之間通過USB或者無線網(wǎng)絡(luò)建立連接你雌,分別負責Debugger和Android設(shè)備的虛擬機進行通信。一旦連接建立起來,Debugger和Android VM通過“橋梁”進行數(shù)據(jù)的交換婿崭,ADB Server和ADBD對它們來說是透明的拨拓。
遠程調(diào)試
綜上,要實現(xiàn)遠程調(diào)試氓栈,關(guān)鍵需要實現(xiàn)兩部分功能:
能夠自定義JDWP通道渣磷。
能模擬ADB和ADBD實現(xiàn)消息的轉(zhuǎn)發(fā)。
先看下如何實現(xiàn)自定義JDWP通道授瘦。
JDWP啟動過程
我們看下Android 5.0系統(tǒng)在啟動一個應(yīng)用時是如何啟動JDWP Thread的醋界。
點擊圖片查看大圖
通過上圖可以看到,Android在創(chuàng)建虛擬機的同時會創(chuàng)建一個JDWP-Thread提完,JDWP默認有ADB和Socket兩種通信方式形纺。要實現(xiàn)遠程調(diào)試,ADB這種方式肯定不適用徒欣,所以能否實現(xiàn)一個自定義的Socket通道來實現(xiàn)JDWP的消息轉(zhuǎn)發(fā)成了問題的關(guān)鍵挡篓。
Hack-Native-JDWP
通過閱讀JDWP啟動源碼(Android-API-21)發(fā)現(xiàn),要想讓JDWP通過自定義的Socket通道進行通信帚称,需要滿足兩個條件:
能夠修改全局變量gJdwpOptions的值官研,使其配置為Socket模式,并指明對應(yīng)的端口號闯睹。
使用新的gJdwpOptions參數(shù)重新啟動JDWP-Thread戏羽。
在Android中,JDWP相關(guān)代碼分別被編譯成libart.so(Art)和libdvm.so(Dalvik)楼吃。修改或調(diào)用其他so庫中的代碼需要用到動態(tài)加載始花,使用動態(tài)加載,應(yīng)用程序需要先指定要加載的庫孩锡,然后將該庫作為一個可執(zhí)行程序來使用(即調(diào)用其中的函數(shù))酷宵。動態(tài)加載API 就是為了動態(tài)加載而存在的,它允許共享庫對用戶空間程序可用躬窜。下面表格展示了這個完整的 API:
函數(shù)描述
dlopen使對象文件可被程序訪問
dlsym獲取執(zhí)行了dlopen函數(shù)的對象文件中的符號的地址
dlerror返回上一次出現(xiàn)錯誤的字符串
dlclose關(guān)閉目標文件
在介紹如何調(diào)用動態(tài)加載功能之前浇垦,先介紹一下C/C++編譯器在編譯目標文件時所進行的名字修飾(符號化)。
符號化
上文提到要想自定義JDWP-Thread荣挨,首先需要修改gJdwpOptions的值男韧,該值是在debugger.cc中通過Dbg::ParseJdwpOptions方法來設(shè)置的,所以只要用新的配置重新調(diào)用一次ParseJdwpOptions即可默垄。
如何找到Dbg::ParseJdwpOptions這個函數(shù)地址呢此虑?為了保證每個函數(shù)、變量名都有唯一的標識口锭,編譯器在將源代碼編譯成目標文件時會對變量名或函數(shù)名進行名字修飾朦前。
先看一個例子,下面的C++程序中兩個f()的定義:
intf(void){return1; }intf(int){return0; }voidg(void){inti = f(), j = f(0); }
這些是不同的函數(shù),除了函數(shù)名相同以外沒有任何關(guān)系韭寸。如果不做任何改變直接把它們當成C代碼这溅,結(jié)果將導(dǎo)致一個錯誤:C語言不允許兩個函數(shù)同名。所以棒仍,C++編譯器將會把它們的類型信息編碼成符號名,結(jié)果類似下面的代碼:
int__f_v (void) {return1; }int__f_i (int)? {return0; }void__g_v (void) {inti = __f_v(), j = __f_i(0); }
可以通過nm命令查看so文件中的符號信息臭胜。
nm -D libart.so | grep ParseJdwpOptions
001778d0 T _ZN3art3Dbg16ParseJdwpOptionsERKNSt3__112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEE
這樣就得到了ParseJdwpOptions函數(shù)在動態(tài)鏈接庫文件中符號化以后的函數(shù)名莫其。
找到符號化了的函數(shù)名后,就可以通過調(diào)用動態(tài)鏈接庫中的函數(shù)重新啟動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的配置乱陡,重新設(shè)置為可調(diào)試void(*allowJdwp)(bool);? ? allowJdwp = (void(*)(bool)) dlsym(handler,"_ZN3art3Dbg14SetJdwpAllowedEb");? ? allowJdwp(true);void(*pfun)();//關(guān)閉之前啟動的jdwp-threadpfun = (void(*)()) dlsym(handler,"_ZN3art3Dbg8StopJdwpEv");? ? pfun();//重新配置gJdwpOptionsbool(*parseJdwpOptions)(conststd::string&);? ? parseJdwpOptions = (bool(*)(conststd::string&)) dlsym(handler,"_ZN3art3Dbg16ParseJdwpOptionsERKNSt3__112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEE");std::stringoptions ="transport=dt_socket,address=8000,server=y,suspend=n";? ? parseJdwpOptions(options);//重新startJdwppfun = (void(*)()) dlsym(handler,"_ZN3art3Dbg9StartJdwpEv");? ? pfun();
以上代碼關(guān)閉了之前可能存在的JDWP-Thread,同時開啟一個本地的Socket通道來進行通信仪壮,這樣就能通過本地的Socket通道來進行JDWP消息的傳遞憨颠。
突破7.0動態(tài)鏈接的限制
通過上面代碼可知,實現(xiàn)自定義的JDWP通道主要是采用動態(tài)調(diào)用libart.so/libdvm.so中的函數(shù)實現(xiàn)积锅。但從 Android 7.0 開始爽彤,系統(tǒng)將阻止應(yīng)用動態(tài)鏈接非公開 NDK庫,詳情請參考《Android 7.0行為變更》缚陷,強制調(diào)用會產(chǎn)生如下Crash:
java.lang.UnsatisfiedLinkError: dlopen failed: library"/system/lib/libart.so"needed or dlopened by"/system/lib/libnativeloader.so"is not accessibleforthe namespace"classloader-namespace"
如何繞過這個限制來動態(tài)調(diào)用libart.so中的方法适篙?既然直接調(diào)用dlopen會失敗,那是不是可以模擬dlopen和dlsym的實現(xiàn)來繞過這個限制箫爷?
dlopen和dlsym分別返回動態(tài)鏈接庫在內(nèi)存中的句柄和某個符號的地址嚷节,所以只要能找到dlopen返回的句柄并通過句柄找到dlsym符號對應(yīng)的地址,就相當于實現(xiàn)了這兩個函數(shù)的功能虎锚。libart.so會在程序啟動之后就被加載到內(nèi)存中硫痰,可以在/proc/self/maps找到當前進程中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被分成了三個連續(xù)子空間,從e2d50000開始窜护。
如何才能在內(nèi)存中找到想要打開的函數(shù)地址效斑?我們先看下ELF文件結(jié)構(gòu):
要實現(xiàn)dlsym,首先要保證查找的符號在動態(tài)符號表中能找到柱徙,在ELF文件中鳍悠,SHT_DYNSYM對應(yīng)的Section定義了當前文件中的動態(tài)符號;SHT_STRTAB定義了動態(tài)庫中所有字符串坐搔;SHT_PROGBITS則定義了動態(tài)庫中定義的信息藏研。如何找到這些Section:
通過內(nèi)存映射的方式把libart.so映射到內(nèi)存中;
按照ELF文件結(jié)構(gòu)解析映射到內(nèi)存中的libart.so概行;
解析SHT_DYNSYM蠢挡,并把當前section復(fù)制到內(nèi)存中;
解析SHT_STRTAB,并把當前section復(fù)制到內(nèi)存中(后面需要根據(jù)SHT_STRTAB來找到特定的符號)业踏;
解析SHT_PROGBITS禽炬,得到當前內(nèi)存映射的偏移地址,這里要注意:不同進程中相同動態(tài)庫的同一個函數(shù)的偏移地址是一樣的勤家。
以上邏輯的部分代碼片段如下:
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 = (structctx *)calloc(1,sizeof(structctx));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) {caseSHT_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 動態(tài)符號表的個數(shù)ctx->nsyms = (sh->sh_size/sizeof(Elf_Sym)) ;break;caseSHT_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;//當前段內(nèi)容為program defined information:程序定義區(qū)caseSHT_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ù)要找的符號名在SHT_DYNSYM中對應(yīng)的位置得到具體的函數(shù)指針腹尖,部分代碼如下:
void*fake_dlsym(void*handle,constchar*name){intk;structctx *ctx = (structctx *) 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) {//動態(tài)庫的基地址 + 當前符號section地址 - 偏移地址return(char*)ctx->load_addr + sym->st_value - ctx->bias;? ? ? ? }return0;}
通過以上模擬dlopen和dlsym的邏輯,我們成功繞過了系統(tǒng)將阻止應(yīng)用動態(tài)鏈接非公開 NDK庫的限制伐脖。
消息轉(zhuǎn)發(fā)
完成上面邏輯以后就可以通過本地Socket在虛擬機和用戶進程之間傳遞JDWP消息热幔。但是要實現(xiàn)遠程調(diào)試,還需要遠程下發(fā)虛擬機的調(diào)試指令并回傳執(zhí)行結(jié)果讼庇。我們通過App原有Push通道加上線上消息轉(zhuǎn)發(fā)服務(wù)绎巨,實現(xiàn)了整個調(diào)試工具的消息轉(zhuǎn)發(fā)功能:
Proguard對調(diào)試的影響
正常發(fā)布到市場的項目都會通過Proguad進行混淆,不同力度的混淆配置會生成不同的字節(jié)碼文件蠕啄。對調(diào)試功能影響比較大的配置有兩個:
LineNumberTable
LocalVariableTable
如果Proguard中沒有對這兩個屬性進行Keep场勤,那經(jīng)過Proguard處理的方法字節(jié)碼中會缺失這兩個模塊,對調(diào)試的影響分別是無法在方法的某一行設(shè)置斷點和無法獲取當前本地變量的值(但能獲取到方法參數(shù)變量和類成員變量)歼跟。一般為了在應(yīng)用發(fā)生崩潰時能獲取到調(diào)用棧中每個函數(shù)對應(yīng)的行號和媳,需要保留LineNumberTable,同時為了減少包體積會放棄LocalVariableTable哈街。在沒有LocalVariableTable的情況下窗价,可以通過調(diào)用Execute命令得到一些運行時結(jié)果間接得獲取到本地變量。
JDI的實現(xiàn)
整個消息交互流程跑通以后叹卷,接下來要做的就是根據(jù)JDI規(guī)范作進一步的封裝撼港。為了方便快速調(diào)試,目前調(diào)試工具的前端實現(xiàn)主要參考了LLDB的調(diào)試流程骤竹,通過設(shè)置命令的方式進行調(diào)試帝牡,整體樣式如下圖所示:
點擊圖片查看大圖
總結(jié)
本文從調(diào)查線上問題的常見手段入手,介紹了到店餐飲移動團隊在實現(xiàn)遠程調(diào)試過程中的嘗試和探索蒙揣。通過遠程調(diào)試可以方便快捷地獲取用戶當前App運行時的狀態(tài)靶溜,助力開發(fā)者快速定位線上問題。