C言語で作るLC-3仮想マシン 第3回: TRAPと端末入力でゲームを動かす | UNIX Cafe

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

System Note $ cat /proc/ai-disclosure

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

C言語で作るLC-3仮想マシン 第3回: TRAPと端末入力でゲームを動かす | UNIX Cafe

目次

Write your Own Virtual Machine:VMをUNIX端末につなぐ

前回までで、LC-3 VMは命令をフェッチし、デコードし、実行できるようになりました。ADDBR、ロード・ストア命令も実装したので、CPUとしての中心部分はかなり揃っています。

しかし、この段階ではまだ本格的なプログラムは動かせません。画面へ文字を出したり、キーボードから入力を受け取ったりする仕組みがないからです。

第3回では、LC-3プログラムとホスト側のUNIX端末をつなぎます。TRAP、メモリマップドI/O、termiosによる端末制御を実装し、最後に2048.objrogue.objを実際に動かします。

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

今回作るもの

この回(第3回)で追加する主な機能は、以下の通りです。

  • TRAP命令GETC / OUT / PUTS / IN / PUTSP / HALT
  • メモリマップドI/OMR_KBSR / MR_KBDR
  • キー入力の確認select()によるノンブロッキング入力
  • UNIX端末制御termiosによる1文字入力の有効化
  • 終了時の復旧処理SIGINTハンドラと端末設定の復元

これらが揃うことで、LC-3プログラムはVMの外側にあるターミナルとやり取りできるようになります。最終的には、次のようにゲームを起動できるところまで進めます。

./main programs/2048.obj
./main programs/rogue.obj

前回までのVM

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

  • .objファイルの読み込み
  • プログラムカウンタ(PC)からの命令フェッチ
  • オペコードの取り出し
  • ADDANDNOTなどの計算命令
  • BRJMPJSRなどの分岐・ジャンプ命令
  • LDLDILDRSTSTISTRなどのメモリ読み書き

ここまでで、VMはかなりCPUらしくなりました。ただし、まだ外の世界とはつながっていません。プログラムがどれだけ正しく計算できても、画面に文字を出せなければ、ゲーム画面を表示できません。

そこで今回は、OP_TRAPの中身を本格的に実装し、さらにキーボード入力をLC-3のメモリから読めるようにします。

今回の作業の進め方

この記事では、入出力に必要な部品を順番に追加していきます。実際にlc3.cへ追記するときは、先に定数と補助関数を追加してから、最後に実行ループ内のswitchを仕上げると迷いにくいです。

今回の変更は、lc3.c全体で見ると次の位置に入ります。

lc3.c
├─ include
│  └─ signal.h / unistd.h / sys/time.h / sys/types.h / termios.h を追加
│
├─ enum
│  ├─ レジスタ定義
│  ├─ オペコード定義
│  ├─ 条件フラグ定義
│  ├─ TRAPコード定義
│  └─ MR_KBSR / MR_KBDR を追加
│
├─ グローバル変数
│  ├─ memory[]
│  ├─ reg[]
│  └─ original_tio を追加
│
├─ 補助関数
│  ├─ sign_extend()
│  ├─ update_flags()
│  ├─ check_key() を追加
│  ├─ disable_input_buffering() を追加
│  ├─ restore_input_buffering() を追加
│  └─ handle_interrupt() を追加
│
├─ メモリ操作
│  ├─ mem_write()
│  └─ mem_read() にキーボードI/O処理を追加
│
└─ main()
   ├─ .objファイルを読み込む
   ├─ signal(SIGINT, handle_interrupt) を追加
   ├─ disable_input_buffering() を追加
   ├─ PCを x3000 に設定する
   ├─ while (running)
   │  └─ switch (op)
   │     └─ case OP_TRAP を完成させる
   └─ restore_input_buffering() を追加

つまり、第3回では「端末とつながるための入口」をいくつか作り、最後にOP_TRAPからそれらを呼び出せるようにします。

第3回で作業する場所

作業順としては、次の流れで進めると安全です。

  1. includeを追加するsignal.hunistd.hsys/time.hなど)
  2. メモリマップドI/O用の定数を追加するMR_KBSRMR_KBDR
  3. キー入力確認用のcheck_key()を追加する
  4. mem_read()をI/O対応にする
  5. 端末制御用の関数を追加するdisable_input_buffering()restore_input_buffering()
  6. OP_TRAPcaseを完成させる

第2回で書いた命令セットはそのまま使います。今回はその上に、入出力と端末制御を追加していくイメージです。

TRAPとは何か

TRAPは、LC-3プログラムから見るとOS呼び出しのような命令です。

たとえば、文字を1つ読む、文字を1つ出す、文字列を出す、プログラムを停止する、といった処理をTRAP経由で呼び出します。

LC-3 program
  TRAP x22
      ↓
VMのCコード
  case TRAP_PUTS:
      ↓
host terminal
  putc(...)

今回作っているVMでは、LC-3のTRAPルーチンを本物のLC-3 OSとして実装するのではなく、Cの標準入出力へ直接つなぎます。

TRAP命令の共通処理

OP_TRAPcaseは、次の順番で実装します。(while

// TRAP命令の次に実行する番地をR7へ保存する
reg[R_R7] = reg[R_PC];

この部分は、既存のcase OP_TRAP:の先頭、switch (instr & 0xFF)の前に配置します。

case OP_TRAP:
    // TRAP命令の次に実行する番地をR7へ保存する
    reg[R_R7] = reg[R_PC];

    // 命令の下位8bitを取り出し、どのTRAP処理を呼ぶか判定する
    switch (instr & 0xFF)
    {
        ...
    }
    break;

instr & 0xFFで、命令の下位8bitを取り出します。これがtrap vectorです。

TRAP x20  GETC
TRAP x21  OUT
TRAP x22  PUTS
TRAP x23  IN
TRAP x24  PUTSP
TRAP x25  HALT

R7には戻り番地を保存します。フェッチ後なので、ここに入るのは「次に実行する命令」のアドレスです。

GETCを実装する

GETCは、キーボードから1文字読みます。読み込んだ文字はR0に入れます。(switch

case TRAP_GETC:
    // キーボードから1文字読み込み、R0へ保存する
    reg[R_R0] = (uint16_t)getchar();

    // R0の値に応じて条件フラグ(N/Z/P)を更新する
    update_flags(R_R0);
    break;

GETCは、読んだ文字を画面には表示しません。ただ入力して、R0へ保存するだけです。

OUTを実装する

OUTは、R0に入っている文字を1つ出力します。(switch

case TRAP_OUT:
    // R0の下位8bitを文字として標準出力へ出す
    putc((char)reg[R_R0], stdout);

    // バッファに残さず、すぐ画面へ反映する
    fflush(stdout);
    break;

fflush(stdout)を呼んでいるのは、文字をすぐ画面へ出すためです。ゲームの表示では、出力がバッファに残ると動きが分かりにくくなります。

PUTSを実装する

PUTSは、LC-3メモリ上の文字列を出力します。R0には文字列の先頭アドレスが入っています。(switch

case TRAP_PUTS:
{
    // R0に入っているアドレスを、LC-3文字列の先頭として扱う
    uint16_t* c = memory + reg[R_R0];

    // 0のwordが出るまで、1wordずつ文字として出力する
    while (*c)
    {
        putc((char)*c, stdout);
        ++c;
    }
    fflush(stdout);
}
break;

LC-3のPUTSでは、1つの16bit wordに1文字が入っています。文字列は0のwordで終わります。

0041 0072 0065 0020 0079 006f 0075
 A    r    e   sp    y    o    u

0041A0072rです。上位byteが0なので、バイナリ表示では文字の前に00が付いたように見えます。

INを実装する

INは、プロンプトを出してから1文字読みます。読み込んだ文字は画面にも表示し、R0へ保存します。(switch

case TRAP_IN:
{
    // 入力を促すメッセージを表示する
    printf("Enter a character: ");

    // キーボードから1文字読み込む
    char c = getchar();

    // 読み込んだ文字を画面にも表示する
    putc(c, stdout);
    fflush(stdout);

    // 読み込んだ文字をR0へ保存し、条件フラグを更新する
    reg[R_R0] = (uint16_t)c;
    update_flags(R_R0);
}
break;

GETCはechoしませんが、INは入力した文字を画面にも出します。この違いを分けて実装します。

PUTSPを実装する

PUTSPは、PUTSと似ていますが、文字列の格納形式が違います。(switch

PUTSは1 wordに1文字でした。PUTSPは、1 wordに最大2文字を詰めます。

case TRAP_PUTSP:
{
    // R0に入っているアドレスを、LC-3文字列の先頭として扱う
    uint16_t* c = memory + reg[R_R0];

    // 0のwordが出るまで、1wordずつ処理する
    while (*c)
    {
        // 下位byteを1文字目として出力する
        char char1 = (*c) & 0xFF;
        putc(char1, stdout);

        // 上位byteを2文字目として取り出す
        char char2 = (*c) >> 8;

        // 上位byteが0でなければ出力する
        if (char2)
        {
            putc(char2, stdout);
        }

        ++c;
    }
    fflush(stdout);
}
break;

下位byteを先に出し、次に上位byteを出します。上位byteが0なら出力しません。

word = 0x6548
lower byte = 0x48 = 'H'
upper byte = 0x65 = 'e'

HALTを仕上げる

HALTはVMの実行ループを止めます。(switch

既存のHALTのコードに、次のコードを追加します。

fflush(stdout);

挿入位置はrunning = 0;の手前です。

case TRAP_HALT:
    // 停止したことが分かるように表示する
    puts("HALT");
    fflush(stdout);

    // 実行ループを抜ける
    running = 0;
    break;

runningを0にすると、while (running)を抜けます。第1回ではHALTだけを先に実装しましたが、ここでTRAP命令全体の中に組み込み直します。

メモリマップドI/Oを定義する

ゲームを動かすには、キーが押されているかどうかをLC-3プログラム側から確認できる必要があります。

LC-3では、特定のメモリアドレスをI/Oとして扱います。今回使うのは次の2つです。(enum

enum
{
    MR_KBSR = 0xFE00,
    MR_KBDR = 0xFE02
};

MR_KBSRはkeyboard status registerです。キー入力があるかどうかを表します。

MR_KBDRはkeyboard data registerです。入力された文字が入ります。

LC-3 memory read xFE00
  ↓
VM checks host keyboard
  ↓
memory[xFE00] = key available flag
memory[xFE02] = key data

普通のメモリ読み込みのように見えて、特定アドレスだけホスト側のキーボード状態につながっています。これがメモリマップドI/Oです。

select()でキー入力の有無を見る

通常のgetchar()は、入力が来るまで待ちます。しかしゲームでは、「今キーが押されているか」を待たずに確認したい場面があります。

そこでselect()を使います。必要なincludeを追加します。(include

#include <unistd.h>
#include <sys/time.h>
#include <sys/types.h>

キー入力の有無を確認する関数です。(補助関数

uint16_t check_key(void)
{
    // select()に渡す読み込み監視用の集合を用意する
    fd_set readfds;
    FD_ZERO(&readfds);

    // 標準入力(キーボード)を監視対象に追加する
    FD_SET(STDIN_FILENO, &readfds);

    // 待ち時間を0にして、入力がなくてもすぐ戻るようにする
    struct timeval timeout;
    timeout.tv_sec = 0;
    timeout.tv_usec = 0;

    // 入力があれば1、なければ0を返す
    return select(1, &readfds, NULL, NULL, &timeout) != 0;
}

timeoutを0にしているため、select()は入力を待ちません。標準入力に読める文字があれば真、なければ偽を返します。

mem_read()をI/O対応にする

第1回では、mem_read()は単なる配列読み込みでした。

uint16_t mem_read(uint16_t address)
{
    // 指定されたアドレスのwordをそのまま返す
    return memory[address];
}

ここに、MR_KBSRが読まれたときだけ特別な処理を追加します。(メモリ操作

uint16_t mem_read(uint16_t address)
{
    // キーボード状態レジスタが読まれたときだけ特別処理を行う
    if (address == MR_KBSR)
    {
        // キー入力があれば、状態レジスタの最上位bitを1にする
        if (check_key())
        {
            memory[MR_KBSR] = (1 << 15);

            // 実際に入力された文字をデータレジスタへ保存する
            memory[MR_KBDR] = getchar();
        }
        else
        {
            // 入力がなければ、状態レジスタを0にする
            memory[MR_KBSR] = 0;
        }
    }

    // 通常のメモリ読み込みと同じように、指定アドレスの値を返す
    return memory[address];
}

キーが押されていれば、MR_KBSRのbit 15を立てます。そして、実際の文字をMR_KBDRに入れます。

キーが押されていなければ、MR_KBSRを0にします。これでLC-3プログラムは、メモリを読むだけでキーボード状態を確認できるようになります。

UNIX端末のcanonical modeを止める

ここまででキー入力の有無を見る関数は作りました。しかし、通常のターミナル設定では、キーを押してもEnterを押すまでプログラムへ入力が渡りません。

この動作はcanonical modeによるものです。2048のようなゲームでは、WASDを1キーずつ受け取りたいので、canonical modeを無効にします。

必要なincludeを追加します。(include

#include <termios.h>

端末設定を保存し、入力バッファリングとechoを無効にする関数を作ります。(グローバル変数

struct termios original_tio;

void disable_input_buffering(void)
{
    // 現在の端末設定を保存しておく
    tcgetattr(STDIN_FILENO, &original_tio);

    // 保存した設定をコピーして、新しい設定を作る
    struct termios new_tio = original_tio;

    // canonical modeとechoを無効にする
    new_tio.c_lflag &= ~(ICANON | ECHO);

    // 新しい端末設定をすぐ反映する
    tcsetattr(STDIN_FILENO, TCSANOW, &new_tio);
}

void restore_input_buffering(void)
{
    // 保存しておいた元の端末設定へ戻す
    tcsetattr(STDIN_FILENO, TCSANOW, &original_tio);
}

ICANONを無効にすると、Enterを待たずに1文字ずつ入力を受け取れます。

ECHOを無効にすると、入力した文字が端末に自動表示されなくなります。ゲーム画面を崩さないために必要です。

変更した端末設定は、プログラム終了時に必ず戻します。

Ctrl-Cでも端末設定を戻す

正常終了時はrestore_input_buffering()を呼べばよいですが、Ctrl-Cで止めた場合も端末設定を戻したいです。

そこで、シグナルハンドラを追加します。(include

#include <signal.h>

handle_interrupt() を追加(補助関数

void handle_interrupt(int signal)
{
    // この関数ではsignalの値を使わないので、未使用警告を避ける
    (void)signal;

    // Ctrl-Cで止めた場合でも端末設定を元に戻す
    restore_input_buffering();
    printf("\n");

    // 割り込み終了としてプログラムを終了する
    exit(-2);
}

signal引数は、シグナルハンドラの型に合わせるために必要です。ただ、この関数内では使わないので、(void)signal;として未使用引数の警告を消します。

main()の仕上げ

第3回のmain()では、プログラム読み込み後にシグナルハンドラを登録し、端末入力設定を切り替えます。

main()では、実行ループに入る前に端末設定を変更します。(main関数

signal(SIGINT, handle_interrupt);
disable_input_buffering();

初期状態の設定の手前の部分です。

signal(SIGINT, handle_interrupt); // ここに追記します。
disable_input_buffering(); // ここに追記します。

reg[R_COND] = FL_ZRO;

enum { PC_START = 0x3000 };
reg[R_PC] = PC_START;

最後に、VMループを抜けたところで端末設定を戻します。(main関数

restore_input_buffering();

実行ループを抜けたら、端末設定を戻します。lc3.cのコードの一番最後の部分です。

                case OP_RES:
                case OP_RTI:
            default:
                abort();
                break;
        }
    }
        restore_input_buffering(); // ここに追記します。
}

これで、正常終了でもHALT時には端末が元に戻ります。Ctrl-Cの場合は、先ほどのhandle_interrupt()が復旧処理を行います。

2048とRogueを取得する

VMで実行するLC-3プログラムを用意します。チュートリアルでは、アセンブル済みの2048.objrogue.objが配布されています。

mkdir -p programs
curl -L -o programs/2048.obj https://www.jmeiners.com/lc3-vm/supplies/2048.obj
curl -L -o programs/rogue.obj https://www.jmeiners.com/lc3-vm/supplies/rogue.obj

この2つはセーブデータではありません。どちらもLC-3用にアセンブル済みのプログラム本体です。

2048.obj   2048ゲーム
rogue.obj  Rogue風ゲーム

2048を実行する

ビルドします。

make

2048を起動します。

./main programs/2048.obj

起動時に、次のように聞かれます。

Are you on an ANSI terminal (y/n)?

通常のmacOSやLinuxのターミナルなら、yを押せば大丈夫です。

操作はキーボードの W/A/S/D キーで行います。

W = 上へ動かす
A = 左へ動かす
S = 下へ動かす
D = 右へ動かす

ここで重要なのは、CのVM本体が2048のルールを実装しているわけではないことです。VMはLC-3の命令を実行しているだけです。その結果として、LC-3用の2048プログラムが動いています。

ゲーム(2048)の開始画面です。

+--------------------------+
|                          |
|                          |
|                          |
|                          |
|                          |
|   2                      |
|                          |
|                     2    |
|                          |
+--------------------------+

キーボードの W/A/S/D キーを使って数字を操作します。
同じ数字のタイルがぶつかると合体します。

+--------------------------+
|                          |
|   16    2           4    |
|                          |
|                     2    |
|                          |
|   2                      |
|                          |
|                          |
|                          |
+--------------------------+

(2048)の終了画面(画面がいっぱになったらゲームオーバーです。)

+--------------------------+
|                          |
|   64   256   128    2    |
|                          |
|   8     64    8     64   |
|                          |
|   4     2     16    2    |
|                          |
|   2     8     2     4    |
|                          |
+--------------------------+

You lost :(

途中で終了したいときは、 Ctrl-C で止められます。

2048の遊び方

1. ゲームの目的

  • 「2048」のタイルを1つ作ることがゴールです。

2. 基本的な遊び方

  • キーを押す: W/A/S/Dキーで上下左右に動かします。
  • タイルが動く: 画面にあるすべてのタイルが、押したキーの方向へ一斉に滑っていきます。
  • 新しいタイルが出る: 1回動かすたびに、空いている場所に「2」か「4」のタイルが新しく1つ現れます。

3. タイルの合体ルール

  • 同じ数字をぶつける: 同じ数字のタイル同士がぶつかると、合体して2倍の数字に変わります。
  • 数字の変化: 「2」と「2」が合体すると「4」になり、「4」と「4」なら「8」になります。これを繰り返して大きな数字を作っていきます。

4. ゲームの終わり

  • クリア(勝ち): マスの中に「2048」のタイルが1つでもできたらクリアです。
  • ゲームオーバー(負け): 16マスのすべてがタイルで埋まり、上下左右どこにも動かせなくなったら終わりです。

Rogueを実行する

Rogue風ゲームも同じVMで実行できます。

./main programs/rogue.obj

画面はたとえば次のようになります。

##################  ############
###################     ########
#######################        #
########################  #  # @
###############################D
################################
################################
  ##############################
#  #############################
##    ##########################
#####  #########################
######  ########################
#######   ######################
#########    ###################
############  ##  ##############
#############      #############

You survived!
On to another dungeon? (n)o or any key to continue.

表示の意味は、おおよそ次の通りです。

#      壁
空白   通路や床
@      プレイヤー
D      ドア、出口、またはイベント表示

nを押すと終了し、それ以外のキーを押すと次のダンジョンへ進みます。

キーボードの W/A/S/D キーを使ってプレイヤー(@)を操作します。

W = 上へ動かす
A = 左へ動かす
S = 下へ動かす
D = 右へ動かす

クリアすると次の画面が現れますので、次々と進めていきます。

################################
################################
################################
################################
######             #############
###      ##  ##  #  ############
##    #############  ###########
@  #################    #       
#####################       ###D
################################
################################
################################
################################
################################
################################
################################
You survived!
On to another dungeon? (n)o or any key to continue.

ゲームを終了する場合は、(n)を入力します。
途中で終了したいときは、 Ctrl-C で止められます。

今回の完成に必要なinclude

第3回まで進むと、includeは次のようになります。

#include <stdio.h>
#include <stdint.h>
#include <signal.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/time.h>
#include <sys/types.h>
#include <termios.h>

元のチュートリアル由来のコードでは、環境によって<fcntl.h><sys/mman.h>を含めている場合もあります。ただ、このVMの現在の実装で実際に使っている中心は、標準入出力、整数型、シグナル、UNIX端末制御、select()関連です。

TRAP全体のコード

第3回で完成するOP_TRAPcaseは次の形です。

case OP_TRAP:
    // TRAP命令の次に実行する番地をR7へ保存する
    reg[R_R7] = reg[R_PC];

    // 命令の下位8bitを取り出し、どのTRAP処理を呼ぶか判定する
    switch (instr & 0xFF)
    {
        case TRAP_GETC:
            // キーボードから1文字読み込み、R0へ保存する
            reg[R_R0] = (uint16_t)getchar();

            // R0の値に応じて条件フラグ(N/Z/P)を更新する
            update_flags(R_R0);
            break;

        case TRAP_OUT:
            // R0の下位8bitを文字として標準出力へ出す
            putc((char)reg[R_R0], stdout);

            // バッファに残さず、すぐ画面へ反映する
            fflush(stdout);
            break;

        case TRAP_PUTS:
        {
            // R0に入っているアドレスを、LC-3文字列の先頭として扱う
            uint16_t* c = memory + reg[R_R0];

            // 0のwordが出るまで、1wordずつ文字として出力する
            while (*c)
            {
                putc((char)*c, stdout);
                ++c;
            }
            fflush(stdout);
        }
        break;

        case TRAP_IN:
        {
            // 入力を促すメッセージを表示する
            printf("Enter a character: ");

            // キーボードから1文字読み込む
            char c = getchar();

            // 読み込んだ文字を画面にも表示する
            putc(c, stdout);
            fflush(stdout);

            // 読み込んだ文字をR0へ保存し、条件フラグを更新する
            reg[R_R0] = (uint16_t)c;
            update_flags(R_R0);
        }
        break;

        case TRAP_PUTSP:
        {
            // R0に入っているアドレスを、LC-3文字列の先頭として扱う
            uint16_t* c = memory + reg[R_R0];

            // 0のwordが出るまで、1wordずつ処理する
            while (*c)
            {
                // 下位byteを1文字目として出力する
                char char1 = (*c) & 0xFF;
                putc(char1, stdout);

                // 上位byteを2文字目として取り出す
                char char2 = (*c) >> 8;

                // 上位byteが0でなければ出力する
                if (char2)
                {
                    putc(char2, stdout);
                }

                ++c;
            }
            fflush(stdout);
        }
        break;

        case TRAP_HALT:
            // 停止したことが分かるように表示する
            puts("HALT");
            fflush(stdout);

            // 実行ループを抜ける
            running = 0;
            break;
    }
    break;

トラブルシュート

ターミナルの入力表示がおかしくなった

VMを実行したあと、ターミナルで入力した文字が見えなくなることがあります。これは端末設定が戻らないままプログラムが終了したときに起こります。

その場合は、次のコマンドで復旧できます。

reset

根本対策としては、正常終了時にrestore_input_buffering()を呼び、Ctrl-C用にhandle_interrupt()を登録しておきます。ただし、abort()などで異常終了した場合は復旧処理まで到達しないことがあるので、その場合はresetで戻します。

2048の表示が崩れる

まず、起動時の質問にyと答えたか確認します。

Are you on an ANSI terminal (y/n)?

また、PUTSPUTSPの実装ミスでも表示が崩れます。PUTSは1 wordに1文字、PUTSPは1 wordに2文字です。

すぐabortする

abort()で落ちる場合は、想定外のopcodeを読んでいます。よくある原因は次の通りです。

  • swap16()によるendian変換を忘れている
  • opcodeのcaseに実装漏れがある
  • PC相対offsetの基準を間違えている
  • JSRJMPPCを壊している

キー入力が効かない

キー入力が効かない場合は、次を確認します。

  • disable_input_buffering()を呼んでいるか
  • ICANONを無効にしているか
  • mem_read()MR_KBSRの特別処理をしているか
  • check_key()select()が待ち時間0で呼ばれているか

まとめ

今回は、LC-3 VMに入出力を追加し、実際のゲームを動かせるところまで仕上げました。

  • TRAP_GETCで1文字入力を実装した
  • TRAP_OUTで1文字出力を実装した
  • TRAP_PUTSでLC-3形式の文字列を出力した
  • TRAP_INでプロンプト付き入力を実装した
  • TRAP_PUTSPで2文字詰めの文字列を出力した
  • MR_KBSRMR_KBDRでキーボード入力をメモリマップドI/Oとして扱った
  • termiosでUNIX端末を1文字入力向けに切り替えた
  • 2048.objrogue.objを実行した

完成したVMは、ゲームのルールを一切知りません。ただLC-3の命令をフェッチし、デコードし、実行しているだけです。

その上で2048やRogueが動くところに、仮想マシンを自作する面白さがあります。

次に試せること

このVMを土台にすると、次のような発展もできます。

  • LC-3アセンブリでHello Worldを書く
  • 小さいLC-3アセンブラを作る
  • VMにトレース機能を追加する
  • 命令実行数を数えて簡易プロファイルする
  • rogue.objを逆アセンブルして眺める

次回はLC-3アセンブリを書く

今回は、LC-3 VMに `TRAP` と端末入出力を追加し、2048やRogueを動かせるところまで進めました。次回は、既存のゲーム用 `.obj` ではなく、自分でLC-3アセンブリを書いてHello Worldを表示してみます。

もう一度最初から読み直す

ここまでで、C言語だけでLC-3 VMを作り、実際にゲームを動かすところまで進めました。
VMの骨格から順番に確認したい場合は、第1回から読み直すと全体の流れを追いやすくなります。

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

この記事を書いた人

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

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

目次