3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

「CHIP-8って何?」から始めるエミュレータ開発 Part1

Last updated at Posted at 2025-12-09

はじめに

エミュレータって作ってみたいけど、いきなりゲームボーイとかファミコンとかは難しそう...って思ってました。

そこで見つけたのが 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);
}

スプライト描画の仕組み

  1. VxとVyからX,Y座標を取得
  2. メモリのIアドレスからNバイト読み込む
  3. 各バイトを8ピクセルとして描画
  4. XOR描画(既に点灯してるピクセルは消える)
  5. ピクセルが消えたら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でお会いしましょう!👋

3
0
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
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?