Java-JDWP协议探究

简介

JDWP 与其他许多协议不同,它仅仅定义了数据传输的格式,但并没有指定具体的传输方式。这就意味着一个JDWP 的实现可以不需要做任何修改就正常工作在不同的传输方式上。
JDWP 是语言无关的。理论上我们可以选用任意语言实现JDWP 。然而我们注意到,在JDWP 的两端分别是target vmdebugger
Target vm 端,JDWP 模块必须以Agent library 的形式在Java 虚拟机启动时加载,并且它必须通过Java 虚拟机提供的JVMTI 接口实现各种debug 的功能,所以必须使用C/C++ 语言编写。而debugger 端就没有这样的限制,可以使用任意语言编写,只要遵守JDWP 规范即可。
JDI(Java Debug Interface)就包含了一个Java 的JDWP debugger 端的实现,JDK 中调试工具jdb 也是使用JDI 完成其调试功能的。


协议分析

JDWP 大致分为两个阶段:

  • 握手。
  • 应答。
    握手是在传输层连接建立完成后,做的第一件事:
    Debugger 发送 14 bytes 的字符串“JDWP-Handshake”到 target Java 虚拟机,Target Java 虚拟机回复“JDWP-Handshake”。握手完成,debugger 就可以向 target Java 虚拟机发送命令了。

JDWP 是通过命令(command)和回复(reply)进行通信的,这与 HTTP 有些相似。JDWP 本身是无状态的,因此对 command 出现的顺序并不受限制。
JDWP 有两种基本的包(packet)类型:

  • 命令包(command packet)。
  • 回复包(reply packet)。

Debugger 和target Java 虚拟机都有可能发送command packet 。Debugger 通过发送command packet 获取target Java 虚拟机的信息以及控制程序的执行。Target Java 虚拟机通过发送command packet 通知 debugger 某些事件的发生,如到达断点或是产生异常。
Reply packet 是用来回复command packet 该命令是否执行成功,如果成功reply packet 还有可能包含command packet 请求的数据,比如当前的线程信息或者变量的值。从target Java 虚拟机发送的事件消息是不需要回复的。

JDWP 是异步的,command packet 的发送方不需要等待接收到reply packet 就可以继续发送下一个command packet 。


Packet 的结构

Packet 分为包头(header)和数据(data)两部分组成。
包头部分的结构和长度是固定,而数据部分的长度是可变的,具体内容视packet 的内容而定。
Command packet 和Reply packet 的包头长度相同,都是11 个bytes,这样更有利于传输层的抽象和实现。

Command packet

1.jpg

  • Length 是整个packet 的长度,包括length 部分。因为包头的长度是固定的11 bytes,所以如果一个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: 预留的自定义和扩展命令。

Reply packet

2.jpg

  • Length、Id 作用与command packet 中的一样。
  • Flags 目前对于reply packet 值始终是 0x80。我们可以通过Flags 的值来判断接收到的packet 是command 还是 reply。
  • Error Code 用来表示被回复的命令是否被正确执行了。零表示正确,非零表示执行错误。
  • Data 的内容和结构依据不同的command 和reply 都有所不同。比如请求一个对象成员变量值的command,它的data 中就包含该对象的id 和成员变量的id 。而reply 中则包含该成员变量的值。

传输接口(Java Debug Wire Protocol Transport Interface)

如何使JDWP 能够无缝的使用不同的传输实现,而又无需修改JDWP 本身的代码? JDWP 传输接口(Java Debug Wire Protocol Transport Interface)为我们解决了这个问题。

JDWP 传输接口定义了一系列的方法用来定义JDWP 与传输层实现之间的交互方式。
首先传输层的必须以动态链接库的方式实现,并且暴露一系列的标准接口供JDWP 使用。与JNI 和JVMTI 类似,访问传输层也需要一个环境指针(jdwpTransport),通过这个指针可以访问传输层提供的所有方法。
当JDWP agent 被Java 虚拟机加载后,JDWP 会根据参数去加载指定的传输层实现(Sun 的 JDK 在 Windows 提供 socket 和 share memory 两种传输方式,而在 Linux 上只有 socket 方式)。
传输层实现的动态链接库实现必须暴露jdwpTransport_OnLoad 接口,JDWP agent 在加载传输层动态链接库后会调用该接口进行传输层的初始化。

jdwpTransport_OnLoad 接口定义如下:

1
2
3
4
5
6
JNIEXPORT jint JNICALL 
jdwpTransport_OnLoad(JavaVM *jvm,
jdwpTransportCallback *callback, // callback 参数指向一个内存管理的函数表,传输层用它来进行内存的分配和释放
jint version,
jdwpTransportEnv** env // env 参数是环境指针,指向的函数表由传输层初始化
);

JDWP 传输层定义的接口主要分为两类:

  • 连接管理。
  • I/O 操作。

连接管理

连接管理接口主要负责连接的建立和关闭。
一个连接为JDWP 和debugger 提供了可靠的数据流。Packet 被接收的顺序严格的按照被写入连接的顺序。
连接的建立是双向的,即JDWP 可以主动去连接debugger 或者JDWP 等待debugger 的连接。对于主动去连接debugger,需要调用Attach() 方法

在连接建立后,会立即进行握手操作,确保对方也在使用JDWP 。因此方法参数中分别指定了attch 和握手的超时时间。

JDWP 等待debugger 连接的方式,首先需要调用StartListening() 方法,定义如下:

1
2
3
jdwpTransportError 
StartListening(jdwpTransportEnv* env, const char* address,
char** actualAddress)

该方法将使JDWP 处于监听状态,随后调用Accept() 方法接收连接:

1
2
3
jdwpTransportError 
Accept(jdwpTransportEnv* env, jlong acceptTimeout, jlong
handshakeTimeout)

与Attach() 方法类似,在连接建立后,会立即进行握手操作。

I/O 操作

I/O 操作接口主要是负责从传输层读写packet 。
有 ReadPacket 和 WritePacket 两个方法:

1
2
3
4
jdwpTransportError 
ReadPacket(jdwpTransportEnv* env, jdwpPacket* packet)
jdwpTransportError
WritePacket(jdwpTransportEnv* env, const jdwpPacket* packet)

其结构jdwpPacket 与我们开始提到的JDWP packet 结构一致,定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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;

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;

typedef struct jdwpPacket {
union {
jdwpCmdPacket cmd;
jdwpReplyPacket reply;
} type;
} jdwpPacket;

命令实现机制

JDWP 作为一种协议,它的作用就在于充当了调试器与Java 虚拟机的沟通桥梁。通俗点讲,调试器在调试过程中需要不断向Java 虚拟机查询各种信息,那么JDWP 就规定了查询的具体方式。

我们通过一个最简单的VirtualMachine(命令集合 1)的Version 命令的Java 测试用例来说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
CommandPacket packet = new CommandPacket( 
JDWPCommands.VirtualMachineCommandSet.CommandSetID,
JDWPCommands.VirtualMachineCommandSet.VersionCommand);

ReplyPacket reply = debuggeeWrapper.vmMirror.performCommand(packet);

String description = reply.getNextValueAsString();
int jdwpMajor = reply.getNextValueAsInt();
int jdwpMinor = reply.getNextValueAsInt();
String vmVersion = reply.getNextValueAsString();
String vmName = reply.getNextValueAsString();

logWriter.println("description\t= " + description);
logWriter.println("jdwpMajor\t= " + jdwpMajor);
logWriter.println("jdwpMinor\t= " + jdwpMinor);
logWriter.println("vmVersion\t= " + vmVersion);
logWriter.println("vmName\t\t= " + vmName);

分析:
我们会创建一个VirtualMachine 的Version 命令的命令包实例packet 。该命令包主要就是配置了两个参数: CommandSetID 和VersionComamnd ,它们的值均为1。表明我们想执行的命令是属于命令集合1 的命令1,即VirtualMachine 的Version 命令。
然后在performCommand 方法中我们发送了该命令并收到了JDWP 的回复包reply,通过解析reply,我们得到了该命令的回复信息。

结果:

1
2
3
4
5
6
7
8
9
description = Java 虚拟机 version 1.6.0 (IBM J9 VM, J2RE 1.6.0 IBM J9 2.4 Windows XP x86-32 
jvmwi3260sr5-20090519_35743 (JIT enabled, AOT enabled)
J9VM - 20090519_035743_lHdSMr
JIT - r9_20090518_2017
GC - 20090417_AA, 2.4)
jdwpMajor = 1
jdwpMinor = 6
vmVersion = 1.6.0
vmName = IBM J9 VM

测试用例的执行结果显示,我们通过该命令获得了Java 虚拟机的版本信息,这正是VirtualMachine 的Version 命令的作用。
前面已经提到,JDWP 接收到的是调试器发送的命令包,返回的就是反馈信息的回复包。我们模拟的调试器会发送VirtualMachine 的Version 命令。
JDWP 在执行完该命令后就向调试器返回Java 虚拟机的版本信息。返回信息的包内容同样是在JDWP Spec 里面规定的。

在JDWP 内部是如何处理接收到的命令并返回回复包的呢?
1.jpg
JDWP 接收和发送的包都会经过TransportManager 进行处理。
JDWP 的应用层与传输层是独立的,就在于TransportManager 调用的是JDWP 传输接口(Java Debug Wire Protocol Transport Interface),所以无需关心底层网络的具体传输实现。
TransportManager 的主要作用就是充当JDWP 与外界通讯的数据包的中转站,负责将JDWP 的命令包在接收后进行解析或是对回复包在发送前进行打包,从而使JDWP 能够专注于应用层的实现。
对于收到的命令包,TransportManager 处理后会转给PacketDispatcher ,进一步封装后会继续转到CommandDispatcher 。然后CommandDispatcher 会根据命令中提供的命令组号和命令号创建一个具体的CommandHandler 来处理JDWP 命令。
其中,CommandHandler 才是真正执行JDWP 命令的类。为每个JDWP 命令都定义一个相对应的CommandHandler 的子类,当接收到某个命令时,就会创建处理该命令的CommandHandler 的子类的实例来作具体的处理。

2.jpg

单线程执行的命令

对于一个可以直接在该线程中完成的命令(我们称为单线程执行的命令),一般其内部会调用JVMTI 方法和JNI 方法来真正对Java 虚拟机进行操作。

多线程执行的命令

对于一些较为复杂的命令,是无法在CommandHandler 子类的处理线程中完成的。
例如:ClassType 的InvokeMethod 命令,它会要求在指定的某个线程中执行一个静态方法,显然CommandHandler 子类的当前线程并不是所要求的线程。

为了解决这个JDWP 线程会先把这个请求先放到一个列表中,然后等待,直到所要求的线程执行完那个静态方法后,再把结果返回给调试器。


事件处理机制

前面介绍的VirtualMachine 的Version 命令过程非常简单,就是一个查询和信息返回的过程。
在实际调试过程中,一个JDI 的命令往往会有数条这类简单的查询命令参与,而且会涉及到很多更为复杂的命令。要了解更为复杂的JDWP 命令实现机制,就必须介绍JDWP 的事件处理机制

我们通过介绍在调试过程中断点的触发是如何实现的,来为大家揭示其中的实现机制。
任意调试一段Java 程序,并在某一行中加入断点。然后,我们执行到该断点,此时所有Java 线程都处于suspend 状态,这是很常见的断点触发过程。
为了记录在此过程中JDWP 的行为,我们使用了一个开启了trace 信息的JDWP。虽然这并不是一个复杂的操作,但整个trace 信息也有几千行。
可见,作为相对底层的JDWP ,其实际处理的命令要比想象的多许多,为了介绍JDWP 的事件处理机制,我们挑选了其中比较重要的一些trace 信息来说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[RequestManager.cpp:601] AddRequest: event=BREAKPOINT[2], req=48, modCount=1, policy=1 
[RequestManager.cpp:791] GenerateEvents: event #0: kind=BREAKPOINT, req=48
[RequestManager.cpp:1543] HandleBreakpoint: BREAKPOINT events: count=1, suspendPolicy=1,
location=0
[RequestManager.cpp:1575] HandleBreakpoint: post set of 1
[EventDispatcher.cpp:415] PostEventSet -- wait for release on event: thread=4185A5A0,
name=(null), eventKind=2

[EventDispatcher.cpp:309] SuspendOnEvent -- send event set: id=3, policy=1
[EventDispatcher.cpp:334] SuspendOnEvent -- wait for thread on event: thread=4185A5A0,
name=(null)
[EventDispatcher.cpp:349] SuspendOnEvent -- suspend thread on event: thread=4185A5A0,
name=(null)
[EventDispatcher.cpp:360] SuspendOnEvent -- release thread on event: thread=4185A5A0,
name=(null)

调试器需要发起一个断点的请求,这是通过 JDWP 的 Set 命令完成的。在 trace 中,我们看到 AddRequest 就是做了这件事。可以清楚的发现,调试器请求的是一个断点信息(event=BREAKPOINT[2])。

在 JDWP 的实现中,这一过程表现为:
在 Set 命令中会生成一个具体的request , JDWP 的RequestManager 会记录这个 request(request 中会包含一些过滤条件,当事件发生时 RequestManager 会过滤掉不符合预先设定条件的事件 ),并通过JVMTI 的 SetEventNotificationMode() 方法使这个事件触发生效(否则事件发生时 Java 虚拟机不会报告)。
3.jpg

当断点发生时,Java 虚拟机就会调用 JDWP 中预先定义好的处理该事件的回调函数。在 trace 中,HandleBreakpoint 就是我们在 JDWP 中定义好的处理断点信息的回调函数。它的作用就是要生成一个 JDWP 端所描述的断点事件来告知调试器(Java 虚拟机只是触发了一个 JVMTI 的消息)。

由于断点的事件在调试器申请时就要求所有 Java 线程在断点触发时被 suspend,那这一步由谁来完成呢?
这里要谈到一个细节问题,HandleBreakpoint 作为一个回调函数,其执行线程其实就是断点触发的Java 线程。
显然,我们不应该由它来负责suspend 所有Java 线程。

原因很简单,我们还有一步工作要做,就是要把该断点触发信息返回给调试器。
如果我们先返回信息,然后 suspend 所有 Java 线程,这就无法保证在调试器收到信息时所有Java 线程已经被suspend。
反之,先 Suspend 了所有 Java 线程,谁来负责发送信息给调试器呢?
为了解决这个问题,我们通过 JDWP 的 EventDispatcher 线程来帮我们 suspend 线程和发送信息。
实现的过程是,我们让触发断点的 Java 线程来PostEventSet(trace 中可以看到),把生成的 JDWP 事件放到一个队列中,然后就开始等待。
由 EventDispatcher 线程来负责从队列中取出 JDWP 事件,并根据事件中的设定,来 suspend 所要求的 Java 线程并发送出该事件。

在这里我们在事件触发的 Java 线程和 EventDispatcher 线程之间添加了一个同步机制,当事件发送出去后事件触发的Java 线程会把JDWP 中的该事件删除,到这里整个JDWP 事件处理就完成了。


后话

关于Java 线程在执行断点之后,都会处于suspend 状态,但是线程在这种状态会出现死锁的情况。
因此,现在大部分的线程停止都是处于wait 状态,通过设置超时时间或者通过notify() 唤醒线程。


引用

https://www.ibm.com/developerworks/cn/java/j-lo-jpda3/index.html


个人备注

此博客内容均为作者学习所做笔记,侵删!
若转作其他用途,请注明来源!