NES 的 CPU 只有 16-bit 的位址線——最多定址 64KB。扣掉 RAM、PPU 暫存器和 I/O,留給遊戲程式碼的只剩 32KB($8000-$FFFF)。但 Super Mario Bros. 3 有 384KB、Final Fantasy III 有 512KB——這些遊戲怎麼塞進去的?
答案是卡匣上的 Mapper 硬體。Mapper 是焊在卡匣 PCB 上的晶片,負責在 CPU 和 ROM 之間做 bank switching——把 ROM 的不同「分頁」切換到 CPU 看得到的位址空間。不同的 Mapper 用不同的切換策略,從最簡單的「固定不切」到最複雜的「8 個可獨立切換的 bank + scanline IRQ 計數器」。
這個專案實作了 6 種 Mapper,覆蓋了 NES 遊戲庫約 60% 的遊戲。
專案結構
nes-mapper/
├── Cargo.toml
└── src/
├── lib.rs # Factory function + 模組匯出(24 行)
├── nrom.rs # Mapper 0:無 bank switching(140 行)
├── mmc1.rs # Mapper 1:5-bit 串列暫存器(307 行)
├── uxrom.rs # Mapper 2:16KB PRG 切換(118 行)
├── cnrom.rs # Mapper 3:8KB CHR 切換(110 行)
├── mmc3.rs # Mapper 4:細粒度切換 + scanline IRQ(499 行)
├── axrom.rs # Mapper 7:32KB PRG + 單畫面鏡像(114 行)
依賴只有 nes-cpu(Mapper trait 定義和 iNES ROM 格式)。
Mapper Trait:統一的卡匣介面
所有 Mapper 都實作同一個 trait:
pub trait Mapper {
fn read_prg(&self, addr: u16) -> u8; // CPU 讀程式碼/資料
fn write_prg(&mut self, addr: u16, val: u8); // CPU 寫入(觸發 bank switch)
fn read_chr(&self, addr: u16) -> u8; // PPU 讀圖形資料
fn write_chr(&mut self, addr: u16, val: u8); // PPU 寫 CHR-RAM
fn mirroring(&self) -> Mirroring; // Nametable 鏡像模式
fn as_irq(&mut self) -> Option<&mut dyn MapperIrq> { None }
}Factory function 根據 iNES header 的 mapper 編號建立對應的實作:
pub fn from_rom(rom: &INesRom) -> anyhow::Result<Box<dyn Mapper>> {
match rom.mapper {
0 => Ok(Box::new(nrom::Nrom::new(rom))),
1 => Ok(Box::new(mmc1::Mmc1::new(rom))),
2 => Ok(Box::new(uxrom::UxRom::new(rom))),
3 => Ok(Box::new(cnrom::Cnrom::new(rom))),
4 => Ok(Box::new(mmc3::Mmc3::new(rom))),
7 => Ok(Box::new(axrom::AxRom::new(rom))),
n => anyhow::bail!("Unsupported mapper: {n}"),
}
}Box<dyn Mapper> 讓 CPU 和 PPU 不需要知道具體是哪一種 Mapper——runtime polymorphism 在這裡是最自然的選擇。
1. Mapper 0(NROM):最簡單的開始
NROM 沒有 bank switching。PRG ROM 16KB 或 32KB 直接映射到 CPU 位址空間:
pub struct Nrom {
prg_rom: Vec<u8>,
prg_ram: [u8; 8192], // $6000-$7FFF
chr: Vec<u8>,
chr_ram: bool,
mirroring: Mirroring,
}
impl Mapper for Nrom {
fn read_prg(&self, addr: u16) -> u8 {
match addr {
0x6000..=0x7FFF => self.prg_ram[(addr - 0x6000) as usize],
0x8000..=0xFFFF => {
let offset = (addr - 0x8000) as usize % self.prg_rom.len();
self.prg_rom[offset]
}
_ => 0,
}
}
}關鍵是那個 % self.prg_rom.len()——如果 PRG 只有 16KB,$8000-$BFFF 和 $C000-$FFFF 會映射到同一份資料。這就是硬體鏡像,不需要 if-else,一個 modulo 就搞定。
NROM 約佔 NES 遊戲庫的 10%,包括初代 Super Mario Bros.、Donkey Kong、Excitebike 等早期經典。
2. Mapper 1(MMC1):5-bit 串列暫存器
MMC1 是 NES 最常見的 Mapper 之一(~28% 的遊戲),用了一個很特別的串列通訊協議來設定暫存器——每次寫入只傳 1 bit,5 次寫入完成一個 5-bit 的值。
pub struct Mmc1 {
prg_rom: Vec<u8>,
prg_ram: [u8; 8192],
chr: Vec<u8>,
chr_ram: bool,
// 5-bit 串列 shift register
shift: u8,
shift_count: u8,
// 內部暫存器
control: u8, // 鏡像模式 + PRG/CHR 模式
chr_bank_0: u8, // CHR 低 4KB bank
chr_bank_1: u8, // CHR 高 4KB bank
prg_bank: u8, // PRG bank 選擇
}寫入協議的實作:
fn write_prg(&mut self, addr: u16, val: u8) {
match addr {
0x8000..=0xFFFF => {
if val & 0x80 != 0 {
// 高位元 = reset
self.shift = 0;
self.shift_count = 0;
self.control |= 0x0C; // 重置到 PRG mode 3
return;
}
// 每次寫入送 1 bit,LSB first
self.shift |= (val & 1) << self.shift_count;
self.shift_count += 1;
if self.shift_count == 5 {
let value = self.shift;
// 用位址的 bits 14-13 決定目標暫存器
match (addr >> 13) & 0x03 {
0 => self.control = value, // $8000-$9FFF
1 => self.chr_bank_0 = value, // $A000-$BFFF
2 => self.chr_bank_1 = value, // $C000-$DFFF
_ => self.prg_bank = value, // $E000-$FFFF
}
self.shift = 0;
self.shift_count = 0;
}
}
// ...
}
}為什麼 Nintendo 要用這麼迂迴的串列協議?因為 NES 的 CPU data bus 只有 8 bits,但卡匣的寫入信號實際上只有一根線(/ROMSEL)。MMC1 用 5 次寫入湊出 5 bits,巧妙地突破了硬體限制。
Control register 控制了 PRG 和 CHR 的 bank switching 模式:
| PRG Mode (bits 3-2) | $8000-$BFFF | $C000-$FFFF |
|---|---|---|
| 0, 1 | 32KB 切換(偶數 bank) | 同上(奇數 bank) |
| 2 | 固定第一個 bank | 可切換 |
| 3(預設) | 可切換 | 固定最後一個 bank |
fn read_prg(&self, addr: u16) -> u8 {
match addr {
0x8000..=0xBFFF => {
let bank = match self.prg_mode() {
0 | 1 => (self.prg_bank as usize & 0xFE) % self.prg_bank_count,
2 => 0, // 固定第一個
_ => (self.prg_bank as usize) % self.prg_bank_count,
};
let offset = bank * 0x4000 + (addr - 0x8000) as usize;
self.prg_rom[offset % self.prg_rom.len()]
}
0xC000..=0xFFFF => {
let bank = match self.prg_mode() {
0 | 1 => ((self.prg_bank as usize & 0xFE) + 1) % self.prg_bank_count,
2 => (self.prg_bank as usize) % self.prg_bank_count,
_ => self.prg_bank_count - 1, // 固定最後一個
};
let offset = bank * 0x4000 + (addr - 0xC000) as usize;
self.prg_rom[offset % self.prg_rom.len()]
}
// ...
}
}MMC1 還能動態切換 nametable 的鏡像模式——四種模式用 control register 的低 2 bits 選擇:
fn mirroring(&self) -> Mirroring {
match self.control & 0x03 {
0 => Mirroring::SingleScreenLower,
1 => Mirroring::SingleScreenUpper,
2 => Mirroring::Vertical,
_ => Mirroring::Horizontal,
}
}使用 MMC1 的知名遊戲:The Legend of Zelda、Metroid、Mega Man 2、Final Fantasy。
3. Mapper 2 & 3(UxROM / CNROM):一個管 PRG、一個管 CHR
UxROM 和 CNROM 是最直覺的 bank switching mapper:
UxROM(Mapper 2):16KB PRG 切換,CHR 用 RAM
$8000-$BFFF:可切換的 16KB bank$C000-$FFFF:固定最後一個 bank- 寫入
$8000+的值 = 目標 bank 編號
CNROM(Mapper 3):PRG 固定,8KB CHR 切換
- PRG 永遠固定(16KB 鏡像或 32KB)
- 寫入
$8000+的值 = CHR bank 編號 - CHR 是 ROM,不是 RAM
兩者加起來不到 250 行,但覆蓋了不少經典:Castlevania(UxROM)、Solomon’s Key(CNROM)。
4. Mapper 7(AxROM):32KB 一次切
AxROM 是最豪邁的 mapper——每次切 32KB,整個 $8000-$FFFF 一口氣換掉:
fn read_prg(&self, addr: u16) -> u8 {
match addr {
0x8000..=0xFFFF => {
let offset = self.prg_bank * 0x8000 + (addr - 0x8000) as usize;
self.prg_rom[offset % self.prg_rom.len()]
}
_ => 0,
}
}特別的是 AxROM 可以切換 single-screen mirroring——用寫入值的 bit 4 選擇使用 VRAM 的上半還是下半:
fn write_prg(&mut self, addr: u16, val: u8) {
if let 0x8000..=0xFFFF = addr {
self.prg_bank = (val as usize & 0x07) % self.prg_bank_count;
self.mirroring = if val & 0x10 != 0 {
Mirroring::SingleScreenUpper
} else {
Mirroring::SingleScreenLower
};
}
}使用 AxROM 的遊戲:Battletoads、Rare 的早期作品。
5. Mapper 4(MMC3):最複雜的 Mapper
MMC3 是 NES 上最精密的 Mapper 之一——8 個可獨立設定的 bank register、兩種 PRG 模式、CHR inversion、加上一個 scanline IRQ 計數器。約 10% 的 NES 遊戲使用 MMC3。
pub struct Mmc3 {
prg_rom: Vec<u8>,
prg_ram: [u8; 8192],
chr: Vec<u8>,
chr_ram: bool,
bank_select: u8, // 目標暫存器 + 模式控制
bank_regs: [u8; 8], // 8 個 bank 暫存器
mirroring: Mirroring,
// Scanline IRQ
irq_latch: u8,
irq_counter: u8,
irq_reload: bool,
irq_enabled: bool,
irq_pending: bool,
}Bank Register 的雙重角色
$8000 的寫入同時控制目標選擇和模式設定:
bit 7: CHR inversion(交換 pattern table 的高低 bank 對應)
bit 6: PRG mode(交換 $8000 和 $C000 的 bank 來源)
bit 2-0: 目標 bank register(R0-R7)
fn write_prg(&mut self, addr: u16, val: u8) {
match addr {
0x8000..=0x9FFF => {
if addr & 1 == 0 {
self.bank_select = val; // 選擇目標 + 設定模式
} else {
let target = (self.bank_select & 0x07) as usize;
self.bank_regs[target] = val; // 寫入選中的暫存器
}
}
// ...
}
}8 個 bank register 的角色分配:
| Register | 正常模式 | 反轉模式 |
|---|---|---|
| R0 | CHR 2KB @ $0000-$07FF | CHR 2KB @ $1000-$17FF |
| R1 | CHR 2KB @ $0800-$0FFF | CHR 2KB @ $1800-$1FFF |
| R2-R5 | CHR 1KB @ $1000-$1FFF | CHR 1KB @ $0000-$0FFF |
| R6 | PRG 8KB @ $8000(mode 0)或 $C000(mode 1) | |
| R7 | PRG 8KB @ $A000(固定) |
PRG Bank 映射
MMC3 的 PRG 切換以 8KB 為單位,比 MMC1 的 16KB 更細:
fn prg_offset(&self, addr: u16) -> usize {
let second_last = self.prg_bank_count - 2;
let last = self.prg_bank_count - 1;
let r6 = (self.bank_regs[6] as usize) % self.prg_bank_count;
let r7 = (self.bank_regs[7] as usize) % self.prg_bank_count;
let bank = match addr {
0x8000..=0x9FFF => if self.prg_mode() { second_last } else { r6 },
0xA000..=0xBFFF => r7,
0xC000..=0xDFFF => if self.prg_mode() { r6 } else { second_last },
_ => last, // $E000-$FFFF 永遠是最後一個 bank
};
bank * 0x2000 + (addr & 0x1FFF) as usize
}Mode 0 和 Mode 1 的差別只是 $8000 和 $C000 對調——R6 可以出現在前面或後面。$A000(R7)和 $E000(最後一個 bank)則不受模式影響。
CHR Bank 映射:2KB 和 1KB 混合
CHR 的映射最精巧——8KB 的 CHR 空間被分成 8 個 1KB slot,但 R0 和 R1 各管 2KB(強制對齊到偶數 bank),R2-R5 各管 1KB:
fn chr_offset(&self, addr: u16) -> usize {
let slot = (addr >> 10) as usize; // 0-7
let bank = if !self.chr_inversion() {
match slot {
0 => (self.bank_regs[0] & 0xFE) as usize, // R0 low
1 => (self.bank_regs[0] | 0x01) as usize, // R0 high
2 => (self.bank_regs[1] & 0xFE) as usize, // R1 low
3 => (self.bank_regs[1] | 0x01) as usize, // R1 high
4 => self.bank_regs[2] as usize, // R2
5 => self.bank_regs[3] as usize, // R3
6 => self.bank_regs[4] as usize, // R4
_ => self.bank_regs[5] as usize, // R5
}
} else {
// 反轉:R2-R5 和 R0-R1 的位置交換
match slot {
0 => self.bank_regs[2] as usize,
1 => self.bank_regs[3] as usize,
2 => self.bank_regs[4] as usize,
3 => self.bank_regs[5] as usize,
4 => (self.bank_regs[0] & 0xFE) as usize,
5 => (self.bank_regs[0] | 0x01) as usize,
6 => (self.bank_regs[1] & 0xFE) as usize,
_ => (self.bank_regs[1] | 0x01) as usize,
}
};
let bank = bank % self.chr_bank_count;
bank * 0x0400 + (addr & 0x03FF) as usize
}& 0xFE 和 | 0x01 是巧妙的 bit manipulation——把 bank 編號強制對齊到偶數(R0 的 low half)然後 +1 得到 high half。
Scanline IRQ:畫到哪一行就中斷
MMC3 獨有的殺手功能是 scanline counter IRQ。PPU 每畫完一條 scanline 就呼叫 clock_scanline(),mapper 遞減計數器,到 0 時觸發 IRQ 中斷:
impl MapperIrq for Mmc3 {
fn clock_scanline(&mut self) {
if self.irq_counter == 0 || self.irq_reload {
self.irq_counter = self.irq_latch;
self.irq_reload = false;
} else {
self.irq_counter -= 1;
}
if self.irq_counter == 0 && self.irq_enabled {
self.irq_pending = true;
}
}
fn take_irq(&mut self) -> bool {
let pending = self.irq_pending;
self.irq_pending = false;
pending
}
}遊戲用這個功能做 split-screen 特效:
- 設定 IRQ latch = 目標 scanline 數
- 啟用 IRQ
- PPU 畫到那一行時觸發 IRQ
- IRQ handler 裡切換 scroll、bank 或其他設定
- 結果:畫面上半和下半可以顯示不同的捲動位置或圖案
Super Mario Bros. 3 用這個做狀態列固定在畫面頂部、同時下方的關卡地圖自由捲動。
控制 IRQ 的暫存器:
| 位址 | 功能 |
|---|---|
$C000(偶) |
設定 latch 值(目標 scanline 數) |
$C001(奇) |
設定 reload flag |
$E000(偶) |
關閉 IRQ + 清除 pending |
$E001(奇) |
啟用 IRQ |
6. Mapper 覆蓋率
六種 Mapper 覆蓋的遊戲數量:
| Mapper | 名稱 | 遊戲比例 | 代表作 |
|---|---|---|---|
| 0 | NROM | ~10% | Super Mario Bros., Donkey Kong |
| 1 | MMC1 | ~28% | Zelda, Metroid, Mega Man 2 |
| 2 | UxROM | ~11% | Castlevania, Contra |
| 3 | CNROM | ~6% | Solomon’s Key, Gradius |
| 4 | MMC3 | ~10% | SMB3, Kirby’s Adventure |
| 7 | AxROM | ~3% | Battletoads |
| 合計 | ~68% |
剩下的 32% 分散在幾十種少見的 mapper 中,每種只有幾款遊戲使用。
7. 測試策略
每個 Mapper 都有完整的單元測試,用「填充 pattern」來驗證 bank 映射:
fn make_mmc3(prg_8k_banks: usize, chr_1k_banks: usize) -> Mmc3 {
let mut prg_rom = vec![0u8; prg_8k_banks * 0x2000];
// 每個 8KB bank 填充自己的 bank 編號
for bank in 0..prg_8k_banks {
for i in 0..0x2000 {
prg_rom[bank * 0x2000 + i] = bank as u8;
}
}
// ...
}這樣讀出來的值就是 bank 編號——assert_eq!(m.read_prg(0x8000), 5) 直接驗證「$8000 映射到 bank 5」。
六種 Mapper 合計超過 60 個測試案例,涵蓋:
- 各種 bank switching 模式
- Bank wrapping(modulo 溢出)
- 固定 bank vs 可切換 bank
- CHR ROM 唯讀 vs CHR RAM 可寫
- 鏡像模式動態切換
- IRQ 計數器的完整生命週期
總結
| Mapper | 行數 | 核心機制 |
|---|---|---|
| NROM (0) | ~140 | Address modulo 鏡像 |
| MMC1 (1) | ~307 | 5-bit 串列 shift register |
| UxROM (2) | ~118 | 直接寫入 = bank 編號 |
| CNROM (3) | ~110 | 直接寫入 = CHR bank 編號 |
| MMC3 (4) | ~499 | 8 register 間接尋址 + scanline IRQ |
| AxROM (7) | ~114 | 32KB 整塊切換 + 鏡像控制 |
Mapper 是 NES 架構裡最能體現「硬體創意」的部分。在 CPU 定址空間固定的限制下,卡匣廠商發明了各種巧妙的 bank switching 機制——從 NROM 的「不切」到 MMC1 的「5 次寫入才能設定一個值」到 MMC3 的「8 個 register + scanline 中斷」。每一種 Mapper 都是一個微型硬體設計,在有限的資源內榨出最大的可能性。
參考資源
- nesdev.org Mapper Wiki — 所有 Mapper 的完整文件
- MMC1 Reference — MMC1 串列協議詳解
- MMC3 Reference — MMC3 bank switching 和 scanline IRQ
- iNES Header Format — ROM 檔案格式和 mapper 編號
- NES Mapper List — 完整的 mapper 編號列表