ターミナルでゲームを動かす!C言語で始めるLC-3仮想マシン自作入門(第1回:16bit CPUの骨格を作る) | UNIX Cafe

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

System Note $ cat /proc/ai-disclosure

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

C言語で作るLC-3仮想マシン 第1回: 16bit CPUの骨格を作る | UNIX Cafe
目次

Write your Own Virtual Machine(Justin Meiners 著)

今回から3回に分けて、C言語でLC-3の仮想マシンを作っていきます。

LC-3は教育用の16bit CPUです。実用CPUとして使うものではありませんが、命令、レジスタ、メモリ、プログラムカウンタ、条件フラグといったCPUの基本要素が小さくまとまっています。そのため、仮想マシンを自作する題材として扱いやすいです。

最終的には、LC-3用にアセンブル済みの2048.objやrogue.objを読み込んで、ターミナル上でゲームを動かします。

第1回では、まだゲームは動かしません。まずはLC-3の.objファイルを読み込み、メモリ上の命令を1つ取り出し、HALT命令で停止する最小のVMを作ります。

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

Static Badge Static Badge Static Badge Static Badge

全12回の流れ、読む順番、完成コードへのリンクは、LC-3仮想マシン自作入門のまとめ記事に整理しています。

▼ 参考記事:

Special Thanks to Justin Meiners

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

小さなCPUの動きを、C言語で確かめる

CPUや仮想マシンを自作すると聞くと、最初は大きなテーマに見えるかもしれません。ですが、この連載で作るのは実用的な高速VMではなく、LC-3の命令、PC、レジスタ、メモリ、TRAPの動きを学ぶための小さな教材VMです。

第1回では、LC-3の.objファイルを読み込み、memory[65536]とreg[]で仮想CPUの状態を持ち、PCが指す命令を1つ取り出してTRAP HALTで停止するところまで作ります。Justin Meiners氏のチュートリアルを手がかりに、まずは「命令を読む」「opcodeを判定する」「停止する」というVMの骨格から確認していきます。

C言語で小さな仮想マシン「LC-3」を作る理由

LC-3は教育用の16bit CPUです。実用CPUとして使うものではありませんが、命令、レジスタ、メモリ、プログラムカウンタ、条件フラグ、TRAPといったCPUの基本要素が小さくまとまっています。

この連載の目的は、高速で実用的なVMを作ることではありません。LC-3用の.objファイルを読み込み、命令をfetchし、opcodeをdecodeし、レジスタやメモリを更新する流れを、自分のCコードとして確認できるようにすることです。

LC-3はVM自作の題材として扱いやすい

LC-3のメモリ空間は16bitアドレスで表せる65536 wordです。C側では、これをuint16_t memory[65536]のような配列として用意できます。

レジスタもR0からR7までの汎用レジスタに加えて、PCとCONDを配列で持てます。CPUの状態を、Cの配列と整数としてそのまま見える形にできるのが、この題材の扱いやすいところです。

命令もすべて16bit wordです。上位4bitをopcodeとして取り出し、switch文で命令ごとの処理へ分岐します。つまりVMの中心は、「命令を読む」「opcodeを見る」「レジスタやメモリを更新する」という処理の繰り返しです。

この連載で作るもの

最初の3回では、LC-3 VMを動かすところまで進めます。第1回でVMの骨格を作り、第2回で主要命令を追加し、第3回でTRAPと端末入力をつないで2048.objやrogue.objを動かします。

そのあと、第4回から第5回ではVMに渡す.objを自分で作ります。第6回以降では、実行中の命令、レジスタ、メモリ、実行回数を観察できる機能を足していきます。

第1回で扱う範囲は、その出発点です。まずは.objファイルをLC-3メモリに置き、PCが指す命令を1つ読み、TRAP HALTで止まるところまでを確実につなげます。

今回の作業の進め方

この記事では、LC-3 VMのいちばん小さい骨格を作ります。実際にlc3.cを書くときは、先に「CPUが持っている部品」を定義してから、.objファイルを読み込み、最後に実行ループでHALTできるところまでつなげると迷いにくいです。

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

lc3.c
├─ include
│  └─ stdio.h / stdint.h / stdlib.h を追加
│
├─ enum
│  ├─ レジスタ定義を追加
│  ├─ 条件フラグ定義を追加
│  ├─ オペコード定義を追加
│  └─ TRAP_HALT を追加
│
├─ グローバル変数
│  ├─ memory[] を追加
│  └─ reg[] を追加
│
├─ .obj読み込み
│  ├─ swap16() を追加
│  ├─ read_image_file() を追加
│  └─ read_image() を追加
│
├─ メモリ操作
│  ├─ mem_write() を追加
│  └─ mem_read() を追加
│
└─ main()
   ├─ コマンドライン引数を確認する
   ├─ .objファイルを読み込む
   ├─ PCを x3000 に設定する
   ├─ while (running)
   │  ├─ mem_read(reg[R_PC]++) で命令をfetchする
   │  ├─ instr >> 12 でopcodeを取り出す
   │  └─ switch (op)
   │     └─ case OP_TRAP
   │        └─ TRAP_HALT なら "HALT" を表示して停止する
   └─ プログラムを終了する

つまり、第1回では「LC-3のプログラムをメモリに置き、PCが指す命令を1つ読む」ための土台を作ります。そのうえで、最初に実装する命令としてTRAP HALTだけを扱います。

第1回で作業する場所

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

  1. includeを追加する
  2. レジスタ、条件フラグ、オペコード、TRAPコードをenumで定義する
  3. memory[]とreg[]をグローバル変数として用意する
  4. .objファイルを読み込む関数を追加する
  5. mem_write()とmem_read()を追加する
  6. main()で.objファイルを読み込み、PCをx3000に設定する
  7. while (running)の中で命令をfetchし、opcodeを取り出す
  8. case OP_TRAPの中でTRAP_HALTだけを実装する

第1回の段階では、ADDやBRなどの通常命令はまだ実装しません。switch (op)にはOP_TRAPだけを置き、ほかのopcodeはabort()で止めておきます。

第1回で作るもの

第1回のゴールは、次のような「最小限のプログラム」を実行できるVM(仮想マシン)を作ることです。

3000 f025

これは、LC-3の .obj ファイルの中身を16進数で表したものです。先頭の 3000 は命令ではなく、プログラムを配置する開始位置(origin)です。それぞれ次のような意味を持っています。

  • 3000:プログラムをメモリの x3000 番地に読み込む(開始位置の指定)
  • f025:TRAP x25(プログラムを停止する HALT 命令)

実行結果

完成したVMにこのファイルを読み込ませて実行すると、画面に「HALT」と表示して安全に停止します。

make
./main programs/halt.obj

# 出力結果
HALT

このように、1つの命令を正しく読み込んで終了するだけの、最もシンプルな状態を目指します。

LC-3 VMは何をするプログラムか?

「仮想マシン(VM)」というと大げさに聞こえますが、今回つくるプログラムの中心はとてもシンプルです。C言語を使って、「小さなCPUのマネをするプログラム」を書くだけです。


具体的には、CPUの基本である以下のサイクルをぐるぐると繰り返します。

  1. PCが指すメモリから命令を読む
  2. PCの値を1つ進める
  3. 命令の上位4ビットから「opcode(命令の種類)」を取り出す
  4. opcodeごとの処理を実行する
  5. HALTされるまで繰り返す

PCはlc3.cの23行目にある次の部分です。

R_PC, /* program counter */

VM本体はゲームのルールを知らない

面白いのは、VM本体はゲームのルール(『2048』や『Rogue』の仕組み)をいっさい知らないということです。

VMの仕事は、ただ「LC-3の命令を順番に正しく読み、正しく実行する」だけ。それなのに、LC-3用に作られたプログラムを読み込ませるだけで、結果としてターミナル上でゲームが動き出します。この「シンプルなのに複雑なものが動くおもしろさ」こそが、VM自作の魅力です。

.obj ファイルの中身を見てみる

LC-3 VMが読み込む .obj ファイルは、テキストファイルではありません。16ビット(2バイト)のデータ(word)がずらりと並んだバイナリファイルです。

中身を覗いてみるには、バイナリを16進数で表示できる xxd コマンドを使うと便利です。

xxd programs/2048.obj | head

実行すると、次のような出力が表示されます。

00000000: 3000 2c17 ea18 e082 f022 e028 4a7d 0204  0.,......".(J}..
00000010: b022 2222 2422 7440 489c 49dd 48a9 200a  ."""$"t@H.I.H. .
00000020: 0201 0ffb 49d8 e063 f022 e03c 4a6d 03f4  ....I..c.".<Jm..
00000030: f025 4000 0000 0001 0007 0008 000f 0001  .%@.............
00000040: 0006 0009 000e 0002 0005 000a 000d 0003  ................
00000050: 0004 000b 000c 321c 326b 32fd 0041 0072  ......2.2k2..A.r
00000060: 0065 0020 0079 006f 0075 0020 006f 006e  .e. .y.o.u. .o.n
00000070: 0020 0061 006e 0020 0041 004e 0053 0049  . .a.n. .A.N.S.I
00000080: 0020 0074 0065 0072 006d 0069 006e 0061  . .t.e.r.m.i.n.a
00000090: 006c 0020 0028 0079 002f 006e 0029 003f  .l. .(.y./.n.).?

ここで重要なのは、先頭にある 3000 というデータです。
これは origin(オリジン:配置起点) と呼ばれ、「このプログラムをLC-3メモリのどこへ読み込むか」という開始位置を表しています。

ファイル構造とメモリのイメージ

【.objファイルの構造】

ファイルの中には、16ビット(2バイト)のデータがこのように順番に並んでいます。

[ 3000 ] [ 2c17 ] [ ea18 ] [ e082 ] ...

【展開されたメモリの配置】

ファイルから読み込まれると、メモリの中にはこのように配置されます。

x3000 番地 : 2c17 (1つ目の命令)
x3001 番地 : ea18 (2つ目の命令)
x3002 番地 : e082 (3つ目の命令)

【ここがポイント】先頭の 3000  は、配置起点

先頭にある 3000 は「ここからメモリに並べてね」という開始アドレス(Origin)の指示です。

そのため、最初のデータである 2c17 が x3000番地 に配置されます。それ以降の ea18 や e082 は、ファイルの中に住所が書いてあるわけではなく、VMがデータを読み込むたびに自動的に「次は3001、次は3002…」と住所を1つずつ進めながらメモリへ綺麗に配っていきます。

最初の lc3.c を作ろう

それでは、いよいよ仮想マシンのベースとなる lc3.c を作成していきましょう!
第1回の実装で必要となるヘッダファイルは、次の3つだけです。

#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>

stdint.hを使う理由

stdint.hを使うのは、LC-3が16bit wordのCPUだからです

LC-3は、すべてのデータを「16ビット(16bit word)」で処理するCPUですが、C言語のint 型は、プログラムを動かすパソコンの環境(32ビットや64ビットなど)によってデータの幅が変わるため、ここではuint16_tを使って16bit幅を明示します。

レジスタを定義する

LC-3にはR0からR7までの汎用レジスタがあります。さらに、次に実行する命令の位置を持つPC、条件フラグを持つCONDを用意します。

これらのレジスタをC言語の配列でまとめて管理するために、まずは次のように enum(列挙型)を使ってレジスタに番号を割り振ります。

enum
{
    R_R0 = 0,
    R_R1,
    R_R2,
    R_R3,
    R_R4,
    R_R5,
    R_R6,
    R_R7,
    R_PC,   /* プログラムカウンタ */
    R_COND, /* 条件フラグ */
    R_COUNT /* レジスタの総数(配列のサイズ用) */
};

R_COUNT配列のサイズとして活用する

C言語の enum は特に指定がなければ手前の値に +1 されるため、R_COUNT には自動的にレジスタの総数である 10 が入ります。

これを利用して、次のようにレジスタを格納する配列のサイズとして活用しています。

uint16_t reg[R_COUNT];

配列(reg)のメモリイメージ

この配列の中身は、メモリ上で次のように綺麗に一列に並ぶことになります。

reg[] 
 ├── [0] R_R0  (汎用レジスタ0)
 ├── [1] R_R1  (汎用レジスタ1)
 ├── [2] R_R2  (汎用レジスタ2)
 ├── [3] R_R3  (汎用レジスタ3)
 ├── [4] R_R4  (汎用レジスタ4)
 ├── [5] R_R5  (汎用レジスタ5)
 ├── [6] R_R6  (汎用レジスタ6)
 ├── [7] R_R7  (汎用レジスタ7)
 ├── [8] R_PC  (プログラムカウンタ)
 └── [9] R_COND (条件フラグ)

こうして番号で管理しておくことで、プログラムから reg[R_PC] のように、目的のレジスタへ直感的にアクセスできるようになります。

条件フラグを定義する

LC-3には、直前に行った計算の結果が「正」「ゼロ」「負」のどれだったかを記録する条件フラグがあります。

このフラグは、C言語の enum を使って次のように「ビット単位」で定義します。

enum
{
    FL_POS = 1 << 0, /* 正 (1)  ビット0番目 */
    FL_ZRO = 1 << 1, /* ゼロ (2) ビット1番目 */
    FL_NEG = 1 << 2  /* 負 (4)  ビット2番目 */
};

1 << n(ビットシフト)を使う理由

1 << 0 や 1 << 1 という書き方は、ビットシフト演算と呼ばれるものです。
それぞれの値は、2進数で表すと次のように独立した「1マス(1ビット)」に対応しています。

  • FL_POS:0001 (16進数で 0x1)
  • FL_ZRO:0010 (16進数で 0x2)
  • FL_NEG:0100 (16進数で 0x4)

このように定義しておくことで、それぞれの状態が混ざることなく、1つのレジスタ(先ほど定義した R_COND)の中で綺麗に管理できるようになります。

この条件フラグは、第2回で「条件によって処理をジャンプさせる命令(分岐命令 BR)」を実装するときに使いますので、今回は、事前準備として定義だけしておきます。

opcode(命令の種類)を定義する

LC-3は、すべての命令が16ビット(2バイト)の固定長で作られています。
そのうちの「上位4ビット」が、CPUに対して「足し算をして」「メモリから読み込んで」といった指示を出す opcode(命令コード) になります。

たとえば、冒頭で紹介した HALT プログラムの命令(f025)を2進数に直してみると、次のような構成になっています。

命令データ(16ビット): 1111 0000 0010 0101 (16進数で f025)

上位4ビット(1111)を取り出すと、10進数で 15 になります。これが、今回実装する OP_TRAP 命令の正体です。

16種類のopcodeを定義する

LC-3の命令(opcode)は、4ビットで表現できる 0〜15 の全部で16種類あります。

今回実装するのは OP_TRAP だけですが、今後のためにC言語の enum で16種類すべての命令をまとめて定義しておきます。

enum
{
    OP_BR = 0, /* 分岐命令 */
    OP_ADD,    /* 加算(足し算) */
    OP_LD,     /* 読み込み */
    OP_ST,     /* 書き込み */
    OP_JSR,    /* サブルーチン呼び出し */
    OP_AND,    /* 論理積(ビットAND) */
    OP_LDR,    /* ベース+オフセット読み込み */
    OP_STR,    /* ベース+オフセット書き込み */
    OP_RTI,    /* 未使用(例外復帰) */
    OP_NOT,    /* 論理否定(ビットNOT) */
    OP_LDI,    /* 間接読み込み */
    OP_STI,    /* 間接書き込み */
    OP_JMP,    /* ジャンプ */
    OP_RES,    /* 未使用(予約領域) */
    OP_LEA,    /* 有効アドレスロード */
    OP_TRAP    /* トラップ(システムコール) */
};

たくさん並んでいますが、今回実装するのは、一番最後の OP_TRAP(15番)だけです。
その他の命令は、次回の第2回で追加していきますので、今回は形だけ作っておけば大丈夫です。

TRAPコード(OSの機能呼び出し)を定義する

LC-3における TRAPは、プログラムから見ると「OSの機能を呼び出す」ような命令です。

キーボードから文字を読み込む、画面に文字や文字列を表示する、プログラムを停止する、といった「ハードウェアを動かす処理」をCPUにお願いするときに使われます。

TRAPの具体的な機能(トラップコード)は、次のように 16 進数で定義されています。

enum
{
    TRAP_GETC  = 0x20, /* キーボードから1文字入力(画面には表示しない) */
    TRAP_OUT   = 0x21, /* 画面に1文字出力 */
    TRAP_PUTS  = 0x22, /* 画面に文字列を出力 */
    TRAP_IN    = 0x23, /* 画面にプロンプトを表示して1文字入力 */
    TRAP_PUTSP = 0x24, /* 画面にバイトパッキングされた文字列を出力 */
    TRAP_HALT  = 0x25  /* プログラムを停止する(HALT) */
};

今回の第1回では、プログラムを終了させる TRAP_HALT(0x25)だけ を扱います。

命令データ f025 を分解してみる

ここで、冒頭で登場した最小のプログラム f025 をもう一度見てみましょう。
この16ビットのデータを2進数に直して分解すると、仕組みがすっきりと見えてきます。

16進数:   f    0    2    5
2進数:  1111 0000 0010 0101

このように、上位4ビットが f(1111)なので「これはTRAP命令だな」と判断し、さらに下位8ビットが 25(0010 0101)なので「TRAP_HALTの処理を呼び出せばいいんだな」とVMが解釈することになります。

メモリとレジスタ配列の実装

LC-3のアドレスは 16ビット で指定します。そのため、扱えるアドレス空間は 216 = 65,536個になります。

この仕様に合わせて、VMの内部にメモリとレジスタを定義するために、次のコードを追加します。

#define MEMORY_MAX (1 << 16) /* 1を16回左にシフト(2の16乗 = 65536) */

uint16_t memory[MEMORY_MAX]; /* 仮想メモリ(65536個) */
uint16_t reg[R_COUNT];       /* 仮想レジスタ */

※ R_COUNT は、LC-3にある全レジスタの個数を表す定数です

「ホストマシン」と「VM」のメモリを区別しよう

ここで定義した memory や reg は、C言語の変数や配列ですが、特別な役割を持っています。

  • memory(仮想メモリ):あなたが今使っている現実のパソコン(ホストマシン)のメモリの中に確保された、「VM専用のメモリ世界」そのものです。
  • reg(仮想レジスタ):これも同様に、C言語の配列を使いつつ、意味としては「仮想CPUの内部にある超高速な小物入れ(レジスタ)」をそのまま表現しています。

このように、現実のパソコンのメモリ(配列)の上に、仮想のCPU環境をまるごと再現していく。これが仮想マシンの仕組みです。

エンディアン(バイト順)の変換関数を作る

ここがVM自作における、最初のちょっとした難所です。

コンピュータが16ビット(2バイト)以上のデータをメモリに保存するとき、「データの並び順」には2つの流派があります。

  • ビッグエンディアン:上の桁(左側)から順番に並べる
  • リトルエンディアン:下の桁(右側)から順番に並べる

これは、日付を「2026年8月15日」と書くか、「15日8月2026年」と書くかの違いのようなものです。

LC-3のプログラムファイル(.obj)に書き込まれているデータは、「ビッグエンディアン」という形式になっています。

一方、私たちが普段使っている現代のパソコン(IntelやAMDのWindows/Linux、Apple SiliconのMacなど)では、多くの場合「リトルエンディアン」が使われています。

この「並び順の違い」のせいで、ファイルからそのまま16ビットのデータを読み込むと、データが前後あべこべになってしまいます。

たとえば、ファイルの中に f025 という命令が入っていても、私たちのパソコンでそのまま読み込むと、前後がひっくり返って 25f0 という全く違う命令として読み込まれてしまうのです。

そのため、ファイルからデータを読み込むときに、この並び順を正しく入れ替える「変換関数」を自作する必要があります。

具体的に何が起きるのか?

たとえば、ファイル上に 30 00 という順番でデータが並んでいたとします。LC-3の世界では、これで「x3000(16進数)」を表しています。

  • LC-3の期待: 0x3000 として読んでほしい
  • 手元のパソコン: 0x0030(ただの30)として読んでしまう!

これではプログラムの配置場所(origin)から思いっきりズレてしまいます。そこで、ファイルから16ビットのデータを読み込むたびに、上位バイトと下位バイトを入れ替える(スワップする)ための関数を用意します。

uint16_t swap16(uint16_t x)
{
    return (x << 8) | (x >> 8);
}

swap16 の仕組み

パソコンが間違えて読み込んでしまった 0x0030(変数 x) をこの関数に入れると、次のようなビット手品で、本来の正しい 0x3000 へと変換されます。

読み込んだデータ x   : 0x0030
------------------------------------------------------
x << 8 (左にずらす) : 0x3000 
x >> 8 (右にずらす) : 0x0000 
------------------------------------------------------
| (ふたつを合体)    : 0x3000 ➔ 正しい 0x3000 に大復活!

この変換を入れることで、手元のパソコンでもLC-3のビッグエンディアンなデータを正しく解釈できるようになります。

.obj ファイルをメモリに読み込む関数を作る

準備が整ったので、いよいよ .obj ファイルを読み込んで、仮想メモリ(memory 配列)に配置する関数を作ります。

ここでは2つの関数を用意します。
ファイルを安全に開いて閉じる read_image と、実際に中身を解析してメモリに展開する read_image_file です。

void read_image_file(FILE* file)
{
    /* 1. 先頭の16ビット(origin)を読み込む */
    uint16_t origin;
    fread(&origin, sizeof(origin), 1, file);
    origin = swap16(origin); /* ビッグエンディアンを変換 */

    /* 2. 残りのデータを配置するメモリ位置を計算 */
    uint16_t max_read = MEMORY_MAX - origin;
    uint16_t* p = memory + origin; /* 例:x3000 番地へのポインタ */
    
    /* 3. 残りのデータを一気に読み込む */
    size_t read = fread(p, sizeof(uint16_t), max_read, file);

    /* 4. 読み込んだすべてのデータのエンディアンを変換 */
    while (read-- > 0)
    {
        *p = swap16(*p);
        ++p;
    }
}

int read_image(const char* image_path)
{
    /* バイナリ読み込みモード("rb")でファイルを開く */
    FILE* file = fopen(image_path, "rb");
    if (!file) { return 0; } /* ファイルが開けなかったらエラー */

    read_image_file(file);
    fclose(file);
    return 1; /* 成功 */
}

🛠️ 処理全体の流れ

プログラムの処理は、次の4ステップでシンプルに進みます。

[1] ファイルの先頭16ビット(2バイト)を読む
         ↓
[2] それを「origin(メモリに配置するスタート位置:x3000など)」として解釈する
         ↓
[3] 残りの命令データを、メモリの `memory + origin` の位置に一気に読み込む
         ↓
[4] ループを回し、読み込んだ各命令(word)を `swap16()` で手元のCPU用に変換する

ここがポイント!

なぜ "rb" モードなのか?

  • fopen のモードを "rb"(Read Binary)にしているのは、テキストとしてではなく、ファイルをバイナリとしてそのまま読み込むための指定です。

ポインタの足し算 memory + origin

  • C言語の配列名memoryは、メモリの一番最初の場所を指します。そこに origin(たとえば 0x3000)を足すことで、「LC-3メモリ上の x3000 番地からデータを書き込む」という指定を行っています。

メモリを読み書きする関数を作る

次に、仮想メモリ(memory 配列)に対してデータを読み書きするための専用関数を作ります。
今回の段階では、以下のようにただ配列にアクセスするだけの非常にシンプルな実装で十分です。

void mem_write(uint16_t address, uint16_t val)
{
    memory[address] = val; /* 指定された番地に値を書き込む */
}

uint16_t mem_read(uint16_t address)
{
    return memory[address]; /* 指定された番地から値を読み出す */
}

なぜわざわざ「関数」にするのか?

コードを見て、直接 memory[address] と書けばいいのでは?と思った方もいるかもしれません。確かにその通りで、今はこのままでも全く問題なく動きます。

しかし、このように関数として独立させておくことには、将来の拡張性を高めるための大きなメリットがあります。

この連載の最終回(第3回)では、キーボードからの入力をリアルタイムで受け取る機能を実装します。その際、特定のメモリ番地(メモリマップドI/Oと呼ばれる領域)を読み込もうとしたときに、「キーボードが押されているかチェックする」という特別な処理を割り込ませる必要があります。

そこで、あらかじめ mem_read という関数を挟んでおけば、第3回になったときにこの関数の中身を少し書き換えるだけで対応できるようになります。ですので、今のうちに綺麗な設計の土台を作っておきます。

実行ループ(fetch-decode-execute)を実装する

メモリ、レジスタ、そしてファイルの読み込み関数がすべて揃いました。いよいよ仮想マシンの心臓部である実行ループ(メインループ)を main 関数の中に書いていきます。

プログラムの全体像は次のようになります。

int main(int argc, const char* argv[])
{
    /* 1. コマンドライン引数のチェック */
    if (argc < 2)
    {
        printf("lc3 [image-file1] ...\n");
        exit(2);
    }

    /* 2. 指定された .obj ファイルをすべてメモリにロード */
    for (int j = 1; j < argc; ++j)
    {
        if (!read_image(argv[j]))
        {
            printf("failed to load image: %s\n", argv[j]);
            exit(1);
        }
    }

    /* 3. 初期状態の設定 */
    reg[R_COND] = FL_ZRO;      /* 条件フラグを「ゼロ」に初期化 */
    enum { PC_START = 0x3000 };
    reg[R_PC] = PC_START;      /* プログラムカウンタを x3000 に設定 */

    /* 4. 心臓部:CPUの実行ループ */
    int running = 1;
    while (running)
    {
        /* 【Fetch】命令を読み込み、同時にPCを1つ進める */
        uint16_t instr = mem_read(reg[R_PC]++);
        
        /* 【Decode】命令の上位4ビット(opcode)を取り出す */
        uint16_t op = instr >> 12;

        /* 【Execute】opcodeに応じた処理を実行する */
        switch (op)
        {
            case OP_TRAP:
                /* 下位8ビットをチェックしてTRAPの種類を特定 */
                switch (instr & 0xFF)
                {
                    case TRAP_HALT:
                        puts("HALT");
                        running = 0; /* ループを抜けてVMを停止 */
                        break;
                }
                break;

            default:
                /* 未実装の命令が来たら強制終了 */
                abort();
                break;
        }
    }
}

コード解説:CPUの挙動を再現するポイント

① 命令の読み込み(Fetch)とPCの更新

この1行には、CPUの非常に重要なエッセンスが詰まっています。

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

ここでは、PCが指しているメモリから命令を読み込んだ直後、後置インクリメント(++)によって自動的にPCの値が1つ進みます。

【初期状態】 PC = x3000
    ↓
【Fetch】   instr = memory[x3000] を読み出す
    ↓
【PC更新】  PC = x3001 に進む(※ここがポイント!)

この「命令を取り出した(Fetchした)瞬間、PCはすでに次の命令の場所を指している」という性質は、低レイヤの世界では超重要です。第2回で実装する「分岐命令(ジャンプ)」や「相対アドレスを使ったロード命令」を正しく計算するための大前提となります。

② 命令の解析(Decode)

命令全体の16ビットから、上位4ビットにある opcode だけを抽出しています。

uint16_t op = instr >> 12;

右に12回ビットシフトすることで、不要な下位12ビットを押し出し、知りたかった上位4ビット(0 〜 15 の数値)だけを綺麗に取り出しています。

③ 命令の実行(Execute)

今回は、16種類ある命令の中から OP_TRAP だけを switch 文で待ち受けます。さらにその中で、下位8ビット(instr & 0xFF)が TRAP_HALT(0x25)である場合だけを処理します。

case TRAP_HALT:
    puts("HALT");
    running = 0; /* フラグを倒してループを終了 */
    break;

これにより、プログラムが TRAP x25(HALT)を読み込んだ瞬間に、画面に「HALT」と表示してVMのループが安全に停止するようになります!

テスト用の最小プログラム halt.obj を作る

作成したVMの動作を検証するために、テスト用の最小のLC-3バイナリファイルを作成します。

中身は、先ほど解説した通り以下のわずか2ワード(4バイト)だけです。

  • 3000 (配置場所:origin)
  • f025 (停止命令:TRAP_HALT)

これをコンパイラ(アセンブラ)を使わずに、ターミナル上のコマンドだけで直接バイナリファイルとして書き出すには、次のように実行します。

# 1. 保存用のディレクトリを作成
mkdir -p programs

# 2. 16進数データを直接バイナリに変換してファイルへ書き出し
printf '\x30\x00\xf0\x25' > programs/halt.obj

xxd コマンドで確認する

xxd programs/halt.obj

ターミナルに次のような出力が表示されれば、テストファイルの作成はバッチリ成功です!

00000000: 3000 f025                                0..%

(※右端の 0..% の部分は、お使いの環境によって見え方が異なる場合があります)

これで、メモリの x3000 番地に f025(HALT命令)が配置される、最もシンプルなプログラムが用意できました。

Makefile を用意してコンパイルする

プログラムとテスト用ファイルが揃ったので、最後にコンパイル(ビルド)を簡単に行うための Makefile を作成します。同じディレクトリに Makefile という名前で以下の内容を保存してください。

main: lc3.c
	$(CC) lc3.c -o main -Wall -Wextra -pedantic -std=c99

clean:
	rm -f main

⚠️ 注意(Makefileのルール):$(CC) や rm の行の先頭は、スペースではなく必ず タブ(Tab)キー でインデントしてください。

ここがポイント!

出力ファイル名は main

  • ターゲット名も出力ファイル名も main に揃えています。そのため、実行するときは ./main コマンドを使用します。

厳格なコンパイルオプション

  • -Wall -Wextra -pedantic というオプションをつけています。これは「少しでも怪しいコードがあれば、警告(Warning)をすべて画面に出す」という指定です。仮想マシンの開発では、小さなミス(型の不一致やビット演算の勘違い)が致命的なバグに繋がるため、最初から厳密にコンパイルする癖をつけておきます。

C99規格の指定

  • -std=c99 を指定することで、C言語のC99規格(for 文の中で変数を宣言できるなど、現代的で書きやすいC言語のルール)を使ってコンパイルするようにしています。

コンパイルして実行する

それでは、いよいよ作ったプログラムをコンパイルして動かしてみます。

ターミナルで次のコマンドを入力して、ビルドを行います。

make

エラーや警告が出ずに実行ファイル main が生成されればコンパイル成功です。
続いて、先ほど作成した最小のテスト用プログラム(halt.obj)を指定して、自作の仮想マシンを起動します!

./main programs/halt.obj

画面に次のように表示されれば、第1回の目標は見事に達成です!

HALT

現時点で abort() で落ちても大丈夫!

このVMは、現段階では HALT 以外の命令を読み込むと default: ルートに入り、abort() ですぐにクラッシュ(強制終了)する仕様になっています。

「他の命令で落ちてしまって大丈夫なの?」と不安に思うかもしれませんが、現時点ではこれで100点満点、まったく問題ありません。

第1回の目的は、「ファイルを読み込み、メモリ上に展開し、命令を1つ取り出して opcode を解析し、HALT 命令を検知して安全に止まる」という、VMの最小の骨組みを作ることでした。HALT という文字が出て、安全に終了できたら、この骨組みが正しく動いている証明になります。

第1回の完成コード

ここまでのパーツをすべて組み合わせた、今回の lc3.c の全体像は次のようになります。このコードをそのままファイルにコピー&ペーストして、コンパイルと実行を試してみてください!

#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>

/* レジスタの定義 */
enum
{
    R_R0 = 0,
    R_R1,
    R_R2,
    R_R3,
    R_R4,
    R_R5,
    R_R6,
    R_R7,
    R_PC,   /* プログラムカウンタ */
    R_COND, /* 条件フラグ */
    R_COUNT
};

/* 条件フラグのビットマスク定義 */
enum
{
    FL_POS = 1 << 0, /* 正 */
    FL_ZRO = 1 << 1, /* ゼロ */
    FL_NEG = 1 << 2, /* 負 */
};

/* opcode(命令の種類)の定義 */
enum
{
    OP_BR = 0,
    OP_ADD,
    OP_LD,
    OP_ST,
    OP_JSR,
    OP_AND,
    OP_LDR,
    OP_STR,
    OP_RTI,
    OP_NOT,
    OP_LDI,
    OP_STI,
    OP_JMP,
    OP_RES,
    OP_LEA,
    OP_TRAP
};

/* TRAPコード(OS機能)の定義 */
enum
{
    TRAP_GETC  = 0x20,
    TRAP_OUT   = 0x21,
    TRAP_PUTS  = 0x22,
    TRAP_IN    = 0x23,
    TRAP_PUTSP = 0x24,
    TRAP_HALT  = 0x25
};

/* 仮想メモリ空間のサイズ(65536) */
#define MEMORY_MAX (1 << 16)

/* 仮想ハードウェアの器を確保 */
uint16_t memory[MEMORY_MAX];
uint16_t reg[R_COUNT];

/* エンディアン変換(バイトスワップ)関数 */
uint16_t swap16(uint16_t x)
{
    return (x << 8) | (x >> 8);
}

/* オブジェクトファイルの解析とメモリ展開 */
void read_image_file(FILE* file)
{
    uint16_t origin;
    fread(&origin, sizeof(origin), 1, file);
    origin = swap16(origin);

    uint16_t max_read = MEMORY_MAX - origin;
    uint16_t* p = memory + origin;
    size_t read = fread(p, sizeof(uint16_t), max_read, file);

    while (read-- > 0)
    {
        *p = swap16(*p);
        ++p;
    }
}

/* バイナリモードでファイルを開くローダー */
int read_image(const char* image_path)
{
    FILE* file = fopen(image_path, "rb");
    if (!file) { return 0; }

    read_image_file(file);
    fclose(file);
    return 1;
}

/* メモリ書き込み */
void mem_write(uint16_t address, uint16_t val)
{
    memory[address] = val;
}

/* メモリ読み込み */
uint16_t mem_read(uint16_t address)
{
    return memory[address];
}

/* メイン処理:実行ループ */
int main(int argc, const char* argv[])
{
    if (argc < 2)
    {
        printf("lc3 [image-file1] ...\n");
        exit(2);
    }

    for (int j = 1; j < argc; ++j)
    {
        if (!read_image(argv[j]))
        {
            printf("failed to load image: %s\n", argv[j]);
            exit(1);
        }
    }

    /* 初期状態のセットアップ */
    reg[R_COND] = FL_ZRO;
    enum { PC_START = 0x3000 };
    reg[R_PC] = PC_START;

    int running = 1;
    while (running)
    {
        /* 1. Fetch */
        uint16_t instr = mem_read(reg[R_PC]++);
        /* 2. Decode */
        uint16_t op = instr >> 12;

        /* 3. Execute */
        switch (op)
        {
            case OP_TRAP:
                switch (instr & 0xFF)
                {
                    case TRAP_HALT:
                        puts("HALT");
                        running = 0;
                        break;
                }
                break;

            default:
                abort();
                break;
        }
    }
}

まとめ

第1回となる今回は、LC-3仮想マシン(VM)の最も重要な「骨格」を作り上げました。
ここで、今回実装した内容を振り返ってみましょう。

  • LC-3のメモリを memory[65536] として用意した
  • LC-3のレジスタを reg[] として用意した
  • .obj ファイルの origin を読み、メモリへ正しく配置した
  • プログラムカウンタ(PC)から命令を読み込んだ(Fetch)
  • 上位4ビットから命令の種類を解析した(Decode)
  • TRAP_HALT 命令を検知してVMを安全に停止させた(Execute)

今の段階では、この仮想マシンは「安全に止まること」しかできません。しかし、「命令を読み込み、opcodeを解析し、処理を分岐させる」というCPUの心臓部(中心システム)はすでに完成しています。

次回(第2回)は、ADD(足し算)、AND(論理積)、BR(条件分岐)、LD(読み込み)などの主要な命令を一気に実装し、「実際に計算して自ら判断・分岐できる本物のVM」へと進化させていきます。

次回は命令セットを実装する

今回は、LC-3 VMの最小構成として、.obj の読み込み、命令のfetch、opcodeのdecode、TRAP HALT までを実装しました。
次回は ADD、BR、LD などの命令を追加し、計算と分岐ができるVMにしていきます。

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

この記事を書いた人

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

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

目次