featured.svg

NES 的心臟是一顆跑在 1.79 MHz 的 6502 CPU——同樣的處理器也驅動了 Apple II、Commodore 64、Atari 2600。這顆 8-bit 處理器只有 3 個通用暫存器、256 bytes 的 stack、64KB 的定址空間,但當年幾乎所有經典遊戲都在這麼小的硬體上跑出來。

這個專案用 Rust 實作了一個 cycle-accurate 的 6502 CPU 模擬器,通過了模擬器界的黃金標準 nestest——包含所有官方和非官方指令的驗證。最後還附帶一個用 Iced 寫的 Snake 小遊戲,讓你看到 6502 組合語言真的在你的桌面上跑。

專案結構

nes-cpu/
├── Cargo.toml
└── src/
    ├── lib.rs            # 模組匯出 + nestest 整合測試
    ├── cpu.rs            # CPU 核心:暫存器、fetch-decode-execute、中斷
    ├── bus.rs            # 記憶體匯流排:RAM 鏡像、I/O 路由、mapper 委派
    ├── opcodes.rs        # 256 格指令表:op! macro + OnceLock
    ├── mapper.rs         # Mapper trait(卡匣硬體抽象)
    ├── ines.rs           # iNES ROM 格式解析器
    └── demos/
        └── snake/main.rs # Snake 遊戲 demo(Iced GUI)

核心依賴非常精簡——只有 anyhow 用在測試,icedrand 是 demo 的 optional feature。CPU 模擬本身零外部依賴。

6502 CPU 101

開始看程式碼之前,先快速認識一下 6502 的基本架構:

┌─────────────────────────────────────────┐
│              6502 CPU                   │
├─────────────────────────────────────────┤
│  A  (Accumulator)     8-bit  算術主力   │
│  X  (Index X)         8-bit  索引/計數  │
│  Y  (Index Y)         8-bit  索引/計數  │
│  SP (Stack Pointer)   8-bit  → $0100-$01FF │
│  PC (Program Counter) 16-bit 下一條指令 │
│  P  (Status Flags)    8-bit  NV-BDIZC  │
└─────────────────────────────────────────┘

Status flags 用 bitmask 表示,在 Rust 裡這樣定義:

pub mod flags {
    pub const C: u8 = 0b0000_0001; // Carry
    pub const Z: u8 = 0b0000_0010; // Zero
    pub const I: u8 = 0b0000_0100; // IRQ Disable
    pub const D: u8 = 0b0000_1000; // Decimal (ignored on 2A03)
    pub const B: u8 = 0b0001_0000; // Break
    pub const U: u8 = 0b0010_0000; // Unused (always 1)
    pub const V: u8 = 0b0100_0000; // Overflow
    pub const N: u8 = 0b1000_0000; // Negative
}

整個 CPU 狀態就這麼一個 struct:

pub struct Cpu {
    pub a: u8,
    pub x: u8,
    pub y: u8,
    pub pc: u16,
    pub sp: u8,
    pub p: u8,
    pub cycles: u64,
    pub bus: Bus,
}

注意 cyclesu64——追蹤從開機以來執行了多少個 cycle,這對 PPU 同步至關重要。

1. Opcode 表:用 Macro 填滿 256 格

6502 的 opcode 是一個 byte(0x00-0xFF),所以剛好 256 種可能。每個 opcode 對應一組資訊:指令類型、定址模式、基本 cycle 數、是否有 page-crossing penalty。

#[derive(Clone, Copy, Debug)]
pub struct Instruction {
    pub kind: OpKind,
    pub mnemonic: &'static str,
    pub mode: AddrMode,
    pub cycles: u8,
    pub page_cross_penalty: bool,
}

OnceLock 做 lazy initialization,確保整個表只建一次:

pub fn get_opcodes() -> &'static [Instruction; 256] {
    use std::sync::OnceLock;
    static TABLE: OnceLock<[Instruction; 256]> = OnceLock::new();
    TABLE.get_or_init(build_table)
}

填表用了一個 op! macro,讓定義看起來非常乾淨:

let mut t = [KIL; 256]; // 預設全部填 KIL(CPU halt)

macro_rules! op {
    ($byte:expr, $kind:expr, $mne:expr, $mode:expr, $cyc:expr) => {
        t[$byte] = Instruction::new($kind, $mne, $mode, $cyc, false);
    };
    ($byte:expr, $kind:expr, $mne:expr, $mode:expr, $cyc:expr, page) => {
        t[$byte] = Instruction::new($kind, $mne, $mode, $cyc, true);
    };
}

// ── LDA ──
op!(0xA9, Lda, "LDA", Immediate,  2);
op!(0xA5, Lda, "LDA", ZeroPage,   3);
op!(0xBD, Lda, "LDA", AbsoluteX,  4, page); // page-crossing +1
op!(0xB1, Lda, "LDA", IndirectY,  5, page);
// ...256 行填完所有指令

這個設計很務實——先把 256 格全部初始化為 KIL(CPU halt),然後逐一覆寫有效的 opcode。未定義的 opcode 自然就是 KIL,不需要額外處理。151 個官方指令加上 105 個非官方指令,剛好把 256 格全部填滿。

2. Fetch-Decode-Execute:CPU 的心跳

整個 CPU 模擬的核心就是 step() 函式——每呼叫一次,執行一條指令:

graph LR A[Fetch Opcode] --> B[查表
256 entries] B --> C[解析地址
12 種模式] C --> D[計算 Cycles
+ page cross] D --> E[執行指令
match OpKind] E --> A
pub fn step(&mut self) -> String {
    let log_line = self.log_state();

    let opcode_byte = self.bus.read(self.pc);
    self.pc = self.pc.wrapping_add(1);

    let instr = get_opcodes()[opcode_byte as usize];
    let (addr, page_crossed) = self.resolve_addr(instr.mode);

    let extra = if instr.page_cross_penalty && page_crossed { 1u64 } else { 0 };
    let cost = instr.cycles as u64 + extra;

    // Add cycle cost BEFORE execute so that PPU catch-up during
    // bus reads sees the correct CPU cycle count.
    self.cycles += cost;

    self.execute(instr, addr);

    log_line
}

幾個值得注意的細節:

  • 先加 cycle 再執行:這不是隨便決定的順序。PPU 在 CPU 讀寫 bus 時會做 catch-up 同步,它需要看到「這條指令已消耗的 cycle 數」才能正確追上。如果先執行再加 cycle,PPU 的時序就會差一點
  • wrapping_add:6502 是 8/16-bit 系統,溢出要 wrap around,不能 panic
  • 查表 O(1):直接用 opcode byte 當 index,不需要 if-else 或 HashMap

3. 定址模式:同一條指令,12 種方式找資料

6502 最有趣的地方之一就是它的 12 種定址模式。同一個 LDA 指令可以從立即值、零頁、絕對位址、甚至間接尋址取得資料。resolve_addr() 負責根據定址模式算出實際地址:

fn resolve_addr(&mut self, mode: AddrMode) -> (u16, bool) {
    match mode {
        AddrMode::Implied | AddrMode::Accumulator => (0, false),

        AddrMode::Immediate => {
            let addr = self.pc;
            self.pc = self.pc.wrapping_add(1);
            (addr, false)
        }

        AddrMode::AbsoluteX => {
            let lo = self.bus.read(self.pc) as u16;
            let hi = self.bus.read(self.pc.wrapping_add(1)) as u16;
            self.pc = self.pc.wrapping_add(2);
            let base = (hi << 8) | lo;
            let addr = base.wrapping_add(self.x as u16);
            let page_crossed = (base & 0xFF00) != (addr & 0xFF00);
            (addr, page_crossed)
        }

        // ...其他 9 種模式
    }
}

回傳值是 (u16, bool) 的 tuple——地址加上「是否跨頁」。跨頁偵測的邏輯很簡單:比較高 byte 有沒有變。

Indirect JMP 的 Page-Wrap Bug

6502 有一個著名的硬體 bug:JMP ($xxFF) 做間接跳躍時,如果指標的低 byte 是 0xFF,高 byte 不是從下一頁($(xx+1)00)讀,而是從同一頁的開頭$xx00)讀(wrap around)。

AddrMode::Indirect => {
    // 6502 page-wrap bug: if low byte of pointer is 0xFF,
    // high byte is read from the SAME page (wraps around).
    let lo = self.bus.read(self.pc) as u16;
    let hi = self.bus.read(self.pc.wrapping_add(1)) as u16;
    self.pc = self.pc.wrapping_add(2);
    let ptr = (hi << 8) | lo;
    let lo2 = self.bus.read(ptr) as u16;
    let hi2 = self.bus.read((ptr & 0xFF00) | ((ptr + 1) & 0x00FF)) as u16;
    ((hi2 << 8) | lo2, false)
}

關鍵在這行:(ptr & 0xFF00) | ((ptr + 1) & 0x00FF)——高 byte 強制保持在同一頁。這不是 bug fix,而是忠實地重現硬體 bug。如果不這樣做,某些依賴這個行為的遊戲就會出錯。

4. Cycle Accuracy:差一個 cycle 就會出 bug

NES 的 PPU 跑在 CPU 的 3 倍速度。如果 CPU 的 cycle 計數不精確,畫面就會撕裂或閃爍。Cycle accuracy 來自三個地方:

基本 cycle 數:每條指令有固定的 cycle 消耗,直接從表裡查。

Page-crossing penalty:當定址跨越 256-byte 的頁邊界時,load/compare 類指令額外花 1 cycle。但 RMW(Read-Modify-Write)指令不受影響——它們的 cycle 數是固定的。

Branch penalty:分支指令的 cycle 計算最精緻:

fn branch(&mut self, condition: bool, offset: u16) {
    if condition {
        let offset_signed = offset as i8 as i32;
        let new_pc = (self.pc as i32 + offset_signed) as u16;
        self.cycles += if (self.pc & 0xFF00) != (new_pc & 0xFF00) { 2 } else { 1 };
        self.pc = new_pc;
    }
}

短短 7 行,包含了三條規則:

狀況 Cycle 數
分支未觸發 2(基本)
分支觸發,同頁 2 + 1 = 3
分支觸發,跨頁 2 + 2 = 4

5. Memory Bus:2KB RAM 變 8KB

NES 只有 2KB 的內部 RAM,但映射到 $0000-$1FFF 整個 8KB 的空間——透過 address masking 做硬體鏡像:

graph TB subgraph "CPU Address Space ($0000-$FFFF)" A["$0000-$07FF
2KB RAM"] B["$0800-$1FFF
RAM 鏡像 ×3
(mask: 0x07FF)"] C["$2000-$3FFF
PPU Registers
(mask: 0x0007)"] D["$4000-$401F
APU + I/O"] E["$6000-$7FFF
PRG-RAM (Mapper)"] F["$8000-$FFFF
PRG-ROM (Mapper)"] end

Bus 的實作用 pattern matching 做 address routing:

pub fn read(&mut self, addr: u16) -> u8 {
    match addr {
        0x0000..=0x1FFF => self.ram[(addr & 0x07FF) as usize],
        0x2000..=0x3FFF => {
            if let Some(io) = &mut self.io {
                io.read(0x2000 + (addr & 0x0007))
            } else {
                0xFF
            }
        }
        0x6000..=0xFFFF => {
            if let Some(m) = &self.mapper {
                m.borrow().read_prg(addr)
            } else {
                0xFF
            }
        }
        _ => 0xFF, // open bus
    }
}

PPU 暫存器只有 8 個($2000-$2007),但映射到 $2000-$3FFF 整個 8KB——同樣是用 mask 0x0007 處理。PPU 和 joypad 的具體讀寫透過 BusIo trait 委派出去,讓 CPU 模組不需要知道 PPU 的實作細節。

另外還有一個 peek() 方法——不觸發副作用的讀取,專門給 log 和 disassembly 用。這很重要,因為讀 PPU status register($2002)會清除 VBlank flag,如果 log 時也觸發這個副作用,就會改變程式行為。

6. Nestest:模擬器界的 Gold Standard

nestest 是一個專門驗證 CPU 正確性的 ROM。它測試所有官方和非官方指令,執行結果會寫入記憶體 $02(官方)和 $03(非官方)——0 代表全部通過。

驗證方式是和一份 reference log 逐行比對:

#[test]
fn nestest() {
    let rom = INesRom::load("roms/nestest.nes").expect("failed to load nestest ROM");
    let bus = Bus::from_rom(rom);
    let mut cpu = Cpu::new(bus);

    let reference_log = std::fs::read_to_string("roms/nestest.log")
        .expect("failed to load nestest log");
    let ref_lines: Vec<&str> = reference_log.lines().collect();
    let mut our_lines: Vec<String> = Vec::new();

    loop {
        let log_line = cpu.step();
        our_lines.push(log_line);
        if cpu.pc == 0xC66E { break; }
    }

    // 檢查通過條件
    let official = cpu.bus.read(0x0002);
    let unofficial = cpu.bus.read(0x0003);
    assert_eq!(official, 0, "official opcodes failed: ${official:02X}");
    assert_eq!(unofficial, 0, "unofficial opcodes failed: ${unofficial:02X}");

    // 逐行比對 log
    for (i, (ours, reference)) in our_lines.iter().zip(ref_lines.iter()).enumerate() {
        assert!(cpu_columns_match(ours, reference),
            "log mismatch at line {}:\n  OURS: {}\n  REF:  {}", i + 1, ours, reference);
    }
}

Log 的格式長這樣——和 nestest 官方格式完全相同:

C000  4C F5 C5  JMP $C5F5                       A:00 X:00 Y:00 P:24 SP:FD PPU:  0, 21 CYC:7
C5F5  A2 00     LDX #$00                        A:00 X:00 Y:00 P:24 SP:FD PPU:  0, 30 CYC:10

每行包含 PC、raw bytes、反組譯、暫存器值、PPU 座標和 cycle 數。比對時忽略 PPU 欄位(因為精確的 PPU timing 是 nes-ppu 的責任)。

7. Snake Demo:6502 Assembly 在桌面跑

為了證明 CPU 模擬器真的能跑東西,專案附了一個 Snake 遊戲——309 bytes 的 6502 機器碼,用 Iced GUI 框架顯示:

// 309 bytes 的 Snake 遊戲機器碼
const SNAKE_ROM: [u8; 309] = [
    0x20, 0x06, 0x06, 0x20, 0x38, 0x06, 0x20, 0x0d, 0x06, ...
];

遊戲透過 memory-mapped I/O 運作:

  • $FF:最後一次按鍵的 ASCII 碼(w/a/s/d)
  • $FE:亂數產生器(host 每個 tick 寫入)
  • $0200-$05FF:32×32 的螢幕記憶體,每個 byte 代表一個像素的顏色

Game loop 很簡單——每個 tick 寫入輸入和亂數,跑 100 步 CPU:

self.cpu.bus.write(0xFF, last_key);
self.cpu.bus.write(0xFE, rng.gen_range(1..16));

for _ in 0..STEPS_PER_TICK {
    self.cpu.step();
    if self.cpu.bus.read(self.cpu.pc) == 0x00 { // BRK = game over
        self.game_over = true;
        break;
    }
}

然後從 $0200-$05FF 讀出每個像素的值,畫到 Iced 的 Canvas 上。這個 demo 完美展示了模擬器的本質——host 提供 I/O 介面,guest code(6502 assembly)透過記憶體映射和 host 溝通。

總結

模組 行數 角色 核心 Pattern
cpu.rs ~825 CPU 核心:暫存器、fetch-decode-execute、中斷 match OpKind dispatch
opcodes.rs ~490 256 格指令表 OnceLock + op! macro
bus.rs ~230 記憶體匯流排:RAM 鏡像、I/O 路由 BusIo trait + address masking
ines.rs ~160 iNES ROM 解析器 Header validation + bank extraction
mapper.rs ~35 Mapper trait 定義 Mapper + MapperIrq traits
demos/snake ~210 Snake 遊戲 demo Memory-mapped I/O + canvas::Program

下一步:PPU 與完整的 NES

CPU 只是 NES 的一半。下一個專案 nes-ppu 會實作 PPU(Picture Processing Unit),然後用一個 System struct 把 CPU 和 PPU 串起來——PPU 跑 CPU 的 3 倍速度,每次 CPU 讀寫 PPU 暫存器時要做 catch-up 同步。那才是真正刺激的部分。

參考資源