Rust实现MOS 6502 CPU模拟器

TL;DR

从零用 Rust 实现 MOS 6502 CPU,再到完整的 NES 模拟器:两周时间,247 个 opcode,一个 8-bit 的世界。

项目地址


前言

本人对系统底层和CPU有着浓厚的兴趣,恰好看到喜欢的Youtuber Tsoding 制作了有关MOS 6502的内容,于是决定使用Rust开发这个项目,顺便感受一下Rust的零成本抽象等高级特性。

曾经饱受C++各类隐式操作和无尽特性的折磨,被迫生锈(也可能是我没有领悟到C++的真谛吧)


参考资料

开发文档

第一步是获取相关文档支持,这个过程花费了不少时间,很多网站已经无法访问,最终找到了一份完整的 Programming Manual。

开发者手册 包含了指令集和寻址方式等必要信息。

测试套件

使用 Functional Test 开源测试套件来验证模拟器的正确性。


零页(Zero Page)

6502 的内存寻址支持 0~64KB,这 64KB 被划分为 256 个页,每个页包含 256 个字节。

零页就是第一个内存页,其高位字节默认为 $00,地址范围为 $0000$00FF(十进制的 0-255)。

在绝对寻址时需要提供 16 位地址(2 个字节),而零页寻址由于高位默认为 0,只需提供 1 个低位字节即可,从而节省了内存空间。


从零开始:搭一个 CPU

一切始于一个念头:如果我能用代码复刻一颗 1975 年的芯片,那我对计算机的理解一定会更深入。

6502 只有 5 个核心寄存器:累加器 A、索引寄存器 X/Y、栈指针 SP、程序计数器 PC,外加一个塞满了标志位的状态寄存器 P。相比现代 CPU 的几百个寄存器,它简朴得几乎寒酸。但正是这种精简,让它成为学习体系结构的绝佳入口。

泛型总线:一次关键的抽象

最初的决策至今让我觉得庆幸:把 Cpu 定义为泛型 Cpu<B: Bus>。这让 CPU 核心完全不关心内存长什么样:测试时,总线是一个 64KB 的平坦数组;跑 NES 时,总线变成一套精密的地址路由系统。CPU 逻辑和总线实现彻底解耦,各自独立演进。

pub struct Cpu<B: Bus> {
    pub a: u8,
    pub x: u8,
    pub y: u8,
    pub sp: u8,
    pub pc: u16,
    pub p: u8,
    pub bus: B,
}

13 种寻址方式:内存的花式访问

6502 的 13 种寻址方式就像一套组合拳。立即寻址最直白,操作数就在指令里;零页寻址只用一个字节就能访问内存的前 256 字节;间接索引寻址 (ZP),Y 则像套娃一样先读指针再偏移。

其中最让我着迷的是 JMP 间接寻址的页边界 Bug。当间接指针跨页时(比如 $20FF),原版 NMOS 6502 会错误地从同一页的 $2000 读取高字节,而不是正确的 $2100。这不是模拟器要修的 Bug,而是必须忠实还原的硬件怪癖。很多早期游戏甚至依赖这个行为来实现特殊效果。

一个巨大的 match:256 个 opcode 的分发

pub fn step(&mut self) -> u8 {
    let opcode = self.fetch();
    match opcode {
        0xA9 => { let addr = self.imm(); self.lda(addr); 2 }
        0x85 => { let addr = self.zp();  self.sta(addr); 3 }
        // ... 247 个合法 opcode + 非法 opcode
        _ => self.illegal(opcode),
    }
}

6502 有 151 个官方 opcode,但真正让人兴奋的是那 105 个"非法"操作码。它们在硬件上并非空操作,MOS 只是从没公开过它们的行为。通过 SST 测试套件的 10,000 个随机测试用例,我们实现了 247/256 个 opcode(96.5%),剩下 9 个是"不稳定 opcode",它们的行为随 CPU 制造批次而变化,甚至同一批次的芯片都可能表现不同。

在借助LLM的情况下实现到这里,已经尽力

欢迎提交PR

测试:和 Klaus Dormann 的测试 ROM 斗智

验证 CPU 正确性的标准方式是跑 Klaus Dormann 的功能测试。这个测试 ROM 会把每条指令都过一遍,一旦发现错误就进入死循环。

调试过程中最让我头疼的是栈操作。6502 的 JSR 压入的是 PC - 1(而不是 PC),RTS 弹出后加 1。这看起来像 Bug,但查阅多个硬件参考后确认,它就是这么设计的。6502 的设计者们选择了一种反直觉但自洽的实现。


交互式调试器:Monitor

调试到后期,手动追踪寄存器状态变得不可忍受。于是我实现了一个 Monitor 模块,一个交互式的 REPL 调试器,支持单步执行、断点、内存查看、反汇编和寄存器查看。

反汇编器是一个查找表实现,支持所有 13 种寻址模式的输出格式化。断点系统用 Vec<Breakpoint> 存储,支持按地址或指令条件触发。

cargo run -- tests/roms/6502_functional_test.bin --debug

走向 NES:让芯片驱动一台游戏机

当 CPU 通过所有测试后,我迫不及待地想让它跑起一个真正的 NES 游戏。这需要把一个孤零零的 CPU 变成一台完整的 NES。

解析 iNES:游戏的 DNA

每个 .nes 文件都是一段数字时光胶囊。16 字节的文件头藏着卡带的全部秘密:魔数 NES\x1A、PRG ROM 和 CHR ROM 的容量、Mapper 编号、镜像模式……解析它就像拆开一个 80 年代的卡带盒。

PPU:把数据变成像素

NES 的 PPU(Picture Processing Unit)是整个项目最复杂的部分。它负责把 2KB 的 nametable、8KB 的 pattern table 和 32 字节的调色板变成 256x240 的像素画面。

我把它拆成几个阶段攻克:

  1. PPU 寄存器。实现 $2000-$2007 的读写接口,包括 Loopy 地址系统,那套精巧的 txw 寄存器控制着像素级的滚动偏移
  2. 背景渲染。逐像素解码 nametable → attribute table → pattern table → palette,再像织布一样一针一线地写入 RGB 帧缓冲
  3. 精灵渲染。从 OAM 解码 64 个精灵,处理水平/垂直翻转、优先级判断(精灵可以藏在背景后面)和精灵调色板
  4. 帧率控制。通过 Bus::tick() 在每次 CPU 指令后推进 PPU 时钟,保持 60 FPS 的丝滑节奏

PPU 的时序是个深坑。1 个 CPU 周期 = 3 个 PPU dot,NMI 中断在 VBlank 期间触发。精灵 0 Hit 检测需要延迟到扫描线 200 才能让 Mario 的滚动更平滑,这是典型的"硬件怪癖"驱动的修复。每修一个时序 Bug,都像是在和 40 年前的硬件工程师对话。

SDL2:像素跃上屏幕

有了帧缓冲,接下来就是把它亮出来。SDL2 窗口以 3 倍缩放显示 256x240 的画面,每帧把 RGB 缓冲推送到纹理上。键盘映射让 PC 键盘变成 NES 手柄:

按键功能
方向键D-Pad
A / SA / B
EnterStart

APU:让芯片开口说话

NES 的 APU(Audio Processing Unit)包含 5 个声道:2 个 Pulse、1 个 Triangle、1 个 Noise、1 个 DMC。我实现了前 4 个的基础功能:帧计数器驱动包络和长度计数,最后通过 mixer 混合输出。虽然简单,但听到 8-bit 音效从模拟器里冒出来的时候,还是挺有成就感的。


Mapper:内存的魔术

不同的 NES 游戏使用不同的 Mapper 来扩展内存。

Mapper特点
NROM(Mapper 0)最老实,直接映射 PRG ROM 和 CHR ROM
MMC1(Mapper 1)玩起了花活:通过串行寄存器动态切换 16KB 的 bank,还能控制 nametable 的镜像模式

MMC1 的串行寄存器实现让我吃尽苦头:每次写入一位(通过 bit 7),累积 5 次后才触发一次完整的寄存器更新。CHR RAM 的写入支持也是让超级马里奥兄弟正常运行的关键。


时间线:两周的冒险

回望整个过程,从第一行代码到 Donkey Kong 在窗口里跳动,大约经历了两周:

阶段内容
第一周CPU 核心:寄存器、寻址、指令、测试,一步步把 6502 的灵魂注入代码
第二周上半NES 系统:PPU 渲染管线、NMI 中断、DMA 传输,把孤零零的 CPU 变成一台游戏机
第二周下半APU、Mapper、SDL 显示、游戏输入,让画面、声音和操控全部就位

最有成就感的时刻是第一次看到 Donkey Kong 的画面渲染出来。屏幕上那个像素化的大猩猩,让之前所有抽象的寄存器和寻址模式一下子都变得具体起来。


为什么是 Rust?

Rust 的泛型系统让 Cpu<B: Bus> 成为可能,零成本抽象意味着抽象层不会带来运行时开销。模式匹配让 opcode 分发既安全又高效。更重要的是,所有权系统让我在实现 PPU 的帧缓冲和 OAM 精灵数组时,不用时刻提防内存越界。


写在最后

模拟器开发是少数几个能让你同时深入理解计算机体系结构、硬件时序和软件行为的领域。每一个 Bug 都可能指向一个真实的硬件怪癖,每跑通一次测试,都说明我离理解这颗芯片又近了一步。

6502 虽然是 1975 年的设计,但它的精巧至今令人赞叹:仅用几千个晶体管就实现了一个完整的 8 位 CPU,驱动了 Apple II、Commodore 64 和 NES 等划时代的产品。能用现代语言重新演绎这颗芯片,算是一种跨越时空的浪漫。至少我是这么觉得的。

Tags:
recent-work

数字电路知识点记录

TL;DR 最近太无聊,重新拾起cs61c课程,开始有些痛苦的数字电路学习。 我的仓库 这个博客就用来记录一下各种硬件的引脚和电路设计等知识。 Warm up Bssic 在更高抽象的程序中,一般情况下都是串行的。 但是在硬件视角中,都是并行的。(电路很快,所以要有负反馈/回授等 …

Read more →

动态调试xv6内核,复习OS知识

TL;DR 很早之前,我就做完了xv6内核的实验,也算是入门了操作系统 xv6小而精的设计思路的确适合初学者入门这样一个复杂的计算机分支 今天经过一定时间的沉淀后,重新来看看当时没有被完全理解,还有一些被一笔带过的知识 固件代码 过去,我们只关注到了内核被加载之后的内容,每次调试 …

Read more →