はじめに
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] | ↑の逆をする |
実装について
エミュレータの基本部分の実装は以下のようになります。
- メモリ/各種レジスタの定義
- 初期化
- ROMの読み込み
- 命令のフェッチ/デコード/実行 の繰り返し
簡単なプログラムで表すと、こんな感じです。
// 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にあげました。
(今回は勉強のためできるだけ他実装を見ないというのを意識しました)
よくわからないけど、なんかエミュレータ書いてみたい!という人の最初の一歩にはいいと思います。
実装自体もあまり時間をかけずに済みます。
参考資料
- アーキテクチャについて
- CHIP-8でのエミュレータ実装方法について
- ROM置き場