C言語で作るLC-3仮想マシン 第2回: 命令セットを実装する | UNIX Cafe

* 当サイトでは、コンテンツの一部に広告を掲載しています。

System Note $ cat /proc/ai-disclosure

本記事の構成および論理分析にはAI(人工知能)を使用しています。情報の正確性は、システム管理者(UNIXユーザー)による手動検証済みです。

Cで作るLC-3仮想マシン 第2回: 命令セットを実装する | UNIX Cafe
目次

Write your Own Virtual Machine:CPUの「頭脳」を実装する

前回は、LC-3のプログラムファイル(.obj)を読み込み、プログラムカウンタ(PC)から命令を取り出して、最後に「TRAP HALT」で安全に停止するだけの「最小限の仮想マシン(VM)」を作りました。いわば、骨組みだけの状態です。

そして今回、いよいよ「CPUの心臓部」となる本質的な機能を実装していきます。ただ命令を動かすだけでなく、データを使って計算し、次の行動を判断できる「本物の頭脳」をVMの中に組み立てていきます。

GitHubではlc3-vm-cという名前で公開しています。

Repository:https://github.com/k1117n-cmyk/lc3-vm-c

Static Badge Static Badge Static Badge Static Badge

▼ 参考記事:

Special Thanks to Justin Meiners

本連載は、海外のエンジニアである Justin Meiners 氏が公開している名作チュートリアル 『Write your Own Virtual Machine』 をベースに、C言語での低レイヤー開発を実際に体験・写経していく記録です。

「コンピュータの心臓部を、自分の手で作る」

オリジナル著者の Justin 氏が語る通り、CPUや仮想マシンの自作と聞くと、一見とても恐ろしい難攻不落のダンジョンのように思えるかもしれません。しかし、一歩足を踏み入れれば、そこには驚くほどシンプルで美しい「論理のパズル」が広がっています。

いにしえの16bitアーキテクチャ「LC-3」の謎を紐解きながら、コンピューティングの深淵へと潜る最高にエキサイティングな旅。先達が遺してくれた素晴らしい地図(チュートリアル)を手に、あなたも自分だけの仮想マシンをビルドする旅へ出かけてみませんか?

「LC-3」を実装する

この回(第2回)で実装する命令は、以下の通りです。

  • 計算(算術・論理演算)ADD / AND / NOT
  • 条件分岐BR
  • ジャンプ・サブルーチンJMP / RET / JSR / JSRR
  • メモリからの読み込み(ロード)LD / LDI / LDR / LEA
  • メモリへの書き込み(ストア)ST / STI / STR

これらの命令が出揃うことで、第2回が終わるころには、仮想マシン(VM)はかなりCPUらしい姿へと進化します。

なお、今回のステップでは、キーボード入力や画面への文字出力といった「端末制御(周辺機器の扱い)」はまだ実装しません。それらの入出力に関する仕組みは、「第3回」で実装します。

前回までのVM

前回作成した仮想マシン(VM)には、すでに以下の機能が備わっていました。

  • .obj ファイル(プログラムファイル)の読み込み
  • プログラムカウンタ(PC)の開始位置を x3000 に設定
  • メモリからの命令の取り出し(フェッチ)
  • 命令からのオペコード(命令の種類)の抽出
  • TRAP HALT による安全な停止処理

基本の骨組みはできましたが、この段階ではまだデータの計算も、条件に応じた分岐もできません。

そのため、『2048.obj』や『rogue.obj』といった実際のゲームプログラムを動かすには、レジスタでの計算、メモリの読み書き、条件分岐、そしてサブルーチン(関数)の呼び出しといった機能が不可欠です。

そこで今回は、これらの処理を行うLC-3の主要な命令を、プログラムの switch 文の case として一つずつ実装(追加)していきます。

今回の作業の進め方

この記事では命令ごとにコードを説明していきますが、実際にlc3.cへ追記するときは、先に補助関数を追加してから、実行ループ内のswitchを少しずつ大きくしていくと迷いにくいです。

第2回で作業する場所は、この実行ループの前後

全体の流れは次のようになります。実行ループの前後です。全体の流れは次のようになります。

  1. main()より前に sign_extend() を追加する補助関数
  2. main()より前に update_flags() を追加する補助関数
  3. while (running) の中にある switch (op) を広げる
  4. case OP_ADD, OP_AND, OP_NOT … を順番に追加する

補助関数の安全な設置場所

補助関数)を設置する時にわかりやすい設置場所は、void mem_writeの行の手前です。

いきなりコードを書かずに、最初にコメントを書いて場所を確認するとわかりやすいです。

// 第2回記事追記
uint16_t sign_extend(uint16_t x, int bit_count)
{....}
void update_flags(uint16_t r)
{....}
// 第2回追記ここまで

void mem_write(uint16_t address, uint16_t val)

また、このようにコードの前後にコメントを置いておくと、エラーが出た時に探しやすいです。

もうひとつの安全装置は、コードを追加したらコンパイルして確認することです。こまめにエラーチェックをすると、間違っていても、すぐに原因を突き止めることができます。

switchの安全な設置場所

lc3.cには、すでに次の実行ループがあります。

switch (op)は必ずwhile (running)の中に置きます。既に第1回で設置した「case OP_TRAP:」がありますので、第2回のコードはその手前に追記していきます。

while (running)
{
    uint16_t instr = mem_read(reg[R_PC]++);
    uint16_t op = instr >> 12;

    switch (op)
    {
        /* ここに第二回のcaseを追加していく */

     case OP_TRAP:
                 switch (instr & 0xFF)
                 {
                     case TRAP_HALT:
                         puts("HALT");
                         running = 0;
                         break;
                 }
                 break; 
             default:
                 abort();
                 break;
    }
}

この形を保ったまま、まずOP_ADDを追加し、次にOP_ANDOP_NOTOP_BRのように増やしていきます。つまり、このあとの各節はこの中に追加するcaseとして読んでください。

LC-3命令の読み方

LC-3の命令はすべて16ビット(2バイトのデータです。そのうち一番左側にある上位4ビットが「オペコード(命令の種類)」を表し、残りのビットが持つ意味は命令ごとに変化します。

基本のビット配置は、おおまかに以下のようになっています。

  • bits [15:12] : オペコード(opcode)
  • bits [11:9] : 書き込み先レジスタ(destination register)
  • bits [8:6] : 読み込み元レジスタ(source / base register)
  • bits [5:0] : オフセット(移動量)や即値(直接指定する数値)

例えば、ADD R0, R1, #1(レジスタR1の値に1を足してレジスタR0にしまう)という命令は、具体的に以下のようなビット構造(データ)になっています。

0001 000 001 1 00001
^^^^ ^^^ ^^^ ^ ^^^^^
ADD  DR  SR1 I imm5
  • ADD (0001) : 足し算命令を表すオペコード
  • DR (000) : 書き込み先レジスタ(Destination Register = R0)
  • SR1 (001) : 読み込み元レジスタ(Source Register 1 = R1)
  • I (1) : 即値モード(値を直接足すモード)かどうかを決めるフラグ
  • imm5 (00001): 直接足し合わせる5ビットの数値(今回は 1

LC-3 VMの実装とは、まさにこうした16ビットのデータから必要なビット部分を切り出し(抽出し)、C言語の配列で用意した「レジスタ」や「メモリ」の値を更新していく作業のことです。

符号拡張を実装する(補助関数)

LC-3の命令に含まれる即値やオフセット(移動量)は、5ビット、6ビット、9ビット、11ビットといった、非常に小さなビット幅で詰め込まれています。

しかし、仮想マシンの内部では、すべてのデータを16ビットの広さで計算しなければなりません。そこで、小さなビット幅のデータが「マイナスの値」だったときに、その性質を保ったまま16ビットへ引き伸ばす「符号拡張」という処理が必要になります。

コンピューターの世界には「マイナス(-)」という記号がありません。そのため、「1 を足すと、桁がぐるっと回って 0 に戻る数」をマイナスの数として扱います。車の走行距離メーターが 9999 から 0000 に戻るようなイメージです。

例えば、5ビットという狭い世界では、111111 を足すと、溢れた分が消えてちょうど 00000(ゼロ)になります。

数学の世界では「1 に足すと 0 になる数」といえば「-1」のことですが、コンピューターにとっての 11111 は、私たちが普段使う「-1」とまったく同じ役割を果たしていることになるのです。

しかし、この 11111 をそのまま16ビットの広い部屋に引っ越させると、上の空いたスペースが自動的に 0 で埋まってしまいます。

0000000000011111

これを10進数に直すと、ただのプラスの「31」になってしまいます。これに 1 を足しても 32 になるだけで、部屋が広すぎて 0 には戻りません。マイナスの性質が消えてしまったのです。

16ビットの広い世界でも、変わらず「-1(1を足したらゼロになる数)」として働いてもらうためには、空いた上のスペースもすべて 1 で埋めてあげる必要があります。

1111111111111111

これなら、1 を足したときに 1 が繰り上がり、一番左端から溢れて、綺麗にすべてのビットが 0(ゼロ)に戻ります。

C言語では、以下のような関数を用意してこの処理を実装します。

このコードを、main()関数より前に追加してください。補助関数

uint16_t sign_extend(uint16_t x, int bit_count)
{
    // 指定されたビット幅の最上位ビット(符号ビット)が1(マイナス)かどうかをチェック
    if ((x >> (bit_count - 1)) & 1)
    {
        // 1なら、上の空いているビットをすべて1で埋める
        x |= (0xFFFF << bit_count);
    }
    return x;
}

条件フラグを更新する(補助関数)

LC-3には、直前の計算結果やメモリから読み込んだデータが「正(プラス)」「ゼロ」「負(マイナス)」のどれだったかを、自動で記録しておく「条件フラグ」という仕組みがあります。

この処理は、以下のような関数を用意して実装します。

このコードを、main()関数より前に追加してください。補助関数

void update_flags(uint16_t r)
{
    if (reg[r] == 0)
    {
        reg[R_COND] = FL_ZRO; // ゼロ
    }
    else if (reg[r] >> 15)
    {
        reg[R_COND] = FL_NEG; // 負(マイナス)
    }
    else
    {
        reg[R_COND] = FL_POS; // 正(プラス)
    }
}

引数の r は、計算結果や読み込んだデータが格納されたレジスタの番号です。そのレジスタの中身をチェックして、条件フラグ専用のレジスタ(R_COND)の値を以下のように更新します。

reg[r] == 0 のとき    : FL_ZRO
最上位ビットが 1 のとき : FL_NEG
それ以外のとき         : FL_POS

この条件フラグは、あとで実装するBR命令で使います。

注意点として、store系命令は条件フラグを更新しません。メモリへ値を書くだけで、レジスタの中に新しい計算結果を生み出さないからです。フラグが更新されるのは、あくまで「レジスタの中身が新しくなったとき」だけです。

ADDを実装する(switch)

まずは、すべての計算の基本となる加算命令の ADD です。LC-3の ADD 命令には、以下の2つの形式があります。

レジスタモード:ADD R0, R1, R2   R1 + R2 を R0 へ
即値モード  :ADD R0, R1, #1   R1 + 1  を R0 へ

この2つのどちらを使うかは、命令文の bit 5のフラグ を見て判定します。

仮想マシンの switch 文の中には、以下のような case 処理を追加します。

このコード case OP_ADD, OP_AND, OP_NOT … を順番に追加するswitch

case OP_ADD:
{
    // 各ビットを切り出してレジスタ番号やフラグを特定する
    uint16_t r0 = (instr >> 9) & 0x7;       // 書き込み先レジスタ (DR)
    uint16_t r1 = (instr >> 6) & 0x7;       // 1つ目の読み込み元レジスタ (SR1)
    uint16_t imm_flag = (instr >> 5) & 0x1; // 即値モードかどうかのフラグ (bit 5)

    if (imm_flag)
    {
        // 即値モード:下位5ビットを切り出し、符号拡張して足す
        uint16_t imm5 = sign_extend(instr & 0x1F, 5);
        reg[r0] = reg[r1] + imm5;
    }
    else
    {
        // レジスタモード:下位3ビットから2つ目のレジスタ番号を取り出して足す
        uint16_t r2 = instr & 0x7;
        reg[r0] = reg[r1] + reg[r2];
    }

    // 新しい計算結果が格納されたので、条件フラグを更新する
    update_flags(r0);
}
break;

コードのポイント

  • レジスタの特定:16ビットの命令instrを右にシフトして、0x7を2進数で 111で取り出することで、3ビットのレジスタ番号、r0r1を正確に切り出しています。
  • 符号拡張の活用imm_flag1 のときは即値モードです。下位5ビットinstr & 0x1Fに詰め込まれた数値を取り出しますが、この値は「マイナス」の可能性もあります。そのため、先ほど作った sign_extend() 関数を使って、安全に16ビットへ広げてから足し算を行います。
  • フラグの更新:最後に update_flags(r0) を呼び出し、新しく書き換わった r0 の値(正・ゼロ・負)に合わせて条件フラグを自動更新します。これで次の命令へバトンを繋ぐことができます。

ANDを実装する

続いて、ビットごとの論理積を行う AND 命令です。この命令の構造は、先ほどの ADD 命令とほとんど同じです。唯一の違いは、行う計算が「加算(足し算)」ではなく、「ビットごとのAND演算(&)」である点だけです。

ADD 命令と同様に、5ビット目のフラグを見て「レジスタモード」と「即値モード」を切り替えます。

このコード case OP_ADD, OP_AND, OP_NOT … を順番に追加するswitch

case OP_AND:
{
    // 各ビットを切り出してレジスタ番号やフラグを特定する(ADDと同じ)
    uint16_t r0 = (instr >> 9) & 0x7;
    uint16_t r1 = (instr >> 6) & 0x7;
    uint16_t imm_flag = (instr >> 5) & 0x1;

    if (imm_flag)
    {
        // 即値モード:符号拡張した値とAND演算を行う
        uint16_t imm5 = sign_extend(instr & 0x1F, 5);
        reg[r0] = reg[r1] & imm5;
    }
    else
    {
        // レジスタモード:2つのレジスタの値でAND演算を行う
        uint16_t r2 = instr & 0x7;
        reg[r0] = reg[r1] & reg[r2];
    }

    // 計算結果に合わせて条件フラグを更新する
    update_flags(r0);
}
break;

LC-3の命令実装は、まさにこの ADDAND のように、「命令(ビット列)から必要な情報を取り出す」「レジスタやメモリの値を計算・更新する」「必要なら条件フラグを更新する」というシンプルな処理の積み重ねでできています。

NOTを実装する

NOT 命令は、1つの読み込み元レジスタ(r1)のビットをすべて反転(01 に、10 に)して、書き込み先レジスタ(r0)へと保存する命令です。

ADDAND のように相手となる2つ目のレジスタや即値がないため、構造は非常にシンプルです。

このコード case OP_ADD, OP_AND, OP_NOT … を順番に追加するswitch

case OP_NOT:
{
    // 書き込み先レジスタ (DR) と読み込み元レジスタ (SR) を特定する
    uint16_t r0 = (instr >> 9) & 0x7;
    uint16_t r1 = (instr >> 6) & 0x7;

    // ビットを反転(~)して格納する
    reg[r0] = ~reg[r1];
    
    // 計算結果に合わせて条件フラグを更新する
    update_flags(r0);
}
break;

他の計算命令に比べて処理自体はとても単純ですが、「レジスタに新しい結果を書き込む」ことに変わりはありません。そのため、最後に必ず update_flags(r0) を呼び出して条件フラグを更新します。

BRを実装する

BR は「ブランチ(Branch)=条件分岐」を行う命令です。これまでに計算命令などで更新してきた「条件フラグ」をチェックして、条件が合致していればプログラムカウンタ(PC)の値を書き換え、処理を別の場所へジャンプさせます。

仮想マシンには、以下のような case 処理を追加します。

このコード case OP_ADD, OP_AND, OP_NOT … を順番に追加するswitch

case OP_BR:
{
    // 下位9ビットを切り出し、符号拡張して「ジャンプする距離(オフセット)」を求める
    uint16_t pc_offset = sign_extend(instr & 0x1FF, 9);
    
    // 命令側が指定している「分岐したい条件」を3ビットで取り出す
    uint16_t cond_flag = (instr >> 9) & 0x7;

    // 命令が求める条件と、現在のCPUの条件フラグが一致するかチェック
    if (cond_flag & reg[R_COND])
    {
        // 一致していれば、PCに距離を足してジャンプする
        reg[R_PC] += pc_offset;
    }
}
break;

cond_flagは、命令側が「正なら分岐」「ゼロなら分岐」「負なら分岐」のどれを指定しているか、またはその組み合わせを表します。

実装の罠:PCの「1つのズレ」に注意!

この BR 命令を実装する上で、もっとも重要であり、もっともバグを生み出しやすいポイントが「PC(プログラムカウンタ)の基準位置」です。

uint16_t instr = mem_read(reg[R_PC]++);

命令をメモリから読み込んだ直後、PC はすでにインクリメント(++)されて「1」進んでいます。

そのため、BR 命令の中で足し合わせる pc_offset は、「いま実行している BR 命令がある番地」ではなく、「すでに次の命令を指している PC の番地」を基準にして足さなければなりません。

例えば、以下のようなプログラムの並びになっていたとします。

x3000: BR 命令(いま実行している命令)
x3001: 次の命令

BR実行時のPCは x3001

オフセットの計算は、この x3001 を基準に行われます。この「1番地ぶんのズレ」を正しく意識して実装しないと、ジャンプ先がすべて1つずつズレてしまい、プログラムが正常に動かなくなってしまいます。LC-3 VM自作における、最初の大きな落とし穴です。

JMPとRETを実装する

JMPは、指定されたレジスタの中身をプログラムカウンタPCに直接代入する命令です。これにより、条件に関係なく指定したアドレスへ一気に処理をジャンプさせることができます。

仮想マシンには、以下のような case 処理を追加します。

このコード case OP_ADD, OP_AND, OP_NOT … を順番に追加するswitch

case OP_JMP:
{
    // ジャンプ先のアドレスが入っているレジスタ番号(bits [8:6])を取り出す
    uint16_t r1 = (instr >> 6) & 0x7;
    
    // そのレジスタの値をPCに直接セットする
    reg[R_PC] = reg[r1];
}
break;

ここで面白いのが、サブルーチンから元の場所へ戻るための RET命令 の扱いです。

実は、LC-3には RET という独立したオペコードは存在しません。LC-3のルールでは、R7 レジスタを使って JMP するJMP R7という処理そのものを RET として扱うことになっています。

RET == JMP R7

関数を呼び出すとき、戻ってくるためのアドレスはあらかじめ R7 レジスタに保存される仕組みになっています。そのため、この OP_JMP の処理を正しく1つ実装してあげるだけで、自動的に RET 命令もそのまま動くようになります。

JSRとJSRRを実装する

JSRJSRR は、サブルーチンを呼び出すための命令です。呼び出し先へジャンプする前に、処理が終わったあとで戻ってくるための番地(戻り番地)を R7 レジスタに自動で保存する のが大きな特徴です。

仮想マシンには、以下のような case 処理を追加します。

このコード case OP_ADD, OP_AND, OP_NOT … を順番に追加するswitch

case OP_JSR:
{
    // JSR(オフセット加算)か JSRR(レジスタ指定)かを決めるビット11を取り出す
    uint16_t long_flag = (instr >> 11) & 1;
    
    // まず、現在のPCの値を「戻り番地」としてR7にしっかりと退避しておく
    reg[R_R7] = reg[R_PC];

    if (long_flag)
    {
        // bit 11 が 1 のとき(JSR):11ビットの幅広なオフセットを符号拡張して足す
        uint16_t long_pc_offset = sign_extend(instr & 0x7FF, 11);
        reg[R_PC] += long_pc_offset;
    }
    else
    {
        // bit 11 が 0 のとき(JSRR):指定されたレジスタの値をそのまま次のPCにする
        uint16_t r1 = (instr >> 6) & 0x7;
        reg[R_PC] = reg[r1];
    }
}
break;

この命令は、bit 11のフラグによってジャンプ先の決め方が2ルートに分かれます。

  • bit 111 のとき(JSR):命令の中に埋め込まれた11ビットの少し広めなオフセット(移動量)を符号拡張し、現在のPCに足し合わせることでジャンプ先を決めます。
  • bit 110 のとき(JSRR):指定されたレジスタ(r1)の中に格納されているアドレスをそのまま呼び出し先として使います。

ここがポイント

どちらのルートを通る場合でも、ジャンプする前に必ず reg[R_R7] = reg[R_PC]; を実行して戻り番地を記録します。

前の BR 命令のときにお話しした通り、命令を取り出した直後なので、このときの PC はすでに「次の命令の番地」を指しています。そのため、ここには「関数から戻ってきたときに、次に実行すべき正しいアドレス」がぴったり保存されます。先ほど実装した JMP R7RET)は、この仕組みがあるからこそ機能するのです。

Load系(メモリ読み込み)命令を実装する

次に、メモリからデータを読み込んでレジスタに格納する「load(ロード)系」の命令を実装します。LC-3には、名前は似ていてもデータの探し方が異なる4つのロード命令があります。

LD   PC + offset のメモリを読む
LDI  PC + offset のメモリをアドレスとして、さらに読む
LDR  base register + offset のメモリを読む
LEA  PC + offset というアドレスそのものを入れる

これらはすべてレジスタに新しい値を書き込むため、処理の最後で必ず update_flags() を呼び出して条件フラグを更新します。

仮想マシンの switch 文には、それぞれの case 処理を以下のように実装していきます。

1.LD(直接ロード)

現在のPC(次の命令の番地)に、9ビットのオフセットを足したアドレスからデータを直接読み込みます。

以下 case OP_LDI, OP_LDR, OP_LEA … を順番に追加するswitch

case OP_LD:
{
    uint16_t r0 = (instr >> 9) & 0x7;
    uint16_t pc_offset = sign_extend(instr & 0x1FF, 9);
    
    // 計算したアドレスからデータを読み込んでレジスタへ
    reg[r0] = mem_read(reg[R_PC] + pc_offset);
    update_flags(r0);
}
break;

2.LDI(間接ロード)

こちらは間接参照です。PC + offsetの位置に入っているデータを、次にデータを読み込むための『本番のアドレス』として扱います。そのため、mem_read() を2重に呼び出す構造になります。

case OP_LDI:
{
    uint16_t r0 = (instr >> 9) & 0x7;
    uint16_t pc_offset = sign_extend(instr & 0x1FF, 9);
    
    // メモリから読んだ「アドレス」を使って、さらにもう一度メモリを読む
    reg[r0] = mem_read(mem_read(reg[R_PC] + pc_offset));
    update_flags(r0);
}
break;

3.LDR(ベース・レジスタ相対ロード)

LDRは、基準レジスタr1の値にoffsetを足したアドレスからデータを読み込みます。

case OP_LDR:
{
    uint16_t r0 = (instr >> 9) & 0x7;
    uint16_t r1 = (instr >> 6) & 0x7;
    uint16_t offset = sign_extend(instr & 0x3F, 6); // 6ビット幅を符号拡張
    
    // 基準レジスタ(r1)の値をベースにアドレスを計算して読む
    reg[r0] = mem_read(reg[r1] + offset);
    update_flags(r0);
}
break;

4.LEA(有効アドレスロード)

LEAは少し特殊です。メモリを読むのではなく、PC + offsetというアドレスそのものをレジスタへ入れます。

case OP_LEA:
{
    uint16_t r0 = (instr >> 9) & 0x7;
    uint16_t pc_offset = sign_extend(instr & 0x1FF, 9);
    
    // メモリは読まず、アドレスの計算結果をそのまま代入する
    reg[r0] = reg[R_PC] + pc_offset;
    update_flags(r0);
}
break;

load系命令はレジスタへ結果を書き込むので、すべてupdate_flags()を呼びます。

store系(メモリ書き込み)命令を実装する

Store系命令は、レジスタの値をメモリに書き込む命令です。LC-3アーキテクチャでは、以下の3つのStore系命令を実装します。

ST   PC + offset のメモリへ書く
STI  PC + offset のメモリをアドレスとして、そこへ書く
STR  base register + offset のメモリへ書く

1.ST(直接ストア)

STは、PCにオフセットを足したアドレスのメモリへ書き込みます。

以下 case OP_ST, OP_STI, OP_STR … を順番に追加するswitch

case OP_ST:
{
    uint16_t r0 = (instr >> 9) & 0x7;                  // 書き込み元のレジスタ
    uint16_t pc_offset = sign_extend(instr & 0x1FF, 9); // 9ビット幅を符号拡張
    
    // 計算したアドレスへレジスタの値を書き込む
    mem_write(reg[R_PC] + pc_offset, reg[r0]);
}
break;

2.STI(間接ストア)

STIは間接参照を行います。PCにオフセットを足したメモリからアドレスを読み出し、そのアドレス先へデータを書き込みます。そのため、mem_write の中で mem_read を呼び出す構造になります。

case OP_STI:
{
    uint16_t r0 = (instr >> 9) & 0x7;
    uint16_t pc_offset = sign_extend(instr & 0x1FF, 9);
    
    // メモリから読んだ「アドレス」を宛先にして、レジスタの値を書き込む
    mem_write(mem_read(reg[R_PC] + pc_offset), reg[r0]);
}
break;

3.STR(ベース・レジスタ相対ストア)

STRは、ベースレジスタ(r1)の値にオフセットを足したアドレスのメモリへ、レジスタ(r0)のデータを書き込みます。

case OP_STR:
{
    uint16_t r0 = (instr >> 9) & 0x7;
    uint16_t r1 = (instr >> 6) & 0x7;                  // 基準となるレジスタ
    uint16_t offset = sign_extend(instr & 0x3F, 6);    // 6ビット幅を符号拡張
    
    // 基準レジスタ(r1)の値をベースにアドレスを計算して書き込む
    mem_write(reg[r1] + offset, reg[r0]);
}
break;

Store系命令では条件フラグを更新しません。メモリへの書き込みを行うだけで、レジスタの値を新しく更新しないためです。

未使用命令を扱う

OP_RES は予約された命令、OP_RTI はこのVMでは使用しない命令です。これらのような想定外の命令を読み込んだ場合は、プログラムを強制終了(abort)して異常に気づけるようにしておきます。

このコード case OP_RES, OP_RTI を追加する(switch

case OP_RES:
case OP_RTI:

追記する場所は、case OP_TRAP:とdefault:の間です。

    // --- 特殊命令・その他 ---
        
    case OP_TRAP: 
        switch (instr & 0xFF)
        {   
            case TRAP_HALT:
                puts("HALT");
                running = 0;
                break;
        }
        break;
    
        /* ここにcase OP_RES, OP_RTI を追加する */
        case OP_RES:
        case OP_RTI:
        default:
            abort();
            break;
}        

TRAP命令は、まだHALT(停止)だけのままでOK

今回の主役は「命令セット」の実装なので、TRAP命令の本格的な実装は次回に回します。

ただし、プログラムを停止させる HALT だけは、前回と同じように残しておきます。

このコードは第1回で実装済みです。

case OP_TRAP:
    switch (instr & 0xFF)
    {
        // プログラムを停止する処理(これだけあれば最小限の動作テストが可能)
        case TRAP_HALT:
            puts("HALT");
            running = 0; // 実行フラグを0にしてVMを止める
            break;
    }
    break;

なお、PUTS(文字列出力)や OUT(文字出力)などの他のTRAP命令は、第3回で追加する予定です。

デバッグ表示で命令を観察する

VMのバグを追いかけるときは、実行中の命令を画面に少しだけ表示してみると状況が分かりやすくなります。

これは最終的なVMに必須の処理ではありません。第2回で実装した命令が正しく動いているかを確認するために、一時的に while (running) の中へ入れるテスト用のコードです。

挿入する場所は、instrop を取り出した直後、switch (op) の手前です。

このデバッグ表示は任意です。while

static int trace_count = 0;
if (trace_count++ < 20)
{
    /* 確認用: fetchした命令を最初の20個だけ表示する */
    printf("PC=%04x instr=%04x op=%x\n", reg[R_PC] - 1, instr, op);
}

ここで reg[R_PC] - 1 としているのは、命令を読み込んだ(fetchした)時点で、PCの値がすでに「1」進んでしまっているためです。マイナス1をすることで、今実行している命令の正しいアドレスを表示できます。

また、大きなプログラムを実行すると出力が多すぎて画面が埋まってしまうため、最初の20命令だけを表示するように制限をかけています。

前後のコードを含めると、全体の流れは次のようになります。

while (running)
{
    uint16_t instr = mem_read(reg[R_PC]++);
    uint16_t op = instr >> 12;

    /*
     * 確認用のトレース表示。
     * 命令実装が正しく進んでいるか見るために、一時的に入れる。
     * 最終版では削除してよい。
     */
    static int trace_count = 0;
    if (trace_count++ < 20)
    {
        printf("PC=%04x instr=%04x op=%x\n", reg[R_PC] - 1, instr, op);
    }

    switch (op)
    {
        ...
    }
}

もしこのデバッグ機能を最終コードにも残しておきたい場合は、コンパイルするときだけ有効にできる「マクロ」で囲んでおくと扱いやすくなります。

#ifdef DEBUG_TRACE
static int trace_count = 0;
if (trace_count++ < 20)
{
    printf("PC=%04x instr=%04x op=%x\n", reg[R_PC] - 1, instr, op);
}
#endif

こうしておけば、普段のビルドではデバッグ表示されません。動きを確認したいときだけ、コンパイルのコマンドに -DDEBUG_TRACE というオプションを付けて実行します。

cc lc3.c -o main -Wall -Wextra -pedantic -std=c99 -DDEBUG_TRACE

実行ループ内の switch 文(今回の全体像)

第2回で追加したすべての命令を並べると、実行ループ内の switch 文は次のようになります。これまでに実装してきた命令たちが、どのように組み合わさっているかを確認してみましょう。

switch (op)
{
    // --- 演算系命令 ---

    case OP_ADD: // 加算
    {
        uint16_t r0 = (instr >> 9) & 0x7;
        uint16_t r1 = (instr >> 6) & 0x7;
        uint16_t imm_flag = (instr >> 5) & 0x1;

        if (imm_flag)
        {
            uint16_t imm5 = sign_extend(instr & 0x1F, 5);
            reg[r0] = reg[r1] + imm5;
        }
        else
        {
            uint16_t r2 = instr & 0x7;
            reg[r0] = reg[r1] + reg[r2];
        }

        update_flags(r0); // 演算結果で条件フラグを更新
    }
    break;

    case OP_AND: // ビット単位の論理積 (AND)
    {
        uint16_t r0 = (instr >> 9) & 0x7;
        uint16_t r1 = (instr >> 6) & 0x7;
        uint16_t imm_flag = (instr >> 5) & 0x1;

        if (imm_flag)
        {
            uint16_t imm5 = sign_extend(instr & 0x1F, 5);
            reg[r0] = reg[r1] & imm5;
        }
        else
        {
            uint16_t r2 = instr & 0x7;
            reg[r0] = reg[r1] & reg[r2];
        }

        update_flags(r0); // 演算結果で条件フラグを更新
    }
    break;

    case OP_NOT: // ビット単位の論理否定 (NOT)
    {
        uint16_t r0 = (instr >> 9) & 0x7;
        uint16_t r1 = (instr >> 6) & 0x7;
        reg[r0] = ~reg[r1];
        update_flags(r0); // 演算結果で条件フラグを更新
    }
    break;

    // --- 分岐・ジャンプ系命令 ---

    case OP_BR: // 条件分岐
    {
        uint16_t pc_offset = sign_extend(instr & 0x1FF, 9);
        uint16_t cond_flag = (instr >> 9) & 0x7;
        if (cond_flag & reg[R_COND])
        {
            reg[R_PC] += pc_offset; // 条件が一致したらPCを移動
        }
    }
    break;

    case OP_JMP: // レジスタを使ったジャンプ (RETも兼ねる)
    {
        uint16_t r1 = (instr >> 6) & 0x7;
        reg[R_PC] = reg[r1];
    }
    break;

    case OP_JSR: // サブルーチン(関数)呼び出し
    {
        uint16_t long_flag = (instr >> 11) & 1;
        reg[R_R7] = reg[R_PC]; // 戻り先アドレスをR7に保存

        if (long_flag)
        {
            uint16_t long_pc_offset = sign_extend(instr & 0x7FF, 11);
            reg[R_PC] += long_pc_offset;
        }
        else
        {
            uint16_t r1 = (instr >> 6) & 0x7;
            reg[R_PC] = reg[r1];
        }
    }
    break;

    // --- Load系命令(メモリからレジスタへ読み込み) ---

    case OP_LD: // PC相対ロード
    {
        uint16_t r0 = (instr >> 9) & 0x7;
        uint16_t pc_offset = sign_extend(instr & 0x1FF, 9);
        reg[r0] = mem_read(reg[R_PC] + pc_offset);
        update_flags(r0);
    }
    break;

    case OP_LDI: // 間接ロード
    {
        uint16_t r0 = (instr >> 9) & 0x7;
        uint16_t pc_offset = sign_extend(instr & 0x1FF, 9);
        reg[r0] = mem_read(mem_read(reg[R_PC] + pc_offset));
        update_flags(r0);
    }
    break;

    case OP_LDR: // ベース・レジスタ相対ロード
    {
        uint16_t r0 = (instr >> 9) & 0x7;
        uint16_t r1 = (instr >> 6) & 0x7;
        uint16_t offset = sign_extend(instr & 0x3F, 6);
        reg[r0] = mem_read(reg[r1] + offset);
        update_flags(r0);
    }
    break;

    case OP_LEA: // 有効アドレスロード(PC+オフセットの計算結果をレジスタに入れる)
    {
        uint16_t r0 = (instr >> 9) & 0x7;
        uint16_t pc_offset = sign_extend(instr & 0x1FF, 9);
        reg[r0] = reg[R_PC] + pc_offset;
        update_flags(r0);
    }
    break;

    // --- Store系命令(レジスタからメモリへ書き込み) ---

    case OP_ST: // PC相対ストア
    {
        uint16_t r0 = (instr >> 9) & 0x7;
        uint16_t pc_offset = sign_extend(instr & 0x1FF, 9);
        mem_write(reg[R_PC] + pc_offset, reg[r0]);
        // ※ Store系命令は条件フラグを更新しません
    }
    break;

    case OP_STI: // 間接ストア
    {
        uint16_t r0 = (instr >> 9) & 0x7;
        uint16_t pc_offset = sign_extend(instr & 0x1FF, 9);
        mem_write(mem_read(reg[R_PC] + pc_offset), reg[r0]);
    }
    break;

    case OP_STR: // ベース・レジスタ相対ストア
    {
        uint16_t r0 = (instr >> 9) & 0x7;
        uint16_t r1 = (instr >> 6) & 0x7;
        uint16_t offset = sign_extend(instr & 0x3F, 6);
        mem_write(reg[r1] + offset, reg[r0]);
    }
    break;

    // --- 特殊命令・その他 ---

    case OP_TRAP: // システムコール(今回はHALTのみ先行実装)
        switch (instr & 0xFF)
        {
            case TRAP_HALT:
                puts("HALT");
                running = 0; // VMを停止
                break;
        }
        break;

    case OP_RES: // 予約命令
    case OP_RTI: // 未使用命令
    default:
        // 想定外の命令が来た場合は強制終了
        abort();
        break;
}

動作確認:小さなプログラムでテストする

第2回の段階では、まだ画面への入出力機能を完全には実装していません。そのため、「2048」や「Rogue」といった本格的なゲームを動かすのは第3回に回します。

この段階では、シンプルな機能だけを持った「小さなLC-3プログラム」を使って、実装した命令が正しく動いているか確認するのがおすすめです。第2回では、次の2つを使います。

branch.obj    条件フラグとBRを見る
counter.obj   ADDとBRでループする

hello.obj のように文字列を表示するプログラムは、PUTS が必要になります。PUTS などの入出力系TRAP命令は第3回で実装するため、今回はまだ使いません。

テスト用の .obj ファイルを用意する

ここで使う branch.objcounter.obj は、とても小さなLC-3プログラムです。今回はアセンブラを使わず、命令を16進数のバイナリとして直接ファイルに書き出します。

まず、テスト用ファイルを置く programs ディレクトリを作ります。

mkdir -p programs

branch.obj は、AND でゼロフラグを立て、その直後の BRz で分岐するかを確認するためのプログラムです。

printf '\x30\x00\x50\x20\x04\x01\x10\x21\xf0\x25' > programs/branch.obj

中身は次のような流れです。

x3000: AND R0, R0, #0
x3001: BRz x3003
x3002: ADD R0, R0, #1
x3003: HALT

counter.obj は、ADD でカウンタを減らしながら、BRp でループするプログラムです。

printf '\x30\x00\x50\x20\x10\x23\x10\x3f\x03\xfe\xf0\x25' > programs/counter.obj

中身は次のような流れです。

x3000: AND R0, R0, #0
x3001: ADD R0, R0, #3
x3002: ADD R0, R0, #-1
x3003: BRp x3002
x3004: HALT

また、先ほど紹介した「デバッグ用のトレース表示」を一時的にコードに入れておくことで、プログラムが始まった最初の数命令の動きを詳しく追いかけることができます。

プログラムをコンパイルし、テスト用コードを指定して動かしてみましょう。

make
./main programs/branch.obj
./main programs/counter.obj

トレース表示を入れている場合は、次のような実行ログが出力されます。

PC=3000 instr=5020 op=5
PC=3001 instr=1023 op=1
PC=3002 instr=103f op=1
PC=3003 instr=03fe op=0
...
PC=3004 instr=f025 op=f
HALT

トレース表示を入れていない場合は、どちらのプログラムも最後に HALT と表示されればOKです。これは、分岐やループを通ったあと、停止命令まで到達できたことを表しています。

  • 1行目の op=5AND命令 (レジスタの初期化など)
  • 2行目の op=1ADD命令 (カウンタに初期値を入れる処理)
  • 3行目の op=1ADD命令 (カウンタを1減らす処理)
  • 4行目の op=0BR命令 (ループのための条件分岐)

このように、自分が書いた switch 文の命令が順番に呼び出されているのが観察できれば、今回の実装は成功です。

このトレース表示は、あくまで今回の動作確認用です。ADDBRが正しく呼び出されていることを確認できたら、あとでコメントアウトするか削除しておきます。残したままにすると、第3回以降で文字出力やゲームを動かすときに、余計なログが混ざってしまいます。

よくあるバグと解決のヒント

自作のVMがうまく動かないときは、これから紹介する「よくあるバグ」のパターンに陥っていないかチェックしてみてください。

PC(プログラムカウンタ)の計算が「1」ずれる

一番多い原因は、PC相対オフセットの「基準にする場所」を間違えているケースです。

命令を読み込む(fetchする)ときに reg[R_PC]++ とインクリメントしているため、BRLD などのオフセットを足す時点では、PCはすでに「次の命令」を指した状態になっています。この「進んだ後のPCの値」に対してオフセットを加算できているか確認してください。

分岐(BR命令)が正しく動かない

条件分岐がうまく動かない場合は、以下の3つのポイントを順番に確認してみましょう。

  • 演算系やLoad系の命令で、update_flags() を呼び忘れていないか
  • 命令から条件フラグ(cond_flag)を切り出すビット演算が正しいか
  • 符号拡張(sign_extend())に渡しているビット幅(9ビットなど)が間違っていないか

プログラムが強制終了(abort)してしまう

abort() で落ちてしまう場合は、まだ実装していない命令を読み込んでしまったか、命令ではないデータ領域(メモリ)へPCが暴走して飛び込んでしまった可能性があります。

その引き金となる原因として多いのは、次の3つです。

  • エンディアン(Endian)の変換忘れ:データが逆順に読み込まれ、おかしな命令コードになっている
  • PC相対オフセットのずれ:ジャンプ先のアドレス計算が狂い、おかしな場所へ飛んでいる
  • opcode(命令)の実装漏れ:今回の switch 文に必要な命令が漏れている

まとめ

今回は、LC-3 VM(仮想マシン)に主要な命令セットを一気に実装しました。ここまでで、以下のような機能が揃いました。

  • sign_extend()で即値やoffsetを16bitへ符号拡張した
  • update_flags()で条件フラグを更新した
  • ADDANDNOTでレジスタ演算を実装した
  • BRで条件分岐を実装した
  • JMPJSRで制御移動を実装した
  • LDLDILDRLEAでメモリ読み込みを実装した
  • STSTISTRでメモリ書き込みを実装した

ここまででVMは、計算を行い、条件によって分岐し、メモリを自由に読み書きできるようになりました。CPUの基本機能としては、もう立派に完成しています。

最後に必要となるのは、LC-3プログラムと私たちが使っているパソコン(ホスト側)の端末をつなぐ「入出力(I/O)の仕組み」です。

次回は、TRAP命令の本格的な実装UNIX端末の制御に取り組みます。いよいよ「2048」や「Rogue」といった名作ゲームを自作VMの上で実際に動かしてみましょう。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

のいのアバター のい UNIX Cafe マスター

Macintosh Color Classicから始まった旅は、長いWindows時代を経て、Windows10のサポート終了をきっかけにUNIXの世界へ戻ってきました。UNIX Cafeでは、UNIX・Linux・そしてMacな世界を、むずかしい言葉を使わず、物語のように書いています。プログラミングは、アイデアをコンピューターに伝えるための言葉です。簡単な単語と文法を覚えれば、誰でもコマンドを使えます。ぜひ一度、やさしいプログラミングの世界をのぞいてみてください。

目次