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 的像素画面。
我把它拆成几个阶段攻克:
- PPU 寄存器。实现
$2000-$2007的读写接口,包括 Loopy 地址系统,那套精巧的t、x、w寄存器控制着像素级的滚动偏移 - 背景渲染。逐像素解码 nametable → attribute table → pattern table → palette,再像织布一样一针一线地写入 RGB 帧缓冲
- 精灵渲染。从 OAM 解码 64 个精灵,处理水平/垂直翻转、优先级判断(精灵可以藏在背景后面)和精灵调色板
- 帧率控制。通过
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 / S | A / B |
| Enter | Start |
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 等划时代的产品。能用现代语言重新演绎这颗芯片,算是一种跨越时空的浪漫。至少我是这么觉得的。
