mrubyファミリ (組み込み向け軽量Ruby) Advent Calendar 2024、23日目の記事です。メリークリスマス!
mrubyファミリAdvent Calendar、今年は1日目からyharaさんによる衝撃的な記事がありました。
なぜ衝撃的だったかというと、僕も全く同じことをしてた(mrubyをRubyで実装しようとしていた)ので...。
で、その実装の下準備のために mruby/c 1.2 のコードリーディングをしていたわけです。今日はそのメモの内容を整理して、最低限のmruby VMの実装を理解する手助けとしようと思います。
ちなみに今回、以下はしません。
あと、筆者は言語実装の素人なのですが、しかし読みやすくするためにこの実装が何者なのかについて適当に言い切っちゃってる点もあります。ツッコミは優しく、事実確認は適宜ご自身でおなしゃす。
また、mruby/cがどのバージョンでどの使用をサポートしているかについての情報もアドベントカレンダーで公開されました。ぜひ併せてお読みください。この記事で色々な背景を残していただいているおかげで、筆者もコード上の不明点がいくつか明らかになり大変助かりました。
mruby/c 1.2の実装を読む
まず、 mruby/c release 1.0 は試したところコンパイルを通せなかったり、正直シンプルに完成していない箇所があると判断しました。release 1.2が1.x系統で最後にタグがついた実装なので、これをベースに実装を読んでいきます。
mruby/c 1.2 の段階ではさまざまな命令が未実装ではあります。以下の Implemented
の項目にある通り、mruby 1系統の命令からさらに一部にのみ対応していました。
一方でとても基本的な命令は動作できているため、残りの実装も拡張しながら進めていくイメージは湧きそうです。では見ていきましょう。
VM 周りの構造体
まずVM構造体の抜粋:
typedef struct VM { mrbc_irep *irep; uint8_t vm_id; // vm_id : 1..n const uint8_t *mrb; // bytecode mrbc_irep *pc_irep; // PC uint16_t pc; // PC mrbc_value regs[MAX_REGS_SIZE]; mrbc_value *current_regs; mrbc_callinfo *callinfo_tail; mrbc_class *target_class; int32_t error_code; volatile int8_t flag_preemption; int8_t flag_need_memfree; } mrbc_vm; typedef struct VM mrb_vm;
mrbc_irep
への残照が2つ(irep, pc_irep)、プログラムカウンタ(pc)、そして何やらレジスタへの参照が2つある...?(regs/current_regs)
mrbc_callinfo
への参照と、mrbc_class
への参照もあります。error_codeは実行後に保持しているエラーコードですかね。flag_preemptionは後で見ていきますが中断するしないのフラグです。flag_need_memfreeはそのまま名前の通りでしょう。
*mrb
は生のバイトコードをそのまま保持しているようです。idはまあidですね。
typedef struct IREP { uint16_t nlocals; //!< # of local variables uint16_t nregs; //!< # of register variables uint16_t rlen; //!< # of child IREP blocks uint16_t ilen; //!< # of irep uint16_t plen; //!< # of pool uint8_t *code; //!< ISEQ (code) BLOCK mrbc_object **pools; //!< array of POOL objects pointer. uint8_t *ptr_to_sym; struct IREP **reps; //!< array of child IREP's pointer. } mrbc_irep; typedef struct IREP mrb_irep;
mrb_irep
の定義はこうなっており、IREPのバイトコードを解釈した結果、実行コードの塊について必要な情報がまとまっています。変数の数、利用するレジスタの数など数値系はまあコメント通りかと思います。
codeとpoolsというメンバがあり、それぞれopcodeの列と、IREP内のstaticなオブジェクト(ほとんどの場合文字列です)のプールへの参照を保持しています。
ptr_to_symはどうやら生バイトコードのsymbol定義セクションへの参照で、毎回パースしながら参照していそうです(Matz mrubyではsymの情報への配列にしていた記憶も)。
また、IREPは親子関係があるので、子供のIREPへのポインタを持っています。ilen個の子供がいるという感じです。
で、メソッド(など)が呼ばれるたびにmrbc_callinfoというものが仮想的なstackにプッシュされるわけです*1。
typedef struct CALLINFO { struct CALLINFO *prev; mrbc_sym mid; mrbc_irep *pc_irep; uint16_t pc; mrbc_value *current_regs; mrbc_class *target_class; uint8_t n_args; // num of args } mrbc_callinfo; typedef struct CALLINFO mrb_callinfo;
自分の下層にあるcallinfo(prev)、メソッドなどの名前(mid)、当該メソッドやprocなどの引数カウント(n_args)を保持します。またその時の実行コンテクストでの実行情報を持っていて、具体的には:
- IREPとpc
- レジスタ
- 対象class(selfの文脈みたいなやつ?/regs[0]がselfなので必要なんだっけとは思った)
これらが真にコード列を実行するために必要な情報で、callinfoがpushされるたびに切り替わり、脱出するときに元の状態に戻るべきものです。なのでFIFOのようなデータ構造をしばしば使うわけですね。callinfoの場合はprevへの参照を持ったリストって感じでしょうか。
レジスタや対象classについてはVMでも持っていますが、これが現在アクティブなレジスタやclassということで、 callinfo_tail には一つ前の情報が保持されているという設計のようです。 OP_SEND
のコードを後ほど見ますが、そこと合わせるとわかりやすいです。
値を表す構造体
struct RObject { mrbc_vtype tt : 8; union { mrbc_int i; // MRBC_TT_FIXNUM, SYMBOL #if MRBC_USE_FLOAT mrbc_float d; // MRBC_TT_FLOAT #endif struct RClass *cls; // MRBC_TT_CLASS struct RObject *handle; // handle to objects struct RInstance *instance; // MRBC_TT_OBJECT struct RProc *proc; // MRBC_TT_PROC struct RArray *array; // MRBC_TT_ARRAY struct RString *string; // MRBC_TT_STRING const char *str; // C-string (only loader use.) struct RRange *range; // MRBC_TT_RANGE struct RHash *hash; // MRBC_TT_HASH }; }; typedef struct RObject mrbc_object;
struct RObject
はまあ言っていれば tagged union となっていて、vtypeに従ってオブジェクトへの参照を保持している趣です。少し気になるのはRObjectからRObjectへのハンドルも保持しているんですがどういうパターンでしょうかね?
typedef struct RClass { mrbc_sym sym_id; // class name #ifdef MRBC_DEBUG const char *names; // for debug. delete soon. #endif struct RClass *super; // mrbc_class[super] struct RProc *procs; // mrbc_proc[rprocs], linked list } mrbc_class; typedef struct RClass mrb_class;
struct RClass
は名前(sym_id)、スーパークラス、おそらくメソッド一覧(procs)を持っています。RProcが名前の情報も持っているので、これがメソッドテーブル、名前と手続きの紐付けです。
ちなみに、 global.c
でグローバル変数のテーブルも定義されています。またVMには定数のテーブルがないのですが、それもグローバルオブジェクトです。
static mrbc_kv_handle handle_const; //!< for global(Object) constants. static mrbc_kv_handle handle_global; //!< for global variables.
mrbc_kv_handle (struct RKeyValueHandle)
は各所で使われている自前のKVという感じです。これはハッシュテーブルではく、後ろに値を追加しつつバイナリサーチで検索するデータ構造です*2。
// See keyvalue.[ch] typedef struct RKeyValue { mrbc_sym sym_id; //!< symbol ID as key. mrbc_value value; //!< stored value. } mrbc_kv; typedef struct RKeyValueHandle { uint16_t data_size; //!< data buffer size. uint16_t n_stored; //!< # of stored. union { mrbc_kv *data; //!< pointer to allocated memory. struct VM *vm; //!< pointer to VM (if data_size == 0) }; } mrbc_kv_handle;
インスタンスについても見てみます。
#define MRBC_OBJECT_HEADER \ uint16_t ref_count; \ mrbc_vtype tt : 8 // TODO: for debug use only. typedef struct RInstance { MRBC_OBJECT_HEADER; struct RClass *cls; struct RKeyValueHandle ivar; uint8_t data[]; } mrbc_instance; typedef struct RInstance mrb_instance;
MRBC_OBJECT_HEADER
という、vtypeと参照カウントの共通のメンバの示すマクロがあります。
で、 struct RInstance
は自分の所属するクラスと、 RKeyValueHandle
を用いたインスタンス変数のKVを持っています。 data[]
で任意のデータを持たせることもできそうな雰囲気ですが...。
typedef struct RProc { MRBC_OBJECT_HEADER; unsigned int c_func : 1; // 0:IREP, 1:C Func mrbc_sym sym_id; #ifdef MRBC_DEBUG const char *names; // for debug; delete soon #endif struct RProc *next; union { struct IREP *irep; mrbc_func_t func; }; } mrbc_proc; typedef struct RProc mrb_proc;
struct RProc
では、Cの関数かIREPで実現された関数かの区別(RProcはメソッドとprocの両方を表現できるものとして実装されているため)と、unionの形でそれぞれの関数本体への参照を保持しています。
また、 next
として上位のRProcの参照を持たせることができますが、これは将来クロージャを表現するためかなと思われました(ただ、upvalueのようなものは1.2では未実装に見えた)。
ここまで代表的なものを確認しました。他のValueを表現した構造体については、どう実装するか想像したら割とその通りというシンプルなものなので省略しています。
初期化処理まわり
int mrbc_load_mrb(struct VM *vm, const uint8_t *ptr) { int ret = -1; vm->mrb = ptr; ret = load_header(vm, &ptr); // ヘッダをパースしている while( ret == 0 ) { if( memcmp(ptr, "IREP", 4) == 0 ) { ret = load_irep(vm, &ptr); // vm->irep = load_irep_0(vm, &p); してる } else if( memcmp(ptr, "LVAR", 4) == 0 ) { ret = load_lvar(vm, &ptr); // 1.2では最初の4バイトで長さをとってその分スキップしてるだけ... } else if( memcmp(ptr, "END\0", 4) == 0 ) { break; } } return ret; } // load_irep() は vm->irep = load_irep_0(vm, &p); が本体処理 static mrbc_irep * load_irep_0(struct VM *vm, const uint8_t **pos) { mrbc_irep *irep = load_irep_1(vm, pos); // 最初のが親 if( !irep ) return NULL; int i; for( i = 0; i < irep->rlen; i++ ) { // あとは子供 irep->reps[i] = load_irep_0(vm, pos); } return irep; }
VMのための領域をmalloc() した後、 mrbc_load_mrb()
を呼んで初期化していますが、基本的にはptrで渡されたmrubyバイナリデータをパースし、IREPの情報を vm->irep
にセットしているのが主要な処理です。
void mrbc_vm_begin( struct VM *vm ) { vm->pc_irep = vm->irep; vm->pc = 0; vm->current_regs = vm->regs; memset(vm->regs, 0, sizeof(vm->regs)); // clear regs int i; for( i = 1; i < MAX_REGS_SIZE; i++ ) { vm->regs[i].tt = MRBC_TT_NIL; } // set self to reg[0] vm->regs[0].tt = MRBC_TT_CLASS; vm->regs[0].cls = mrbc_class_object; vm->callinfo_tail = NULL; // target_class vm->target_class = mrbc_class_object; vm->error_code = 0; vm->flag_preemption = 0; }
mrbc_vm_begin()
ではその他の値(トップレベルの実行コンテクスト的な)を初期化します。 vm->pc_irep = vm->irep
としているので vm->irep
に入るIREPがエントリポイントというわけですね。
なお mrbc_class_object
はグローバル変数で、prelude処理(init_static() など)をしたらObjectクラスのRClassインスタンスが入ります。他にもそういうグローバル変数があります。
static void mrbc_init_class_object(struct VM *vm) { // Class mrbc_class_object = mrbc_define_class(vm, "Object", 0); mrbc_define_method(vm, mrbc_class_object, "initialize", c_ineffect); mrbc_define_method(vm, mrbc_class_object, "alias_method", c_object_alias_method); //... }
VMの実行
ここまでのデータ構造をはっきり理解していれば、命令の実行は難しいわけではないと想像できます。pcに沿って一つ命令をフェッチして、評価して、pcを動かす... を繰り返すだけですね。
int mrbc_vm_run( struct VM *vm ) { int ret = 0; do { // get one bytecode uint32_t code = bin_to_uint32(vm->pc_irep->code + vm->pc * 4); vm->pc++; // regs mrbc_value *regs = vm->current_regs; // Dispatch int opcode = GET_OPCODE(code); switch( opcode ) { case OP_NOP: ret = op_nop (vm, code, regs); break; case OP_MOVE: ret = op_move (vm, code, regs); break; case OP_LOADL: ret = op_loadl (vm, code, regs); break; //... case OP_ABORT: ret = op_stop (vm, code, regs); break; // reuse default: console_printf("Skip OP=%02x\n", GET_OPCODE(code)); break; } } while( !vm->flag_preemption ); vm->flag_preemption = 0; return ret; }
停止すべき命令に出会ったら(RETURNなど?) flag_preemption
が 1 になると想像できます。なお、mruby/c 1.2の時点(mruby 1.0系統)では命令は固定長なので、フェッチも vm->pc_irep->code + vm->pc * 4
とシンプルに参照できています。mruby 3 系統では命令は可変長ですね。確か2から可変長だったっけ...。
本記事では全ての命令を確認することはしませんが、いくつか基本的な命令を読もうと思います。
単純な命令
まず単純な命令を見てみます。あるメソッドのバイトコード列と考えてください。
LOADI R1 123 ;; 1, 123 LOADI R2 456 ;; 2, 456 MOVE R3 R1 ;; 3, 1 MOVE R4 R2 ;; 4, 2 ADD R3 R4 ;; 3 RETURN R4 ;; 4
こういう命令列を評価するとどうなるでしょう。この命令が return 123 + 456
しているだけですよというのは読み取れるかなと思います。
OP_LOADI
static inline int op_loadi( mrbc_vm *vm, uint32_t code, mrbc_value *regs ) { int ra = GETARG_A(code); mrbc_release(®s[ra]); regs[ra].tt = MRBC_TT_FIXNUM; regs[ra].i = GETARG_sBx(code); return 0; }
命令一つ一つも、基本的にやることが単純なので実装を負いやすいかなと思います。loadiではレジスタに値を入れているだけ。 GETARG_A|sBx()
マクロはバイト列の規約に沿ってオペランドを取り出しています。
loadiの場合元々レジスタにあった値の参照が一つ減るので、 mrbc_release()
で参照を減らし、参照がなくなったらメモリを解放しています。
OP_MOVE
static inline int op_move( mrbc_vm *vm, uint32_t code, mrbc_value *regs ) { int ra = GETARG_A(code); int rb = GETARG_B(code); mrbc_release(®s[ra]); mrbc_dup(®s[rb]); regs[ra] = regs[rb]; return 0; }
これも、 R(A) の方は参照を減らす、 R(B) の方は参照を増やすということをしています。
OP_ADD
static inline int op_add( mrbc_vm *vm, uint32_t code, mrbc_value *regs ) { int ra = GETARG_A(code); if( regs[ra].tt == MRBC_TT_FIXNUM ) { if( regs[ra+1].tt == MRBC_TT_FIXNUM ) { // in case of Fixnum, Fixnum regs[ra].i += regs[ra+1].i; return 0; } }
OP_ADDはオペランドが一つです。オペランドAがある時、 reg[A] に対して reg[A+1] を足してセットするという操作なのでこうなっています。なお、 MRBC_USE_FLOAT の定義により実装される分岐があり、整数と小数同士の演算にも対応しているのですが、そこは省略しています。
OP_RETURN
は OP_SEND
と一緒に参照することにしましょう。
メソッド呼び出し周辺の命令
これら演算や代入系の命令はレジスタと参照、pc(ジャンプの時)を操作するだけなので比較的難しくないのですが、メソッド呼び出しと戻り値、SEND/RETURNの流れはVM実装に慣れていないと追いづらい*3ので少し取り上げます。
OP_SEND
長いので適当にCの関数を分割しています。
static inline int op_send( mrbc_vm *vm, uint32_t code, mrbc_value *regs ) { int ra = GETARG_A(code); int rb = GETARG_B(code); // index of method sym int rc = GETARG_C(code); // number of params mrbc_value recv = regs[ra]; // Block param int bidx = ra + rc + 1; switch( GET_OPCODE(code) ) { case OP_SEND: // set nil mrbc_release( ®s[bidx] ); regs[bidx].tt = MRBC_TT_NIL; break; case OP_SENDB: // 省略 break; default: break; }
まずオペランドを取得します。この際、 R(A)
が新しいself(sendの段階ではレシーバ)、 R(A+1),...,R(A+C)
がメソッドの引数となるので、A+C+1番目のレジスタは使わないものとして解放されています。ブロック呼び出し(OP_SENDB
)ではA+C+1番目のレジスタにRProcが入る決まりですが今回は省略。
const char *sym_name = mrbc_get_irep_symbol(vm->pc_irep->ptr_to_sym, rb); mrbc_sym sym_id = str_to_symid(sym_name); mrbc_proc *m = find_method(vm, &recv, sym_id); if( m == 0 ) { mrb_class *cls = find_class_by_object( vm, &recv ); console_printf("No method. Class:%s Method:%s\n", symid_to_str(cls->sym_id), sym_name ); return 0; }
B番目のシンボルを取得して(この段階ではpc_irepは呼び出し前の状態です)、メソッドを探し出す処理です。 find_method()
は本当に recv->procs
からメソッドを探しているだけ。
// m is C func if( m->c_func ) { m->func(vm, regs + ra, rc); extern void c_proc_call(mrbc_vm *vm, mrbc_value v[], int argc); if( m->func == c_proc_call ) return 0; int release_reg = ra+1; while( release_reg <= bidx ) { mrbc_release(®s[release_reg]); release_reg++; } return 0; }
メソッド struct *RProc m
がC関数ポインタであった場合そのまま呼び出しています。
c_proc_call
との一致を判定している箇所がよくわからなかったのですが、多分 Proc#call
を呼んだ時だけは、 c_proc_call
の中でレジスタのクリーンアップ操作などをしているのでそのまま抜けてOKという感じでしょうか?
それ以外の普通のC関数ポインタの時は引数で使ったレジスタ A+1 .. A+C+1
をここで解放して、抜けます。
// m is Ruby method. // callinfo mrbc_push_callinfo(vm, sym_id, rc);
IREPのメソッドを呼ぶ際は、 callinfo を一つ(仮想的な)call stackにpushします。
void mrbc_push_callinfo( struct VM *vm, mrbc_sym mid, int n_args ) { mrbc_callinfo *callinfo = mrbc_alloc(vm, sizeof(mrbc_callinfo)); if( !callinfo ) return; callinfo->current_regs = vm->current_regs; callinfo->pc_irep = vm->pc_irep; callinfo->pc = vm->pc; callinfo->mid = mid; callinfo->n_args = n_args; callinfo->target_class = vm->target_class; callinfo->prev = vm->callinfo_tail; vm->callinfo_tail = callinfo; }
vm->callinfo_tail
にあるcallinfoは「呼び出し元のcallinfo」ですね。なので今のVMにある値がコピーされていきます。 callinfo->prev = vm->callinfo_tail
としてさらに前のcallinfoへの参照も保持されて、リストとして動作していますね。
// target irep vm->pc = 0; vm->pc_irep = m->irep; // new regs vm->current_regs += ra; return 0; }
pushが終わったら pc
はリセットされ、 pc_irep
は m
のIREPに切り替えられます。これで次のeval loopからは新しいIREPを評価することになります。あと、 current_regs
は今まで利用してきたレジスタがどんどん後ろに追加されることを想定していて、親のレジスタのうち A .. A+C
は新しいcallinfoの 0 .. C
のレジスタになるので、参照を移動させています。
ところで、ブロックが考慮されると使うべき引数レジスタは C+1
までとなり、 op_send()
関数が使いまわされています。ただ呼び出し周りを素直に読めるよう、今回の記事ではバッサリ説明をカットします。
OP_RETURN
SENDで遷移したIREPからはいつかRETURNしないといけません。
static inline int op_return( mrbc_vm *vm, uint32_t code, mrbc_value *regs ) { // return value int ra = GETARG_A(code);
RETURNで戻すべき値は R(A)
にあります。
mrbc_value *regs0 = regs; // return value stored in original regs[0] if return in block mrbc_callinfo *ci = vm->callinfo_tail; if( ci && ci->current_regs[1].tt == MRBC_TT_PROC ){ regs0 = regs - 2; } mrbc_release(regs0); *regs0 = regs[ra]; regs[ra].tt = MRBC_TT_EMPTY;
少しわかりづらいのですが、戻り値は呼び出し先のコンテクストでいう R(0)
、すなわち呼び出し元で言えば R(A)
のレジスタに戻されないといけません。なので、今の R(0)
の位置を一旦保存して、呼び出し先での regs[ra]
の値をそこにコピーするということをしています *4 。
// nregs to release int nregs = vm->pc_irep->nregs; // restore irep,pc,regs mrbc_callinfo *callinfo = vm->callinfo_tail; vm->callinfo_tail = callinfo->prev; vm->current_regs = callinfo->current_regs; vm->pc_irep = callinfo->pc_irep; vm->pc = callinfo->pc; vm->target_class = callinfo->target_class; // clear stacked arguments int i; for( i = 1; i < nregs; i++ ) { mrbc_release( ®s[i] ); }
vm->callinfo_tail
にある情報を戻しています。で、 mrbc_release( ®s[i] );
で呼び出し先の 1 .. 使った分
のレジスタを解放しています。解放するのでIREP毎に利用しているレジスタの数を保持する必要があるんですね。このコードで、 regs
で持っている参照自体は呼び出し先のものなので読み違えないようにします。
// release callinfo mrbc_free(vm, callinfo); return 0; }
最後に callinfo のメモリを解放します。ここまでで、基本的な命令の実行と、メソッド呼び出しと戻る操作(すなわりIREP間での移動)の実装を追ってみました。
他にも、クラスやメソッドを定義する命令もサポートされていますが、流石に長くなってしまったので省略します。
最後なので書く個人的な感想
yhara さんが自作してしまったように、mruby VMの仕様/実装は非常に面白く、実はRubyコミュニティのアイデアの埋蔵金なのではと思っています。例えば、mruby VMのバイトコードにコンパイルできる別言語を作ってみる等は興味深いテーマかもしれません。
ちょっと前にWebAssembly Runtimeを自作したりしたこともあって、あらためて言語VMとは興味深い存在だなと思い直しました。
僕もmrubyで一発当てるぞ!
では、明日は24日目の記事です。
*1:なお、このcallinfoには実行時コンテクストのような情報がありません、従ってクロージャやUPVALUE的な挙動をサポートできないのですが、これは設計思想のようです。ref: https://qiita.com/HirohitoHigashi/items/14ffd29e1c23e6989191#%E3%82%AF%E3%83%AD%E3%83%BC%E3%82%B8%E3%83%A3 UPVALUEの実装を確認するにはmrubyのコードリーディングが必要です
*2:ちなみにHashも実は内部はハッシュテーブルではありませんでした。背景は https://qiita.com/HirohitoHigashi/items/14ffd29e1c23e6989191#hash の通りかと思います
*3:筆者が慣れているとは言っていない
*4:ブロックから戻る時?に regs をさらに2つたどっている背景はちゃんと読めてないですね...