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 用在測試,iced 和 rand 是 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,
}注意 cycles 是 u64——追蹤從開機以來執行了多少個 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() 函式——每呼叫一次,執行一條指令:
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 做硬體鏡像:
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 同步。那才是真正刺激的部分。
參考資源
- nesdev.org Wiki — NES 開發的百科全書
- 6502 Instruction Reference — 每條指令的詳細說明
- nestest ROM — CPU 正確性測試
- The 6502 Undocumented Opcodes — 非官方指令參考