ひとり勉強会

ひとり楽しく勉強会

データ構造とその初期化

VM

まず、YARV仮想マシンを表すデータ構造があります。yarvcore.h で定義されている yarv_vm_t 構造体です。

typedef struct yarv_vm_struct {
    VALUE self;
  ...略...
    struct yarv_thread_struct *main_thread;
    struct yarv_thread_struct *running_thread;

    st_table *living_threads;
  ...略...
} yarv_vm_t;

大ざっぱにまとめると、yarv_vm_t は、VM の上で走っているスレッドの集まりです。それ以外にも結構な数のメンバがあったのですが、ここでは思いっきり省略して引用してます (^-^;)。後でコードを読んでて登場したらその時に改めて読み直そうと思います。先頭の self メンバには、VMを表すRubyのオブジェクトが入ります。
VMインスタンスは、グローバル変数 theYarvVM に格納されます。初期化は、

  yarv_vm_t *vm = ALLOC(yarv_vm_t);
  vm_init2(vm);
  theYarvVM = vm;
          • vm_init2
  MEMZERO(vm, yarv_vm_t, 1);

この関数でまずゼロ初期化して、

    • ruby_init
      • rb_call_inits
  yarv_vm_t *vm;
  vm = theYarvVM;

  thval = yarv_thread_alloc(cYarvThread);
  GetThreadPtr(thval, th);

  vm->main_thread = th;
  vm->running_thread = th;

Init_vmで、現在のスレッドをメインのスレッドとして設定しています。

スレッド

各スレッドは、yarv_thread_t という構造体です。

typedef struct yarv_thread_struct
{
    VALUE self;
    yarv_vm_t *vm;

    /* execution information */
    VALUE *stack;                /* must free, must mark */
    unsigned long stack_size;
    yarv_control_frame_t *cfp;

  ... 略 ...
} yarv_thread_t;

... 略 ... の部分は豪快に60行くらい省略してしまいましたが、yarv_thread_t が管理するデータで一番肝心なのが、上に抜粋した部分だと思います。思いっきり大ざっぱにまとめると、スレッドは

  • スタック (stack, stack_size)
  • 現在の制御フレーム (cfp)

の2つからできています。スタックは単純にベタのメモリ領域です。制御フレームは、プログラム実行中の状態を表すデータ構造です。

制御フレーム

制御フレームは、プログラム実行中の状態を表すデータ構造です。

typedef struct {
    VALUE *pc;                  // cfp[0]
    VALUE *sp;                  // cfp[1]
    VALUE *bp;                  // cfp[2]
    yarv_iseq_t *iseq;          // cfp[3]
    VALUE magic;                // cfp[4]
    VALUE self;                 // cfp[5] // block[0]
    VALUE *lfp;                 // cfp[6] // block[1]
    VALUE *dfp;                 // cfp[7] // block[2]
    yarv_iseq_t *block_iseq;    // cfp[8] // block[3]
    VALUE proc;                 // cfp[9] // block[4]
    ID callee_id;               // cfp[10]
    ID method_id;               // cfp[11] saved in special case
    VALUE method_klass;         // cfp[12] saved in special case
    VALUE prof_time_self;       // cfp[13]
    VALUE prof_time_chld;       // cfp[14]
    VALUE dummy;                // cfp[15]
} yarv_control_frame_t;
  • pc : 次に実行する命令のあるアドレス
  • sp : スタックポインタ
  • bp : ベースポインタ ([bp, sp) が現在のスコープが使うスタック領域)
  • iseq : 現在実行中の命令列
  • ...
  • lfp : 現在のメソッドのローカル変数があるアドレス

などなど。
スレッドと制御フレームの初期化は、まとめて

で行われます。

static void
th_init2(yarv_thread_t *th)
{
    MEMZERO(th, yarv_thread_t, 1);

    /* allocate thread stack */
    th->stack = ALLOC_N(VALUE, YARV_THREAD_STACK_SIZE);
    th->stack_size = YARV_THREAD_STACK_SIZE;

まずスタックのメモリ領域割り当て。

    th->cfp = (void *)(th->stack + th->stack_size);
    th->cfp--;

続いて、制御フレームを割り当て。といっても、スレッド初期化のときはまだ実行コードのコンパイル前なので、ここの制御フレームはほとんどダミーのデータです。制御フレームは、スタック領域の底から上に向かって割り当てられていくようです。(後述)

    th->cfp->pc = 0;
    th->cfp->sp = th->stack;
    th->cfp->bp = 0;
    th->cfp->lfp = th->stack;
    th->cfp->dfp = th->stack;
    th->cfp->self = Qnil;
    th->cfp->magic = 0;
    th->cfp->iseq = 0;
    th->cfp->proc = 0;
    th->cfp->block_iseq = 0;

スタックポインタ sp は、スタックの先頭からはじまります。ダミーの制御フレームなので、他の情報は特に使われないのでゼロクリア。

実行開始

初期化後、Rubyソースのコンパイルが行われて、コンパイル結果の命令列データが yarvcore_eval_iseq 関数に渡されます。ここがVMによる実行の開始点です。

static VALUE
yarvcore_eval_iseq(VALUE iseq)
{
    return yarv_th_eval(GET_THREAD(), iseq);
}

指定したスレッド(ここではメインスレッド)上で命令列をevalする関数 yarv_th_eval に丸投げするだけです。

VALUE
yarv_th_eval(yarv_thread_t *th, VALUE iseqval)
{
    VALUE val;
    volatile VALUE tmp;

    th_set_top_stack(th, iseqval);

    if (!rb_const_defined(rb_cObject, rb_intern("TOPLEVEL_BINDING"))) {
        rb_define_global_const("TOPLEVEL_BINDING", rb_binding_new());
    }
    val = th_eval_body(th);
    tmp = iseqval; /* prohibit tail call optimization */
    return val;
}

末尾呼び出し最適化されるとマズいみたいですね。GCかジャンプタグの関係・・・かな?それはともかく、yarv_th_eval のやることは2つあります。

  • 受け取った命令列を元に制御フレームを設定 (th_set_top_stack)
  • 実行スタート (th_eval_body)

制御フレームの設定が終わっているので、実際に仮想マシンを動かす関数 th_eval_body には改めて命令列を引数として渡す必要はありません。
th_set_top_stack からは、yarvcore.c ではなく vm.c の関数です。いよいよ本格的にVMに突入です。わくわく。

void
th_set_top_stack(yarv_thread_t *th, VALUE iseqval)
{
    yarv_iseq_t *iseq;
    GetISeqPtr(iseqval, iseq);

    if (iseq->type != ISEQ_TYPE_TOP) {
      rb_raise(rb_eTypeError, "Not a toplevel InstructionSequence");
    }

    /* for return */
    th_set_finish_env(th);

    push_frame(th, iseq, FRAME_MAGIC_TOP,
	       ruby_top_self, 0, iseq->iseq_encoded,
	       th->cfp->sp, 0, iseq->local_size);
}

ちょっとth_set_finish_envの説明は後回しにします。渡されたiseqを元に制御フレームの設定をしているのは、push_frame関数です。既存の制御フレームの上に、新しく別の制御フレームを積みます。実行開始時以外にも、メソッド呼び出しやブロック呼び出しなど実行命令列が切り替わるタイミングでpush_frame関数が使われます。

static inline yarv_control_frame_t *
push_frame(yarv_thread_t *th, yarv_iseq_t *iseq, VALUE magic,
	   VALUE self, VALUE specval, VALUE *pc,
	   VALUE *sp, VALUE *lfp, int local_size)

引数は順に、

  • 対象スレッド
  • 新しく実行する命令列
  • フレームの種類を表すマジックナンバー
  • 新しいスコープでの、self オブジェクト
  • フレームの種類ごとにいろいろ
  • 次に実行する命令のアドレス (PC)
  • スタックポインタ (SP)
  • ローカル変数領域のアドレス (LFP)
  • ローカル変数の個数

です。ローカル変数をスタック上に置く場合は lfp=0 で、個数をちゃんと指定して呼び出します。push_frameの中身は・・・

{
    /* nil initialize */
    for (i=0; i < local_size; i++) {
      *sp = Qnil;
      sp++;
    }

スタック上にローカル変数領域を確保。nil で初期化します。

    /* set special val */
    *sp = GC_GUARDED_PTR(specval);
    dfp = sp;

さらに1個特別な値が置かれるらしい。

    if (lfp == 0) {
       lfp = sp;
    }

lfp==0なら、今確保した領域をローカル変数領域として使います。

    cfp = th->cfp = th->cfp - 1;

制御フレームをスタックの底から新しく割り当てて

    cfp->pc = pc;
    cfp->sp = sp + 1;
    cfp->bp = sp + 1;
    cfp->iseq = iseq;
    cfp->magic = magic;
    cfp->self = self;
    cfp->lfp = lfp;
    cfp->dfp = dfp;
    cfp->proc = 0;
    cfp->method_id = 0;
    cfp->callee_id = 0;

引数から来た情報をひたすらセット。

スタックのメモリ配置

最初に積まれたものから順に、制御フレームをCFP1, CFP2, ...、ローカル変数や計算用スタックの領域をLSR1, LSR2, ... と書くことにすると、

th->stack                                 th->stack+th->stack_size
↓                                                              ↓
+----------------------------‖----------------------------------+
| LSR1 | ... | LSRn |        ‖       | CFPn | ... | CFP2 | CFP1 |
+----------------------------‖----------------------------------+
                    ↑                ↑
                    th->cfp->sp       th->cfp

こういう風に左右から使われるみたいです。左右がぶつかったらスタックオーバーフロー。YARVアーキテクチャでは制御フレームとローカル変数領域はまとまっていたみたいですが、変更になったのかな。左側のスタック領域についてさらに細かく見ると

+-----------------------------------------------------------
| LocalVar1 | LocalVar2 | ... | SpecVal | 計算用スタック...
+-----------------------------------------------------------
                       ↑              ↑                ↑
                       lfp             bp                sp

こういう感じ。ローカル変数はlfpから負の添え字で参照します。

th_set_finish_env

さて、話を戻します。th_set_top_stack では、コンパイルした命令列を実行する制御フレームを積む前に、th_set_finish_env という関数を呼んでました。この関数の中身はこうです。なんと、ここでも別の制御フレームを作っています。

VALUE
th_set_finish_env(yarv_thread_t *th)
{
    push_frame(th, 0, FRAME_MAGIC_FINISH,
               Qnil, th->cfp->lfp[0], 0,
               th->cfp->sp, 0, 1);
    th->cfp->pc = &yarv_finish_insn_seq[0];
    return Qtrue;
}

pcにセットされている命令列はこちら(ダイレクトスレッデッドコードの場合はもーちょい複雑ですが、意味的には同じ)。vm.c のグローバル変数

static VALUE yarv_finish_insn_seq[1] = { BIN(finish) };

finish というYARV命令が1個だけの命令列です。finish命令は、insns.defを見ると、こういうもの。

/**
  @c method/iterator
  @e return from this vm loop
  @j VM loop から抜ける
 */
DEFINE_INSN
finish
()
(VALUE val)
(VALUE val)
{
    th->cfp++;
    return val;
}

制御フレームを1つ前のものに戻して、return。コメントにあるとおり、VMの実行ループを抜けるための命令です。次回か次々回に見ていく予定のVMの実行ループ関数(Cで書かれている)は、こんな感じの無限ループで、finish命令があったら、Cで書かれているその実行ループ関数をreturnで抜け出すという実装になっています。

for(;;) {
  switch( compiled_iseq[pc] ) {
    case ...
      ...
    case finish:
      {
        VALUE val = pop_from_stack();
        th->cfp++;
        return val;
      }
    case ...
      ...
  }
}

Rubyのreturn文は、finish命令にはコンパイルされません。leave命令になります。leaveは、制御フレームを1つ前に戻すだけで、VMの実行ループ関数自体は抜け出しません。Rubyで書かれたメソッドからRubyで書かれたメソッドを呼んでそこから戻るような場合、そこでVMを抜ける必要はないからです。
「Cで書かれた関数からRubyのコードを呼び出して、そこからCに戻る」という場合に、finish命令が使われます。たとえば今は Cで書かれた yarv_th_eval 関数からRubyのコードを実行しようとしているので、まさにfinishが必要な状況です。
でもでも、さっき書いたように、Rubyのコードはleave命令で抜けるようにコンパイルされてます。
というジレンマを解決するのが、th_set_finish_env なのでした。コンパイルされた命令列を制御フレームにセットする前に、finish 命令の入った命令列を制御フレームに仕込んでおくのがこの関数の仕事です。そうすると、コンパイルされた命令列の実行が終わって、leave命令で直前の制御フレームに戻ろうとすると、うまいことfinish命令のところに「戻」ってくれちゃうわけです。巧いですねえ。

まとめ

今日は、YARV仮想マシンの実行時のデータ構造についてと、実際にコードの実行に移るまでのそのセットアップについて見てみました。次回は、実際に仮想マシン語を実行する関数 th_eval_body の方に行く予定です。