mrubyのVMざっくり解説

なんとなく、mrubyのVMの基本的なところを書いておこうと思ったので。誰かの参考になれば。
大きな仕様的なところは、
・32bit固定長のバイトコードを解釈して動作する。
レジスタをスタックに確保するタイプのレジスタマシンである。従って、スタックの任意の位置をレジスタ番号で指定して直接読み書き可能。
・メソッドやブロックなどの単位でバイトコードがirep構造体にまとめられている。
みたいな感じ。

スタックの使い方

スタックトップがレジスタのR0となり、以下、R1、R2・・・と名前が付けられる。スタックの型はmrb_valueの配列であり、つまりmrubyオブジェクトが詰まっている。irepのコードを呼び出すとき、スタックの上から順に以下のような情報を積んでから呼ばれる。

(引数が2個あった場合の例)
R0 |self     |
R1 |argument1|
R2 |argument2|
R3 |block    |(なければnil)

更にこの後にローカル変数領域が確保されて、そこから先がワーク領域として使われる。
他のメソッドを呼び出す場合、ワークエリア上にバイトコードでこの情報を構築して、スタックトップをレシーバの位置に変更してからirepを遷移する。呼び出されたメソッド内ではR0がレシーバの位置に来て以下繰り返し。
なので自身のワークエリアの後ろのほうは呼び出したメソッドによって書き換えられる可能性があり、確保した領域全体を自由に使えるわけではない。この設計はレジスタマシン的には自由度を失って最適化の範囲を狭めているが、スタックの節約という意味では正しい。
ちなみにメソッドから戻る場合はR0に戻り値を設定してから戻る。呼び出し側からするとレシーバの位置のレジスタの内容が戻り値に置き換わったように見える。

長くなるので以下は続きで。

irepの中身

irep.hに定義された構造体で、以下の形をしている。

/* Program data array struct */
typedef struct mrb_irep {
  uint16_t nlocals;        /* Number of local variables */
  uint16_t nregs;          /* Number of register variables */
  uint8_t flags;

  mrb_code *iseq;
  mrb_value *pool;
  mrb_sym *syms;
  struct mrb_irep **reps;

  struct mrb_locals *lv;
  /* debug info */
  const char *filename;
  uint16_t *lines;
  struct mrb_irep_debug_info* debug_info;

  size_t ilen, plen, slen, rlen, refcnt;
} mrb_irep;

VMを読むうえで知っておくべきなのは、
nregs : このirepの命令列が使用するスタックの数。
nlocals : self、引数、ブロック、ローカル変数の合計。
pool : このirepで使うリテラルの配列。
syms : このirepで使うシンボルの配列。
iseq : バイトコード配列。
reps : このirepから呼ばれるirepの配列。
ぐらいである。

実際のバイトコード

mrubyを--verboseオプション付きで実行するとバイトコードアセンブラが出力される。
例えばfib39の以下のコードなら

def fib n
  return n if n < 2
  fib(n-2) + fib(n-1)
end

このようなirepが表示される。

irep 00C3ACB8 nregs=8 nlocals=3 pools=0 syms=4 reps=0
000 OP_ENTER    1:0:0:0:0:0:0
001 OP_MOVE     R3      R1      ; R1:n
002 OP_LOADI    R4      2
003 OP_LT       R3      :<      1
004 OP_JMPNOT   R3      006
005 OP_RETURN   R1      return  ; R1:n
006 OP_LOADSELF R3
007 OP_MOVE     R4      R1      ; R1:n
008 OP_SUBI     R4      :-      2
009 OP_SEND     R3      :fib    1
010 OP_LOADSELF R4
011 OP_MOVE     R5      R1      ; R1:n
012 OP_SUBI     R5      :-      1
013 OP_SEND     R4      :fib    1
014 OP_ADD      R3      :+      1
015 OP_RETURN   R3      return

一番上の行には各種保持情報の数が出力される。
この例でいえば、
・使用するスタックは8個。
・self、引数、ブロック、ローカル変数の合計は3個。(self、引数のn、ブロックは無いのでnilを置く場所)
リテラルは無い。実際には1と2の整数リテラルがあるが命令埋め込みになっているのでオブジェクトは保持していない。
・シンボルは4個。(fib、+、-、<)
・ここに含まれていて呼び出すirepは無い。
ということがわかる。

このメソッドの場合、呼ばれたタイミングで以下のようなスタックが構築されているはずである。

R0 |self  |
R1 |n     |
R2 |block |(なければnil)

ブロックに関しては呼び出し側が渡してくるかどうかで変わるが、このメソッドでは内容にかかわらず使わない。とりあえず、nregsが8なのでR3〜R7までがワークエリアということになる。fibは再帰する関数なので、自分で自分を呼び出す前に自分用にこの情報を構築している。そこいらへんを意識して読めばこの命令列については理解できるのではないかと。
後はまあ、VMのコードを見て処理内容を追ってもらえば。

命令の構造

バイトコードは32bit固定長で、基本的には7bitの命令コードと、他、命令により異なる1〜3個のオペランドを持つ。命令フォーマットは全部で9種類である。どの命令がどのフォーマットかはopcode.hを見ると丁寧に書いてある。
例えばOP_MOVEはAB型でBのレジスタからAのレジスタに転送するし、OP_SENDはABC型でAのレジスタをレシーバとしてBのメソッドをC個の引数で呼び出す。別に頑張って覚える必要は無い。見ればなんとなくわかる。

知っておいたほうがいい雑多な知識

基本的には以上だが、mrubyVMの動きとして頭に入れておいたほうがよさげな特殊な話もいろいろある。

OP_ENTERはオペランドに引数パターンの情報を持っていて、前から順にm1:o:r:m2:k:kd:b。それぞれ、m1は普通の省略不可引数、oは初期値がある省略可能引数、rはrest引数、m2はpost引数、kとkdは未使用、bはブロック、の数を表す。rest引数は*付きの仮引数で、post引数は*引数の後ろに書いた省略不可引数のことである。このへんの話を見てもらうと参考になる。かもしれない。OP_ENTERはこの引数省略などをうまいこと処理してスタックの書き直しをしてくれる。引数省略時のデフォルト値がある場合はもうちょっとややこしくてcodegenと協調して特殊な動作をする。このへんの記事の真ん中あたりのOP_ENTERの動作を参考にしてもらえるとよい。かもしれない。

OP_EQ系の比較命令、OP_ADD系の演算命令はOP_SENDを最適化した命令である。レシーバと引数によって直接処理できるものは命令内で処理する。できないものはOP_SENDに処理を移して普通にメソッド呼び出しをする。なので基本的にはOP_SENDと同じ形の命令フォーマット(ABCタイプ)になっていて、それ自体では使わないメソッド名シンボルや引数の数も命令に持っている。
また、OP_SENDとその最適化命令は第1オペランド(A)がレシーバを表していて、第2オペランド(B)がメソッド名のシンボル、第3オペランド(C)が引数の数となっていて、OP_SEND前にレシーバと引数をスタックに積む命令が並ぶが、ブロックを渡さない場合はブロックを積む命令を生成しない。さっきのfibメソッドの場合、例えば013行のOP_SENDでは本来必要なR6へのnil出力命令が無い。これは、OP_SEND内部で自動的にnilを積む処理がされているからである。ブロックを渡す場合はブロックを積む命令が出力されて呼び出しはOP_SENDB命令となる。

OP_SUBIなどは演算する値を命令に埋め込んでいるので、fibメソッドの012行ではR5しか設定していないが、R5のレシーバのオブジェクトが最適化できないものだった場合、命令内でR6に数字(この場合1)、R7にブロック(nil)を積んで、実行中の命令コードを細工したうえでOP_SENDに飛ぶ。見た感じR5までしか使ってないのにnregsが8もあるのはこれが原因である。

もっと理解するには

まあ、こんなもんで通常の動作部分は読めると思うが、例えば例外とか、CallInfo構造体とか、Procオブジェクトとか、CからRubyコードの呼び出しとか、そういう辺りはまた一段とややこしい。命令列を出力するcodegenや、さらにその元ネタとなるASTを出力するparseも難しい。このへんをきちんと理解するには腰をすえて取り掛かる必要があるだろう。
まあ、順番にやっていけばいずれは。