Hesper:用 Rust 构建周期精确的 NMOS 6502 CPU
这是一篇关于 Hesper 项目中 NMOS 6502 CPU 核心实现的深度技术分析。Hesper 用纯 Rust 实现了一颗周期精确(cycle-accurate)的 6502 处理器,覆盖全部 151 个官方 opcode,并通过超过 150 万条外部测试用例和 665 组引脚级对照验证。本文将剖析它的架构理念、核心状态机、优雅的类型设计,以及那些让人会心一笑的工程决策。
为什么要做一颗“周期精确“的 6502?
大多数模拟器满足于指令精确(instruction-accurate):执行一条指令,更新寄存器,报告总周期数。这对运行软件已经足够。但 6502 时代的硬件依赖每个时钟周期的总线活动——读写地址、数据方向、dummy read 的副作用——来驱动外部设备。Apple I 的键盘握手、Apple II 的磁盘控制器、NES 的 PPU 都需要看到 CPU 每个周期在总线上做了什么。
Hesper 的答案是:一个时序阶段(phase)等于一次真实的总线访问。没有投机读取,没有日志专用的额外访问。宿主看到的 BusCycle 就是 CPU 对内存/设备做的那次真实操作。
架构鸟瞰
CLI / Apple I host
│
▼
┌──────────┐
│ Bus │ ← 宿主拥有内存和设备
│ (trait) │
└────┬─────┘
│ &mut dyn Bus
▼
┌──────────┐
│ Cpu │ ← 只拥有寄存器和执行状态
│ │
│ Registers │ ← A, X, Y, SP, PC, Status
│ Execution │ ← 可选的微状态(正在执行的指令)
│ Pin state │ ← IRQ, NMI, RESET, RDY, SO
└──────────┘
│
▼
┌──────────────┐
│ cycle engine │ ← 31 种阶段的状态机
│ (cycle.rs) │
└──────────────┘
│
▼
┌──────────┐
│ decoder │ ← 151 个 opcode 的显式匹配表
└──────────┘依赖方向是单向的:宿主 → CPU → Bus。CPU 不拥有内存,不拥有设备,不做日志,不维护历史。它接收一个 &mut dyn Bus,在上面做一次读或写,把结果作为值返回。宿主决定怎么处理这些信息。
Bus:两个方法的特质
整个设备交互就是这两个方法。&mut self 是刻意的:读取内存映射的设备寄存器会改变设备状态(比如清除中断标志、消费键盘输入)。CPU 的 dummy read——那些地址正确但结果被丢弃的读取——同样会穿过这个接口,触发真实的副作用。这是正确模拟 6502 的关键。
Hesper 附带了一个最简单的 Bus 实现——64 KiB 的平坦 RAM:
Ram::load 方法实现了事务性加载:先检查是否越界,失败时不改变任何字节。这个 usize::from(addr) 是唯一一处 u16 → usize 的转换,发生在 RAM 数组边界上,而不是散落在 CPU 逻辑中。
硬件宽度的类型系统
Hesper 对类型有严格的硬件对应关系:
| 硬件概念 | Rust 类型 | 理由 |
|---|---|---|
| 寄存器 A/X/Y/SP | u8 | 8 位寄存器 |
| 地址 / PC | u16 | 16 位地址总线 |
| 分支偏移 | i8 → i16 | 有符号 8 位,扩展到 16 位做地址运算 |
| 周期/步数预算 | u64 | 宿主侧的大范围计数 |
| 数据总线 | u8 | 8 位数据总线 |
所有硬件溢出都用 wrapping_add / wrapping_sub 显式表达,绝不依赖 Rust 的默认溢出行为。栈地址是 0x0100 | u16::from(sp),零页回绕是 u8 的自然溢出,16 位地址回绕是 u16::wrapping_add。这不是防御性编程——这是在用类型系统精确描述硬件行为。
状态处理器标志:不存储 B
注意:没有 B 标志。6502 的 “B 标志” 不是一个真正存在的硬件寄存器位——它只在压栈时被合成。BRK 压栈时 B=1,硬件中断压栈时 B=0,但从栈恢复时 bit 4 和 bit 5 都被忽略。Hesper 通过不存储它来精确表达这个硬件事实:
31 个阶段的状态机
这是 Hesper 最核心的设计。CPU 的执行不是“一条指令一个函数“,而是一个拥有 31 种阶段(Phase)的状态机:
每个阶段对应一次真实的总线操作。寻址模式决定阶段转移路径,语义操作(加法、比较、位移……)在适当的阶段触发。这种分离意味着:
- 寻址是正交的:
LDA零页和LDA绝对索引共享同一个read_operation,但走不同的阶段路径。 - 时序是自然涌现的:不需要手动计算周期数——阶段转移的数量就是周期数。
- dummy read 是真实的:零页索引的那次“没用的“读取确实发生在总线上,穿过
Bus::read,触发设备副作用。
阶段转移:以绝对索引寻址为例
Fetch → Low → High → Indexed → Memory
│
├── 未跨页 + 读操作:在 Indexed 完成
└── 跨页或写/RMW:继续到 Memory这自然产生了 6502 著名的“跨页+1 周期“行为:如果索引加法没有进位到高字节,Indexed 阶段读到的就是正确数据,可以直接完成。否则需要额外一个周期在 Memory 阶段用正确的高字节重读。
Indexed => 中断的优雅统一
BRK 指令和硬件中断(IRQ、NMI、RESET)在 6502 内部共享同一条微代码路径。Hesper 直接体现了这一点:
// 中断入口复用 BRK 的操作码微代码
new区别在于 StepKind:
这意味着 BRK 的压栈、向量读取、I 标志设置逻辑只写一次,中断入口自然复用。唯一的差异在压栈阶段:
// BRK 的状态字带 B=1,硬件中断带 B=0
self.registers.status.bits
| if matches! else RESET 更巧妙——它走相同的三次“压栈“阶段,但把写变成了读:
let access = if e.kind == Reset else ;这精确还原了真实硬件行为:RESET 期间 R/W 线保持高电平(读),但地址总线上确实出现了三个栈地址。
NMI 抢占向量
6502 有一个著名的微妙行为:如果 NMI 在 BRK/IRQ 的向量读取之前到达,CPU 会改用 NMI 向量。Hesper 在向量选择时自然地表达了这一点:
e.address = if e.kind == Reset else if e.kind == Nmi || self.nmi_edge else ;拉取式观察 vs. 回调注入
很多模拟器用回调注册观察者。Hesper 选择了拉取式(pull-based):每个 cycle() 调用返回一个不可变的 Cycle 值,宿主决定如何使用它。
debug_state() 提供无副作用的快照——它不读总线、不改状态、不分配内存:
这意味着诊断永远不会干扰模拟——不会产生额外的总线访问,不会改变 memory-mapped 设备的状态。
三层驱动接口
宿主可以选择精度:
// 1. 指令级:最简单
let step = cpu.step?;
// 2. 周期级:看到每次总线活动
let cycle = cpu.cycle?;
if let Some = cycle.completed
// 3. 半周期级:在 phi1 和 phi2 之间注入引脚变化
cpu.set_nmi_line; // 在两个半周期之间改变引脚
let result = cpu.half_cycle?;step 不是独立的实现——它是 cycle 的有预算循环:
一个引擎,三种粒度。不可能出现 step 和 cycle 产生不同结果的情况。
可运行示例
示例 1:执行一条 LDA 指令
示例 2:观察 RMW 指令的双重写入
Read-Modify-Write(RMW)指令如 INC 会在同一地址先写回旧值,再写入新值。这对于 memory-mapped 设备来说是可观察的副作用。
示例 3:跨页分支的额外周期
示例 4:NMOS 十进制模式的诡异标志
NMOS 6502 的 BCD 算术有著名的标志位怪异行为:N 和 V 来自中间二进制结果,Z 来自纯二进制和。这不是 bug,是硅片行为。
示例 5:完整的复位与执行循环
示例 6:用 cycle() 观察 JSR 的六个周期
JSR 是 6502 最复杂的指令之一——它需要读取目标地址、把返回地址压栈、跳转。观察它的逐周期行为可以看到 6502 微架构的精妙细节。
物理 RESET:不是 begin_reset 的马甲
Hesper 区分两种复位:
- 宿主复位
begin_reset():立即丢弃执行状态,启动七周期入口。这是测试和初始化用的简便 API。 - 物理 RESET
set_reset_line(true/false):模拟实际的 RESET 引脚。信号在 phi2 下降沿采样,一个周期后生效。断言不会立即清除执行状态——已经开始的总线传送会继续,只是写入被抑制为读取。释放后 CPU 从$FFFC/$FFFD读取新 PC。
物理 RESET 的实现需要保留大量内部状态——数据锁存、ALU 输入、进行中的栈传送——因为 RESET 保持期间,6502 的内部总线仍在运作,产生数据依赖的暂态地址。这些暂态被 Visual6502 的晶体管级模拟所观察到,Hesper 的 419 组 RESET 对照全部通过。
零运行时依赖
hesper-cpu6502 的 Cargo.toml 中没有 [dependencies]:
- 没有
serde(序列化只在测试中使用) - 没有日志库
- 没有内存分配器
- 没有异步运行时
- 编译到
wasm32-unknown-unknown不需要任何适配
CPU 库是纯计算。它取一个 &mut dyn Bus,推进状态,返回值。这使它可以嵌入任何宿主——CLI、浏览器、游戏引擎、嵌入式系统。
指令解码器:151 行显式匹配
pub 没有位模式解码器,没有宏生成的表,没有按 aaa-bbb-cc 拆分的通用解码矩阵。151 个 match arm,一目了然。对应的 105 个未定义字节返回 None,CPU 在取指时将其转为 CpuError::UnsupportedOpcode——不会静默当作 NOP,不会 panic。
这种“笨办法“的好处是审计性:任何人都可以逐行核对每个 opcode 的操作、寻址方式和基础周期数,不需要理解解码算法。
验证:证据驱动,不是“看起来对“
Hesper 的验证体系值得单独一段:
| 验证层 | 规模 | 比较内容 |
|---|---|---|
| SingleStepTests | 1,510,000 条 | 寄存器、内存、周期数、逐周期总线序列 |
| Klaus functional | 30M+ 指令 | 到达成功地址 $3469 |
| Bruce Clark decimal | 17M+ 指令 | 全部 A/N/V/Z/C 标志 |
| Klaus interrupt | 1,049 步 | IRQ/NMI/BRK 顺序计数 |
| Visual6502 pins | 246 组 | 逐周期地址/数据/RW/SYNC |
| Visual6502 RESET | 419 组 | 64 周期的物理 RESET 行为 |
穷举测试中有一个有趣的细节:测试不仅检查最终结果,还验证了过程——每个周期的总线地址、数据和方向都与参考实现一致。这意味着 dummy read、RMW 的双写、栈操作的地址顺序都被覆盖了。
设计哲学总结
| 原则 | 实践 |
|---|---|
| CPU 不拥有副作用 | 不做日志、不持有历史、不分配内存 |
| 一相一访问 | 阶段转移 = 真实总线操作 |
| 类型即文档 | u8 = 寄存器,u16 = 地址,bool = 标志 |
| 拉取而非推送 | 返回值而非回调 |
| 显式而非推导 | 151 行 match 而非解码矩阵 |
| 复用而非重复 | BRK/IRQ/NMI/RESET 共享入口管线 |
| 故障即值 | CpuError 而非 panic |
| 可恢复 | 周期预算耗尽后可继续执行 |
Hesper 的 6502 不是最快的模拟器——它没有 JIT、没有指令批处理、没有投机执行。但它可能是最诚实的之一:每一次总线活动都真实发生,每一个时序细节都有硬件依据,每一个设计决策都直接映射到 MOS 6502 的实际行为。
当你用 cycle() 推进一次时钟,你看到的就是 1975 年那颗芯片在那个周期做的事情。
