#ファミコンエミュ挫折
組み込み系の勉強をするのにファミコンエミュを作ろうかと思いました。しかし初心者がいきなり始めるには少し大変でした。どこかで"chip8のほうが簡単だよ"というのを見たのでそちらにシフトしました。
Chip8と検索すると出てくる画像たちです。キーボードも使えるのでレトロなゲームができます。
###何から始めればいいの?
Chip8というハードウェアは存在せず、仮想マシンで動くインタプリタ型言語です。(ちょっと認識が怪しい)
日本語では資料があまりないので基本的には英語で調べ物をしました。
QiitaにはRustでつくるChip8エミュレータやCHIP-8 & Golang でエミュレータ作成入門してみたといった記事があります。
とはいえ私のようなド素人にはこういったものを読んでも基礎知識がなさ過ぎてわかりません。Wikiやリファレンスを見ても結局何から始めればいいかわからないので困りました。
そんな中で唯一できるかもと思えた動画があったのでそちらを使いChip8を理解するために勉強します。
C#でChip8という動画シリーズです。
##1.まずはROMを準備してバイナリを読み込む
ROMを読むところから始めるということすら分からなかったので一番の難所だったのかもしれません。データを読み込んでそれをもとにCPUにいろいろさせましょう。それがおそらく第一歩です。
ibm logo chip8とでもググればIBMと描かれた画像が入っているROMを手に入れることができます。
バイナリエディタを使って読むとこのようなものが出てきます。
Chip8の命令はすべて2byteなので最初の4つずつ分けて読み込むとよさそうです。
2byte = 16bit = 2^16 = 16^4 なので16進数で4文字です。2進数で表すとテキストがとても長くなってしまうのでこういうところで16進数を使うときれいに表現できますね。10進数であるとこうは行きません。
BinaryReaderで前から4文字ずつ読んでいきます。
static void Main(string[] args)
{
using (BinaryReader reader = new BinaryReader(new FileStream("IBM Logo.ch8", FileMode.Open)))
{
while (reader.BaseStream.Position < reader.BaseStream.Length)
{
var opcode = reader.ReadUInt16();
Console.WriteLine($"{opcode.ToString("X4")}");
}
}
}
このように上から順に読み込まれていきますが、読み込むときはリトルエンディアンで読み込まれるのでバイトオーダーが変わります。00 E0はE0 00、A2 2Aは2A A2に変わります。
Chip8はビッグエンディアンなので00 E0は読み込んだ後も00 E0のままでなくてはなりません。そのため、最初のbyteと二つ目のbyteを入れ替えます。
var opcode = (ushort)((reader.ReadByte() << 8) | reader.ReadByte());
バイナリエディタにある60 0Cを例に挙げます。初めに読み込まれるバイトは60でそれをushort(unsigned 16bit)に格納するので0060となります。二つ目のbyteは0Cなので000Cとなります。
一つ目を左に1バイト分(8ビット)ずらして二つ目のバイトとORをとれば600Cを作れそうです。
これで一つ一つ処理できます。
##2.このデータをどうするのか?
Wikipediaには命令一覧があって、この2バイトを見ればどんな命令を送っているのかがわかります。その命令をすべて組み込んであげればCPUの完成です。今回はオペコード一覧から一番初めが0になっているものだけ見ていきます。
一番初めの命令はほどんどのROMで使わないみたいなので無視します。二つ目のオペコードは00E0で、画面の初期化をします。画面を扱う変数が必要になりそうですね。
二つ目は00EEでサブルーチンから戻ってくる命令です。
サブルーチンが呼ばれた際、処理が終わった後に戻ってくるための元のアドレスを保存するスタックが必要になります。このスタックは元のアドレスを保存するのにしか使われません。
プログラムカウンタ(PC)というものが次の命令がメモリのどこにあるか覚えていてくれます。なので、00EEが呼ばれたときはPCにスタックの一番上のアドレスを入れるという作業を行います。
本来であればROMをメモリに入れるという作業が最初に必要になりますが、それは次回以降にやります。
この二つの命令を実行するのに必要になる変数は画面(Display)、プログラムカウンタ(PC)、スタック(Stack)です。これらをCPUクラスの中に入れます。
画面の大きさは横64,縦32なので64*32の大きさ分バイト列を準備しておきます。
オペコード一覧を見てみると一つ目の桁で分岐されていることがわかります。それをもとにswitch文を書くと上手く実装できそうです。
nibbleという名前で2バイト命令の一つ目の文字のみ抜き取り、nibbleが0の場合の操作を実装します。はじめの文字は0からFまでです。
00E0は画面のクリアなのでDisplayに入っているバイトすべてを0にします。
00EEはスタックの一番上をPCに入れる作業です。それ以外の実装されていないオペコードたちはサポートされていないということで、例外としてすべて表示させます。
public class CPU
{
public ushort PC = 0;
public Stack<ushort> Stack = new Stack<ushort>();
public byte[] Display = new byte[64 * 32];
public void ExecuteOpcode(ushort opcode)
{
ushort nibble = (ushort)(opcode & 0xF000);
switch (nibble)
{
case 0x0000:
if (opcode == 0x00e0)
{
//Display is a single array
for (int i = 0; i < Display.Length; i++) Display[i] = 0;
}
else if (opcode == 0x00ee)
{
PC = Stack.Pop();
}
else
{
throw new Exception($"Unsupported opcode {opcode.ToString("X4")}");
}
break;
default:
throw new Exception($"Unsupported opcode {opcode.ToString("X4")}");
}
}
}
class Program
{
static void Main(string[] args)
{
CPU cpu = new CPU();
using (BinaryReader reader = new BinaryReader(new FileStream("IBM LOGO.ch8", FileMode.Open)))
{
while (reader.BaseStream.Position < reader.BaseStream.Length)
{
var opcode = (ushort)((reader.ReadByte() << 8) | reader.ReadByte());
try
{
cpu.ExecuteOpcode(opcode);
}
catch(Exception e)
{
Console.WriteLine(e.Message);
}
}
}
}
}
結果です。最初の00E0は実行されたので表示されていません。
###次回
次回はオペコードをすべて実装し、コンソール上にROMの内容(IBM画像)を表示します。