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

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

System Note $ cat /proc/ai-disclosure

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

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

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

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

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

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

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

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

Static Badge Static Badge Static Badge Static Badge

目次

C言語で超ミニマムな仮想マシン「LC-3」を作る理由

C言語を使って「LC-3」という超ミニマムな仮想マシン(VM)を作ろうと思ったきっかけは、かつての天才技術者たちが命を削ってやりくりしていた伝説の「限界環境」へタイムスリップできるからです。

技術者たちが命を削った「64KBの聖域」

昔の8ビットパソコンや、ファミコン・MSXなどの時代、使えるメモリはわずか65,536マス(64KB)しかありませんでした。現代のスマホやPCとは比べものにならないほど狭いこの聖域に、ゲームやプログラムのすべてを収めるため、当時の技術者たちは凄まじい工夫を凝らしていました。

ドット絵の反転

  • キャラクターの見た目を1マスでも節約する工夫
  • 左右対称のキャラは「右半分」だけデータを作る
  • 「左半分」はプログラムを使って画面上で反転させて描画する

音楽(BGM)の圧縮

  • 楽譜のデータをそのまま入れるとメモリから溢れてしまう
  • 「同じメロディを繰り返すループ命令」を自作する
  • データ量を限界まで削り落として音楽を鳴らす

1バイトを削る執念

  • 無駄な命令がたった1つあるだけでゲームが動かなくなる世界
  • 1ビット、1バイトを削るために、何日も徹夜してコードを書き直す

8ビットパソコンの工夫

実は、この「65,536マス」のメモリをすべて数え上げるために、当時の技術者たちは「8ビットを2つ並列に繋いで16ビットにする」という工夫をしていました。

📦 工夫1:レジスタ(部屋)を合体させる

8ビットパソコンの中にある数字を入れる部屋(レジスタ)は、その名の通り「8ビット(0〜255)」の大きさしかありません。そのままだと、たった256マス分のメモリしか扱えないのです。

そこで技術者たちは、この8ビットの部屋を2つ横に並べて合体させ、「16ビット(65,536マス)」の大きな部屋として使うことにしました。

🚚 工夫2:データを2回に分けて運ぶ(道路のやりくり)

部屋を合体させて16ビットの住所を扱えるようにはなりましたが、ここで次の問題が発生します。

住所を直接指定する道(アドレスバス)は、最初から16ビット分の幅が用意されていましたが、その住所をCPUの中で組み立てるときや、16ビットのデータをメモリから読み出すときは、データを運ぶ道(データバス)が「8ビット分」の幅しかないことが壁になったのです。

せっかく部屋を合体させて大きなデータを作っても、運ぶ道路が狭くて一度に通り抜けられません。そこで道路が溢れないように、データを「上半分」と「下半分」の2回に分けて、ステップバイステップで運ぶ方法を取りました。

🧩 パズルのようなプログラミング

こうした「部屋を合体させる工夫」や「データを2回に分けて運ぶ工夫」によって、広いメモリを使えるようになりました。しかしその代わり、足し算で255を超えたときの「くり上がり」をわざわざプログラムで書く必要があるなど、プログラミングはパズルのように複雑になってしまいました。

当時の技術者たちは、このように限られた少ない部屋を「合体させたりバラしたり」しながら、必死にやりくりしていたのです。

🚀 LC-3 – 無理のない設計

それに比べて、教育用プロセッサであるLC-3は、最初から「16ビットCPU」として設計されています

部屋も道路も、最初からすべて16ビットの幅で作られているため、昔の8ビットパソコンのような無理なやりくりをすることなく、65,536マスの環境をそのままスムーズに利用することができるのです。

あえて「限界」を再現する

これから私たちが作ろうとしている「LC-3」は、あえてメモリをこれらと全く同じ「65,536マス」という限界サイズに設定しています。

この超ミニマムな環境をC言語で再現することで、現代の豊かな環境では味わえない「限られたリソースを使い切る知恵」と「コンピュータの本質」を深く学ぶことができるのです。

現代における「LC-3」というタイムマシンの価値

今のパソコンやスマートフォンは、メモリが数ギガバイト(何百億マス)もあります。そのため、私たちは普段「メモリが足りなくなるかもしれない」なんて心配をせずに、自由にプログラムを書くことができます。

それはとても幸せなことですが、引き換えに失ってしまったものもあります。それは、コンピュータが本来持っていた「素朴な楽しさ」や、「身近なおもちゃとしてのワクワク感」です。

あえて不自由を楽しむ

そこで登場するのが、この 「LC-3」 です。

LC-3は、あえてメモリを昔の8ビットパソコンと同じ「65,536マス(16ビットアドレス空間)」という限界サイズに設定しています。

  • 現代のPC:メモリは何百億マス(ほぼ無限)
  • LC-3の世界:メモリはわずか65,536マス(限界環境)

「1マス」の美しさを噛み締める

これから作る「lc3.c」は、ただの古い機械のシミュレータではありません。

かつての偉大な技術者たちと同じ視点に立ち、コンピュータの本質である「1マス1マスを噛み締めて動く楽しさと美しさ」を体験するための、素敵なタイムマシンなのです。

それでは、この無駄が一切ない「16ビットの世界」を、一緒にのぞいていきましょう。

第1回で作るもの

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

3000 f025

これは、LC-3のバイナリ(機械語)を16進数で表したものです。それぞれ次のような意味を持っています。

  • 3000:プログラムをメモリの x3000 番地に読み込む(開始位置の指定)
  • f025TRAP 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はcl3.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)の指示です。

そのため、最初のデータである 2c17x3000番地 に配置されます。それ以降の ea18e082 は、ファイルの中に住所が書いてあるわけではなく、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 << 01 << 1 という書き方は、ビットシフト演算と呼ばれるものです。
それぞれの値は、2進数で表すと次のように独立した「1マス(1ビット)」に対応しています。

  • FL_POS0001 (16進数で 0x1)
  • FL_ZRO0010 (16進数で 0x2)
  • FL_NEG0100 (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言語の enum16種類すべての命令をまとめて定義しておきます。

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_HALT0x25)だけ を扱います。

命令データ 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」のメモリを区別しよう

ここで定義した memoryreg は、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)を足すことで、「配列の3000番目からデータを書き込む」という指定を行っています。

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

次に、仮想メモリ(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ビット(015 の数値)だけを綺麗に取り出しています。

③ 命令の実行(Execute)

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

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 という名前で以下の内容を保存してください。

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

clean:
	rm -f main

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

ここがポイント!

出力ファイル名は main

  • ターゲット名は lc3 になっていますが、-o 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」へと進化させていきます。

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

この記事を書いた人

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

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

目次