概述
在Android开发过程中,调试是不可避免的,在IDE的帮助下,只需要在IDE按钮上点击两下便可以进行调试。这让调试的工作变得十分简单方便,以至于开发者只需要熟记各种IDE的debug技巧,无需了解调试原理就可以完成程序的debug。
在调试的时候,开发者可以打断点调试、需改运行参数、dump虚拟机的堆栈信息、远程调试等,那这些都是怎么做到的呢?本文将带你一起探讨 Android 的调试原理。
要学习 Adb 的调试原理,需要从稍微简单一点的 Java 调试原理入手,因此首先介绍一下 Java 调试原理。
手动调试Java
在正式介绍Java的调试原理前,首先进行一次手动的 Java 程序调试。
第一步,编写 java 文件:
public class TestMain {
public static void main(String[] args) throws InterruptedException {
while (true) {
Thread.sleep(1000);
String hello = hello("" + new Random().nextInt(100));
System.out.println(hello);
}
}
private static String hello(String hello) {
return hello;
}
}
第二步,将 java 文件,编译 class 文件:
$javac -g src/com/example/www/TestMain.java -d class
第三步,使用debug模式,运行 class 文件并监听8000端口,挂载 jdwp:
$java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=8000 -cp class/ com.example.www.TestMain
第四步,使用调试工具 jdb 与 8000 端口进行通讯,开始调试:
$jdb -attach localhost:8000
第五步,在 TestMain.java的第十行上打断个点:
> stop at com.example.www.TestMain:10
效果如下:
sxxxx0@wxxxxxeMBP test % jdb -attach localhost:8000
设置未捕获的java.lang.Throwable
设置延迟的未捕获的java.lang.Throwable
正在初始化jdb...
> stop at com.example.www.TestMain:10
设置断点com.example.www.TestMain:10
>
断点命中: "线程=main", com.example.www.TestMain.main(), 行=10 bci=6
main[1] run
>
断点命中: "线程=main", com.example.www.TestMain.main(), 行=10 bci=6
main[1] clear com.example.www.TestMain:10
已删除: 断点com.example.www.TestMain:10
main[1] stop in com.example.www.TestMain.hello
设置断点com.example.www.TestMain.hello
main[1] run
>
断点命中: "线程=main", com.example.www.TestMain.hello(), 行=16 bci=0
main[1]
至此,手动调试已开启,并让调试过程停留在了 TestMain#hello 方法上(源码的第16行)。除了 stop at 可以打行断点外,如上,还可以通过 stop in 打上方法断点。同时,还可以使用 -source 指定源码路径,IDE默认设置的源码路径则是 $PROJECT_ROOT(项目根路径)。 打开 debug config ,可以在IDE中手动设置源码路径,告诉jdb该去哪里找到源码:
如果想了解更多的 java debug 指令可查阅官方文档。
在上面五步的调试中,从第三步开始,可能大部分读者就比较生疏了,因为在调试程序调试时,按下IDE的 debug 按键后,IDE就在后台自动运行了该 class 文件,并且使用 jdb 帮我们将界面上的埋点转化为埋点指令,无需开发者手动调试。
现在,打开 Debug 后在 Console 这里输出的这些提示大概可以理解了吧 :-)
呃呃,这 -agentlib:jdwp=transport=dt_socket,server=y,suspend=n
是什么玩意 ,还是有点懵:-) ,读完这篇文章后你就能知道这些具体是什么回事啦~
JDPA
下面一起看看 JVM 的调试原理吧!Java 的调试体系(Java Platform Debugger Architecture,以下简称:JDPA)定义了 JVM 调试的过程,学习 JVM 的调试原理,实际上就是学习JDPA。
如上图,在JDPA中包含三个部分:
- Java 虚拟机工具接口(JVMTI) : 可以通过『实现JVMTI可以获取、控制被调试虚拟机的运行状态』,JVMTI是调试的基础,JVMTI由JVM自身提供
- Java 调试线协议(JDWP) : The Java Debug Wire Protocol (以下简称:JDWP)定义了调试者(Debugger)和被调试者(Debuggee)通讯协议
- Java 调试接口(JDI) : 调试者通过实现 JDI ,调试者可以向JVM发送调试命令, 接受JVM运行时的状态信息(例如:jdb是JDI的一个实现)
调试的本质就是 Debugger 与 Debuggee 的之间的通讯,JDWP 则是通讯所用的协议。
JDPA工作流程
下图描述了JDPA工作流程:
- Debugger
直接或者间接实现 JDI ,并使用 JDWP 定义的通讯规则发送或者接受来自 JDWP 的数据与命令。例如:JDB ,IDE 自带的调试工具。
- JDWP Agent
JJVMTI 的具体实现,开发时一般采用建立一个 Agent 的方式来使用 JVMTI,它使用 JVMTI 函数,设置一些回调函数,『接受或发送来自 Debugger 的数据与命令,并通过这些数据与命令去获取或者操作被调试虚拟机的运行状态』。
在Java 虚拟机启动时可以选择加载的 JDWP Agent ,例如,进行远程调试,我们需要指定加载jdwp:
$java -agentlib:jdwp=transport=dt_socket
上述参数,不仅指定需要加载 JDWP Agent ,并且指定了 JDWP Agent 与 Debugger 间使用socket进行通讯。
- JDWP
JDWP 规定了 JDI 与 JVMTI 之间的通讯协议,JDWP 并不包含传输层的实现,因此 JDWP 数据可以使用任意的传输方式传输,只需要数据格式满足 JDWP 所规定的格式即可。
- Target JVM
被调试的虚拟机。
JDWP协议
和Http协议一样,JDWP协议同样有握手和应答。
通讯握手
JDWP协议的通讯过程,由一个简单的握手开始,如下图所示:
Debugger 发送字符串”JDWP-Handshake”到 Target Java 虚拟机
Target Java 虚拟机回复”JDWP-Handshake”,握手成功
在 Target JVM 启动时,可以选择『监听指定调试端口』也可以将自己『直接连接到已有的调试端口』上去。再来看看上面那条长长的指令
$java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=8000 -cp class/ com.example.www.TestMain
server
参数用来控制启动选项,y
表示监听指定『调试端口』,n
则表示连接到已有的『调试端口』。
suspend
参数则是是否在调试开始前暂停虚拟机。
所以,上述的指令表达的意思为:
运行 TestMain.class
,并且监听8000这个调试端口,当外部(例如:jdb)向此端口发送一个"JDWP-Handshake"时,就表示对方在请求作为当前运行的虚拟机的调试端。希望了解更多相关内容的同学,可以参考官方文档
通讯数据包
握手完成后,debugger 就可以与 target Java 虚拟机相互发送数据了。JDWP中的数据包分为两种:命令数据包(CmdPacket)、回复数据包(ReplyPacket)。
CmdPacket
首先看 CmdPacket 的结构:
typedef struct {
jint len; // packet length
jint id; // packet id
jbyte flags; // value is 0
jbyte cmdSet; // command set
jbyte cmd; // command in specific command set
jbyte *data; // data carried by packet
} jdwpCmdPacket;
- Length :是整个packet的长度,包括 length 部分。因为包头的长度是固定的11bytes,所以如果一个command packet没有数据部分,则length的值就是11。
- Id :是一个唯一值,用来标记和识别reply所属的command。Reply packet与它所回复的command packet具有相同的Id,异步的消息就是通过Id来配对识别的。
- Flags :目前对于command packet值始终是 0。
- Command Set :相当于一个command的分组,一些功能相近的command被分在同一个Command Set 中。
Command Set的值被划分为 3个部分:
0-63: 从 debugger 发往 target Java 虚拟机的命令
64 – 127: 从 target Java 虚拟机发往 debugger 的命令
128 – 256: 预留的自定义和扩展命令
ReplyPacket
再看看 ReplyPacket :
typedef struct {
jint len; // packet length
jint id; // packet id
jbyte flags; // value 0x80
jshort errorCode; // error code
jbyte *data; // data carried by packet
} jdwpReplyPacket;
Flags : 目前对于 reply packet 值始终是0x80。我们可以通过 Flags 的值来判断接收到的packet 是 command 还是 reply 。
Error Code : 用来表示被回复的命令是否被正确执行了。零表示正确,非零表示执行错误。
Data : 内容和结构依据不同的 command 和 reply 都有所不同。比如请求一个对象成员变量值的 command ,它的 data 中就包含该对象的 id 和成员变量的 id 。而 reply 中则包含该成员变量的值。
读者如果希望更深入了解JDWP 协议,推荐阅读:JDWP 协议及实现。
Android 调试原理
分析完了 Java 的调试原理,下面接着分析Android 调试原理 。Android的调试原理与Java的调试原理相比,要稍微复杂一些。
上图是adb的结构图:
Host 为PC端,在PC端运行着 Adb server 与 Adb clients, 同时运行着手机模拟器( Emulator )。
Target device 为手机, 无论是手机或者是手机模拟器,都运行着 Adbd (Adb daemon)和虚拟机(黄色的椭圆)
在Java的调试中,JDI 与 JVMTI 之间使用 JDWP 协议通讯来完成调试工作。在 Android 的调试>中,Adbd 与 虚拟机也是采用 JDWP 进行通讯,所以 ”Adbd 同 jdb 类似都是 JDI 的具体实现“。
构成介绍
Adb server 启动以后会一直监听本地的 5037 端口。adb client 通过本地的随机端口与 5037 端口建立连接。一个PC可以连接多台手机设备或虚拟机,一个手机也可以同时连接多台PC,这些设备的连接管理由 Adb server 完成。
Adb clients 可视为一个shell窗口(当使用 adb shell 命令时,可创建一个客户端)。当在执行输入 adb shell命令时,客户端会开启一个随机端口去与 5037 端口进行通讯,完成连接本地的服务端程序。如果 Adb server 没有启动,则启动一 Adb server 服务端程序。
如图,在运行 adb devices 命令时启动了 Adb server ,并开始监听 5037 端口,当运行 adb shell 命令后,再查看端口占用情况,可以看到 5037 端口与 53094 端口建立了连接,当关闭 adb shell 窗口后, 53094 端口关闭,这个 53094 端口即为上面所说的 Adb Client 产生的随机端口。
Adb daemon(adbd) 在模拟器或移动设备上运行的后台服务。当 Android 系统起机的时候,由 init 程序启动 adbd 。如果 adbd 挂了,则 adbd会由 init 重新启动。换言之,只要 Android 系统在运行,那 adbd 就是“不死的”,常年在伺服状态
通讯介绍
- Adb clients 采用特定的格式的数据向 Adb server 发送各类命令
这些数据的格式为:Length(4字节) + commend
例如: 000Chost:version 中 000C 表示命令长度,实际命令为 host:version
Server收到Client的请求后,返回的数据遵循如下格式:
如果成功,则返回四个字节的字符串”OKAY“
如果失败,则返回四个字节的字符串”FAIL“和出错原因
如果异常,则返回错误码
当 Adb Client 发送命令并收到 Adb Server 返回的“OKAY”回复后,就可以继续发起操作命令了。
- Adb Server 与 Adb daemon 之间采用的是 [『transport 协议』], Adb daemon 在手机(或模拟器)上启动后将一直监听手机(或模拟器)的 5555 端口。Adb Server启动后会试图与 5555 端口进行通讯,通讯采用无线(TCP协议)或者USB完成传输数据。
总结
至此 Android的调试原理介绍完成,在调试过程中,需要值得注意的是:
- 开发可以设置断点位置外,还可设置源码路径。因此,在调试的时候运行的程序可以不是实际的源代码,只要 断点信息 相同,就可以进行调试。
- 断点信息:行断点的信息(由包名、类名、行号组成);方法断点的信息(由包名、类名、方法名组成)
-
方法断点相对于行断点比较重量级,在调试的时候如果发现程序运行非常缓慢甚至无响应,可以去掉所有的方法断点, 效果立竿见影。(如下图中的 Java Method Breakpoints)
(文章中的错误与不足之处还请广大读者评论留言,一起讨论,一起进步:-)
推荐阅读
Java Platform Debugger Architecture
Java Platform, Standard Edition Tools Reference
Java: slow performance or hangups when starting debugger and stepping
adb原理
JDWP 协议及实现
JPDA 体系概览
Android虚拟机调试器原理与实现
【Android】ADB工具原理探究
adb client, adb server, adbd原理浅析
Android中ADB-server、ADB-client和adbd的简介