はじめに
エミュレータって作ってみたいけど、いきなりゲームボーイとかファミコンとかは難しそう...って思ってました。
そこで見つけたのが CHIP-8 っていう超シンプルな仮想マシン。1970年代に作られた、エミュレータ入門に最適なものです。
というわけで、Rustで一から作っていきます!
この記事で作るもの
- CHIP-8の基本構造(メモリ、レジスタ、スタック)
- 命令のフェッチ・デコード・実行サイクル
- 画面に絵を描画する機能
- 簡単な動作テスト
Part1なので、まずは基礎の基礎を作っていきます。
CHIP-8って何?
CHIP-8は、1970年代にJoseph Weisbeckerさんが作った インタープリタ型のプログラミング言語 です。
厳密には「コンピュータ」じゃなくて「仮想マシン」なんですが、エミュレータの勉強には最高の教材です。
CHIP-8のスペック
| 項目 | 詳細 |
|---|---|
| メモリ | 4KB(4096バイト) |
| ディスプレイ | 64×32 モノクロ |
| レジスタ | 16個の8ビット汎用レジスタ(V0-VF) |
| インデックスレジスタ | 16ビット(I) |
| プログラムカウンタ | 16ビット(PC) |
| スタック | 16段 |
| タイマー | delay_timer、sound_timer |
| キー入力 | 16キー(0-9、A-F) |
めちゃくちゃシンプルですよね!これなら作れそうです。
プロジェクトのセットアップ
まずはRustプロジェクトを作ります。
cargo new chip8-emu
cd chip8-emu
Creating binary (application) package
よし、準備OKです!
CHIP-8の基本構造を実装
定数の定義
まずは必要な定数を定義していきます。
const MEMORY_SIZE: usize = 4096;
const DISPLAY_WIDTH: usize = 64;
const DISPLAY_HEIGHT: usize = 32;
const REGISTER_COUNT: usize = 16;
const STACK_SIZE: usize = 16;
const FONTSET_SIZE: usize = 80;
フォントセット
CHIP-8には最初から0-9とA-Fの16進数フォントが入っています。これはメモリの0x000-0x1FFにロードします。
// CHIP-8のフォントセット(0-F)
const FONTSET: [u8; FONTSET_SIZE] = [
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
];
各文字が5バイトで表現されています。例えば「0」は:
0xF0 → 11110000 → ████
0x90 → 10010000 → █ █
0x90 → 10010000 → █ █
0x90 → 10010000 → █ █
0xF0 → 11110000 → ████
ちゃんと「0」の形になっていますね!
Chip8構造体
次は本体の構造体を作ります。
struct Chip8 {
memory: [u8; MEMORY_SIZE],
display: [[bool; DISPLAY_WIDTH]; DISPLAY_HEIGHT],
v: [u8; REGISTER_COUNT], // V0-VF レジスタ
i: u16, // インデックスレジスタ
pc: u16, // プログラムカウンタ
stack: [u16; STACK_SIZE],
sp: u8, // スタックポインタ
delay_timer: u8,
sound_timer: u8,
}
各フィールドの説明
-
memory: 4KBのメインメモリ -
display: 64×32のディスプレイバッファ(trueで点灯) -
v: V0-VFの16個の汎用レジスタ(VFはフラグ用) -
i: メモリアドレスを指すレジスタ -
pc: 次に実行する命令のアドレス -
stack: サブルーチン用のスタック -
sp: スタックポインタ(スタックの深さ) -
delay_timer: 60Hzで減るタイマー -
sound_timer: 60Hzで減るサウンドタイマー(0より大きいとビープ音)
初期化
impl Chip8 {
fn new() -> Self {
let mut chip8 = Chip8 {
memory: [0; MEMORY_SIZE],
display: [[false; DISPLAY_WIDTH]; DISPLAY_HEIGHT],
v: [0; REGISTER_COUNT],
i: 0,
pc: 0x200, // プログラムは0x200から始まる
stack: [0; STACK_SIZE],
sp: 0,
delay_timer: 0,
sound_timer: 0,
};
// フォントセットをメモリにロード(0x000-0x1FF)
chip8.memory[..FONTSET_SIZE].copy_from_slice(&FONTSET);
chip8
}
}
重要ポイント: プログラムカウンタは0x200(512)から始まります。これはCHIP-8の仕様で、0x000-0x1FFはインタープリタとフォント用に予約されています。
命令の実行サイクル
エミュレータの基本は Fetch → Decode → Execute のサイクルです。
Fetch(フェッチ)
メモリから命令を読み込みます。CHIP-8の命令は2バイトです。
fn fetch(&mut self) -> u16 {
let hi = self.memory[self.pc as usize] as u16;
let lo = self.memory[(self.pc + 1) as usize] as u16;
self.pc += 2;
(hi << 8) | lo
}
例:
- メモリ[0x200] = 0x61
- メモリ[0x201] = 0x08
- → オペコード = 0x6108
Decode & Execute(デコード・実行)
オペコードを解析して、対応する処理を実行します。
fn execute(&mut self, opcode: u16) {
// オペコードを分解
let nibbles = (
((opcode & 0xF000) >> 12) as u8,
((opcode & 0x0F00) >> 8) as u8,
((opcode & 0x00F0) >> 4) as u8,
(opcode & 0x000F) as u8,
);
let x = nibbles.1 as usize;
let y = nibbles.2 as usize;
let n = nibbles.3;
let nn = (opcode & 0x00FF) as u8;
let nnn = opcode & 0x0FFF;
match nibbles {
// 命令の実装(後述)
_ => {
println!("Unknown opcode: 0x{:04X}", opcode);
}
}
}
オペコードは4つのニブル(4ビット)に分解します。
例:0x6108
- nibbles.0 = 0x6
- nibbles.1 = 0x1 → x
- nibbles.2 = 0x0 → y
- nibbles.3 = 0x8 → n
- nn = 0x08
- nnn = 0x108
基本命令の実装
とりあえず画面に絵を描くのに必要な命令だけ実装します。
00E0 - CLS(画面クリア)
(0x0, 0x0, 0xE, 0x0) => {
// 00E0 - CLS: 画面をクリア
self.display = [[false; DISPLAY_WIDTH]; DISPLAY_HEIGHT];
println!("CLS");
}
00EE - RET(リターン)
(0x0, 0x0, 0xE, 0xE) => {
// 00EE - RET: サブルーチンから戻る
self.sp -= 1;
self.pc = self.stack[self.sp as usize];
println!("RET");
}
1NNN - JP(ジャンプ)
(0x1, _, _, _) => {
// 1NNN - JP: アドレスNNNにジャンプ
self.pc = nnn;
println!("JP 0x{:03X}", nnn);
}
2NNN - CALL(サブルーチン呼び出し)
(0x2, _, _, _) => {
// 2NNN - CALL: アドレスNNNのサブルーチンを呼ぶ
self.stack[self.sp as usize] = self.pc;
self.sp += 1;
self.pc = nnn;
println!("CALL 0x{:03X}", nnn);
}
6XNN - LD(レジスタに値をセット)
(0x6, _, _, _) => {
// 6XNN - LD: VXにNNをセット
self.v[x] = nn;
println!("LD V{:X}, 0x{:02X}", x, nn);
}
7XNN - ADD(レジスタに加算)
(0x7, _, _, _) => {
// 7XNN - ADD: VXにNNを加算
self.v[x] = self.v[x].wrapping_add(nn);
println!("ADD V{:X}, 0x{:02X}", x, nn);
}
ANNN - LD I(インデックスレジスタにセット)
(0xA, _, _, _) => {
// ANNN - LD: IにNNNをセット
self.i = nnn;
println!("LD I, 0x{:03X}", nnn);
}
DXYN - DRW(スプライト描画)
これが一番大事な命令です!画面に絵を描く処理です。
(0xD, _, _, _) => {
// DXYN - DRW: スプライトを描画
let x_coord = self.v[x] as usize % DISPLAY_WIDTH;
let y_coord = self.v[y] as usize % DISPLAY_HEIGHT;
self.v[0xF] = 0;
for row in 0..n as usize {
if y_coord + row >= DISPLAY_HEIGHT {
break;
}
let sprite_byte = self.memory[self.i as usize + row];
for col in 0..8 {
if x_coord + col >= DISPLAY_WIDTH {
break;
}
let pixel = (sprite_byte >> (7 - col)) & 1;
if pixel == 1 {
let display_pixel = &mut self.display[y_coord + row][x_coord + col];
if *display_pixel {
self.v[0xF] = 1; // 衝突検出
}
*display_pixel ^= true;
}
}
}
println!("DRW V{:X}, V{:X}, {}", x, y, n);
}
スプライト描画の仕組み
- VxとVyからX,Y座標を取得
- メモリのIアドレスからNバイト読み込む
- 各バイトを8ピクセルとして描画
- XOR描画(既に点灯してるピクセルは消える)
- ピクセルが消えたらVFを1にする(衝突検出)
実行サイクルとテスト
cycle関数
fn cycle(&mut self) {
let opcode = self.fetch();
self.execute(opcode);
if self.delay_timer > 0 {
self.delay_timer -= 1;
}
if self.sound_timer > 0 {
self.sound_timer -= 1;
}
}
ディスプレイ表示
fn print_display(&self) {
println!("\n{}", "=".repeat(DISPLAY_WIDTH + 2));
for row in &self.display {
print!("|");
for &pixel in row {
print!("{}", if pixel { "█" } else { " " });
}
println!("|");
}
println!("{}\n", "=".repeat(DISPLAY_WIDTH + 2));
}
テストプログラム
main関数で簡単なテストをします。画面に顔の絵を2つ描いてみます!
fn main() {
println!("🎮 CHIP-8 Emulator Starting...\n");
let mut chip8 = Chip8::new();
// 簡単なテストプログラム: 画面に絵を描く
let test_program = vec![
// スプライトデータ(5バイト)- シンプルな顔の絵
0b11111000, // █████
0b10001000, // █ █
0b10101000, // █ █ █
0b10001000, // █ █
0b11111000, // █████
// プログラム本体(0x205から)
0x61, 0x10, // LD V1, 16 - X座標を16に
0x62, 0x08, // LD V2, 8 - Y座標を8に
0xA2, 0x00, // LD I, 0x200 - スプライトのアドレス
0xD1, 0x25, // DRW V1, V2, 5 - 5行のスプライトを描画
// もう1つ描画
0x63, 0x20, // LD V3, 32 - X座標を32に
0x64, 0x10, // LD V4, 16 - Y座標を16に
0xA2, 0x00, // LD I, 0x200 - 同じスプライト
0xD3, 0x45, // DRW V3, V4, 5 - 描画
];
chip8.load_rom(&test_program);
chip8.pc = 0x205; // プログラム本体の開始位置
println!("🔄 Executing instructions...\n");
for i in 0..8 {
println!("--- Instruction {} ---", i + 1);
chip8.cycle();
}
println!("📺 Display Output:");
chip8.print_display();
println!("✅ Emulator test completed!");
}
実行結果
さあ、実行してみます!
cargo run
出力:
🎮 CHIP-8 Emulator Starting...
ROM loaded: 21 bytes
🔄 Executing instructions...
--- Instruction 1 ---
LD V1, 0x10
--- Instruction 2 ---
LD V2, 0x08
--- Instruction 3 ---
LD I, 0x200
--- Instruction 4 ---
DRW V1, V2, 5
--- Instruction 5 ---
LD V3, 0x20
--- Instruction 6 ---
LD V4, 0x10
--- Instruction 7 ---
LD I, 0x200
--- Instruction 8 ---
DRW V3, V4, 5
📺 Display Output:
==================================================================
| |
| |
| |
| |
| |
| |
| |
| |
| █████ |
| █ █ |
| █ █ █ |
| █ █ |
| █████ |
| |
| |
| |
| █████ |
| █ █ |
| █ █ █ |
| █ █ |
| █████ |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
==================================================================
✅ Emulator test completed!
Registers:
PC: 0x0215
I: 0x0200
V1: 0x10
V2: 0x08
V3: 0x20
V4: 0x10
やった!動きました!! 🎉
画面に顔の絵が2つ表示されていますね!意外とちゃんと動くものですね...
まとめ
Part1ではCHIP-8エミュレータの基礎を作りました:
実装したもの ✅
- 基本構造(メモリ、レジスタ、スタック)
- Fetch-Decode-Executeサイクル
- 7つの基本命令
- スプライト描画
- ターミナルでのディスプレイ表示
まだできてないこと
- 残りの命令(全部で35個ぐらい)
- キー入力
- タイマーの実装
- サウンド
- 実際のROM読み込み
- GUIでの表示
感想
最初はエミュレータって難しそうって思ってましたが、CHIP-8は本当にシンプルで作りやすいですね!
特にRustで書くと:
- メモリ安全性がある
- 配列の範囲チェックが自動
- match式でオペコードのデコードが書きやすい
次回Part2では、残りの命令を実装して、実際のCHIP-8ゲームROMを動かしてみたいと思います!
参考資料
この記事が役に立ったら、いいね・ストックしてもらえると嬉しいです!
質問やコメントがあればぜひ教えてください。Part2でお会いしましょう!👋