Edited at

Rubyのバイトコードの実行を追う

More than 3 years have passed since last update.

最近「Rubyのしくみ」を読んだので、確認も兼ねてよくあるRubyコードをVMのバイトコード&Cコードまで落としてみたメモ。

以下のコードがどう動くかを見る。

def foo(bar, baz)

bar + baz
end

a = 1
b = 2

foo(a,b)


準備

以下を Ruby 2.3.0 で実行する(この辺バージョンが違うと結構変わるので注意)。

puts RubyVM::InstructionSequence.new(DATA).disassemble

__END__

def foo(bar, baz)
bar + baz
end

a = 1
b = 2

foo(a,b)

こういう出力になる(この出力ロジックは、iseq.cにある)。

== disasm: #<ISeq:<compiled>@<compiled>>================================

local table (size: 3, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
[ 3] a [ 2] b
0000 trace 1 ( 3)
0002 putspecialobject 1
0004 putobject :foo
0006 putiseq foo
0008 opt_send_without_block <callinfo!mid:core#define_method, argc:2, ARGS_SIMPLE>, <callcache>
0011 pop
0012 trace 1 ( 7)
0014 putobject_OP_INT2FIX_O_1_C_
0015 setlocal_OP__WC__0 3
0017 trace 1 ( 8)
0019 putobject 2
0021 setlocal_OP__WC__0 2
0023 trace 1 ( 10)
0025 putself
0026 getlocal_OP__WC__0 3
0028 getlocal_OP__WC__0 2
0030 opt_send_without_block <callinfo!mid:foo, argc:2, FCALL|ARGS_SIMPLE>, <callcache>
0033 leave
== disasm: #<ISeq:foo@<compiled>>=======================================
local table (size: 3, argc: 2 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
[ 3] bar<Arg> [ 2] baz<Arg>
0000 trace 8 ( 3)
0002 trace 1 ( 4)
0004 getlocal_OP__WC__0 3
0006 getlocal_OP__WC__0 2
0008 opt_plus <callinfo!mid:+, argc:1, ARGS_SIMPLE>, <callcache>
0011 trace 16 ( 5)
0013 leave ( 4)

以下を考慮して、ちょっと読みやすくする。


  • 二つの命令列に分かれているので、分ける。


  • traceKernel#set_trace_func のために用意された命令なので、除く

  • プレフィックス _OP_ が付いたものは最適化命令なので、一般的な形に書き直す

== disasm: #<ISeq:<compiled>@<compiled>>================================

local table (size: 3, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
[ 3] a [ 2] b
0000
0002 putspecialobject 1
0004 putobject :foo
0006 putiseq foo
0008 opt_send_without_block <callinfo!mid:core#define_method, argc:2, ARGS_SIMPLE>, <callcache>
0011 pop
0012
0014 putobject 1
0015 setlocal 3, 0
0017
0019 putobject 2
0021 setlocal 2, 0
0023
0025 putself
0026 getlocal 3, 0
0028 getlocal 2, 0
0030 opt_send_without_block <callinfo!mid:foo, argc:2, FCALL|ARGS_SIMPLE>, <callcache>
0033 leave

traceの箇所で区画を分けて見ると、式に対応していて読みやすい。


  1. メソッド foo を定義

  2. 変数 a1 を代入

  3. 変数 b2 を代入

  4. メソッド呼び出し foo(a,b)

下記は、メソッド foo の中身に相当する方のバイトコード。

== disasm: #<ISeq:foo@<compiled>>=======================================

local table (size: 3, argc: 2 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
[ 3] bar<Arg> [ 2] baz<Arg>
0000
0002
0004 getlocal 3, 0
0006 getlocal 2, 0
0008 opt_plus <callinfo!mid:+, argc:1, ARGS_SIMPLE>, <callcache>
0011
0013 leave ( 4)


読み方


2-3行目:ローカルテーブル

local table (size: 3, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])

[ 3] a [ 2] b

ローカル変数+引数の情報。()内を見ていくと、

前半、size: 3は全体のサイズで、ローカル変数の数+1になる(これは事実だが、なぜこうなっているか分かってない)。

後半、argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1] はメソッドの引数に関する情報。[]内はオプショナル引数はあるか、ブロック引数はあるか、、などなどの情報。今見てる出力はトップレベルのものなので何もないが、foo メソッドの出力で同様の箇所を見ると argc: 2 となっていることが分かる。

次の [ 3] a [ 2] b はテーブルの内容で、インデックスと変数名との対応になっている。

これ以降の行は、命令列。(二重)スタックマシンである RubyVM がこれを順に実行していく。なお、各命令の定義は、insns.defに書いてある。


メソッド定義

まずはメソッド定義。

0002 putspecialobject 1

0004 putobject :foo
0006 putiseq foo
0008 opt_send_without_block <callinfo!mid:core#define_method, argc:2, ARGS_SIMPLE>, <callcache>
0011 pop

…とこれはメソッド呼び出しなのだが、これはメソッド呼び出しを見てからのほうが分かりやすいので最後に回す。


ローカル変数の代入

0014 putobject        1

0015 setlocal 3, 0

これがどういう操作かと言うと、


  1. オブジェクト1をスタックの一番上に置く。

  2. 3番目の変数が、スタックの一番上にある値を参照するように設定

setlocal 3, 0 の意味をもうちょっと詳しく。まず、ローカル変数はレキシカルスコープごとに存在する。これはネストするため、どれくらい外側のスコープなのかを指定する必要がある。これが 0 の意味。0 の場合は、現在のスコープを表す。1 であれば、一個外側のスコープを見に行く。3 はそのスコープのどの変数なのかを決めている。今の場合、3番目の変数は a であるので、a がスタックの一番上にある値を参照するようになる。

外側のスコープをどうたどるかという話はここでは割愛。

…その次の命令列も、bに代入するもので動作は同様。

0019 putobject        2

0021 setlocal 2, 0


メソッド呼び出し

0025 putself          

0026 getlocal 3, 0
0028 getlocal 2, 0
0030 opt_send_without_block <callinfo!mid:foo, argc:2, FCALL|ARGS_SIMPLE>, <callcache>

メソッド呼び出しは、VMレベルではレシーバもオペランドとして考えると理解しやすいと思う。

つまり、今回の foo(a,b) のためにはオペランドを3つ用意する必要がある。


  1. self をスタックにプッシュ

  2. 変数 a の値をスタックにプッシュ

  3. 変数 b の値をスタックにプッシュ

  4. 2引数のメソッド foo を呼び出す

3で、レシーバのクラスを元にメソッド探索を行い、引数2つを渡してメソッドを呼び出す。


再:メソッド定義

再びメソッド定義。

0002 putspecialobject 1

0004 putobject :foo
0006 putiseq foo
0008 opt_send_without_block <callinfo!mid:core#define_method, argc:2, ARGS_SIMPLE>, <callcache>
0011 pop

先ほどのメソッド呼び出しの一般形を知った上で見てわかることは、


  • 引数は二つで、メソッド名のシンボルと、中身となる命令列

  • レシーバはスペシャルオブジェクトなるもの

の二点。putspecialobject 1は何らのオブジェクトをスタックにプッシュしていることは間違いないと思うが、定義を見るとrb_mRubyVMFrozenCoreというグローバル変数である。このグローバル変数には、VMの初期化処理で生成されたオブジェクトが入っており、かつこのオブジェクトにはメソッドを定義するためのメソッドなどが定義されている(メタだ)。

結局、この特殊なオブジェクトにメソッド core#define_method を呼ぶと、m_core_define_method が起動する。中で呼んでいる vm_define_methodm_core_define_methodm_core_define_singleton_method の両方で使われているので、普通に現在のクラスにメソッド定義する場合と、特異クラスにメソッド定義する場合の両方を含むことがわかる(ただし、今回はトップレベルでのメソッド定義なので「現在のクラス」に特殊なクラスがセットされてそう)。後はこれを追っていくと、クラスに対してメソッド追加するロジックが見えてくると思うけど、今回はバイトコードが追ってCコード手前くらいに落とせれば良かったので、このへんでやめます。