はじめに
通常Java をデバッグしたいとき、普通は IntellJ idea などでブレークポイントを設定して...とやると思います。
しかし、どうやってデバッグしているのだろう...? と気になることはありませんか?
本記事では Javaをデバッグするためのプロトコルである JDWP ( Java Debugging Wire Protocol ) について解説します。
最終的に、以下のコードで...
import java.util.Scanner;
public class Sample {
public static void main(String[] args) {
String data = new Scanner(System.in).nextLine();
System.out.println(data); // ここにブレークポイントを掛ける
}
}
System.out.println
の行にブレークポイントを掛け、入力された data
の値を得てみたいと思います。(手動で。)
では、よろしゅう。
1. セットアップ
JDWP とは、Java をデバッグするためのプロトコルで、デバッガとJVMの通信するパケットの形式を定義するものです。具体的な通信方式を定義していない のが特徴で、WebSocket や SSH など、任意の方式が使用可能です。
安全性のためには SSH にすべきですが、今回は シンプルに WebSocket 通信を使用しましょう。
1-1. JVM立ち上げ
JDWPでデバッグするためには、まずJVM側でデバッグポートを開けてやる必要があります。
$ javac Sample.java
$ java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005 Sample
-agentlib:jdwp
が肝要で、ここでポートを開ける設定ができます(詳細略)。
JVM起動後、まだ標準入力は与えず、一旦動かしっぱなしで放置します。
公開サーバで走っているJava上でデバッグポートを開けるのは 絶対に やめましょう。任意コード実行の危険があります。
今回はローカルでJVMを立ち上げます。
1-2. まず接続
起動したJVMは一旦放置し、別のプロセスを使います。
好きな方法で、localhost:5005
とのWebSocket 通信を立ち上げます。
通信路の立ち上げに関しての詳しい方法はここでは取り上げません。
Node.js
あたりを使うのが簡単なんじゃないでしょうか。
まだパケットは送ってはいけません。
Rust の tokio_tungstenite
クレートなど、立ち上げ時に自動で HTTP リクエストを送るようなものは使用しないでください。
1-3. Handshake
JDWP は独特のHandshake 方式を採用しています。
接続完了後、socket から "JDWP-Handshake" という 14 byte を送ります。
(一言一句間違えずに!)
これを送ると、JVM側から "JDWP-Handshake" と返ってくるので、これで接続が完了したことになります。
4A 44 57 50 2D 48 61 6E 64 73 68 61 6B 65
2. 実際にパケットを送る
JDWP で送るパケットは、次の方式で定義されます (公式からスクショ)
原則、私たち側から送信するのが Command Packetで、それに対するJVM の応答が Reply Packet となります。
(Event Composite
コマンドを除く)
※ JDWP の数値フィールドでは、ビッグエンディアンを採用しています。
さて、各フィールドを説明します。
(あとでイヤほど見るので読み飛ばしても構いません)
- length (4 bytes)
- パケット全体のバイト長
- length自体 の 4 バイトも含む
- id (4 bytes)
- リクエストID
- Reply Packet にも同様のリクエストIDが付されるので、対応付けるためのID
- 一回の接続中、各Command パケットのID はユニークでなければならない(通常連番を使う)
- flags (1 byte)
- まぁいろんなフラグ
- ただし、現在はCommand と Reply パケットを区別するためのフラグしかない
- 0x80 の部分のビットが立っていたら Reply パケット
- command set, command (各 1 byte)
- コマンドを識別するためのバイト
- error code (2 byte)
- エラーを表す部分で、ここが 非 0 なら エラー
- data (可変長)
- コマンドごと独自のデータがここに乗る
2-1. ID のサイズを得る
JDWP では、オブジェクトのバイト表現をそのまま返すのではなくObjectID
等と言ったID表現で取り扱います。
この ID が結構曲者で、長さが VM ごとに異なります。
そのため、まず最初にID のサイズを得る必要があります。サイズを得るには VirtualMachine
コマンドセットの IdSizes
コマンド ( 参照 ) を使います。
具体的には、次のパケットを飛ばします。
00 00 00 0b // length (11)
00 00 00 00 // リクエストID (0)
00 // フラグ (0 = コマンドパケット)
01 // コマンドセット (1 = Virtual Machine コマンドセット)
07 // コマンド番号 (7 = IdSizes コマンド)
// このコマンドは data を必要としない
そうすると、次のパケットが返ってくるはずです。
(data の内容はJVMごとに違います)
00 00 00 1f // length (31)
00 00 00 00 // リクエストID (0, 送ったパケットのリクエストIDに対応)
80 // フラグ (0x80 = リプライパケット)
00 00 // エラーコード (0 = エラーなし)
// ----------- 以降data
00 00 00 08 // field ID のサイズ (8 byte)
00 00 00 08 // method ID のサイズ (8 byte)
00 00 00 08 // object ID のサイズ (8 byte)
00 00 00 08 // referenceType ID のサイズ (8 byte)
00 00 00 08 // frame ID のサイズ (8 byte)
OpenJDK では全部 8 byte でしたね。
以降は全部 8 バイトである前提で行います。
2-2. 対象クラスのID を得る
ブレークポイントを引っ掛けるため、まず対象クラス (今回は Sample クラス) の内部ID を得ます。
このIDを得るためには VirtualMachine
コマンドセットの ClassesBySignature
コマンド ( 参照 ) を使います。
詳細は省きますが、Sampleクラスの内部表現は LSample; となります。
(この表現をシグネチャと言います)
00 00 00 17 // length (23)
00 00 00 01 // リクエストID (1, 前のリクエストとは違うIDを設定する必要有)
00 // フラグ (0 = コマンドパケット)
01 // コマンドセット (1 = Virtual Machine コマンドセット)
02 // コマンド番号 (2 = ClassesBySignature コマンド)
// ------------------------ 以降data
00 00 00 08 // クラス名, 文字列の長さ
4C 53 61 6D 70 6C 65 3B // クラス名, "LSample;"
00 00 00 1C // length (28)
00 00 00 01 // リクエストID (1)
80 // フラグ (0x80 = リプライパケット)
00 00 // エラーコード (0 = エラーなし)
// ----------- 以降data
00 00 00 01 // シグネチャ数 (1)
01 // タイプタグ (1 = Class)
00 00 00 00 00 00 00 01 // クラスのID (1 <- これが欲しい情報)
00 00 00 07 // ステータス (7 = VERIFIED + PREPARED + INITIALIZED)
ということで、Sample クラスの ID は 1だと分かりました。
補足:クラスは、同じシグネチャ に対し複数存在する場合があります。
複数のクラスローダーが走っている場合、同じパッケージを持つ複数のモジュールを読んでいる場合など
2-3. クラスのメソッド一覧を得る
今回は main()
にブレークポイントを引っ掛けたいので、main() に対応する メソッドのIDを得ます。
このIDを得るためには ReferenceType
コマンドセットの Methods
コマンド ( 参照 ) を使います。
00 00 00 13 // length (19)
00 00 00 02 // リクエストID (2)
00 // フラグ (0 = コマンドパケット)
02 // コマンドセット (2 = ReferenceType コマンドセット)
05 // コマンド番号 (2 = Methods コマンド)
// ------------------------ 以降data
00 00 00 00 00 00 00 01 // クラスのID
00 00 00 5A // length (90)
00 00 00 02 // リクエストID (2)
80 // フラグ (0x80 = リプライパケット)
00 00 // エラーコード (0 = エラーなし)
// ------------------------ 以降data
00 00 00 02 // メソッド数
00 00 01 AB B8 98 9F D8 // 1つ目のメソッドID
00 00 00 06 // 1つ目のメソッド名バイト長
3C 69 6E 69 74 3E // 1つ目のメソッド名 "<init>"
00 00 00 03 // 1つ目のメソッドの型バイト長
28 29 56 // 1つ目のメソッドの型("()V")
00 00 00 01 // 1つ目のメソッドの modifier(詳細略)
00 00 01 AB B8 98 9F D0 // 2つ目のメソッドID <- これが欲しい情報
00 00 00 04 // 2つ目のメソッド名バイト長
6D 61 69 6E // 2つ目のメソッド名 ("main")
00 00 00 16 // 2つ目のメソッドの型バイト長 (22)
28 5B 4C 6A 61 76 61 2F
6C 61 6E 67 2F 53 74 72
69 6E 67 3B 29 56 // 2つ目のメソッドの型("([Ljava/lang/String;)V")
00 00 00 09 // 2つ目のメソッドの modifier
ということでほしいメソッドのID は 0x000001ABB8989FD0
と分かりました。
この値は実行ごとに かなり 異なるので、適宜読み替えてください。
(仕様上、他のIDなども環境依存だけれども。)
補足:<init> はコンストラクタのことです。そう、定義しなくても自動的にコンストラクタが生える仕様がありましたね?
2-4. ブレークポイントを引っ掛ける場所を割り出す
ブレークポイントは実は直接行数で引っ掛けることはできず、行数に対応する実行箇所の内部番号を割り出す必要があります。
この内部番号を得るには、 Method
コマンドセットの LineTable
コマンド ( 参照 ) を使います。
00 00 00 1B // length (27)
00 00 00 03 // リクエストID (3)
00 // フラグ (0 = コマンドパケット)
06 // コマンドセット (6 = Method コマンドセット)
01 // コマンド番号 (1 = LineTable コマンド)
// ------------------------ 以降data
00 00 00 00 00 00 00 01 // 対象クラスのID
00 00 01 AB B8 98 9F D0 // 対象メソッドのID
00 00 00 43 // length (67)
00 00 00 02 // リクエストID (3)
80 // フラグ (0x80 = リプライパケット)
00 00 // エラーコード (0 = エラーなし)
// ------------------------ 以降data
00 00 00 00 00 00 00 00 // 内部番号が何番から始まるか (0)
00 00 00 00 00 00 00 15 // 内部番号が何番まであるか (15)
00 00 00 03 // メソッドの行数 (3)
00 00 00 00 00 00 00 00 // 1行目の内部番号 (0)
00 00 00 05 // 1行目のソース上での行番号 (5)
00 00 00 00 00 00 00 0E // 2行目の内部番号 (14) <- これが欲しい情報
00 00 00 07 // 2行目のソース上での行番号 (7)
00 00 00 00 00 00 00 15 // 3行目の内部番号 (21)
00 00 00 08 // 3行目のソース上での行番号 (8)
今回は 7 行目にブレークポイントを引っ掛けたいので、対応する内部番号である 14 を使用します。
2-5. ブレークポイントをセット
ようやく本題です。
ブレークポイントのセットには EventRequest
コマンドセットの Set
コマンドを使います
00 00 00 2B // length (43)
00 00 00 04 // リクエストID (4)
00 // フラグ (0 = コマンドパケット)
0F // コマンドセット (15 = EventRequest コマンドセット)
01 // コマンド番号 (1 = Set コマンド)
// ------------------------ 以降data
02 // イベント種類 (2 = Breakpoint)
01 // イベントが発生した時にどのスレッドを止めるか (1=現在のスレッドのみ)
00 00 00 01 // イベントのフィルタ(modifier)数
07 // フィルタ種類 (7 = 特定の場所のみに絞る)
01 // 止める場所の参照種別 (1=class)
00 00 00 00 00 00 00 01 // 止める場所のクラスID (1)
00 00 01 AB B8 98 9F D0 // 止める場所のメソッドID
00 00 00 00 00 00 00 0E // 止める行数の内部番号 (14)
00 00 00 0F // length (67)
00 00 00 04 // リクエストID (4)
80 // フラグ (0x80 = リプライパケット)
00 00 // エラーコード (0 = エラーなし)
// ------------------------ 以降data
00 00 00 02 // このイベントの ID (2)
これでブレークポイントのセットは完了しました。
補足:イベントのフィルタ(modifier) を使うことで、特定の場合のみにブレークポイントの結果を受け取ることが可能です。
2-6. JVM 側でプログラムを進め、Event Composite コマンドを受け取る
ここまででブレークポイントのセットは完了しました。
一旦JVM(javaコマンドを実行しているターミナル)に戻り、ブレークポイントに進めてみましょうか
元のプログラムをもう一度思い出すと、下のようになっています。
import java.util.Scanner;
public class Sample {
public static void main(String[] args) {
String data = new Scanner(System.in).nextLine();
System.out.println(data); // ここにブレークポイントを掛けている
}
}
入力を data という変数に入れ、そのまま出力する簡素なものです。
現在入力待ちになっているはずなので、適当な文字列を入力し、ブレークポイントまで処理を進めてみましょう
ここでは "Hello" と入力してみます。
$ javac Sample.java
$ java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005 Sample
Hello
さて、WebSocket に戻ると次のパケットを受け取っているはずです。
Event
コマンドセットの Composite
( 参照 ) コマンドです。
00 00 00 36 // length (54)
00 00 00 01 // リクエストID (1, JVM側からのコマンドであることに注意!)
00 // フラグ (0 = "コマンド" パケット)
40 // コマンドセット (64 = Event コマンドセット)
64 // コマンド番号 (100 = Composite コマンド)
// ------------------------ 以降data
01 // イベントが発生した時にどのスレッドを止めるか (1=現在のスレッドのみ)
00 00 00 01 // 発生したイベント数 (1)
02 // イベント種類 (2=Breakpoint)
00 00 00 02 // イベントID (2, EventRequestSetで 帰ってきた値)
00 00 00 00 00 00 00 02 // 現在のスレッドのID (2) <- 後で使う
01 // 止まった場所の参照種別 (1=class)
00 00 00 00 00 00 00 01 // 止まった場所のクラスID (1)
00 00 01 AB B8 98 9F D0 // 止まった場所のメソッドID
00 00 00 00 00 00 00 0E // 止まった場所の内部番号 (14)
これはJVMのスレッドがブレークポイントによって止まったことを表すパケットです。
止まったスレッドのIDは後で使うのでメモして置いてください。
補足:Event Composite コマンドには応答する必要はありません
2-7. 現在のフレームを得る
Javaのスレッドの中には呼び出し階層と対応した「フレーム」と呼ばれるデータがあります。
ローカル変数の値はここに入っているので、フレームの番号を取ってきましょう。
フレーム番号を得るには ThreadReference
コマンドセットの Frames
( 参照 ) コマンドを使います。
00 00 00 1B // length (27)
00 00 00 05 // リクエストID (5)
00 // フラグ (0 = コマンドパケット)
0B // コマンドセット (11 = ThreadReference コマンドセット)
06 // コマンド番号 (6 = Frames コマンド)
// ------------------------ 以降data
00 00 00 00 00 00 00 02 // スレッドID (2)
00 00 00 00 // 得るフレームの開始地点 (ここでは 0)
00 00 00 01 // 得るフレーム数 (ここでは1)
00 00 00 30 // length (48)
00 00 00 05 // リクエストID (5)
80 // フラグ (0x80 = リプライパケット)
00 00 // エラーコード (0 = エラーなし)
// ------------------------ 以降data
00 00 00 01 // フレーム数 (1)
00 00 00 00 00 00 00 00 // フレームID (0) <- これが欲しい情報
01 // 現在のこのフレームの位置の参照種別 (1=class)
00 00 00 00 00 00 00 01 // 現在のこのフレームの位置のクラスID (1)
00 00 01 AB B8 98 9F D0 // 現在のこのフレームの位置のメソッドID (1)
00 00 00 00 00 00 00 0E // 現在のこのフレームの位置の内部番号 (14)
ローカル変数の値が格納されているフレームの ID は 0だと分かりました。
補足:今回は main 関数で止めているので、スタックフレームの数は1です。
2-8. フレームから変数の値...に紐づくIDを得る
では実際にローカル変数の値を得てみましょう。
まず、得たいローカル変数が、そのメソッドの何番目のローカル変数かを知る必要があります。
あらためて対象のメソッドを眺めましょう
public static void main(String[] args) {
String data = new Scanner(System.in).nextLine();
System.out.println(data); // ここにブレークポイントを掛けている
}
JVM内部では引数もローカル変数としてカウントするため、data
は 2番目のローカル変数 です。
内部では インデックスが0から始まるので、dataの値が欲しいなら "1番目" と言えばよいことになります。
それを踏まえたうえで、StackFrame
コマンドセットの GetValues
コマンド ( 参照 ) を使ってローカル変数の中身を得ます
00 00 00 24 // length (36)
00 00 00 06 // リクエストID (6)
00 // フラグ (0 = コマンドパケット)
10 // コマンドセット (16 = StackFrame コマンドセット)
01 // コマンド番号 (1 = GetValues コマンド)
// ------------------------ 以降data
00 00 00 00 00 00 00 02 // スレッドID (2)
00 00 00 00 00 00 00 00 // フレームID (0)
00 00 00 01 // 値を得たい変数の数 (1)
00 00 00 01 // 変数の番号 (1, インデックスが0開始のため)
73 // 変数の型 ('s' = String)
00 00 00 18 // length (40)
00 00 00 06 // リクエストID (6)
80 // フラグ (0x80 = リプライパケット)
00 00 // エラーコード (0 = エラーなし)
// ------------------------ 以降data
00 00 00 01 // 変数番号 (1)
73 // 変数の型 ('s' = String)
00 00 00 00 00 00 00 03 // 変数の内容のID (3) <- これが欲しい情報
変数の内容がObjectであるため、内容はID(3)で帰ってきます。
最後に、このIDから実際の値を得る必要があります。
補足:変数の番号について、static でないメソッドは内部的に this に相当するローカル変数も作られるので、さらに1増えます。
2-9. 変数の値のIDから実際の値を得る
最後に、IDから実際の値を得ます。今回は変数の型がStringであるため、
StringReference
コマンドセットの Value
コマンド ( 参照 ) を使うことで値を取得できます。
00 00 00 13 // length (35)
00 00 00 07 // リクエストID (7)
00 // フラグ (0 = コマンドパケット)
0A // コマンドセット (10 = StringReference コマンドセット)
01 // コマンド番号 (1 = Value コマンド)
// ------------------------ 以降data
00 00 00 00 00 00 00 03 // 変数の内容のID (3)
00 00 00 14 // length (20)
00 00 00 07 // リクエストID (7)
80 // フラグ (0x80 = リプライパケット)
00 00 // エラーコード (0 = エラーなし)
// ------------------------ 以降data
00 00 00 05 // 文字列長 (5)
48 65 6C 6C 6F // UTF-8 文字列 ("Hello")
これで data 変数の中身が Hello であることが分かりました!!!!!!!!!
2-EX. お片付け
最後に、ブレークポイントで止まったままのプログラムを解放しましょう
スレッドごとの再開をするコマンドもありますが、今回は全体を再開させようと思います。
そのためには VirtualMachine
コマンドの Resume
コマンド ( 参照 )を実行します。
00 00 00 0B // length (11)
00 00 00 08 // リクエストID (8)
00 // フラグ (0 = コマンドパケット)
01 // コマンドセット (1 = VirtualMachine コマンドセット)
09 // コマンド番号 (9 = Resume コマンド)
これでJVM側も無事終了したはずです。
(最後におそらく Event Composite の VM_DEATH が飛んでくると思いますが。)
おわり!
無事変数の値を取れた、ということで以上です。
説明のためにいろいろ端折ったので、気になる部分はいろいろ調べてみてください。
これを調べるために1週間が消えていった...
また、検証のため作ったRustプログラムがありますのでこちらもご査収ください。
参考
JDWP 仕様:
https://docs.oracle.com/en/java/javase/21/docs/specs/jdwp/jdwp-spec.html
JDWP パケット仕様:
https://docs.oracle.com/en/java/javase/21/docs/specs/jdwp/jdwp-protocol.html