mruby 用デバッガ 「nomitory」の作り方

書いた人 : やまね (@yurie)

はじめに

みなさん、mruby を使ったことはありますか? mruby は組込み用途向けの「軽量 Ruby」のことで、るびまでも過去に 0038 号 巻頭言0040 号 巻頭言 で取り上げられていますね。

そして mruby は CRuby 以上に C に依存しているため、デバッグに苦労されているのではないでしょうか。そんな苦労を解消するために mruby 用のデバッガ「nomitory1」を開発し、 RubyConf 2014 で発表しました。この記事では、その中から「Ruby を GDB (The GNU Project Debugger) でデバッグするための実装部分」を取り上げました。

また、対象読者を考慮して、フロントエンド部分には触れません。2 まず、nomitory の実装の基となる GDB、GDB/MI の使い方を紹介し、次にそれを使って Ruby をデバッグするしくみを解説します。

紹介するコードはコマンドラインの GDB からも試すことができるので、GUI が好きではないという方にもお楽しみいただけると思います。

本記事内で使用したコードは https://github.com/yurie/rubima50 にあります。

もしも GDB/MI (GNU Debugger Machine Interface) について検索していたらこのページに辿り着いたという方がいらしたら、GDB と GDB/MI の使い方の章が参考になるでしょう。

nomitory とは

一言で言うと「Eclipse に搭載されている C/C++ 用デバッグ機能を拡張したもの」で、C も Ruby もデバッグできるのが特長です。

Eclipse は Java の IDE (統合開発環境) として知られていますが、プラグイン拡張により、他の言語用の IDE としても使用できます。これを C/C++ 用に拡張したものが Eclipse CDT (C/C++ Development Tooling) です。Eclipse CDT は デバッガに GDB を使用しており、GDB/MI 経由で GDB を操作しています。nomitory は Eclipse CDT を mruby 用に拡張する Eclipse プラグインとして作成しました。

デバッグにほしいもの

デバッガにはデバッグ途中で変数の値を変更したり、実行結果をさかのぼったりする (Reverse Debugging) など多くの機能があります。これらすべてを実装するのは大変です。そこで今回は、デバッグに必要な 3 つの機能を実装します。

  • ブレークポイントの設定
  • ステップ実行
  • 変数の現在値表示

これらの機能があれば、気になる箇所で実行を止めて、変数の値を確認しながら段階的に処理を確認できます。GDB に対して mruby 用に実装するには、GDB の機能がどこまで使えて何を追加しなければならないかを見極めます。

そのために、GDB コマンドをおさらいしましょう。

GDB と GDB/MI の使い方

GDB は、その名のとおり GNU Project のデバッガです。

一方 GDB/MI は GNU Debugger Machine Interface の略称です。名前から何となく分かるように、プログラムからの操作を想定して作られています。そのため、実行結果が機械的に解析しやすい形で返されます。Eclipse などの IDE ではこの GDB/MI でコマンドを実行した結果をもとに画面表示を行っています。GDB/MI では GDB で実行して画面に表示される内容よりも多くの情報を実行結果として返してくれます。

では同じプログラムを GDB と GDB/MI で実行して比べてみましょう。34

ここでは以下のコードを使用します。

#include <stdlib.h>
#include <stdio.h>
#include <string.h>

int main(void)
{
  int line = 0, cnt;

  for (line = 0; line < 10; line++) {
    cnt = line * 5;
    printf("result=%d\n",cnt);
  }

  return 0;
}

起動する

「gdb デバッグしたい実行ファイル名」の場合は GDB が起動しますが、–interpreter=mi または -i=mi オプションをつけると GDB/MI が起動します5。-q オプションは起動時に表示されるメッセージを省略します。6

GDB を起動します。

$ gdb -q sample
Reading symbols from sample...done.
(gdb) 

GDB を mi モードで起動します。

GDB の場合は (gdb) の後ろで入力待ちになっていますが、GDB/MI の場合は (gdb) の後で改行され、次の行の先頭で入力待ちになっています。

$ gdb -q -i=mi sample
=thread-group-added,id="i1"
~"Reading symbols from sample..."
~"done.\n"
(gdb) 

実行する

起動できたら、コマンドを入力してデバッグ対象のプログラムを実行してみましょう。7

GDB/MI のコマンドには、たいてい同等機能の GDB コマンドがあります。GDB/MI のマニュアルには「The corresponding gdb command is ‘xxx’. 」の形で対応する GDB コマンドを記載しています。ただし、すべてのコマンドに対応する GDB コマンドがあるわけではなく、GDB/MI にしかないものもあります。代表的なものとして「変数オブジェクト」があります。

GDB/MI のコマンド名は、- を先頭と単語の区切りに使用しています。ブレークポイントに関するものは -break- で始まり、データ操作に関するものは -data-、スタックに関するものは -stack-、プログラム制御に関するものは -exec- で始まるなど、カテゴリ分けしているものもあります。

GDB は run で実行します。

(gdb) run
Starting program: /Users/xxx/tmp/sample

GDB/MI では、-exec-run を使用します。

(gdb) 
-exec-run
=thread-group-started,id="i1",pid="6401"
=thread-created,id="1",group-id="i1"
^running
*running,thread-id="all"

終了する

GDB は q コマンドでデバッガを終了します。

(gdb) q

GDB/MI では、-gdb-exit を使用します。

(gdb) 
-gdb-exit
^exit

次に、今回実装することにした機能について GDB と GDB/MI のコマンドを見ていきましょう。

ブレークポイントを設定する

sample.c の 10 行目にブレークポイントを設定します。

GDB でブレークポイントを設定するには、break (省略形は b) を使用します。

(gdb) b sample.c:10
Breakpoint 1 at 0x100000f4e: file sample.c, line 10.

GDB/MI では -break-insert を使用します。ファイル名、行番号は -f オプションで指定します。

(gdb) 
-break-insert -f sample.c:10
^done,bkpt=^done,bkpt={number="1",type="breakpoint",disp="keep",enabled="y",addr="0x0000000100000f4e",\
func="main",file="sample.c",fullname="/Users/xxx/tmp/sample.c",line="10",\
thread-groups=["i1"],times="0",original-location="sample.c:10"}

コマンドを実行すると ^done が返ってきます。その後ろに、実行結果 (設定したブレークポイントの情報) が「xxx(属性名)=”値”」の形で続きます。どのコマンドに対して何が返ってくるかは GDB/MI のマニュアル ( https://sourceware.org/gdb/onlinedocs/gdb/GDB_002fMI.html ) を参照してください。

ここでは「number=”1”」は GDB のブレークポイント番号です。number, addr, file, line の値から GDB の実行結果の文字列「Breakpoint 1 at 0x100000f4e: file sample.c, line 10.」を生成できることがわかります。GDB/MI は IDE でも使われています。IDE では、戻り値のうち必要な情報だけを取り出し、整形して表示します。

ブレークポイントに条件をつける

GDB で条件付きブレークポイントを設定するには、if の後ろに条件を記述します。

ここでは、「変数 line の値が 7 のとき」を条件に指定します。

(gdb) b sample.c:10 if line==7
Breakpoint 1 at 0x100000f4e: file sample.c, line 10.

GDB/MI では -c オプション (condition) を使用して条件を記述します。

(gdb) 
-break-insert -c line==7 -f sample.c:10
^done,bkpt=^done,bkpt={number="1",type="breakpoint",disp="keep",enabled="y",addr="0x0000000100000f4e",\
func="main",file="sample.c",fullname="/Users/xxx/tmp/sample.c",line="10",\
thread-groups=["i1"],cond="line==7",times="0",original-location="sample.c:10"}

テンポラリブレークポイント (一時ブレークポイント) を設定する

テンポラリブレークポイントは、1 回限り有効なブレークポイントです。テンポラリブレークポイントは一度停止したら設定自体が削除されてしまうのが、通常のブレークポイントと異なります。

IDE ではよくある「カーソル位置まで実行」は、カーソルで指定された行にテンポラリブレークポイントを設定すると実装できます。8

GDB でテンポラリブレークポイントを設定するには tbreak を使用します。

(gdb) tbreak sample.c:10
Temporary breakpoint 1 at 0x100000f4e: file sample.c, line 10.

GDB/MI は、ブレークポイントを設定するコマンド -break-insert に -t オプションをつけるとテンポラリブレークポイントになります。

(gdb) 
-break-insert -t -f sample.c:10
^done,bkpt={number="1",type="breakpoint",disp="del",enabled="y",addr="0x0000000100000f4e",\
func="main",file="sample.c",fullname="/Users/xxx/tmp/sample.c",line="10",\
thread-groups=["i1"],times="0",original-location="sample.c:10"}

ブレークポイントで停止した時に得られる情報

ブレークポイントで停止した時に得られる情報も、GDB と GDB/MI では異なります。

GDB では次のように表示されます。

Breakpoint 1, main () at sample.c:10
10	    cnt = line * 5;

GDB/MI の場合、次のように表示されます。

*stopped,reason="breakpoint-hit",disp="keep",bkptno="1",frame={addr="0x0000000100000f4e",\
func="main",args=[],file="sample.c",fullname="/Users/xxx/tmp/sample.c",line="10"},\
thread-id="1",stopped-threads="all"

GDB/MI では、ブレークポイントで停止した時は停止理由が reason=”breakpoint-hit” のように”reason=xx”の形で返ってきます。

停止後に実行を再開する

ブレークポイントやステップ実行で一時停止した後、続きを実行したい場合、 GDB では continue (省略形は c) を使用します。

(gdb) c

GDB/MI の場合、 -exec-continue を使用します。

(gdb) 
-exec-continue
^running

ステップ実行する

ステップ実行は、次の実行行が関数内部の場合、関数の中も 1 行ずつ実行します。このような動作をステップインといいます。

GDB は step (省略形は s) を使用します。

(gdb) s
11	    printf("result=%d\n",cnt);

GDB/MI では -exec-step を使用します。

(gdb) 
-exec-step
^running
*running,thread-id="all"
(gdb) 
*stopped,reason="end-stepping-range",frame={addr="0x0000000100000f5b",func="main",\
args=[],file="sample.c",fullname="/Users/xxx/tmp/sample.c",line="11"},thread-id="1",\
stopped-threads="all"

ステップ実行で停止した時は停止理由が reason=”end-stepping-range” となります。

関数単位で実行する

「関数単位で実行する」はステップインとは違い、次の行が関数の場合は、関数内では止まらず関数を実行した後に止まります。このような動作をステップオーバーと言います。

GDB は next (省略形は n) を使用します。

(gdb) n
11	    printf("result=%d\n",cnt);

GDB/MI では -exec-next を使用します。

(gdb) 
-exec-next
^running
*running,thread-id="all"
(gdb) 
*stopped,reason="end-stepping-range",frame={addr="0x0000000100000f5b",func="main",\
args=[],file="sample.c",fullname="/Users/xxx/tmp/sample.c",line="11"},thread-id="1",\
stopped-threads="all"

変数の値を表示する

GDB は「print 変数名」(省略形は p) を使用します。

(gdb) p line
$1 = 10

GDB/MI では「-data-evaluate-expression 変数名」を使用します。

(gdb) 
-data-evaluate-expression line
^done,value="10"

nomitory のしくみ

ここまでは、GDB と GDB/MI の話をしました。

ここからは、GDB または GDB/MI を使って、mruby 上の Ruby コードをデバッグするにはどのようにしたら実現できるか述べます。

使用しているサンプルコードは https://github.com/yurie/rubima50/blob/master/rubima2.c です。

ブレークポイントのしくみ

Ruby のコードにブレークポイントを設定しても GDB にはわかりません。一方、C で実装されている mruby 本体のコードに「今 Ruby のどの行を実行しているか」が分かるところがあれば、その情報をもとにブレークポイントを設定できます。 codefetch.png

上図は mrubyVM でバイトコードが実行されるときの動作イメージです。

VM のバイトコードを実行しているところで「今実行しているバイトコードは Ruby のコードのどの部分か」が取得できます。 それが、図の「CODE_FETCH_HOOK」のところです。詳しくは、次の code_fetch_hook で紹介します。

code_fetch_hook

mruby.h ( https://github.com/mruby/mruby/blob/master/include/mruby.h ) に定義されている mrb_state 構造体に以下のような定義があります。9

#ifdef ENABLE_DEBUG
void (*code_fetch_hook)(struct mrb_state* mrb, struct mrb_irep *irep, mrb_code *pc, mrb_value *regs);
(略)
#endif

code_fetch_hook 関数ポインタに指定された関数は、バイトコードを実行するたびに毎回呼び出されます。(図の CODE_FETCH_HOOK のところです) ここでは説明の都合上、code_fetch 関数という名前の関数をセットすることにします。以降、code_fetch 関数とはここにセットした関数名を指します。

code_fetch_hook に指定する関数 (code_fetch 関数) の引数は、 以下のようになります。

  • 1 番目が mrb_state 構造体
  • 2 番目がバイトコードなどの情報が入っている mrb_irep 構造体
  • 3 番目が現在実行しているバイトコードへのポインタ
  • 最後が VM のレジスタ

code_fetch 関数内では、この引数の mrb_irep 構造体に入っている情報から、実行している Ruby のファイル名、行番号、変数の値など、デバッグに必要な情報を取り出します。

停止する条件

この code_fetch 関数内で、

  • 現在実行している Ruby のファイル名 = ブレークポイントを設定したい Ruby のファイル名
  • 現在実行している Ruby の行番号 = ブレークポイントを設定したい Ruby の行番号

上記2つの条件が一致したときブレークポイントで止まるようにすると、Ruby コードのブレークポイントが実装できます。 nomitory の場合、この 2 つの条件をブレークポイントの条件式で指定するようにしました。

Ruby コード 1 行はバイトコード 1 行とは限らない

ここでひとつ気をつけなければならないことがあります。 code_fetch 関数は、バイトコードを実行するたびに呼ばれます。つまり、1 行の Ruby のコードが複数のバイトコードになる場合、同じ行に対して複数回呼ばれてしまいます。

ここでは簡単のため 、以下のスクリプト rubima.rb を使用します。

x = 2
y = 1
y = x + y

bytecode3.png

この問題を回避するために、1 行の Ruby コードに対して 1 度しか呼ばれないところに条件付きブレークポイントを設定します。nomitory では、code_fetch 関数が 1 行の Ruby コードに対して 2 回以上呼ばれていないかチェックした後の処理に、条件付きブレークポイントを設定するようにしました。

参考) バイトコードを出力するには -v オプションとファイル名をつけて mruby コマンドを実行します。

~/git/mruby/bin/mruby -v rubima.rb

実行結果例を以下に示します。

赤丸で囲った部分が Ruby コードの行番号です。3 行目は複数行のバイトコードになっていることがわかります。 bytecode-output5.png

ブレークポイントの実装例 10

GDB (C) の場合

(gdb) b sample.c:15

GDB (Ruby) の場合

(gdb) break rubima2.c:89 if (((int)md_strcmp((const char *)filename,(const char *)"rubycode.rb"))==0 \
&& line==13)
Breakpoint 2 at 0x100001411: file rubima2.c, line 89.

GDB/MI (Ruby) の場合

(gdb) 
-break-insert -c "(md_strcmp((const char *)filename,\
(const char *)\"/Users/xxx/rubima2/rubycode.rb\"))==0 && line==13" -f rubima2.c:89
^done,bkpt={number="1",type="breakpoint",disp="keep",enabled="y",addr="0x0000000100001411",\
func="mrb_gdb_code_fetch",file="rubima2.c",fullname="/Users/xxx/rubima2/rubima2.c",\
line="89",thread-groups=["i1"],cond="(md_strcmp((const char *)filename,\
(const char *)\"/Users/xxx/rubima2/rubycode.rb\"))==0 && line==13",times="0",\
original-location="rubima2.c:89"}

ステップ実行のしくみ

GDB の場合はステップインは step コマンド、ステップオーバーは next コマンドでした。当然ですが、この GDB の step, next は C のコードを基準としていますので、Ruby のコードを実行する際には使用できません。 そのため、同等の処理を別途実装します。

早速、実装方法をみていきましょう。

ステップイン

まずはステップインです。ステップインの実現はそう難しくはありません。先ほどの Ruby 用ブレークポイントのしくみを利用できます。

code_fetch 関数内に、ファイル名、行番号を指定しないでブレークポイントを設定すると、ステップインの実装ができます。Ruby のコード 1 行が複数行のバイトコードになっていた場合の対策は code_fetch 関数内で行っているので、ここでは単純に「次に止まるのは次の行が実行されたとき」とみなすことができます。 しかし、ファイル名、行番号を指定しないでブレークポイントを設定すると、プログラムが終了するまで 1 行動作しては止まります。1 行だけ実行したい場合は、一度止まったらブレークポイントを削除しなければなりません。

そこで nomitory では、テンポラリブレークポイントを使用します。テンポラリブレークポイントは 1 度だけ有効なブレークポイントです。step が呼ばれたら、テンポラリブレークポイントを設定して実行します。こうすれば、一度止まった後は再度 step (または next) を呼ばない限り止まらなくなるので、期待するステップインの動作になります。

ステップインの実装例

GDB (C) の場合

(gdb) step

GDB (Ruby) の場合

(gdb) tbreak rubima2.c:89
Temporary breakpoint 2 at 0x100001411: file rubima2.c, line 89.

GDB/MI (Ruby) の場合

(gdb)
-break-insert -t -f rubima2.c:89
^done,bkpt={number="2",type="breakpoint",disp="del",enabled="y",addr="0x0000000100001411",\
func="mrb_gdb_code_fetch",file="rubima2.c",fullname="/Users/xxx/rubima2/rubima2.c",\
line="89",thread-groups=["i1"],times="0",original-location="rubima2.c:89"}

ステップオーバー

次はステップオーバーです。ステップオーバーは、ステップインの処理を拡張します。

ステップオーバーは関数の場合その中に入りません。さて、今実行している行が関数の中かどうかはどのように判断すればよいでしょうか。

関数の中に入ると変わる値を監視すればよいですね。その監視対象が callinfo です。

callinfo は関数の呼び出しごとに作られる関数スタックで、関数に入ると一つ増え、関数を抜けると一つ減ります。mruby の場合、mrb_callinfo 構造体が mruby.h に定義されています。

実行前にこの callinfo のスタックの深さを取得しておき、callinfo の深さを条件にしたテンポラリブレークポイントを実行します。そうすると、関数の中に入るとはスタックが深くなるためブレークポイントでは止まらず、関数から出るとブレークポイントで止まるようになります。

ステップオーバーの実装例

GDB (C) の場合

(gdb) next

GDB (Ruby) の場合

(gdb) tbreak rubima2.c:89 if mrb_gdb_get_callinfosize(mrb)<=0
Temporary breakpoint 3 at 0x100001411: file rubima2.c, line 89.

GDB/MI (Ruby) の場合

(gdb)
-break-insert -t -c mrb_gdb_get_callinfosize(mrb)<=0 -f rubima2.c:89
^done,bkpt={number="3",type="breakpoint",disp="del",enabled="y",addr="0x0000000100001411",\
func="mrb_gdb_code_fetch",file="rubima2.c",fullname="/Users/xxx/rubima2/rubima2.c",\
line="89",thread-groups=["i1"],cond="mrb_gdb_get_callinfosize(mrb)<=0",times="0",\
original-location="rubima2.c:89"}

変数表示のしくみ

最後に Ruby の変数の値を表示する方法を説明します。

GDB で C の変数の値は「print 変数名」で分かりますが、Ruby の変数は認識してくれません。

そこで mruby 側で変数名を引数で渡すと、変数の値を返す関数を作成し、GDB なら print、GDB/MI の場合は -data-evaluate-expression で呼び出します。

この変数を返す関数の中身は次のようなしくみです。

変数に関する情報も mrb_state 構造体にあります。引数で渡された変数名に該当するシンボルを mrb_state 構造体にある配列リストから探して値を取得します。

変数の値を取得するには、Ruby の inspect メソッドを使用します。 ここで気をつけなければならないのが、デバッガの実装に Ruby の関数を使用したときの動作です。inspect メソッドは Ruby の関数のため、デバッグ対象のプログラム同様にバイトコードとして実行され、 code_fetch 関数も呼び出してしまいます。呼び出された code_fetch 関数はデバッグ対象のプログラムではなくデバッガが呼び出した inspect メソッドの実行結果を返します。このようなことが起きないように inspect メソッド実行中は code_fetch 関数を呼ばないようにしておきましょう。

nomitory の場合、IDE の一部として機能しているので、変数ウィンドウに変数一覧を表示するための関数も作成しました。11

変数表示の実装例

GDB (C) の場合

print v

GDB (Ruby) の場合

(gdb) p mrb_gdb_get_localvalue(mrb, "v")
$2 = 0x1000814c0 <ret> "result={name=\"v\",value=\"5\",type=\"Fixnum\"}"

GDB/MI (Ruby) の場合

-data-evaluate-expression "mrb_gdb_get_localvalue(mrb, \"v\")"
^done,value="0x1000814c0 <ret> \"result={name=\\\"v\\\",value=\\\"5\\\",type=\\\"Fixnum\\\"}\""

おわりに

GDB を使って mruby 上で動作する Ruby のデバッグを可能にする方法を紹介しました。もし、Eclipseでnomitoryを使ってみたくなったら、http://yamanekko.github.io/nomitory/ を参考にインストールしてください。

GDB/MI については資料が容易に見つけられなかったこともあり、紹介しました。

一番苦労したのは Java で CDT を拡張したところですが、さすがに場違いすぎるので別の機会に紹介できればと思います。

mruby を使いはじめたものの、気がつくと周辺整備ばかりしていて、結局 mruby を使ったコードはほとんど書けていません。 ようやくデバッガの目処がついたので、今度こそ mruby を使って何か作ることができそうです。

何かお気づきの点がありましたら、 https://github.com/yurie/rubima50/issues にいただけると幸いです。

最後になりましたが、この原稿は SESSAME メンバー、たいやき部部員のみなさんにレビューしていただきました。この場を借りて御礼申し上げます。(レビューしていただいた後にサンプルコードを追加したので気がついたことがあればこっそり教えてください!)

著者について

チーム yamanekko のサブリーダー (リーダーは猫のちーちゃん)

組込み方面で “たのしい mruby” の実現に向けて日々模索中。

主に組込み向けの mrbgems を作りながら、Eclipse を mruby 用にカスタマイズしています。

最近手がけているのは、EV3RT12 の API を mruby 用に移植すること。


  1. nomitory という名前は発表後のたいやき部新年会で「すとうさん」がつけてくれました。感謝 

  2. フロントエンドの実装は Java なので 

  3. GDB, GDB/MI のコマンドについては、簡単のため、複数の書き方があるものは 1 種類のみ紹介します。 

  4. GDB でデバッグするには、あらかじめ GCC で -g3 オプションをつけてコンパイルしておきます。 

  5. 現時点では mi1 と mi2 の 2 種類がありますが、mi のみ指定すると最新のもの (mi2) が指定されたことになります。 https://sourceware.org/gdb/onlinedocs/gdb/Interpreters.html#Interpreters 

  6. 引用する際にシンプルに表示したかったため使用しただけです。 

  7. まだ何も設定していませんから、ここで試した場合は、プログラムは最後まで実行されて終了します。びっくりしないでね:) 

  8. nomitory ではまだ実装していませんが・・・ 

  9. この機能を使用するには、mrbconf.h で #define ENABLE_DEBUG を有効にします。 

  10. Ruby の実装例は https://github.com/yurie/rubima50/blob/master/rubycode.rb と https://github.com/yurie/rubima50/blob/master/rubima2.c を使用しています。 

  11. 変数一覧を返す関数を mruby 側で作成しておき、 -data-evaluate-expression で呼んでいるだけですが 

  12. EV3 用の RTOS(Real-Time Operating System)