YARV: GDBとちょっとしたHackによる仮想マシン内のスタックの状態変化の観察

はじめに

仮想マシンが導入されたRubyの動作を理解するには、仮想マシン内のスタックの状態変化を頭の中でイメージできるようになることが重要です。そこで今回は、仮想マシン内のスタックの状態変化を追いやすくする小さなツールを作り、そのツールをGDBと組み合わせて使うことで、仮想マシン内のスタックの状態変化を観察できるようにする方法を紹介します。なお、使用するソースコードはr11701です。

ツールの作成

仮想マシン内のスタックの状態変化を追いやすくするために、以下の機能をrubyに追加します。

  • breakpoint: RubyプログラムからSIGTRAPを発生させる
  • dump_stack: スレッドに関連づけられているスタックの中身をinspectした状態でダンプする

前者を実装すると、GDB上でRubyプログラムを実行し、「breakpoint」と書かれた所が実行された時に、制御がGDBに移るようにできます。これによって、関心のある位置におけるスタックの状態を、GDBで簡単に調査できるようになります。

vm_dump.cで定義されているvm_stack_dump_raw関数を使うと、スレッドに関連づけされている、スタックと制御フレームをダンプできます。しかし、この関数は、スタックの中身を生のVALUEでダンプするため、何のデータがスタックに格納されているのかわかりづらいという問題があります。そこで、後者を実装し、スタックに何が格納されているのか簡単に確認できるようにします。

ちなみに、vm_stack_dump_raw関数は以下のようなダンプを出力します。stack frameの一番右側の列が、VALUEを16進数で表現した値です。

(gdb) p vm_stack_dump_raw(th, th->cfp)
-- stack frame ------------
0000 (0x605000): 00000004
0001 (0x605004): 00000005
0002 (0x605008): 004ce4d8
0003 (0x60500c): 004ce4c4
0004 (0x605010): 004ce4b0
0005 (0x605014): 00000004
0006 (0x605018): 00000001
0007 (0x60501c): 00000004
0008 (0x605020): 00000004
0009 (0x605024): 00000001 <- lfp <- dfp
-- control frame ----------
c:0004 p:---- s:0010 b:0010 l:000009 d:000009 CFUNC  :breakpoint
c:0003 p:0063 s:0007 b:0007 l:000006 d:000006 TOP    stack.rb:21
c:0002 p:---- s:0002 b:0002 l:000001 d:000001 FINISH 
c:0001 p:---- s:0000 b:-001 l:000000 d:000000 ------ 
---------------------------

作成したツールのソースコードは、以下の通りです。

前者については、int3ソフトウェア割り込みが発生するようにしています。rb_define_global_functionのAPIを使って、rb_f_breakpoint関数を、Rubyプログラムからbreakpoint関数として利用できるようにしています。簡単のため、object.cのInit_Object関数の最後で、その関連付けをするようにしました。

後者については、rb_p関数を使って、スタックの中身をinspectした形でダンプするようにしています。スタックの底からtopに向かってダンプします。vm_dump.cに定義します。

% svn diff
Index: object.c
===================================================================
--- object.c    (revision 11701)
+++ object.c    (working copy)
@@ -2284,6 +2284,13 @@
  *  <code>Symbol</code> (such as <code>:name</code>).
  */
 
+VALUE
+rb_f_breakpoint(void)
+{
+    asm volatile("int3");
+    return 0;
+}
+
 void
 Init_Object(void)
 {
@@ -2465,4 +2472,6 @@
     id_eql = rb_intern("eql?");
     id_inspect = rb_intern("inspect");
     id_init_copy = rb_intern("initialize_copy");
+
+    rb_define_global_function("breakpoint", rb_f_breakpoint, 0);
 }
Index: vm_dump.c
===================================================================
--- vm_dump.c   (revision 11701)
+++ vm_dump.c   (working copy)
@@ -608,3 +608,14 @@
     }
 #endif
 }
+
+void
+dump_stack(rb_thread_t *th)
+{
+    VALUE *p = th->stack;
+    while (p < th->cfp->sp) {
+        fprintf(stderr, "%p: ", p);
+        rb_p(*p);
+        p++;
+    }
+}

GDBによる観察

以下のような、RubyプログラムとGDBを使って、仮想マシン内のスタックの状態変化を観察してみます。このプログラムに与えられた引数によって、breakpointが実行される位置が変更できるようにしてあります。また、トップレベルとfooメソッドとbarメソッドで、ローカル変数が作られています。

% vim stack.rb
$arg = ARGV.shift.to_i || 1

def foo(x, y, z)
  a = "a (foo)"
  breakpoint if $arg == 2
  bar(x, y)
  breakpoint if $arg == 4
end

def bar(x, y)
  b = "b (bar)"
  puts x
  puts y 
  breakpoint if $arg == 3
end

x = "x (top)"
y = "y (top)"
z = "z (top)"

breakpoint if $arg == 1

foo(x, y, z)

それでは、このstack.rbをGDB上で動作させてみます。
まず、stack.rbに引数「1」を与えてrunしてみます。
そうすると、SIGTRAPシグナルでGDBに制御が移ります。SIGTRAPシグナルが発生したのは、先ほど作ったrb_f_breakpoint関数の中でint3ソフトウェア割り込みが発生させたためです。

% ./ruby -v
ruby 1.9.0 (2007-02-11 patchlevel 0) [i686-darwin8.8.1]
% gdb --quiet ./ruby
Reading symbols for shared libraries ... done
(gdb) run stack.rb 1
Starting program: /Users/ysano/ruby-trunk/ruby stack.rb 1
Reading symbols for shared libraries .... done

Program received signal SIGTRAP, Trace/breakpoint trap.
rb_f_breakpoint () at object.c:2292
2292    }
(gdb) bt
#0  rb_f_breakpoint () at object.c:2292
#1  0x0008788f in th_eval (th=0x2ce000, initial=0) at insns.def:1289
#2  0x0008a47b in th_eval_body (th=0x2ce000) at vm.c:1598
#3  0x0008a76f in rb_thread_eval (th=0x2ce000, iseqval=5040120) at vm.c:1804
#4  0x00002e91 in ruby_exec_internal () at eval.c:228
#5  0x00002ebc in ruby_exec () at eval.c:241
#6  0x00006fc0 in ruby_run () at eval.c:260
#7  0x00002533 in main (argc=3, argv=0xbffffa18, envp=0xbffffa28) at main.c:47

フレーム#1に移動して、先ほど作ったdump_stack関数で、th_eval_body関数の第一引数に渡されたスレッド(th)に関連付けされたスタックをダンプしてみます。ダンプされた結果を見ると、0x605008〜0x605010に、トップレベルで定義したローカル変数がスタックに積まれていることがわかります。(その他の値がなぜこのように積まれているのかは、まだ理解できていません。。。)

(gdb) f 1
#1  0x0008788f in th_eval (th=0x2ce000, initial=0) at insns.def:1289
1289        macro_eval_invoke_method(recv, klass, id, num, mn, blockptr);
(gdb) whatis th
type = rb_thread_t *
(gdb) p dump_stack(th)
0x605000: nil
0x605004: 2
0x605008: "x (top)"
0x60500c: "y (top)"
0x605010: "z (top)"
0x605014: nil
0x605018: 0
0x60501c: nil
0x605020: nil
0x605024: 0
$1 = void
(gdb) 

次に、stack.rbの引数に2を与えて、再度観察してみます。
fooメソッドが呼ばれた時点で、実行が停止します。
スタックが拡張されていることがわかります。
0x605020〜0x605028は、fooメソッドに渡された引数で、0x60502cは、fooメソッド内で定義したローカル変数aです。
それらの前にある0x60501cのnilは、foo(関数風メソッド)を呼び出した時に積まれた、ダミーのレシーバオブジェクトです。
0x605018と0x605034では0が出力されていますが、これは恐らくspecvalです。(specvalは論理的な固まりの境界を表現しているのかな?)

(gdb) run stack.rb 2
Starting program: /Users/ysano/ruby-trunk/ruby stack.rb 2
Reading symbols for shared libraries .... done

Program received signal SIGTRAP, Trace/breakpoint trap.
rb_f_breakpoint () at object.c:2292
2292    }
(gdb) f 1
#1  0x0008788f in th_eval (th=0x2ce000, initial=0) at insns.def:1289
1289        macro_eval_invoke_method(recv, klass, id, num, mn, blockptr);
(gdb) p dump_stack(th)
0x605000: nil
0x605004: 2
0x605008: "x (top)"
0x60500c: "y (top)"
0x605010: "z (top)"
0x605014: nil
0x605018: 0
0x60501c: nil
0x605020: "x (top)"
0x605024: "y (top)"
0x605028: "z (top)"
0x60502c: "a (foo)"
0x605030: nil
0x605034: 0
0x605038: nil
0x60503c: nil
0x605040: 0
$1 = void
(gdb)

しつこく、stack.rbの引数に3を与えて、再度観察してみます。
スタックがさらに拡張されました。規則性が見えてきました。

(gdb) run stack.rb 3
Starting program: /Users/ysano/ruby-trunk/ruby stack.rb 3
Reading symbols for shared libraries .... done
x (top)
y (top)

Program received signal SIGTRAP, Trace/breakpoint trap.
rb_f_breakpoint () at object.c:2292
2292    }
(gdb) f 1
#1  0x0008788f in th_eval (th=0x2ce000, initial=0) at insns.def:1289
1289        macro_eval_invoke_method(recv, klass, id, num, mn, blockptr);
(gdb) p dump_stack(th)
0x605000: nil
0x605004: 2
0x605008: "x (top)"
0x60500c: "y (top)"
0x605010: "z (top)"
0x605014: nil
0x605018: 0
0x60501c: nil
0x605020: "x (top)"
0x605024: "y (top)"
0x605028: "z (top)"
0x60502c: "a (foo)"
0x605030: nil
0x605034: 0
0x605038: nil
0x60503c: "x (top)"
0x605040: "y (top)"
0x605044: "b (bar)"
0x605048: nil
0x60504c: 0
0x605050: nil
0x605054: nil
0x605058: 0
$1 = void
(gdb) 

最後に、stack.rbの引数に4を与えて、実行すると、引数に2を渡した時とスタックが同じ状態になります。(試してみて確認してみて下さい。) このような結果になったのは、両者のbreakpointが同じスコープで実行されたからです。

おわりに

今回は、仮想マシン内のスタックの状態変化を観察してみました。観察をしやすくするために、rubyGDBと連携して利用するツールを組み込みました。観察を通して、スタックの状態変化を頭の中で少しイメージできるようになりました。
スタックの状態変化は、仮想マシン内の状態変化のごく一部に過ぎません。今後は、他の状態変化についてもGDBで動的解析できるような工夫をして、理解を深めたいと思います。

参考文献

今回紹介したbreakpointメソッドの実装には、Binary Hacksで解説されているHack #92 「Cのプログラムの中でブレークポイントを設定する」を用いました。

Binary Hacks ―ハッカー秘伝のテクニック100選

Binary Hacks ―ハッカー秘伝のテクニック100選