28
16

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

CHIP-8 & Golang でエミュレータ作成入門してみた

Last updated at Posted at 2018-11-24

はじめに

CPUとかあんまり詳しくないのですが、ファミコンのエミュレータを書きたいなと思い、
自作エミュレータで学ぶx86アーキテクチャを買って読み、よし作ろうと思っていろいろ調べてたら

まず最初にCHIP-8のエミュレータ書いてみるのがいいよ

というのを見かけ、数日でさくっと書けそうだったのでgolangで書いてみました。
(海外では割とそうみたい?なのか、実装がたくさんありました)

日本語の資料は全く見かけなかったため、CHIP-8について簡単に説明したいと思います。
※コンピュータ用語やCPU自体の説明等はほとんどありません。
※エミュレータの実装方法についてもあまり説明しません。

CHIP-8について

インタプリタ型の言語であるらしい。COSMAC-VIPなるもの等で使われていたとか。
(SuperChip/CHIP48なるものもあるみたいですが、ここでは対象としません)

CHIP-8アーキテクチャ

以下簡単な説明です。間違ってたらごめんさい。
(最後に参考資料をのせてますので、詳しくはそちらを参考にしてください。)

メモリ

4KB(0x000-0xFFF)のメモリが必要。メモリマップは以下。

アドレス      説明
0x000-0x1FF オリジナルではインタプリタ自身が置かれる
0x200-0xFFF CHIP-8のROMが置かれる領域(プログラム+データ)

エミュレータ等の実装では、プログラム自身は当然上記領域外に置かれるため、
大抵0x000-0x1FFにはフォントデータが置かれる。

レジスタ

  • 汎用レジスタ
    • 8bit - 16個のレジスタ(V0-VF)
    • VFはフラグのみに使われる
      • carry/borrow/なにか
      • フラグがどう更新されるかは、命令実行の結果による。
  • インデックスレジスタ
    • 16bitレジスタ
    • メモリアドレスを記憶するために使われる
  • DelayTimer/SoundTimer
    • 後述
  • その他
    • スタックポインタ
      • スタックの位置管理
    • プログラムカウンタ
      • 実行する命令のアドレス管理

スタック

16bit x 16で、サブルーチンからの戻りアドレスのみ記憶される。

タイマー

2つのタイマーがある。0より大きい場合、60Hzごとにデクリメントされる。

  • DelayTimer
    • 実行のタイミングを取るために使われる。
  • SoundTimer
    • 0になるまで音を鳴らす。

キー入力

16個のキーがある。2/4/6/8は大体up/left/right/downとして使われる。

1 2 3 C
4 5 6 D
7 8 9 E
A 0 B F

サウンド

音がなれば、なんでもいいらしい。今回はsin波を鳴らしてみたつもり。

ディスプレイ

64 x 32のモノクロディスプレイ。左上が(0,0)で、右下が(63,31)
描画はXORで行われる。
描画に使われるデータは、1byte(8bit) = 8pixelで表現される。
(Nバイトの描画データで、8xNの領域に描画される。詳しくは次のフォントデータを参照)

フォントデータ

8 x 5の16文字(0-F)のフォント。

golangで書くと以下のようなデータになる。

/* 1
|  *  | 0b00100000 | 0x20 |
| **  | 0b01100000 | 0x60 |
|  *  | 0b00100000 | 0x20 |
|  *  | 0b00100000 | 0x20 |
| *** | 0b01110000 | 0x70 |
...
*/
var characterSprites = []uint8{
	0xF0, 0x90, 0x90, 0x90, 0xF0, // 0
	0x20, 0x60, 0x20, 0x20, 0x70, // 1
	0xF0, 0x10, 0xF0, 0x80, 0xF0, // 2
	0xF0, 0x10, 0xF0, 0x10, 0xF0, // 3
	0x90, 0x90, 0xF0, 0x10, 0x10, // 4
	0xF0, 0x80, 0xF0, 0x10, 0xF0, // 5
	0xF0, 0x80, 0xF0, 0x90, 0xF0, // 6
	0xF0, 0x10, 0x20, 0x40, 0x40, // 7
	0xF0, 0x90, 0xF0, 0x90, 0xF0, // 8
	0xF0, 0x90, 0xF0, 0x10, 0xF0, // 9
	0xF0, 0x90, 0xF0, 0x90, 0x90, // A
	0xE0, 0x90, 0xE0, 0x90, 0xE0, // B
	0xF0, 0x80, 0x80, 0x80, 0xF0, // C
	0xE0, 0x90, 0x90, 0x90, 0xE0, // D
	0xF0, 0x80, 0xF0, 0x80, 0xF0, // E
	0xF0, 0x80, 0xF0, 0x80, 0x80, // F
}

命令一覧

命令はすべて2byteで表現される。
以下の命令がある。

テーブル内表記の説明
Vx -> x番目のレジスタ
PC -> プログラムカウンタ
I  -> インデックスレジスタ
DT -> Delay Timer
ST -> Sound Timer
オペコード 命令         説明
0x00E0 CLS ディスプレイをクリアする
0x00EE RET サブルーチンから戻る(スタックからpopしたアドレスに戻る)
0x1NNN GOTO 0x0NNN 0x0NNNにジャンプする(PC=0x0NNN)
0x2NNN CALL 0xNNN 0x0NNNのサブルーチンを呼び出す(スタックに戻り先をpushして)
0x3XNN SE Vx, 0xNN Vx==0xNNであれば、次の命令をスキップする
0x4XNN SNE Vx, 0xNN Vx!=0xNNであれば、次の命令をスキップする
0x5XNN SE Vx, Vy Vx==Vyであれば、次の命令をスキップする
0x6XNN LD Vx, 0xNN Vx=0xNN
0x7XNN ADD Vx, 0xNN Vx+=0xNN(フラグ変更なし)
0x8XY0 LD Vx, Vy Vx=Vy
0x8XY1 OR Vx, Vy Vx=Vx
0x8XY2 AND Vx, Vy Vx=Vx&Vy
0x8XY3 XOR Vx, Vy Vx=Vx^Vy
0x8XY4 ADD Vx, Vy Vx=Vx+Vy(桁あふれしたらVF=1、しなければVF=0)
0x8XY5 SUB Vx, Vy Vx=Vx-Vy(桁借りしたらVF=0、しなければVF=1)
0x8XY6 SHR Vx Vx=Vx>>1(その前にVxの最下位ビットをVFにセットする)
0x8XY7 SUBN Vx, Vy Vx=Vy-Vx(桁借りしたらVF=0、しなければVF=1)
0x8XYE SHL Vx Vx=Vx<<1(その前にVx最上位ビットをVFにセットする)
0x9XY0 SNE Vx,Vy Vx!=Vyであれば、次の命令をスキップする
0xANNN LD I, 0xNNN I=0xNNN
0xBNNN JP V0, 0xNNN PC=V0+0xNNN
0xCXNN RND Vx, 0xNN Vx=rand()&0xNN
0xDXYN DRW Vx, Vy, 0xN (Vx, Vy)から幅:8、高さ:0xNを、Iが示すアドレスから始まるNバイトのデータで描画する
0xEX9E SKP Vx Vxが示すキーが押されていたら、次の命令をスキップする
0xEXA1 SKNP Vx Vxが示すキーが押されていなければ、次の命令をスキップする
0xFX07 LD Vx, DT Vx=DT
0xFX0A LD Vx, K Vxに押されているキーをセットする(何も押されていなければ、次の命令へ進まない)
0xFX15 LD DT, Vx DT=Vx
0xFX18 LD ST, Vx ST=Vx
0xFX1E ADD I, Vx I=I+Vx
0xFX29 LD F, Vx I=fontData[Vx]
0xFX33 BCD Vx 10進数で100の位、10の位、1の位を、*(I+0),*(I+1),*(I+2)にセットする
0xFX55 LD [I],Vx V0-Vx(Vxを含む)を、Iから始まるアドレスにセットする
0xFX65 LD Vx, [I] ↑の逆をする

実装について

エミュレータの基本部分の実装は以下のようになります。

  1. メモリ/各種レジスタの定義
  2. 初期化
  3. ROMの読み込み
  4. 命令のフェッチ/デコード/実行 の繰り返し

簡単なプログラムで表すと、こんな感じです。

// 1. メモリ/レジスタ等の定義
type Chip8 struct {
	mem   [4096]uint8    // memory
	pc    uint16         // program counter
	v     [16]uint8      // registers
	i     uint16         // index register
	dt    uint8          // delay timer
	st    uint8          // sound timer
	sp    uint8          // stack pointer
	stack [16]uint16     // stack
	keys  [16]uint8      // keyboards state
	disp  [64 * 32]uint8 // graphics
}

func main() {
    // 2. 初期化
    c := &Chip8{ pc : 0x200, sp : 0x0f }
    copy(c.mem, fondData)
    
    // 3. ROMの読み込み
    f, _ := os.Open(*romPath)
    binary, _ := ioutil.ReadAll(f)
    copy(c.mem[0x200:], []uint8(binary))
    
    // 4. 命令のフェッチ/デコード/実行 の繰り返し
    for {
        // フェッチ
        op := uint16(c.mem[c.pc])<<8 | uint16(c.mem[c.pc+1])
        // プログラムカウンタ更新
        c.pc += 2
        // 実行...
    }
}

その他、考えなければならないこととして

  • 画面の表示
  • サウンド
  • キー入力
  • DelayTimer/SoundTimerの同期
  • 実行速度

などがあります。自分は以下のようにしてみました。

  • 画面の表示/サウンド/キー入力
  • DelayTimer/SoundTimer
    • 60Hzごとのデクリメントなので、VBlankと同期させてみました。
  • 実行速度
    • これがよくわかっていません。他のソースをみてなんとなく決めました。
    • 大体のROMはDelayTimerを使って同期しているため、気にしなくても大丈夫かも?

おわりに

できたものはGitHubにあげました。
(今回は勉強のためできるだけ他実装を見ないというのを意識しました)

よくわからないけど、なんかエミュレータ書いてみたい!という人の最初の一歩にはいいと思います。
実装自体もあまり時間をかけずに済みます。

参考資料

28
16
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
28
16

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?