NES Mapper 模擬器:Bank Switching、MMC1 串列暫存器與 MMC3 Scanline IRQ
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 都是一個微型硬體設計,在有限的資源內榨出最大的可能性。
參考資源
NES Joypad 模擬器:Strobe 協議、Input Mapping 與完整系統整合
前兩篇我們完成了 NES 的 CPU 和 PPU——能跑指令、能畫畫面,但玩家還沒辦法操作。NES 的控制器看似簡單(就八個按鈕),但它用了一個很優雅的串列協議:CPU 透過 strobe 信號鎖存按鈕狀態,然後逐 bit 讀取——八次讀取拿到八個按鈕,就像一個 8-bit shift register。
這個專案不只實作了 joypad 的硬體協議,還負責把整個 NES 系統串起來——CPU、PPU、Mapper、Joypad 全部接在同一條 bus 上,最後做出一個可以真的玩遊戲的 NES 模擬器。
專案結構
nes-joypad/
├── Cargo.toml
└── src/
├── lib.rs # 模組匯出
├── joypad.rs # Joypad 核心:strobe/shift-register 協議(178 行)
├── input.rs # 輸入映射:鍵盤 + 手把(99 行)
├── composite_io.rs # 複合 Bus I/O 分發器(38 行)
├── system.rs # 完整 NES 系統整合器(147 行)
└── demos/
└── play/main.rs # NES 播放器 demo(98 行)
依賴:nes-cpu、nes-ppu、nes-mapper,加上 optional 的 minifb(視窗)和 gilrs(手把)。
NES 控制器的硬體協議
NES 控制器只有 8 個按鈕——A、B、Select、Start、上、下、左、右——但 CPU 不是一次讀到所有按鈕的。它用了一個串列通訊協議:
sequenceDiagram
participant CPU
participant Joypad
CPU->>Joypad: 寫入 $4016 = 1(strobe on)
Note over Joypad: 持續從 button_state 載入
CPU->>Joypad: 寫入 $4016 = 0(strobe off)
Note over Joypad: 鎖存!凍結 shift register
CPU->>Joypad: 讀取 $4016 → A
CPU->>Joypad: 讀取 $4016 → B
CPU->>Joypad: 讀取 $4016 → Select
CPU->>Joypad: 讀取 $4016 → Start
CPU->>Joypad: 讀取 $4016 → Up
CPU->>Joypad: 讀取 $4016 → Down
CPU->>Joypad: 讀取 $4016 → Left
CPU->>Joypad: 讀取 $4016 → Right
CPU->>Joypad: 讀取 $4016 → 1(open bus)
為什麼要這樣設計?因為 NES 的 CPU bus 是 8-bit,但控制器只用了每次讀取的 bit 0。串列協議讓一個 I/O 位址就能讀完 8 個按鈕,節省了寶貴的位址空間。
1. Joypad 核心:四個欄位搞定一切
Joypad 的狀態非常精簡:
pub struct Joypad {
/// 即時按鈕狀態(bit N = Button N 被按下)
button_state: u8,
/// 鎖存快照——strobe 下降沿時凍結
shift_register: u8,
/// 下一次讀取要回傳的 bit 位置(0-7,之後回傳 open bus)
read_index: u8,
/// strobe 信號狀態
strobe: bool,
}
按鈕的定義用 enum 對應 shift register 的讀取順序:
#[repr(u8)]
pub enum Button {
A = 0, B = 1, Select = 2, Start = 3,
Up = 4, Down = 5, Left = 6, Right = 7,
}
#[repr(u8)] 確保 enum 的值就是 shift register 裡的 bit 位置——A 最先被讀出、Right 最後。
2. Strobe 協議:鎖存的藝術
Strobe 協議的核心在 write_strobe() 和 read_bit():
pub fn write_strobe(&mut self, val: u8) {
let new_strobe = val & 1 != 0;
if self.strobe && !new_strobe {
// Falling edge: 鎖存並重置
self.shift_register = self.button_state;
self.read_index = 0;
}
self.strobe = new_strobe;
if self.strobe {
self.shift_register = self.button_state;
}
}
pub fn read_bit(&mut self) -> u8 {
if self.strobe {
return self.button_state & 1; // strobe 開著時永遠回傳 A 鍵
}
if self.read_index < 8 {
let bit = (self.shift_register >> self.read_index) & 1;
self.read_index += 1;
bit
} else {
1 // open bus
}
}
幾個關鍵行為:
| 狀態 |
行為 |
| Strobe = 1 |
持續從 button_state 載入,讀取永遠回傳 A 鍵 |
| Strobe 1→0 |
鎖存:凍結當前按鈕狀態到 shift register |
| 讀取第 1-8 次 |
依序回傳 A、B、Select、Start、上、下、左、右 |
| 讀取第 9+ 次 |
回傳 1(open bus,標準控制器行為) |
為什麼 strobe 開著時只回傳 A 鍵? 因為在 strobe 模式下,shift register 每次讀取前都會被重新載入——等於 read_index 永遠停在 0,而 bit 0 就是 A 鍵。
3. 雙控制器匯流排:一個 Strobe 控兩隻
NES 支援兩個控制器,但 strobe 信號是共享的——CPU 寫入 $4016 時,兩個控制器同時被 strobe。讀取時則分開:$4016 讀 P1,$4017 讀 P2。
CompositeBusIo 把所有 I/O 設備統一到一條 bus 上:
pub struct CompositeBusIo {
pub ppu_io: PpuBusIo,
pub joypad1: Rc<RefCell<Joypad>>,
pub joypad2: Rc<RefCell<Joypad>>,
}
impl BusIo for CompositeBusIo {
fn read(&mut self, addr: u16) -> u8 {
match addr {
addr::JOYPAD1 => self.joypad1.borrow_mut().read_bit(),
addr::JOYPAD2 => self.joypad2.borrow_mut().read_bit(),
_ => self.ppu_io.read(addr),
}
}
fn write(&mut self, addr: u16, val: u8) {
match addr {
addr::JOYPAD1 => {
// Strobe 同時鎖存兩個控制器
self.joypad1.borrow_mut().write_strobe(val);
self.joypad2.borrow_mut().write_strobe(val);
}
_ => self.ppu_io.write(addr, val),
}
}
}
這個 38 行的模組是整個系統的「交通警察」——CPU 的每一次 I/O 讀寫都經過這裡,然後被路由到正確的設備(PPU 或 Joypad)。
4. 輸入映射:鍵盤與手把
模擬器需要把實際的輸入裝置映射到 NES 的 8 個按鈕。鍵盤映射很直覺:
pub fn keyboard_to_buttons(window: &minifb::Window) -> u8 {
let mut state = 0u8;
if window.is_key_down(Key::Up) { state |= 1 << Button::Up as u8; }
if window.is_key_down(Key::Down) { state |= 1 << Button::Down as u8; }
if window.is_key_down(Key::Left) { state |= 1 << Button::Left as u8; }
if window.is_key_down(Key::Right) { state |= 1 << Button::Right as u8; }
if window.is_key_down(Key::Z) { state |= 1 << Button::A as u8; }
if window.is_key_down(Key::X) { state |= 1 << Button::B as u8; }
if window.is_key_down(Key::A) { state |= 1 << Button::Select as u8; }
if window.is_key_down(Key::S) { state |= 1 << Button::Start as u8; }
state
}
手把映射多了一個 deadzone 處理——類比搖桿在中心附近的微小偏移要過濾掉,避免誤觸:
// 左搖桿 deadzone = 0.5
if let Some(axis) = gp.axis_data(Axis::LeftStickX) {
if axis.value() < -0.5 { state |= 1 << Button::Left as u8; }
if axis.value() > 0.5 { state |= 1 << Button::Right as u8; }
}
最終的 state 是一個 u8 bitfield,可以直接用 |= 合併鍵盤和手把的輸入——兩者任一按下就算按下。
5. 完整 NES 系統整合
System 把所有元件串在一起:
pub struct System {
pub cpu: Cpu,
pub ppu: Rc<RefCell<Ppu>>,
pub joypad1: Rc<RefCell<Joypad>>,
pub joypad2: Rc<RefCell<Joypad>>,
cpu_cycles: Rc<Cell<u64>>,
ppu_cycles: Rc<Cell<u64>>,
}
初始化的接線圖:
graph TD
ROM[iNES ROM] --> Mapper[Mapper
bank switching]
Mapper --> Bus[CPU Bus]
Mapper --> PPU[PPU]
Bus --> CPU[6502 CPU]
subgraph CompositeBusIo
PpuBusIo[PPU Bus I/O
catch-up ticking]
JP1[Joypad 1]
JP2[Joypad 2]
end
Bus -->|I/O delegate| CompositeBusIo
PpuBusIo --> PPU
CPU_CYC[cpu_cycles
Rc Cell u64] -.->|shared| CPU
CPU_CYC -.->|shared| PpuBusIo
PPU_CYC[ppu_cycles
Rc Cell u64] -.->|shared| PpuBusIo
from_rom() 的關鍵是共享狀態的接線——Mapper、PPU、Joypad 都用 Rc<RefCell<>> 包裝,因為多個元件需要同時存取它們。CPU 和 PPU 的 cycle counter 則用 Rc<Cell<u64>> 做零成本的共享。
初始化結束後,還要把 PPU「追上」CPU 的 reset cycle 數:
cpu.reset();
cpu_cycles.set(cpu.cycles);
let reset_ppu_dots = cpu.cycles * 3;
{
let mut p = ppu.borrow_mut();
for _ in 0..reset_ppu_dots {
p.tick();
}
}
ppu_cycles.set(reset_ppu_dots);
CPU reset 會消耗 7 個 cycle,PPU 需要追上 7 × 3 = 21 個 dot,確保兩者從一開始就同步。
6. 主迴圈:每幀一次的節奏
Demo 的主迴圈非常乾淨——每幀做三件事:讀取輸入、跑一幀模擬、更新畫面:
window.set_target_fps(60);
while window.is_open() && !window.is_key_down(Key::Escape) {
// 1. 讀取輸入
let mut p1_state = keyboard_to_buttons(&window);
#[cfg(feature = "gamepad")]
if let (Some(ref mut gilrs), Some(gp_id)) = (&mut gilrs_ctx, gamepad_id) {
while gilrs.next_event().is_some() {} // drain events
p1_state |= gamepad_to_buttons(gilrs, gp_id);
}
sys.joypad1.borrow_mut().set_buttons(p1_state);
// 2. 跑一幀
sys.run_until_frame();
// 3. 更新畫面
let ppu = sys.ppu.borrow();
window.update_with_buffer(&*ppu.framebuffer, WIDTH, HEIGHT)?;
}
run_until_frame() 會一直呼叫 step() 直到 PPU 設定 frame_ready(VBlank 開始),然後把 256×240 的 framebuffer 丟給 minifb 視窗,用 3 倍 scale 顯示(768×720)。
gilrs 的 gamepad 輸入有個小細節:每次讀取前要先 drain 所有待處理的事件(while gilrs.next_event().is_some() {}),否則 gilrs 的內部狀態不會更新。
7. 測試:驗證硬體協議的每個角落
Joypad 的協議雖然簡單,但邊界情況不少。測試覆蓋了所有行為:
#[test]
fn read_order_matches_button_enum() {
let mut jp = Joypad::new();
jp.set_buttons(1 << Button::Start as u8);
jp.write_strobe(1);
jp.write_strobe(0);
assert_eq!(jp.read_bit(), 0); // A
assert_eq!(jp.read_bit(), 0); // B
assert_eq!(jp.read_bit(), 0); // Select
assert_eq!(jp.read_bit(), 1); // Start ← 只有這個是 1
assert_eq!(jp.read_bit(), 0); // Up
assert_eq!(jp.read_bit(), 0); // Down
assert_eq!(jp.read_bit(), 0); // Left
assert_eq!(jp.read_bit(), 0); // Right
}
#[test]
fn open_bus_after_8_reads() {
let mut jp = Joypad::new();
jp.write_strobe(1);
jp.write_strobe(0);
for _ in 0..8 { jp.read_bit(); }
assert_eq!(jp.read_bit(), 1); // open bus
assert_eq!(jp.read_bit(), 1); // 一直是 1
}
| 測試 |
驗證行為 |
strobe_latches_all_buttons |
全部按下後 strobe,8 個 bit 都是 1 |
read_order_matches_button_enum |
讀取順序:A→B→Select→Start→↑→↓→←→→ |
open_bus_after_8_reads |
第 9 次以後讀取回傳 1 |
strobe_high_returns_a_button |
strobe 開著時只回傳 A 鍵 |
no_buttons_reads_all_zero |
沒按任何鍵,8 次讀取全是 0 |
multiple_buttons_simultaneously |
同時按 A 和 Right,正確回傳 |
總結
| 模組 |
行數 |
角色 |
核心 Pattern |
joypad.rs |
~178 |
Strobe/shift-register 協議 |
Falling edge latch + serial read |
input.rs |
~99 |
鍵盤/手把映射 |
Bitfield OR 合併多輸入源 |
composite_io.rs |
~38 |
Bus I/O 分發器 |
Address-based routing |
system.rs |
~147 |
完整 NES 系統整合 |
Rc<RefCell<>> 共享 + cycle sync |
demos/play |
~98 |
NES 播放器 |
60 FPS frame loop |
Joypad 本身的協議很簡單——四個欄位、幾十行核心邏輯。但這個 crate 真正的意義是把所有東西串起來。CompositeBusIo 讓 CPU、PPU、Joypad 共享一條 bus;System 協調它們的時序;最後 demos/play 把這一切變成一個可以拿手把玩的 NES 模擬器。
下一步是 nes-mapper——NES 卡匣上的 bank switching 硬體,讓 32KB 的 CPU 定址空間能跑 512KB 甚至更大的遊戲。
參考資源
NES PPU 模擬器:Catch-Up 同步、VBlank 競態與 Edge-Triggered NMI
上一篇我們寫了 NES 的 CPU——一顆 cycle-accurate 的 6502 模擬器。但 CPU 只是 NES 的一半。真正讓畫面動起來的是 PPU(Picture Processing Unit)——一顆跑在 CPU 3 倍速度的圖形處理器,每秒畫 60 幀、每幀 256×240 像素。
PPU 的難度不在「畫像素」,而在時序。CPU 和 PPU 的時鐘比是 1:3,任何一個 cycle 的偏差都可能讓畫面撕裂、NMI 中斷丟失、或觸發只有在特定 cycle 才會出現的硬體競態條件。這個專案的目標是通過 blargg 的 ppu_vbl_nmi 測試套件——NES 模擬器界最刁鑽的 VBlank/NMI 時序驗證。
專案結構
nes-ppu/
├── Cargo.toml
└── src/
├── lib.rs # 模組匯出
├── ppu.rs # PPU 核心:暫存器、渲染管線、NMI 邊緣偵測(1056 行)
├── bus_io.rs # CPU↔PPU 橋接:catch-up ticking(80 行)
├── system.rs # System:CPU/PPU 協調器(156 行)
├── main.rs # blargg 測試 harness(97 行)
└── demos/
└── render/main.rs # 即時渲染視窗(minifb)
依賴:nes-cpu(CPU 模擬器)、nes-mapper(卡匣 mapper)、anyhow、optional minifb(渲染 demo)。
NES 畫面是怎麼生出來的
PPU 用 scanline 逐行畫面:
Scanline 0-239: 可見畫面(256 像素/行)
Scanline 240: idle
Scanline 241: VBlank 開始 → NMI 中斷 → 遊戲邏輯更新
Scanline 242-260: VBlank 持續
Scanline 261: Pre-render → 清除 flags、準備下一幀
每條 scanline 有 341 個 dot(PPU cycle)。整幀 = 262 scanlines × 341 dots = 89,342 個 PPU cycle。因為 PPU 跑 CPU 的 3 倍速,一幀大約等於 ~29,781 個 CPU cycle。
VBlank 期間(scanline 241-260)PPU 不畫東西,遊戲程式趁這段空檔更新 VRAM——這就是為什麼 NMI 中斷對遊戲來說如此重要。
1. PPU 狀態:一堆暫存器和 Shift Register
PPU 的狀態比 CPU 複雜很多:
pub struct Ppu {
// 控制暫存器
pub ctrl: u8, // $2000: NMI enable, sprite size, pattern table...
pub mask: u8, // $2001: background/sprite enable, color emphasis
pub status: u8, // $2002: VBlank, sprite 0 hit, overflow
// Loopy scroll 暫存器
pub v: u16, // 當前 VRAM 位址 (15-bit)
pub t: u16, // 暫存 VRAM 位址 (15-bit)
pub fine_x: u8, // X fine scroll (0-7 pixels)
pub addr_latch: bool, // 寫入對切換
// 記憶體
pub vram: [u8; 2048], // 2 個 nametable
pub palette: [u8; 32], // 調色盤
pub oam: [u8; 256], // Sprite 記憶體 (64 sprites × 4 bytes)
// 時序
pub scanline: i16, // 0-261
pub dot: u16, // 0-340
pub odd_frame: bool,
// NMI(edge-triggered)
pub nmi_output: bool, // $2000 bit 7
pub nmi_occurred: bool, // VBlank flag
pub nmi_pending: bool, // Edge-detected NMI
pub nmi_line: bool, // 前一次 NMI line 狀態
pub nmi_pending_age: u16, // NMI 設定後經過的 PPU ticks
pub nmi_write_delay: u8, // Register-write 觸發的延遲
// 背景 shift registers
bg_pattern_lo: u16,
bg_pattern_hi: u16,
bg_attr_lo: u16,
bg_attr_hi: u16,
// 渲染輸出
pub framebuffer: Box<[u32; 256 * 240]>,
// ...
}
其中最微妙的是 NMI 相關的 5 個欄位——光是「要不要觸發 NMI 中斷」這件事就需要 edge detection、age tracking 和 write delay 三種機制。
2. CPU/PPU 同步:Catch-Up 模式
NES 的 PPU 跑 CPU 的 3 倍速。最直覺的模擬方式是「CPU 跑一步、PPU 跑三步」,但這忽略了一個關鍵問題:CPU 讀寫 PPU 暫存器時,PPU 需要先追上到正確的時間點。
這就是 catch-up ticking。System 用兩個共享計數器追蹤 CPU 和 PPU 的進度:
pub struct System {
pub cpu: Cpu,
pub ppu: Rc<RefCell<Ppu>>,
cpu_cycles: Rc<Cell<u64>>,
ppu_cycles: Rc<Cell<u64>>,
// ...
}
每一步的流程:
graph TD
A[檢查 NMI pending] -->|是| B[CPU 執行 NMI handler]
A -->|否| C[處理 OAM DMA]
C --> D[預讀 opcode,設定 cpu_cycles]
D --> E[CPU 執行一條指令]
E --> F[PPU catch-up
補跑剩餘 dots]
F --> G[檢查 mapper IRQ]
G --> A
pub fn step(&mut self) -> u64 {
if self.ppu.borrow_mut().take_nmi() {
self.cpu.nmi();
self.cpu_cycles.set(self.cpu.cycles);
}
let dma_cycles = self.handle_dma();
// 預讀指令的 base cycle cost,這樣 PPU catch-up
// 在 bus read 時能看到正確的 CPU cycle 數
let opcode = self.cpu.bus.peek(self.cpu.pc);
let instr = nes_cpu::opcodes::get_opcodes()[opcode as usize];
let base_cost = instr.cycles as u64;
self.cpu_cycles.set(self.cpu.cycles + base_cost);
let prev_cycles = self.cpu.cycles;
let _ = self.cpu.step();
let elapsed = self.cpu.cycles - prev_cycles;
self.cpu_cycles.set(self.cpu.cycles);
// 補跑 PPU 尚未 tick 的部分
let target_ppu = self.cpu.cycles * 3;
let current_ppu = self.ppu_cycles.get();
if target_ppu > current_ppu {
let remaining = target_ppu - current_ppu;
let mut ppu = self.ppu.borrow_mut();
for _ in 0..remaining {
ppu.tick();
}
self.ppu_cycles.set(target_ppu);
}
elapsed + dma_cycles
}
關鍵在 cpu_cycles 的預設值:在 CPU 執行指令之前就設定為 cycles + base_cost。這樣如果指令中途讀寫 PPU 暫存器(透過 bus),PPU 的 catch-up 就能看到正確的時間點。
3. $2002 讀取:VBlank 競態條件
PPU 最刁鑽的硬體行為之一:如果 CPU 剛好在 VBlank 被設定的前一個 cycle 讀 $2002,VBlank flag 就不會被設定——整幀都不會。
這個 race condition 在 bus_io.rs 裡用 split catch-up 實作:
impl BusIo for PpuBusIo {
fn read(&mut self, addr: u16) -> u8 {
let target = self.cpu_cycles.get() * 3;
if addr == 0x2002 {
// 先 catch-up 到 target-1
let split_target = target.saturating_sub(1);
self.catch_up_to(split_target);
let mut ppu = self.ppu.borrow_mut();
// 如果 PPU 現在在 (241, 0),下一個 tick 就要設 VBlank
// 此時讀 $2002 會抑制 VBlank
if ppu.scanline == timing::VBLANK_LINE && ppu.dot == 0 {
ppu.suppress_vbl = true;
}
let val = ppu.read_register(addr);
ppu.tick(); // 最後一個 tick
self.ppu_cycles.set(target);
val
} else {
self.catch_up_to(target);
self.ppu.borrow_mut().read_register(addr)
}
}
}
為什麼要「分兩段」catch-up?因為我們需要在最後一個 PPU tick 之前檢查 PPU 是否正好站在 VBlank 即將觸發的位置 (scanline 241, dot 0)。如果是的話,設定 suppress_vbl flag,讓下一個 tick 跳過 VBlank 設定。
在 tick() 裡是這樣檢查的:
if self.scanline == timing::VBLANK_LINE && self.dot == timing::EVENT_DOT {
if self.suppress_vbl {
self.suppress_vbl = false; // 消費 flag,不設 VBlank
} else {
self.status |= status::VBLANK;
self.nmi_occurred = true;
self.update_nmi(true);
}
}
4. NMI Edge Detection:不是 Level-Triggered
6502 的 NMI 是 edge-triggered,不是 level-triggered——只有在 /NMI 線從高到低的瞬間才會觸發,持續為低不會重複觸發。這在模擬器裡代表你必須追蹤 NMI 線的前後狀態:
fn update_nmi(&mut self, from_tick: bool) {
let active = self.nmi_output && self.nmi_occurred;
if active && !self.nmi_line {
// Rising edge: NMI 線剛從 inactive → active
self.nmi_pending = true;
self.nmi_pending_age = 0;
} else if !active && self.nmi_line {
// Falling edge: 只有 tick 產生的才能取消 pending NMI
if from_tick && self.nmi_write_delay == 0 {
self.nmi_pending = false;
}
}
self.nmi_line = active;
}
這裡的 from_tick 參數區分了兩種 NMI 狀態變化的來源:
- PPU tick 產生的(如 pre-render 清 VBlank):可以取消 pending NMI
- 暫存器寫入產生的(如
$2000 關閉 NMI enable):不能取消已經被 CPU latch 的 NMI
CPU 何時「看到」NMI?take_nmi() 有一個 age-based 的機制:
pub fn take_nmi(&mut self) -> bool {
if self.nmi_write_delay > 0 {
self.nmi_write_delay -= 1;
if self.nmi_write_delay == 0 && self.nmi_pending {
self.nmi_pending = false;
return true;
}
return false;
}
// NMI 必須存在夠久(≥3 PPU ticks),CPU 才能在
// penultimate cycle 偵測到
if self.nmi_pending && self.nmi_pending_age >= 3 {
self.nmi_pending = false;
return true;
}
false
}
3 PPU ticks = 1 CPU cycle——剛好是 CPU 在指令的倒數第二個 cycle 做 NMI polling 的時間。如果 NMI 在最後一刻被設定又馬上被取消(不到 3 PPU ticks),CPU 就「來不及看到」。
5. 背景渲染管線
PPU 每 8 個 dot 取一個 tile 的資料,透過一連串精確時序的 fetch:
if (self.dot >= 1 && self.dot <= 256) || (self.dot >= 321 && self.dot <= 336) {
self.update_bg_shifters();
match self.dot % 8 {
1 => self.fetch_nametable_byte(), // Tile ID
3 => self.fetch_attribute_byte(), // 調色盤 bits
5 => self.fetch_pattern_lo(), // Pattern bitplane 0
7 => self.fetch_pattern_hi(), // Pattern bitplane 1
0 => self.load_bg_shifters(), // 載入 16-bit shift register
_ => {}
}
}
每個像素從 16-bit shift register 取出,用 fine_x 選擇位移量實現 pixel-level 捲動:
fn render_pixel(&mut self) {
let x = (self.dot - 1) as usize;
let y = self.scanline as usize;
let (bg_pixel, bg_palette) = if self.mask & mask::SHOW_BG != 0 {
let bit_select = 15 - self.fine_x as u16;
let lo = (self.bg_pattern_lo >> bit_select) & 1;
let hi = (self.bg_pattern_hi >> bit_select) & 1;
let pixel = ((hi << 1) | lo) as u8;
let attr_lo = (self.bg_attr_lo >> bit_select) & 1;
let attr_hi = (self.bg_attr_hi >> bit_select) & 1;
let palette = ((attr_hi << 1) | attr_lo) as u8;
(pixel, palette)
} else {
(0, 0)
};
// Sprite compositing + priority multiplexer...
}
16-bit shift register 同時持有兩個 tile 的資料,每個 dot 左移一位。這讓 PPU 能在不同 tile 邊界之間平滑捲動——fine_x 決定從哪一個 bit 開始取。
NES PPU 的捲動機制用了一對 15-bit 暫存器 v(current)和 t(temporary),被稱為 Loopy registers:
yyy NN YYYYY XXXXX
│││ ││ │││││ │││││
│││ ││ │││││ └────── Coarse X (0-31) tile column
│││ ││ └─────────── Coarse Y (0-29) tile row
│││ └────────────── Nametable select (0-3)
└────────────────── Fine Y (0-7) pixel row within tile
捲動邏輯分散在渲染的不同時間點:
// Dot 256: 增加 fine Y → coarse Y(換到下一行)
if self.dot == 256 {
self.increment_scroll_y();
}
// Dot 257: 從 t 複製水平捲動位元到 v
if self.dot == 257 {
self.copy_horizontal_bits();
}
// Pre-render scanline, dots 280-304: 從 t 複製垂直捲動位元到 v
if prerender && self.dot >= 280 && self.dot <= 304 {
self.copy_vertical_bits();
}
increment_scroll_y() 特別有趣——Y 到 29 就 wrap 到 0 並切換 nametable(因為 NES 的 nametable 是 30 rows × 32 columns),但 Y=31 只是 wrap 到 0 而不切換(允許遊戲利用這個行為做特殊效果)。
7. Sprite 系統
PPU 支援 64 個 sprite,每個 4 bytes(Y, tile index, attributes, X)。每條 scanline 最多顯示 8 個:
fn evaluate_sprites(&mut self) {
let sprite_height: i16 = if self.ctrl & ctrl::SPRITE_SIZE != 0 { 16 } else { 8 };
self.sprite_count = 0;
self.sprite_zero_on_line = false;
for i in 0..64 {
let y = self.oam[i * 4] as i16;
let diff = self.scanline - y;
if diff < 0 || diff >= sprite_height { continue; }
if self.sprite_count >= 8 {
self.status |= status::SPRITE_OVERFLOW;
break;
}
// 載入 tile pattern,處理垂直/水平翻轉
let flip_v = attr & 0x80 != 0;
let flip_h = attr & 0x40 != 0;
// ...取 pattern bitplane、reverse_bits() 做水平翻轉
if i == 0 { self.sprite_zero_on_line = true; }
self.sprite_count += 1;
}
}
Sprite 0 Hit 是很多遊戲用來做 split-screen 的技巧——當 sprite 0 的非透明像素和背景的非透明像素重疊時設定 flag,遊戲可以 poll 這個 flag 來判斷「畫到哪一行了」。
渲染時的 priority multiplexer 決定最終顏色:
| Background |
Sprite |
Priority bit |
顯示 |
| 透明 |
透明 |
- |
背景色 |
| 透明 |
非透明 |
- |
Sprite |
| 非透明 |
透明 |
- |
Background |
| 非透明 |
非透明 |
0(前景) |
Sprite |
| 非透明 |
非透明 |
1(後景) |
Background |
8. blargg 測試套件
blargg 的 ppu_vbl_nmi 測試是一組 10 個 ROM,每個測試針對不同的 VBlank/NMI 邊界行為。測試用一個簡單的記憶體協定回報結果:
$6000 = status: $80=running, $00=pass, $01+=failure code
$6001-03 = signature: $DE $B0 $61 (表示結果有效)
$6004+ = null-terminated 結果訊息
loop {
total_cycles += sys.step();
let sig0 = sys.cpu.bus.read(0x6001);
let sig1 = sys.cpu.bus.read(0x6002);
let sig2 = sys.cpu.bus.read(0x6003);
if sig0 == 0xDE && sig1 == 0xB0 && sig2 == 0x61 {
let status = sys.cpu.bus.read(0x6000);
if status != 0x80 {
let message = read_result_message(&mut sys);
if status == 0x00 {
println!("PASS");
} else {
println!("FAIL: {}", message);
}
}
}
}
10 個測試覆蓋的場景:
| 測試 |
驗證內容 |
| 01 |
基本 VBlank 時序 |
| 02 |
$2002 讀取抑制 VBlank |
| 03 |
基本 NMI 時序 |
| 04 |
NMI + 抑制時序 |
| 05 |
NMI 狀態轉換精確偏移 |
| 06 |
完整 VBlank 抑制 pattern |
| 07 |
VBlank 期間啟用 NMI 的時序 |
| 08 |
VBlank 期間關閉 NMI 的時序 |
| 09 |
奇偶幀基本行為 |
| 10 |
Pre-render 結束時的奇偶幀時序 |
9. Odd-Frame Skip
NES PPU 有一個小巧的優化:在奇數幀,pre-render scanline 會跳過一個 dot,讓畫面更新稍微提前。
if self.scanline == timing::PRERENDER_LINE
&& self.dot == timing::ODD_SKIP_DOT // dot 339
&& self.odd_frame
&& self.rendering_enabled()
{
self.dot = timing::LAST_DOT; // 跳到 340
// 下一個 tick 會增到 341,觸發 scanline wrap
}
這個行為只在 rendering enabled 時發生。實際效果是奇數幀比偶數幀少一個 PPU cycle(89,341 vs 89,342),這讓 NTSC 的畫面更新率精確到 60.0988 Hz。
10. 即時渲染 Demo
專案包含一個用 minifb 寫的即時渲染器,可以實際載入 NES ROM 看到畫面:
let mut sys = System::from_rom(rom)?;
window.set_target_fps(60);
while window.is_open() && !window.is_key_down(Key::Escape) {
sys.run_until_frame();
let ppu = sys.ppu.borrow();
window.update_with_buffer(&*ppu.framebuffer, WIDTH, HEIGHT)?;
}
run_until_frame() 反覆呼叫 step() 直到 PPU 設定 frame_ready(VBlank 開始),然後把 framebuffer 丟給視窗。256×240 的 NES 畫面用 3 倍 scale 顯示。
總結
| 模組 |
行數 |
角色 |
核心 Pattern |
ppu.rs |
~1056 |
PPU 核心:暫存器、渲染、NMI |
tick() state machine + edge detection |
bus_io.rs |
~80 |
CPU↔PPU 橋接 |
Split catch-up for $2002 race |
system.rs |
~156 |
CPU/PPU 協調器 |
Shared Rc<Cell<u64>> cycle counters |
main.rs |
~97 |
blargg 測試 harness |
Memory-mapped test protocol |
demos/render |
~55 |
即時渲染視窗 |
run_until_frame() + minifb |
PPU 模擬的核心挑戰不在渲染演算法(那部分其實很直覺),而在 CPU/PPU 同步的精確時序。VBlank 抑制、NMI edge detection、age-based cancellation——這些都是真實硬體上 CPU 和 PPU 之間電氣信號傳播時間造成的行為,要在軟體裡精確重現這些 race condition,比畫像素難多了。
參考資源
用 Rust 寫一顆 6502 CPU:Cycle-Accurate NES 模擬器
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() 函式——每呼叫一次,執行一條指令:
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 同步。那才是真正刺激的部分。
參考資源
用 Iced 打造漫畫閱讀器:Arc<dyn Trait>、LRU 快取與非同步預載
最近用 Iced 實作了一個漫畫閱讀器——能打開 CBZ、CB7 格式的漫畫壓縮檔,支援鍵盤翻頁,還有背景預載機制讓翻頁幾乎感受不到延遲。
這篇文章聚焦在幾個有趣的設計決策:trait object 搭配 Arc 的多格式抽象、以「距離」驅逐的 LRU 快取、以及 smol::unblock 把同步 I/O 推進執行緒池的技巧。
專案概述
comic-viewer 支援的功能:
- 開啟 CBZ(ZIP)、CB7(7-Zip)格式漫畫檔
- CBR(RAR)格式顯示友善的錯誤訊息(需外部函式庫,目前為 stub)
- 鍵盤翻頁(方向鍵、PageUp/PageDown)
- 頁面快取(最多 7 頁),快取命中時翻頁瞬間完成
- 背景預載:目前頁面前後各 2 頁
- Tokyo Night Storm 主題的深色 UI
專案結構很清楚:
comic-viewer/
├── Cargo.toml
└── src/
├── main.rs # Iced 應用程式主體(App、Message、PageCache)
└── reader/
├── mod.rs # ComicReader trait + open() 工廠函式
├── cbz.rs # ZIP 格式實作
├── cb7.rs # 7-Zip 格式實作
└── cbr.rs # RAR stub
1. Arc<dyn ComicReader>:多格式的 Trait Object 抽象
不同壓縮格式的解壓縮方式差異很大,但對應用程式來說只需要「給我第 N 頁的圖片」。ComicReader trait 完美地封裝這個差異:
pub trait ComicReader: Send + Sync + std::fmt::Debug {
fn title(&self) -> &str;
fn page_count(&self) -> usize;
fn extract_page(&self, index: usize) -> Result<iced::widget::image::Handle, String>;
}
幾個設計細節值得注意:
Send + Sync:因為 App.comic 是 Option<Arc<dyn ComicReader>>,而 Arc<T> 要求 T: Send + Sync 才能跨執行緒共享。背景預載任務會 clone 這個 Arc 並在 smol 的執行緒池跑,所以必須是 Sync
&self 而非 &mut self:extract_page 刻意設計成不可變借用,這樣就能從多個執行緒同時呼叫,不需要加鎖
std::fmt::Debug:讓整個 App struct 可以 derive Debug(Iced 有時會要求)
工廠函式用副檔名做格式分派:
pub fn open(path: &Path) -> Result<Box<dyn ComicReader>, String> {
match path
.extension()
.and_then(|e| e.to_str())
.map(str::to_lowercase)
.as_deref()
{
Some("cbz") => cbz::CbzReader::open(path).map(|r| Box::new(r) as Box<dyn ComicReader>),
Some("cbr") => cbr::CbrReader::open(path).map(|r| Box::new(r) as Box<dyn ComicReader>),
Some("cb7") => cb7::Cb7Reader::open(path).map(|r| Box::new(r) as Box<dyn ComicReader>),
ext => Err(format!("Unsupported format: {}", ext.unwrap_or("none"))),
}
}
呼叫端接到 Box<dyn ComicReader> 後立刻包進 Arc::from(box),讓後續可以廉價 clone 給多個背景任務:
Task::perform(
async move { smol::unblock(move || reader::open(&path).map(Arc::from)).await },
Message::ComicLoaded,
)
Arc::from(box) 把 Box<dyn ComicReader> 轉成 Arc<dyn ComicReader>——這個轉換是零成本的,只是改變了「誰擁有這塊記憶體的所有權計數器」。
2. CBZ 與 CB7 的不同解壓策略
ZIP 和 7-Zip 的壓縮原理不同,導致兩種 reader 的實作策略截然不同。
CBZ(ZIP):隨機存取,按需解壓
ZIP 格式的每個檔案是獨立壓縮的,可以不看前面的 entry 直接跳到任意 entry 開始解壓。CbzReader 利用這個特性,只在記憶體中保留整份 .cbz 的原始位元組,每次 extract_page 再用 Cursor 包起來重新開一個 ZipArchive:
#[derive(Debug)]
pub struct CbzReader {
title: String,
pages: Vec<String>, // 排序後的圖片檔名列表
archive_bytes: Vec<u8>, // 整份 .cbz 的原始位元組
}
impl ComicReader for CbzReader {
fn extract_page(&self, index: usize) -> Result<image::Handle, String> {
let filename = self.pages.get(index)
.ok_or_else(|| format!("Page index {index} out of bounds"))?;
let cursor = std::io::Cursor::new(&self.archive_bytes);
let mut archive = zip::ZipArchive::new(cursor).map_err(|e| e.to_string())?;
let mut file = archive.by_name(filename).map_err(|e| e.to_string())?;
let mut bytes = Vec::new();
file.read_to_end(&mut bytes).map_err(|e| e.to_string())?;
Ok(image::Handle::from_bytes(bytes))
}
}
每次 extract_page 都重新建立 ZipArchive,看似浪費,但因為 ZipArchive::new 只是 parse ZIP 的 central directory(在檔案末尾,很快),解壓單頁的成本主要在 inflate,整體還是很快的。
CB7(7-Zip):區塊壓縮,開檔時全部解壓
7-Zip 使用 LZMA/LZMA2 區塊壓縮,整個壓縮包的資料是相互依賴的,沒辦法隨機存取單一 entry。Cb7Reader 乾脆在 open() 時一次解壓全部圖片,把位元組存進記憶體:
#[derive(Debug)]
pub struct Cb7Reader {
title: String,
pages: Vec<Vec<u8>>, // 每頁的原始圖片位元組,開檔時全部解壓好
}
impl Cb7Reader {
pub fn open(path: &Path) -> Result<Self, String> {
let mut reader = SevenZReader::open(path, Password::empty())
.map_err(|e| e.to_string())?;
let mut entries: Vec<(String, Vec<u8>)> = Vec::new();
reader.for_each_entries(|entry, reader| {
if entry.is_directory() || !super::is_image_file(entry.name()) {
return Ok(true); // 跳過,繼續
}
let mut bytes = Vec::new();
reader.read_to_end(&mut bytes).map_err(sevenz_rust::Error::from)?;
entries.push((entry.name().to_owned(), bytes));
Ok(true)
}).map_err(|e| e.to_string())?;
entries.sort_by(|(a, _), (b, _)| a.cmp(b));
// ...
}
}
開檔比 CBZ 慢(要解壓全部內容),但 extract_page 就只是 .clone() 一個 Vec<u8>,非常快。
這是一個典型的「把開銷前移」設計:把慢的動作(解壓縮)移到啟動時,換取後續每次存取都接近零成本。
CBR(RAR):誠實的 Stub
RAR 格式需要 unrar native library,不是純 Rust 可以輕鬆搞定的。CbrReader 選擇一個直白但很務實的做法:
impl CbrReader {
pub fn open(_path: &Path) -> Result<Self, String> {
Err(
"CBR (RAR) format is not yet supported. \
Consider converting to CBZ using Calibre or 7-Zip."
.to_string(),
)
}
}
在 open() 時就立刻回傳清楚的錯誤和建議,不讓使用者走到一半才踩雷。這樣的 stub 還有個好處:整個程式的型別系統是完整的,未來只需要填充 CbrReader::open 的實作就好,其餘不用改。
3. 以「距離」驅逐的 LRU 快取
傳統 LRU 快取驅逐最久未使用的項目,但對漫畫閱讀器來說「最久未使用」不是最好的策略——你更想保留目前頁面周圍的頁面,而不是剛才看完的頁面。
PageCache 改用「距離目前頁面最遠的項目優先驅逐」:
const CACHE_CAPACITY: usize = 7;
struct PageCache {
entries: HashMap<usize, image::Handle>,
}
impl PageCache {
fn insert(&mut self, index: usize, handle: image::Handle, current_page: usize) {
if !self.entries.contains_key(&index) && self.entries.len() >= CACHE_CAPACITY {
// 找距離 current_page 最遠的 key
let evict = *self
.entries
.keys()
.max_by_key(|&&k| k.abs_diff(current_page))
.expect("cache is non-empty");
self.entries.remove(&evict);
}
self.entries.insert(index, handle);
}
}
usize::abs_diff 計算兩個 usize 的絕對差值,不需要轉成有號整數,在 Rust 1.60 加入標準函式庫。
有一個借用技巧值得注意:
// usize is Copy,所以可以在可變借用前先把 key 複製出來
let evict = *self
.entries
.keys()
.max_by_key(|&&k| k.abs_diff(current_page))
.expect("cache is non-empty");
self.entries.remove(&evict); // 這裡才開始可變借用
如果寫成 let evict_ref = self.entries.keys().max_by_key(...) 然後直接傳給 remove,借用檢查器會拒絕——因為 keys() 返回的 iterator 持有對 self.entries 的不可變借用,而 remove 需要可變借用。提前 * 解引用並把 usize(Copy 型別)複製出來,借用就在那一行結束了。
為什麼 capacity 選 7?前後各 2 頁(4 頁)加上目前頁面(1 頁)= 5,多留 2 頁緩衝,讓快速翻頁時舊頁面還在快取裡。
持有 image::Handle 的好處是:Iced 會把解碼後的圖片上傳到 GPU 紋理,而 Handle 是對那份紋理的引用(Arc 語義)。只要 Handle 活著,GPU 上的資料就不會被回收——快取命中時翻頁是真的零解碼。
4. Iced 的 MVU 架構與 Task 系統
Iced 採用 Elm 架構(Model-View-Update,MVU)。整個應用程式的運作流程是:
graph LR
A[用戶操作 / 事件] -->|產生| B[Message]
B -->|傳入| C[update 函式]
C -->|修改| D[App 狀態]
C -->|回傳| E[Task]
E -->|完成後產生| B
D -->|傳入| F[view 函式]
F -->|產生| G[Element UI 樹]
G -->|渲染到螢幕| A
Message enum 是所有事件的完整清單:
#[derive(Debug, Clone)]
enum Message {
OpenFile,
FileSelected(Option<PathBuf>),
ComicLoaded(Result<Arc<dyn reader::ComicReader>, String>),
NextPage,
PrevPage,
PagePreloaded(usize, Option<image::Handle>),
}
Task<Message> 是 Iced 的非同步工作單元。update 函式回傳 Task,Iced 的 runtime 會執行它,完成後把結果包成 Message 再餵回 update。這讓狀態轉換邏輯永遠是同步、純粹的,而非同步副作用完全由 Task 處理。
開檔的流程就是兩個 Task 串接:
// 第一步:呼叫原生對話框
Message::OpenFile => {
Task::perform(
async {
rfd::AsyncFileDialog::new()
.add_filter("Comic Book Archive", &["cbz", "cbr", "cb7"])
.pick_file()
.await
.map(|f| f.path().to_owned())
},
Message::FileSelected, // 完成後產生 FileSelected(Option<PathBuf>)
)
}
// 第二步:在執行緒池解壓縮並建立 Reader
Message::FileSelected(Some(path)) => Task::perform(
async move { smol::unblock(move || reader::open(&path).map(Arc::from)).await },
Message::ComicLoaded, // 完成後產生 ComicLoaded(Result<Arc<...>, String>)
),
5. smol::unblock:把同步 I/O 推進執行緒池
Iced 的 async runtime 是基於 futures-executor(smol 生態系),不是 Tokio——從 Cargo.lock 可以確認整個依賴樹裡根本沒有 tokio。但不管底層是哪套 executor,async 執行緒都不適合跑耗時的同步運算(如解壓縮)。如果直接在 async task 裡呼叫 zip::ZipArchive::new(),會阻塞整個執行緒,造成 UI 卡頓。
smol::unblock 是解法:把一個同步的閉包包起來,丟進 smol 的阻塞執行緒池(blocking thread pool)跑,並回傳一個 Future:
// 背景預載的單頁提取
let comic = Arc::clone(comic);
Task::perform(
async move {
let handle = smol::unblock(move || comic.extract_page(idx).ok()).await;
(idx, handle)
},
|(idx, handle)| Message::PagePreloaded(idx, handle),
)
smol::unblock 的原理很直觀:它維護一個獨立的執行緒池,專門用來跑阻塞工作。move || 閉包在那個執行緒上同步執行,.await 等它完成,結果就像一個普通的 Future。
這裡 Arc::clone(comic) 非常關鍵——每個預載任務需要獨立持有對 ComicReader 的引用,才能在自己的執行緒上呼叫 extract_page,而且這份 clone 是 O(1) 的引用計數遞增,不是深複製。
6. 預載窗口與 Task::batch
preload_adjacent 計算出需要預載的頁面集合,然後用 Task::batch 同時啟動所有任務:
fn preload_adjacent(&self, around: usize) -> Task<Message> {
let Some(comic) = &self.comic else { return Task::none(); };
let page_count = comic.page_count();
let start = around.saturating_sub(PRELOAD_LOOKAHEAD); // 避免 usize 下溢
let end = (around + PRELOAD_LOOKAHEAD).min(page_count - 1);
let candidates: Vec<usize> = (start..=end)
.filter(|&i| i != around && !self.page_cache.contains(i))
.collect();
if candidates.is_empty() {
return Task::none();
}
let tasks: Vec<Task<Message>> = candidates
.into_iter()
.map(|idx| {
let comic = Arc::clone(comic);
Task::perform(
async move {
let handle = smol::unblock(move || comic.extract_page(idx).ok()).await;
(idx, handle)
},
|(idx, handle)| Message::PagePreloaded(idx, handle),
)
})
.collect();
Task::batch(tasks) // 並行啟動所有預載任務
}
saturating_sub 是個小細節:around 是 usize,如果 around < PRELOAD_LOOKAHEAD 直接相減會 panic(或在 debug 模式 panic,release 模式則 wrap around)。saturating_sub 讓結果最低為 0,不需要額外的 if around >= PRELOAD_LOOKAHEAD 判斷。
Task::batch 把多個 Task 打包成一個,Iced 會並行執行它們。這意味著如果需要預載頁面 4 和頁面 6,兩個解壓縮任務會同時在執行緒池跑,而不是串行。
7. on_press_maybe:條件式按鈕啟用
Iced 提供了一個很方便的 API 來處理「條件式可按」的按鈕:
let can_prev = self.current_page > 0;
let can_next = self
.comic
.as_ref()
.is_some_and(|c| self.current_page + 1 < c.page_count());
let prev_btn = button(text("◄ Previous").size(14))
.on_press_maybe((has_comic && can_prev).then_some(Message::PrevPage));
let next_btn = button(text("Next ►").size(14))
.on_press_maybe((has_comic && can_next).then_some(Message::NextPage));
on_press_maybe 接受 Option<Message>:Some(msg) 代表按鈕可按,None 代表禁用。搭配 bool::then_some(true 回傳 Some(value),false 回傳 None),可以非常簡潔地表達「有符合條件才能按」。
禁用狀態的視覺樣式也透過 nav_button_style 函式裡的 button::Status::Disabled 分支處理:
fn nav_button_style(_theme: &Theme, status: button::Status) -> button::Style {
let bg = match status {
button::Status::Hovered | button::Status::Pressed => iced::color!(0x565f89),
button::Status::Disabled => iced::color!(0x292e42), // 暗色,表示禁用
_ => iced::color!(0x414868),
};
let text_color = match status {
button::Status::Disabled => iced::color!(0x3d4168), // 文字也變暗
_ => iced::color!(0xc0caf5),
};
// ...
}
8. Subscription:鍵盤事件的全域監聽
Subscription 是 Iced 中持續監聽某個事件來源的機制,不同於 Task(一次性任務),Subscription 會在應用程式整個生命週期都保持活躍。
鍵盤翻頁用 iced::keyboard::on_key_press 建立訂閱:
fn subscription(&self) -> Subscription<Message> {
iced::keyboard::on_key_press(|key, _modifiers| match key.as_ref() {
iced::keyboard::Key::Named(Named::ArrowRight)
| iced::keyboard::Key::Named(Named::PageDown) => Some(Message::NextPage),
iced::keyboard::Key::Named(Named::ArrowLeft)
| iced::keyboard::Key::Named(Named::PageUp) => Some(Message::PrevPage),
_ => None,
})
}
閉包接受按鍵和修飾鍵,回傳 Option<Message>:Some(msg) 代表這個按鍵要觸發對應的 message,None 代表忽略。這讓翻頁鍵(方向鍵 + PageUp/PageDown)不需要點擊按鈕就能使用,閱讀體驗更自然。
key.as_ref() 是必要的——iced::keyboard::Key 包含 owned 的字元資料,.as_ref() 轉成借用版本才能做 pattern matching 而不移動所有權。
總結
| 概念 |
在專案中的應用 |
Arc<dyn Trait> |
ComicReader 跨執行緒共享,零成本 clone 給預載任務 |
Send + Sync bound |
確保 trait object 可安全跨執行緒使用 |
&self on trait method |
允許多執行緒並發呼叫 extract_page,不需鎖 |
| 按需解壓 vs 全量預載 |
ZIP 隨機存取 vs 7-Zip 區塊壓縮,各自最佳策略 |
| 距離驅逐快取 |
比傳統 LRU 更適合線性翻頁場景 |
abs_diff + Copy 借用技巧 |
規避借用檢查器限制的慣用寫法 |
Task + Task::batch |
Iced 的非同步副作用模型,並行預載 |
smol::unblock |
把同步阻塞 I/O 推進獨立執行緒池 |
on_press_maybe |
條件式按鈕啟用,搭配 then_some 很簡潔 |
Subscription |
全域鍵盤事件監聽,生命週期同整個應用程式 |
漫畫閱讀器是一個很好的 Rust GUI 練習專案:需要處理多種檔案格式(trait object 的最佳用武之地)、需要快取和預載(資料結構設計)、需要在不卡 UI 的前提下做 I/O(非同步設計)。如果你正在學 Iced 的 MVU 架構,這個專案的規模剛好——不會太小而沒有學習點,也不會大到難以消化。
參考資源