Android远程调试

android studio 教程 | 2019-01-27 17:33

上周学习了JPDA里关于JVMTI的基本知识,然后动手写了一个简单的Agent实现。刚好最近看到美团技术博客里,关于Android远程调试的一篇文章。-remote-debug.html 于是动手写了一下。注: 本文大部分内容,和美团的这篇技术博客相似,可以理解为根据美团的文章进行实践,然后记录笔记,以及附加一些我在实践过程中遇到的一些问题。

身为一个Android开发者,肯定遇到过很多线上问题无法复现,难以排查的情况。有时候因为没有线上问题对应的机型,另外有些时候,即便是机型一样,也很难模拟用户使用时的具体情景,导致问题迟迟无法解决。而我们开发过程中,遇到绝大部分问题,除了尝试复现,还可以结合Debug等手段,获取当前程序运行的状态,包括变量信息、堆栈信息、线程信息等等。这个时候我们就在想,如果可以远程调试就好了。而我们知道,Java程序时支持远程调试的,那Android是不是也一样呢?答案是肯定的。

Android虽然采用了Dalvik以及ART模式来适配手机,但本质还是一种特殊的JVM, 之前介绍JPDA的时候已经介绍了JVM调试框架。要想让JVM支持调试,那么必须在JVM启动的时候加载支持JVMTI的Agent,通过这个Agent和JVMTI通信,设置断点、获取堆栈信息等。Hotspot VM以及Dalvik VM都自带了JVMTI和JDWP实现。即我们可以用任意我们喜欢的JDI去进行调试,例如IDE自带的调试工具,或者jdb, 甚至自己动手写一个JDI工具来调试。

在一般的Java程序中,要支持调试,必须(排除自己实现JDWP的情况)使用如下命令来启动Java程序:

1java -jar ${jarName} -Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=8888启动之后,就可以用各种调试工具通过ip:port连接到该程序上去进行调试(根据transport不同,连接方式会有区别)。而开发Android程序的时候,要想让Android程序支持调试,通常来讲,会有如下两种方式:

1. 需要将AndroidManifest.xml中(或者build.gradle中对应的buildType)的debuggable设置成true;

2. 将系统设置的ro.debuggable设置成1;

其中第二个对于绝大部分Android开发者来说几乎不会用到,因为它需要在编译源码的时候就将该值设置好,或者是拥有root权限之后,去修改这个值。

美团技术博客介绍的方法,就是第三种 不通常 的方法。即debuggable为false,也没有root权限去将ro.debuggable修改为1. 扯了这么多,其实原理都是一样,只有一个,只有当目标程序的虚拟机环境支持jdwp,才能支持调试。那么,怎么样绕过以上两种方法,来将jdwp开启呢?

Android的jdwp涉及的源码很多,我也并没有全部去分析。参考美团的文章,我们最关心的功能开启调试功能,代码主要在runtime/debugger.cc的StartJdwp()方法。以Android 5.0的源码+/android-cts-5.0_r9/runtime/debugger.cc#641为例:

1void Dbg::StartJdwp() { 2  if (!gJdwpAllowed || !IsJdwpConfigured()) { 3    // No JDWP for you! 4    return; 5  } 6  CHECK(gRegistry == nullptr); 7  gRegistry = new ObjectRegistry; 8  // Init JDWP if the debugger is enabled. This may connect out to a 9  // debugger, passively listen for a debugger, or block waiting for a10  // debugger.11  gJdwpState = JDWP::JdwpState::Create(&gJdwpOptions);12  if (gJdwpState == NULL) {13    // We probably failed because some other process has the port already, which means that14    // if we don't abort the user is likely to think they're talking to us when they're actually15    // talking to that other process.16    LOG(FATAL) << "Debugger thread failed to initialize";17  }18  // If a debugger has already attached, send the "welcome" message.19  // This may cause us to suspend all threads.20  if (gJdwpState->IsActive()) {21    ScopedObjectAccess soa(Thread::Current());22    if (!gJdwpState->PostVMStart()) {23      LOG(WARNING) << "Failed to post 'start' message to debugger";24    }25  }26}我们看到,决定jdwp能否开启有两个因素,一个是gJdwpAllowed,另外一个是IsJdwpConfigured().

1bool Dbg::IsJdwpConfigured() {2  return gJdwpConfigured;3}45// JDWP is allowed unless the Zygote forbids it.6static bool gJdwpAllowed = true;7// Was there a -Xrunjdwp or -agentlib:jdwp= argument on the command line?8static bool gJdwpConfigured = false;我们看到gJdwpAllowed默认是true的,但是gJdwpConfigured默认是false. 如注释所说,如果启动程序的时候,命令行参数带有-Xrunjdwp或者-agentlib:Jdwp=时,这个值就应该变成true.

然后我们接着找,很快就找到了对应的代码:

1/* 2 * Parse the latter half of a -Xrunjdwp/-agentlib:jdwp= string, e.g.: 3 * "transport=dt_socket,address=8000,server=y,suspend=n" 4 */ 5bool Dbg::ParseJdwpOptions(const std::string& options) { 6  VLOG(jdwp) << "ParseJdwpOptions: " << options; 7  std::vector<std::string> pairs; 8  Split(options, ',', pairs); 9  for (size_t i = 0; i < pairs.size(); ++i) {10    std::string::size_type equals = pairs[i].find('=');11    if (equals == std::string::npos) {12      LOG(ERROR) << "Can't parse JDWP option '" << pairs[i] << "' in '" << options << "'";13      return false;14    }15    ParseJdwpOption(pairs[i].substr(0, equals), pairs[i].substr(equals + 1));16  }17  if (gJdwpOptions.transport == JDWP::kJdwpTransportUnknown) {18    LOG(ERROR) << "Must specify JDWP transport: " << options;19  }20  if (!gJdwpOptions.server && (gJdwpOptions.host.empty() || gJdwpOptions.port == 0)) {21    LOG(ERROR) << "Must specify JDWP host and port when server=n: " << options;22    return false;23  }24  gJdwpConfigured = true;25  return true;26}到这里已经很明了了,只需要将ParseJdwpOptions的参数,用我们想要的参数传进去,然后重新调用StartJdwp就可以了。

那么不禁要问,这些都是系统的源码啊,我怎么才能去调用系统源码里的方法呢?这个时候需要介绍一下,上面的这些代码,最终打包后生成了libart.so这个动态链接库,libart.so顾名思义是Android Runtime的动态链接库,它包含了很多功能,我们本文讲的jdwp只是其中的一小块。Android系统在启动程序的时候一定会将libart.so动态加载进来。动态链接库有一个特点,就是它只能被加载一次,后续如果你替换了动态链接库重新加载,使用的仍然是之前加载的那一个。这个特性有时候很烦,特别是在做热更新方案的时候,so库就只能等程序重启才能更新。但是在这个时候,这个恶心牛逼的特性,就帮上了大忙。

Java加载动态链接库有两种办法,一个是Java层的System.loadLibrary,另一个是JNI层的dlopen. Android开发者对于前者应该不陌生,而后者则需要有一定JNI开发经验或者熟悉Linux开发的同学才了解了。dlopen用来加载动态链接库,加载成功后,返回其在内存中的句柄。而dlsym则是用来获取某个方法(符号后的函数名)的地址。而因为动态链接库只能被加载一次,所以无论后续调用多少次dlopen(不考虑异常情况),其对应的内存块地址都是一样的。什么是符号化的函数名呢?因为写代码的时候,常常会有名字一样的函数(方法),即我们常说的重载。而C语言是不允许函数同名的,因此编译器就将整个方法,包括它的类型信息编码符号化。类似如下的代码:

1// 符号化之前2int  f (void) { return 1; }3int  f (int)  { return 0; }4void g (void) { int i = f(), j = f(0); }5// 符号化之后6int  __f_v (void) { return 1; }7int  __f_i (int)  { return 0; }8void __g_v (void) { int i = __f_v(), j = __f_i(0); }我们可以通过nm命令来查看一个动态链接库里所有的符号化名称。而当我们的程序动态链接库发生崩溃时,我们也可以通过nm结合addr2line来将崩溃信息和源码关联上。

我们通过nm命令查找StartJdwp, StopJdwp, ParseJdwpOptions等函数的符号化函数名,得到如下结果:

注: 以下libart.so为Android 5.0版本,不同版本的符号化函数名可能不同:

1➜  nm libart.so | grep StartJdwp 20015f070 T _ZN3art3Dbg9StartJdwpEv 0 t _ZN3art4JDWPL15StartJdwpThreadEPv 4➜  nm libart.so | grep StopJdwp 500180880 T _ZN3art3Dbg8StopJdwpEv 6➜  nm libart.so | grep ParseJdwpOptions 70017ef40 T _ZN3art3Dbg16ParseJdwpOptionsERKNSt3__112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEE 8➜  nm libart.so | grep SetJdwpAllowed 900153180 T _ZN3art3Dbg14SetJdwpAllowedEb10➜  得到这些符号化函数名之后,我们就可以使用dlopen, dlsym, 还有dlclose来开启jdwp啦。代码如下:

1void reloadJdwpPreNougat(jboolean open) { 2    void *handler = dlopen("/system/lib/libart.so", RTLD_NOW); 3    if(handler == NULL){ 4        const char* err = dlerror(); 5        LOGD("dlerror: %s", err); 6    } 7    LOGD("handler address: %p", &handler); 8    //对于debuggable false的配置,重新设置为可调试 9    void (*allowJdwp)(bool);10    allowJdwp = (void (*)(bool)) dlsym(handler, "_ZN3art3Dbg14SetJdwpAllowedEb");11    allowJdwp(true);1213    void (*pfun)();14    //关闭之前启动的jdwp-thread15    pfun = (void (*)()) dlsym(handler, "_ZN3art3Dbg8StopJdwpEv");16    pfun();1718    if (open == JNI_TRUE) {19        //重新配置gJdwpOptions20        bool (*parseJdwpOptions)(const std::string&);21        parseJdwpOptions = (bool (*)(const std::string&)) dlsym(handler,22                                                                "_ZN3art3Dbg16ParseJdwpOptionsERKNSt3__112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEE");23        std::string options = "transport=dt_socket,address=8000,server=y,suspend=n";24        parseJdwpOptions(options);2526        //重新startJdwp27        pfun = (void (*)()) dlsym(handler, "_ZN3art3Dbg9StartJdwpEv");28        pfun();29    }30    dlclose(handler);31}我们来看一看logcat的日志输出:

101-26 19:50:06.660 7621-7621/? D/test: Java click reloadJdwp201-26 19:50:06.660 7621-7621/? D/native-lib: reload jdwp called to 1.301-26 19:50:06.660 7621-7621/? D/native-lib: os version: 22401-26 19:50:06.660 7621-7621/? D/native-lib: handler address: 0xbf8096f4501-26 19:50:06.661 7621-7629/? I/art: Debugger is no longer active601-26 19:50:06.666 7621-7621/? I/art: JDWP will listen on port 8000这个时候,我们就已经把jdwp成功以socket的方式启动了,监听端口是8000. 我们就可以用任何我们喜欢的JDI工具去进行调试,比如在Android Studio里新建一个Remote Debug配置。或者是使用jdb来attach上去。我这里使用的是模拟器,模拟器的ip地址是10.0.2.15,难以直接连上去,所以在连接之前,使用adb forward tcp:8000 tcp:8000绑定端口,将请求转发过去。

到目前,我们实现了在Release编译的程序里打开jdwp调试,但仅仅只是打开了这个功能,如果需要支持远端调试,仍然还有很多工作要做。我暂时列了如下几点:

1. 适配不同版本的Android系统,特别是Android 7.0之后,对于系统动态链接库的dlopen做了限制,美团的技术文章也有提到,也提供了解决方案,我这里参考了一个开源库

2. 编写我们自己的JDI和JDWP,能够下发调试命令;

3. 通过Push通道下发命令,需要在手机端启动一个新的进程(或者线程),将请求通过socket转发到虚拟机的JDWP去执行,并得到相应的结果信息;

4. 回传信息;

注: Android 7.0 dlopen适配时,不同的编译方式可能需要修改下代码,比如用clang++编译,针对void *指针使用+来进行地址偏移会报错。

1error: arithmetic on a pointer to void  可以将其转化为char *之后再进行操作。