はじめに
前回の記事で、Claude Code で開発中の SystemVerilogシミュレータ mrun(仮)
の途中経過を報告させていただきました。
今回は、その記事で少しだけ触れたアーキテクチャの核心部分、バイトコードインタープリタ について、もう少し深く掘り下げてみたいと思います。なぜ、C++コードを直接生成するVerilatorのような方式ではなく、一手間かけてバイトコードという中間形式を挟むのか?その設計思想と、mrun(仮)
で実装した具体的な仕組みについて解説します。
なぜバイトコード方式なのか?
SystemVerilogシミュレータの実装方式は、大別して2つあります。
- 静的コード生成方式(例: Verilator): SystemVerilogコードを解析し、直接高速なC++やSystemCのコードを生成してコンパイルする方式。実行速度は最速クラスですが、設計変更のたびにC++のコンパイルが必要になり、柔軟性に欠ける場合があります。
- インタープリタ/JIT方式(例: 多くの商用ツール、mrun(仮)): ソースコードを独自の中間表現(バイトコード)に変換し、それをVM(仮想マシン)で解釈実行する方式。実行速度は静的コード生成に譲りますが、デバッグのしやすさや動的な機能(DPI-Cなど)との親和性が高いのが特徴です。
mrun(仮)
では、以下の理由からバイトコードインタープリタ方式を選択しました。
- 開発の分離と効率化: パーサー(Slang)と実行エンジン(Interpreter)を明確に分離できるため見通しが良く、分担開発に適しています。
- 高度なデバッグ機能の実装: バイトコードレベルで実行をステップ実行したり、VMの状態をスナップショットしたりできるため、インタラクティブなデバッガの実装が容易になります。
- 将来的な性能向上: 今はシンプルなインタープリタですが、将来的にプロファイラを導入し、ボトルネックとなるバイトコード列だけをネイティブコードに JIT(Just-In-Time) コンパイルする、といった段階的な性能改善が可能です。
mrun(仮) のバイトコードアーキテクチャ
mrun(仮)
の実行エンジンは、SystemVerilogのセマンティクスを効率的に実行するために特化した、ドメイン特化型のVMです。
1. 命令セット(OpCode)と実装上の注意点
mrun(仮)
のVMはシミュレーション特有の操作を直接命令にしています。
// mrun/src/vm/opcode.hpp (一部抜粋)
enum class OpCode : uint8_t {
// === 基本制御 ===
NOP = 0x00, // 何もしない
HALT = 0x01, // シミュレーション終了
JUMP = 0x02, // 無条件ジャンプ (相対アドレス)
BRANCH_IF_TRUE = 0x03, // 条件分岐 (True)
BRANCH_IF_FALSE = 0x04, // 条件分岐 (False)
// === 信号/変数/定数操作 ===
LOAD_NET_VAL = 0x10, // ネットの値をスタックにロード
STORE_BA = 0x11, // ブロッキング代入
STORE_NBA = 0x12, // ノンブロッキング代入
LOAD_CONST_IMM = 0x18, // 即値定数をロード
// === 算術/論理演算 ===
// 4値論理(0,1,X,Z)を考慮した演算
ADD_4STATE = 0x30, // 4値加算
EQUAL_4STATE = 0x40, // 4値比較 (===)
LOGICAL_NOT_4STATE = 0x50, // 4値論理NOT
// === システムタスク/関数呼び出し ===
SYSCALL = 0x70, // システムタスク呼び出し
// === 高度な制御構造 ===
WAIT_EVENT = 0xD0, // イベント待機 (always @(...) の実装核)
FOREACH_START = 0xDC, // foreachループ開始
FOREACH_NEXT = 0xDE, // foreachループ次要素へ
// ... 他、全256オペコードのうち約180を現在使用
};
これらの命令の実装には、いくつかの重要な注意点があります。
-
STORE_NBA
: ノンブロッキング代入<=
に対応します。後述しますが、この命令は値を即座に更新せず、スケジューラのNBA更新キューに「更新予定」を登録するだけです。同一タイムスロット内で同じ信号(LHS)に複数のNBAがスケジュールされた場合、IEEE 1800規格に基づき、最後にスケジュールされた値が採用されます(last-write-wins)。 -
WAIT_EVENT
:always @(posedge clk)
のようなセンシティビティリストを実現する心臓部です。この命令に到達したプロセスは中断されますが、同一タイムスロット内で他のプロセスによってイベントが発火した場合、即座に(0-delayで)再スケジューリングされる可能性があります。mrun
のスケジューラは、この「0-delay再発火」を正しく処理し、かつイベントキューへのプロセスの重複登録を防止する設計になっています。 -
FOREACH_START
/FOREACH_NEXT
:foreach
ループは、queue
や動的配列のような可変長配列を扱う必要があります。そのため、mrun(仮)
のforeach
関連命令は、ループの境界をコンパイル時に固定(unroll)するのではなく、実行時に配列の現在のサイズを評価する動的な実装になっています。これにより、ループ中に配列サイズが変化するケースにも正しく対応できます。
2. バイトコードの生成例
例えば、シンプルなDフリップフロップを考えてみましょう。
// dff.sv
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
q <= 1'b0;
else
q <= d;
end
このコードは、mrun(仮)
内部の Slang To IR Converter
によって、以下のようなバイトコード(のイメージ)にコンパイルされます。先頭の数字は説明用の命令オフセット(命令単位)で、ジャンプ命令のオペランドも同様に相対的な命令オフセットを示します。
// プロセス P1 (dff.sv:1 の always ブロック)
// --- 初期化時 ---
0000: WAIT_EVENT (posedge clk), (negedge rst_n)
// --- イベント発生時、ここから再開 ---
0001: LOAD_NET_VAL rst_n_id // rst_n の値をロード
0002: LOGICAL_NOT_4STATE // !rst_n
0003: BRANCH_IF_FALSE +4 // ifの結果が偽なら4命令先へジャンプ
// then節: q <= 1'b0;
0004: LOAD_CONST_IMM 1'b0 // 定数0をロード
0005: STORE_NBA q_id // qへのノンブロッキング代入をスケジュール
0006: JUMP -7 // ループの先頭(WAIT_EVENT)に戻る
// (PCは次の命令を指すため、実質 new_pc = pc + (-7))
// else節: q <= d;
0007: LOAD_NET_VAL d_id // dの値をロード
0008: STORE_NBA q_id // qへのノンブロッキング代入をスケジュール
0009: JUMP -10 // ループの先頭に戻る
ここで重要なのは、0003: BRANCH_IF_FALSE
の挙動です。SystemVerilogのif
文は4値論理を扱います。この命令は条件が 「真でない」 場合(つまり1'b0
、1'bX
、1'bZ
のいずれか)に分岐を実行します。
3. VMとスケジューラの協調動作:NBAコミットの深層
VMとスケジューラの連携は、シミュレータの正確性を保証する上で最も複雑な部分です。特にノンブロッキング代入(NBA)の扱いは重要です。
以前の図では簡略化していましたが、IEEE 1800規格に準拠したより正確なイベント処理フローは以下のようになります。
この図の通り、NBAの処理は単純な1ステップではありません。
-
登録 (Active/Inactive Region): プロセス実行中、
STORE_NBA
命令は、どの信号にどの値を代入するかの「予定」をNBA_Queue
に登録するだけです。 -
評価 (NBA Region): タイムスロット内の全アクティブプロセスが中断した後、スケジューラは
NBA_Queue
に溜まった全ての更新予定の右辺値(RHS)を評価・確定します。 - 適用 (Value Update Phase): 評価済みの値を使って、一斉にターゲットとなる信号(左辺値, LHS)の値を更新します。
この 「評価」と「適用」のフェーズを厳密に分離することが極めて重要です。この分離を怠ると、$monitor
のようなObserved
リージョンで動作するシステムタスクの表示タイミングがずれたり、SystemVerilog Assertions (SVA) の挙動が非準拠になったりする危険があります。mrun
のスケジューラも、このフェーズ分離を実装の核としています。
Java / C# (.NET) VMとの本質的な違い
中間コードに変換しVMで実行、というと、Javaや C# (.NET) を連想すると思います。
mrun(仮)
のVMがJVMやCLRと似ているのは、「中間コードを実行する」という点だけです。その目的と制約は大きく異なります。
-
時間管理:
mrun(仮)
のVMは、シミュレーション時間という内部的な仮想時間に厳密に従います。リアルタイムの経過時間は関係ありません。 -
決定論: 同じシード値で実行すれば、
mrun(仮)
は常にビットレベルで同一のVCD波形を生成します。これは検証ツールとして必須の要件です。 -
メモリ管理: 予測不能な停止を避けるため、
mrun(仮)
ではGC(ガベージコレクション)を使いません。現在は高性能な汎用アロケータであるmimalloc
を採用してメモリ管理のオーバーヘッドを削減していますが、将来的にはシミュレーション中のオブジェクト(信号値やイベント等)を専用のメモリプールで管理し、動的な確保・解放をさらに抑制する予定です。
まとめ
mrun(仮)
におけるバイトコードインタープリタ方式の採用は、単なる実装の好みではなく、HDLシミュレーションというドメインの要求に最適化した結果のアーキテクチャ選択です。
- ✅ ドメイン特化の命令セットで、4値論理や動的配列を含むシミュレーションのセマンティクスを効率的に表現。
- ✅ 厳密なフェーズ分離を実装したスケジューラとの協調動作により、決定論的なイベント駆動を実現。
- ✅ 0-delay再発火やNBA衝突のようなエッジケースを考慮した堅牢な設計。
以上