上一篇我們寫了 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>>,
// ...
}每一步的流程:
補跑剩餘 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 開始取。
6. Loopy Scroll Register
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,比畫像素難多了。
參考資源
- nesdev.org PPU Wiki — PPU 的完整技術文件
- PPU Scrolling (Loopy) — Loopy register 的詳細解說
- blargg’s PPU Tests — ppu_vbl_nmi 測試套件
- PPU Rendering — 渲染管線時序圖