スタックマシンVMを実装する——バイトコードを解釈実行するfetch-decode-executeループ

バイトコードを「実行」する

バイトコードコンパイラは、ASTをOP_CONSTANTOP_ADDといった命令のバイト列に変換するところまでを担当します。しかしバイト列は、それだけではただのデータです。これを実際に解釈して実行する側——スタックマシンVM——を今回実装しました。

VMのやることは単純明快です。命令ポインタ(instruction pointer)が指すバイトを1つ読み、それが何の命令かを判定し、対応する処理をしてポインタを進める。これをループし続けるだけです。この「読む→判定する→実行する」の繰り返しをfetch-decode-executeループと呼びます。

pub struct Vm {
    chunk: Chunk,
    ip: usize,
    stack: Vec<Value>,
}

pub fn run(&mut self) -> Result<(), RuntimeError> {
    loop {
        let line = self.chunk.lines[self.ip];
        match self.read_op() {
            OpCode::Constant => {
                let value = self.read_constant();
                self.push(value);
            }
            // ...(以下、全命令に対応する分岐が続く)
            OpCode::Return => return Ok(()),
        }
    }
}

ipが実CPUのプログラムカウンタに、stackがレジスタ相当のワークスペースに対応します。Return命令に到達したらOk(())を返してループを抜ける、という単純な終了条件にしています。まだ関数が無いので、これは「1つのスクリプト全体の実行が終わった」ことをそのまま意味します。

演算子はランタイムの値の種類で分岐する

コンパイラの段階で、OP_ADDは「整数同士か浮動小数点数同士か」を区別しない汎用的な命令として設計しました。この判断のツケは、実行するVM側で払うことになります。

fn exec_binary_arith(&mut self, op_byte: u8, line: usize) -> Result<(), RuntimeError> {
    let op = OpCode::decode(op_byte);
    let right = self.pop();
    let left = self.pop();
    let result = match (left, right) {
        (Value::Int(a), Value::Int(b)) => Value::Int(self.int_arith(op, a, b, line)?),
        (Value::Float(a), Value::Float(b)) => Value::Float(float_arith(op, a, b)),
        (left, right) => {
            unreachable!("type checker guarantees matching numeric operands, got {left:?} and {right:?}")
        }
    };
    self.push(result);
    Ok(())
}

IntFloatが混ざる組み合わせや、そもそも数値でない組み合わせはunreachable!にしています。これは手抜きではなく、型検査を通過したプログラムである以上そのような組み合わせは理論上あり得ないという設計上の保証に基づいています。もし本当にここへ到達したら、それは実行時の入力が悪いのではなく型検査器かコンパイラのバグということになります。

比較演算子(<<=>>=)も同じ構造です。

fn exec_compare(&mut self, op_byte: u8) {
    let op = OpCode::decode(op_byte);
    let right = self.pop();
    let left = self.pop();
    let result = match (left, right) {
        (Value::Int(a), Value::Int(b)) => compare_ordered(op, a, b),
        (Value::Float(a), Value::Float(b)) => compare_ordered(op, a, b),
        (left, right) => unreachable!("..."),
    };
    self.push(Value::Bool(result));
}

fn compare_ordered<T: PartialOrd>(op: OpCode, a: T, b: T) -> bool {
    match op {
        OpCode::Lt => a < b,
        OpCode::LtEq => a <= b,
        OpCode::Gt => a > b,
        OpCode::GtEq => a >= b,
        _ => unreachable!(),
    }
}

compare_orderedをジェネリクスにしたことで、i64f64という異なる型に対して同じ比較ロジックを1つの関数で共有できています。一方==/!=は、Value自体がderive(PartialEq)されているため、型を問わずleft == rightと書くだけで済みます(配列同士の比較まで含めて、Rustの標準的な構造的等価性がそのまま使えます)。

型検査だけでは防げない実行時エラー

型検査は「整数と整数を足しているか」までは保証しますが、「その足し算の結果がi64に収まるか」までは保証してくれません。同様に「割り算の右辺が整数型か」は保証しても「その値がゼロでないか」は実行してみるまで分かりません。これらは型システムの範囲外にある、正真正銘の実行時エラーです。

Rustのi64のオーバーフローは、デバッグビルドではpanic、リリースビルドでは黙ってラップアラウンドするという、ビルド構成によって挙動が変わる罠があります。言語処理系がこれに引きずられてビルド設定次第で動作が変わるのは避けたいので、checked_addなどの明示的にオーバーフローを検出できるメソッドを使い、失敗したらRuntimeErrorとして一貫して報告するようにしました。

fn int_arith(&self, op: OpCode, a: i64, b: i64, line: usize) -> Result<i64, RuntimeError> {
    match op {
        OpCode::Add => a.checked_add(b).ok_or_else(|| self.error(line, "integer overflow".to_string())),
        OpCode::Sub => a.checked_sub(b).ok_or_else(|| self.error(line, "integer overflow".to_string())),
        OpCode::Mul => a.checked_mul(b).ok_or_else(|| self.error(line, "integer overflow".to_string())),
        OpCode::Div if b == 0 => Err(self.error(line, "division by zero".to_string())),
        OpCode::Div => a.checked_div(b).ok_or_else(|| self.error(line, "integer overflow".to_string())),
        OpCode::Mod if b == 0 => Err(self.error(line, "division by zero".to_string())),
        OpCode::Mod => a.checked_rem(b).ok_or_else(|| self.error(line, "integer overflow".to_string())),
        _ => unreachable!(),
    }
}

浮動小数点数の割り算はIEEE754の仕様通り、ゼロ除算をエラーにせずinf/NaNを返す挙動をそのまま利用しています(f64/演算子自体がその挙動を持つため、特別な処理は不要です)。

配列の添字アクセスも同様です。型検査が保証するのは「添字がint型であること」だけで、その値が配列の範囲内かどうかは実行時に初めて分かります。

fn exec_index(&mut self, line: usize) -> Result<(), RuntimeError> {
    let index = self.pop();
    let array = self.pop();
    let (Value::Array(items), Value::Int(index)) = (array, index) else {
        unreachable!("type checker guarantees an array and an int index")
    };
    let value = usize::try_from(index)
        .ok()
        .and_then(|index| items.get(index))
        .ok_or_else(|| self.error(line, format!("array index out of bounds: {index}")))?;
    self.push(value.clone());
    Ok(())
}

負の添字はusize::try_fromが失敗するので、範囲外アクセスと同じエラーパスにまとめて流し込めます。

OP_JUMP_IF_FALSEは値をpopしない

制御フロー命令のうち、OP_JUMP_IF_FALSEの実装には注意が必要でした。

OpCode::JumpIfFalse => {
    let offset = self.read_u16();
    let Value::Bool(condition) = self.peek(0) else {
        unreachable!("type checker guarantees a bool condition");
    };
    if !condition {
        self.ip += offset as usize;
    }
}

条件値をpopではなくpeek(スタックの一番上を覗くだけで取り除かない)で読んでいるのがポイントです。これはコンパイラ側の設計と対応しています。コンパイラはif文をコンパイルする際、OP_JUMP_IF_FALSEを発行した直後に必ず明示的なOP_POPを続けて発行するようにしていました。

self.compile_expr(condition, line, column)?;
let then_jump = self.emit_jump(OpCode::JumpIfFalse, line);
self.chunk.write_op(OpCode::Pop, line); // ← 条件値を捨てるのはこちらの役目

もしOP_JUMP_IF_FALSE自身が値をpopしてしまうと、この後に続く明示的なOP_POPが「本来無いはずの値」を余分に取り除いてしまい、スタックの整合性が崩れます。&&/||の短絡評価も同じ理由で、ジャンプ命令とPOP命令の役割分担を厳密に守る必要があります。バイトコードの設計とVMの実装は独立した作業に見えて、実際にはこうした細部で密接に対応しています。

代入文もprint文も無い言語で、VMの実行結果をどう検証するか

この言語にはまだx = 1;のような再代入も、値を画面に出力する組み込み関数もありません。この制約は、VMのテストを書く上で地味に厄介な問題を生みます。「if文の分岐が正しく実行されたこと」や「forループが正しく回ったこと」を、どうやって外から観測すればよいのでしょうか。

トップレベルのletはスタックに残り続ける

1つ目の手がかりは、コンパイラの設計上、トップレベルのスコープに対してはend_scopeが一度も呼ばれないという事実です。トップレベルでletした変数は、プログラム終了(OP_RETURN)まで一切popされません。つまり実行後のstackをそのまま覗けば、トップレベルのletで計算した値がそのまま残っています。

#[test]
fn arithmetic_on_locals() {
    let vm = run("let x = 1 + 2 * 3;");
    assert_eq!(vm.stack()[0], Value::Int(7));
}

「片方の分岐だけが実行時エラーを踏む」ように仕込む

算術やローカル変数の検証はこれで十分ですが、ifwhileforの制御フローそのもの(「本当に片方の分岐だけが実行され、もう片方は実行されなかったか」)は、値を観測するだけでは確認できません。ここで使ったのが、「実行されてほしくない側の分岐にわざとゼロ除算を仕込んでおき、エラーにならなければ実行されなかった証拠になる」という手法です。

#[test]
fn if_true_branch_runs_and_else_branch_is_skipped() {
    assert!(try_run("if (true) { 1; } else { let bug = 1 / 0; }").is_ok());
}

#[test]
fn for_loop_continue_skips_the_rest_of_the_body() {
    // continueが正しくジャンプしなければ、n == 2のときに直後のifも
    // 実行されてゼロ除算に到達してしまう。
    assert!(try_run(
        "for (n in [1, 2, 3]) { \
             if (n == 2) { continue; } \
             if (n == 2) { let bug = 1 / 0; } \
         }"
    )
    .is_ok());
}

副作用を起こす手段が「実行時エラーを起こすこと」しか無い言語だからこそ生まれた、少し変わったテストの書き方ですが、これによって値の観測に頼らずとも「特定の分岐が実行されたか・されなかったか」を厳密にテストできます。while (true) { break; }のようなテストは、もしbreakが壊れていればテストが無限ループして返ってこなくなるという形で異常を検知します(アサーション失敗ではなくタイムアウトという形にはなりますが、これもVMの制御フローを検証する上では意味のある失敗の仕方です)。

動作確認

算術演算子を一通り試すスクリプトを実行してみます。

let a = 10;
let b = 3;
let sum = a + b;
let quotient = a / b;
let remainder = a % b;
let is_greater = a > b;
$ cargo run -- examples/arithmetic.na
stack after execution: [Int(10), Int(3), Int(13), Int(3), Int(1), Bool(true)]

sumが13、quotientが3、remainderが1、is_greatertrueと、それぞれのローカル変数スロットに正しい値が残っています。

bを0に変えてゼロ除算を発生させると、行番号付きでランタイムエラーが報告されます。

$ cargo run -- examples/arithmetic.na
na: runtime error (4): division by zero

let quotient = a / b;は4行目なので、正しい行番号が報告されています。VM自体のテストは、算術・比較・論理演算・配列・シャドーイング・if/while/for/break/continueまで含めて23件、既存の54件と合わせて77件が通ることを確認しています。

備考

まだprintに相当する組み込み関数が無いため、CLIの出力は暫定的なもの

計算結果を画面に表示する正式な手段がまだ無いので、main.rsでは実行後にスタックの中身をそのまま{:?}でダンプしています。

println!("stack after execution: {:?}", vm.stack());

これはあくまで動作確認のための暫定的な仕組みで、トップレベルのletがpopされずに残るという実装都合に依存しています。組み込み関数を導入する段階で、本来のprint文に置き換える予定です。

ランタイムエラーは行番号のみで、列番号は持たない

これまでのLexError/ParseError/TypeError/CompileErrorはいずれも行番号と列番号の両方を持っていましたが、RuntimeErrorは行番号だけです。これは手を抜いたわけではなく、Chunklinesがそもそも行番号しか記録していない(バイト列の各バイトに対して行番号だけを保持する設計にした)ためです。バイトコードのレベルまで列番号を引き回す価値は薄いと判断し、コンパイル時点で情報を落としています。

スタックオーバーフローへの対策はまだ無い

self.stack: Vec<Value>は際限なく伸びる実装になっており、深いネストや際限ない再帰があった場合の保護がありません。今のところ関数(したがって再帰)が無く、ループも配列の長さ分しか回らないため実害はありませんが、関数を導入する際にはコールスタックの深さ制限を検討する必要があります。

cargo builddisassemble系のメソッドが未使用という警告が出る

main.rsからchunk.disassemble(...)の呼び出しを削除したため、disassembleとその内部で使うヘルパー群がテストコード以外から呼ばれなくなり、dead_code警告が出るようになりました。実行結果を直接確認できるようになった今、逆アセンブラの出番は減りましたが、バイトコードの構造を人の目で追いたいときには引き続き使えるので、テストコードからの利用は残しています。