android 远程调试工具,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 ----(      |--------------| 

\    |  back-end  |

\    |--------------|

/          |

comm channel -(            | 

\          |

|--------------|

| front-end    |

|--------------| 

|      UI      |

|--------------|

下面重点介绍一下JDWP协议。

JDWP协议

JDWP协议是用于调试器与目标虚拟机之间进行调试交互的通信协议,它的通信会话主要包含两类数据包:

Command Packet:命令包。调试器发送给虚拟机Command,用于获取程序状态或控制程序执行;虚拟机发送Command给调试器,用于通知事件触发消息。

Reply Packet:回复包,虚拟机发送给调试者回复命令的请求或者执行结果。

JDWP的数据包主要包含包头和数据两部分,包头字段含义如下:

bda6366a6a24

数据包部分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系统的一个很重要的调试工具)。整体的调试模型如下:

____________________________________            |                                    |            |ADBServer(host)|            |                                    | Debugger LocalSocket RemoteSocket  |            |                          ||      |            |___________________________||_______|                                        ||                              Transport ||(TCPforemulator - USBfordevice)||                                        ||            ___________________________||_______            |                          ||      |            |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的。

bda6366a6a24

点击图片查看大图

通过上图可以看到,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()的定义:

intf(void){return1; }intf(int){return0; }voidg(void){inti = f(), j = f(0); }

这些是不同的函数,除了函数名相同以外没有任何关系。如果不做任何改变直接把它们当成C代码,结果将导致一个错误:C语言不允许两个函数同名。所以,C++编译器将会把它们的类型信息编码成符号名,结果类似下面的代码:

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函数在动态链接库文件中符号化以后的函数名。

找到符号化了的函数名后,就可以通过调用动态链接库中的函数重新启动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)(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();

以上代码关闭了之前可能存在的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 accessibleforthe 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文件结构:

bda6366a6a24

要实现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,得到当前内存映射的偏移地址,这里要注意:不同进程中相同动态库的同一个函数的偏移地址是一样的。

以上逻辑的部分代码片段如下:

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 动态符号表的个数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;//当前段内容为program defined information:程序定义区caseSHT_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,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) {//动态库的基地址 + 当前符号section地址 - 偏移地址return(char*)ctx->load_addr + sym->st_value - ctx->bias;        }return0;}

通过以上模拟dlopen和dlsym的逻辑,我们成功绕过了系统将阻止应用动态链接非公开 NDK库的限制。

消息转发

完成上面逻辑以后就可以通过本地Socket在虚拟机和用户进程之间传递JDWP消息。但是要实现远程调试,还需要远程下发虚拟机的调试指令并回传执行结果。我们通过App原有Push通道加上线上消息转发服务,实现了整个调试工具的消息转发功能:

bda6366a6a24

Proguard对调试的影响

正常发布到市场的项目都会通过Proguad进行混淆,不同力度的混淆配置会生成不同的字节码文件。对调试功能影响比较大的配置有两个:

LineNumberTable

LocalVariableTable

如果Proguard中没有对这两个属性进行Keep,那经过Proguard处理的方法字节码中会缺失这两个模块,对调试的影响分别是无法在方法的某一行设置断点和无法获取当前本地变量的值(但能获取到方法参数变量和类成员变量)。一般为了在应用发生崩溃时能获取到调用栈中每个函数对应的行号,需要保留LineNumberTable,同时为了减少包体积会放弃LocalVariableTable。在没有LocalVariableTable的情况下,可以通过调用Execute命令得到一些运行时结果间接得获取到本地变量。

JDI的实现

整个消息交互流程跑通以后,接下来要做的就是根据JDI规范作进一步的封装。为了方便快速调试,目前调试工具的前端实现主要参考了LLDB的调试流程,通过设置命令的方式进行调试,整体样式如下图所示:

bda6366a6a24

点击图片查看大图

总结

本文从调查线上问题的常见手段入手,介绍了到店餐饮移动团队在实现远程调试过程中的尝试和探索。通过远程调试可以方便快捷地获取用户当前App运行时的状态,助力开发者快速定位线上问题。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/294075.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

.NET 6新特性试用 | 自动生成高性能日志记录代码

前言要想记录日志&#xff0c;常用的方式是访问ILogger实例提供的日志记录方法&#xff1a;private readonly ILogger<WeatherForecastController> _logger;public WeatherForecastController(ILogger<WeatherForecastController> logger) {_logger logger; }[Htt…

3150 Pibonacci数 - Wikioi

题目描述 Description   你可能听说过的Fibonacci数和圆周率Pi。   如果你让这两个概念合并&#xff0c;一个新的深奥的概念应运而生&#xff1a;Pibonacci数。   这些数可以被定义为对于x>0&#xff1a;     如果0<x<4&#xff0c;则P(x) 1 …

Oracle Enterprises Manager 12C安装

前言 随着时代的进步与发展&#xff0c;Oracle官方于2012年12月1日起正式公布不再为Oracle10g版本提供免费的技术支持服务&#xff0c;而另一款新产品12C也即将面试&#xff0c;C即cloud&#xff0c;伴随着云计算的脚步&#xff0c;他终于粉墨登场了&#xff0c;熊熊第一时间下…

一个简单的MVC模式练习

控制层Action接受从模型层DAO传来的数据&#xff0c;显现在视图层上。 package Action;import java.sql.Connection; import java.sql.SQLException; import java.sql.Statement;import DAO.StuDAO; import Model.Student; import Util.DBUtil;public class StuAction {public …

GraphQL 到底有什么魔力?

GraphQL 起源时间退回到 2012年的一个下午, 美国加利福尼亚州, facebook 的工程师们发现他们才上架没多久的移动端应用就收到了很多差评, 用户反映app响应慢&#xff0c;耗电严重等&#xff0c;经过分析后发现, 应用在第一次启动时, 会请求大量的后端api接口, 这其中包括用户自…

android root工具twrp,安卓手机没有twrp的情况,如何下刷入magisk并获得root权限.

安装adb工具从以上地址下载,然后解压到任意目录(例如C:\adb).将此目录添加到windows环境变量path中.在cmd中输入adb, fastboot等命令,如果"显示"xxx"不是内部或外部命令命令",那么说明你弄错了,去了解一下什么是环境变量吧.如果显示一堆英文,那么说明安装完…

VBA即用型代码手册之打开有密码保护的工作薄

我给VBA下的定义&#xff1a;VBA是个人小型自动化处理的有效工具。可以大大提高自己的劳动效率&#xff0c;而且可以提高数据的准确性。我这里专注VBA,将我多年的经验汇集在VBA系列九套教程中。 作为我的学员要利用我的积木编程思想&#xff0c;积木编程最重要的是积木如何搭建…

⑥又是星期五,小试牛刀(编写定制标签)

前言 今天有点迷茫。是身体累了&#xff0c;还是心累了。有种觉得想大哭而无力的感觉&#xff08;可能心还没成熟,或是昨天羽毛球打累了&#xff09;。加油&#xff0c;Jeff Li. 活在当下&#xff0c;坚持打好基础&#xff0c;坚持刷ACM&#xff0c;坚持coding。 今天下午的小故…

技术分享|前端性能 关键性能指标以及测量工具介绍

源宝导读&#xff1a;对于一款商业软件产品而言&#xff0c;其性能表现往往会直接关系到它的生死存亡&#xff0c;这种说法一点也不夸张&#xff0c;数据显示&#xff0c;40&#xff05;的人放弃了加载时间超过3秒的网站。但是一个网页的加载时间&#xff0c;响应时间的“快”“…

ELKstack-Elasticsearch各类安装部署方法

手动安装部署Elasticsearch 开篇 开篇闲话&#xff0c;如果是新人在接触ELKstack什么都不懂的情况下&#xff0c;可以先看看各位前人写的关于ELKstack的博客&#xff0c;如何对ELKstack部署安装及使用&#xff0c;但是别人的东西只能作为借鉴&#xff0c;在自己搭建使用一定要以…

美国超人气漫画科普书!85%以上中学物理考点,5岁孩子秒懂物理

▲ 点击查看今年的高考&#xff0c;不得不说&#xff0c;一度被浙江学霸刷屏&#xff01;他总分720分&#xff0c;物理等3门满分的成绩&#xff0c;最终获得“浙江高考状元”。状元能摘得桂冠&#xff0c;除了日积月累的不断努力外&#xff0c;最重要的是&#xff0c;不断调整自…

gevent 学习笔记一

import geventdef a():print a starting....gevent.sleep(1)print a endingdef b():print b starting....gevent.sleep(1)print b endinggevent.joinall([gevent.spawn(a), gevent.spawn(b) ]) 结果显示&#xff0c;用gevent并不会因为gevent.sleep而造成阻塞,但用内置的time.s…

当前联机日志损坏恢复

2019独角兽企业重金招聘Python工程师标准>>> 环境模拟 删除状态为active的联机日志&#xff0c;然后强行关闭数据库 处理过程 SQL> startup ORACLE instance started. Total System Global Area 167772160 bytes Fixed Size 1260720 bytes Variable Size 1426071…

权威杂志评选出的十个最伟大的公式,爱因斯坦的质能方程竟然只能排第六!简直神仙打架....

全世界只有3.14 % 的人关注了爆炸吧知识难决高下各有千秋当数学家得出方程式和公式&#xff0c;如同看到雕像&#xff0c;美丽的风景&#xff0c;听到优美的曲调等等一样而得到充分的快乐。——柯普宁公式&#xff0c;是数学世界中一道美丽的风景&#xff0c;一个小小的等式&am…

在Idea中测试各JVM语言的交互性

为什么80%的码农都做不了架构师&#xff1f;>>> 背景&#xff1a; 假设出现这样的场景&#xff0c;一个Java项目中&#xff0c;需要用其他语言来编写相关模块&#xff0c;但需要能被Java调用 测试工具&#xff1a;Idea12 测试语言&#xff1a;Groovy、Scala、Ko…

android pcm调节音量,调整PCM语音数据的音量

通过编程实现调整PCM的音量&#xff0c;具体做法是乘上一个固定的数&#xff0c;但是要考虑数据的溢出问题&#xff0c;代码如下&#xff1a;//调节PCM数据音量//comment : 对PCM数据的音量进行放大//parameter :// pData PCM数据// nLen PCM数据的长度// nBitsPerSample 每个S…

.NET 大会今日开幕 |这些白嫖福利不看肠子都悔青

{ 12.18 线上开幕 文末有福利 }2021 .NET 开发者大会&#xff0c;今日开幕你是否已经满怀期待&#xff0c;同时又有很多疑问“ 会场在哪里&#xff1f;” "哪些大咖会参加&#xff1f;"“ 技术主题有哪些&#xff1f;” “ 什么时间有福利&#xff1f;”…时间不多了…

asp.net web常用控件FileUpload(文件上传控件)

2019独角兽企业重金招聘Python工程师标准>>> FileUpload控件的主要中能&#xff1a;向指定目录上传文件&#xff0c;该控件包括一个文本框和一个浏览按钮。 常用的属性&#xff1a;FileBytes&#xff0c;FileContent、FileName、HasFile、PostedFile。 常用的方法&a…