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:两个方法的特质

pub trait Bus {
    /// 读取可能有副作用——这是一个 memory-mapped 设备接口。
    fn read(&mut self, addr: u16) -> u8;
    fn write(&mut self, addr: u16, value: u8);
}

整个设备交互就是这两个方法。&mut self 是刻意的:读取内存映射的设备寄存器会改变设备状态(比如清除中断标志、消费键盘输入)。CPU 的 dummy read——那些地址正确但结果被丢弃的读取——同样会穿过这个接口,触发真实的副作用。这是正确模拟 6502 的关键。

Hesper 附带了一个最简单的 Bus 实现——64 KiB 的平坦 RAM:

pub struct Ram {
    bytes: [u8; 65_536],
}

impl Bus for Ram {
    fn read(&mut self, addr: u16) -> u8 {
        self.bytes[usize::from(addr)]
    }
    fn write(&mut self, addr: u16, value: u8) {
        self.bytes[usize::from(addr)] = value;
    }
}

Ram::load 方法实现了事务性加载:先检查是否越界,失败时不改变任何字节。这个 usize::from(addr) 是唯一一处 u16usize 的转换,发生在 RAM 数组边界上,而不是散落在 CPU 逻辑中。

硬件宽度的类型系统

Hesper 对类型有严格的硬件对应关系:

硬件概念Rust 类型理由
寄存器 A/X/Y/SPu88 位寄存器
地址 / PCu1616 位地址总线
分支偏移i8i16有符号 8 位,扩展到 16 位做地址运算
周期/步数预算u64宿主侧的大范围计数
数据总线u88 位数据总线

所有硬件溢出都用 wrapping_add / wrapping_sub 显式表达,绝不依赖 Rust 的默认溢出行为。栈地址是 0x0100 | u16::from(sp),零页回绕是 u8 的自然溢出,16 位地址回绕是 u16::wrapping_add。这不是防御性编程——这是在用类型系统精确描述硬件行为。

状态处理器标志:不存储 B

pub struct Status {
    pub carry: bool,
    pub zero: bool,
    pub interrupt_disable: bool,
    pub decimal: bool,
    pub overflow: bool,
    pub negative: bool,
}

注意:没有 B 标志。6502 的 “B 标志” 不是一个真正存在的硬件寄存器位——它只在压栈时被合成。BRK 压栈时 B=1,硬件中断压栈时 B=0,但从栈恢复时 bit 4 和 bit 5 都被忽略。Hesper 通过不存储它来精确表达这个硬件事实:

impl Status {
    pub fn bits(self) -> u8 {
        u8::from(self.carry)
            | (u8::from(self.zero) << 1)
            | (u8::from(self.interrupt_disable) << 2)
            | (u8::from(self.decimal) << 3)
            | 0x20  // bit 5 始终为 1
            | (u8::from(self.overflow) << 6)
            | (u8::from(self.negative) << 7)
    }

    pub fn from_bits(bits: u8) -> Self {
        Self {
            carry: bits & 0x01 != 0,
            zero: bits & 0x02 != 0,
            interrupt_disable: bits & 0x04 != 0,
            decimal: bits & 0x08 != 0,
            // bit 4 和 bit 5 被忽略
            overflow: bits & 0x40 != 0,
            negative: bits & 0x80 != 0,
        }
    }
}

31 个阶段的状态机

这是 Hesper 最核心的设计。CPU 的执行不是“一条指令一个函数“,而是一个拥有 31 种阶段(Phase)的状态机:

pub enum Phase {
    Fetch,          // 取指(SYNC 读取)
    Implied,        // 隐含寻址的 dummy read
    Low, High,      // 操作数低/高字节
    ZeroIndex,      // 零页索引的 dummy read
    PointerLow, PointerHigh,  // 间接寻址的指针
    Indexed,        // 索引寻址的中间地址读取
    Memory,         // 最终有效地址的读/写
    RmwOld, RmwNew, // RMW 的旧值回写和新值写入
    Relative,       // 分支偏移读取
    BranchTaken, BranchCross,  // 分支成功/跨页
    StackDummyPc, StackDummy,  // 栈操作的 dummy read
    PushRegister, PullRegister, // 压栈/出栈
    ReturnLow, ReturnHigh, ReturnDummy, // RTS/RTI
    JsrDummy, JsrPushHigh, JsrPushLow, JsrHigh, // JSR
    Padding,        // BRK/中断的填充读取
    PushHigh, PushLow, PushStatus, // 中断/BRK 压栈
    VectorLow, VectorHigh, // 向量读取
    ResetHold,      // 物理 RESET 保持
}

每个阶段对应一次真实的总线操作。寻址模式决定阶段转移路径,语义操作(加法、比较、位移……)在适当的阶段触发。这种分离意味着:

  • 寻址是正交的LDA 零页和 LDA 绝对索引共享同一个 read_operation,但走不同的阶段路径。
  • 时序是自然涌现的:不需要手动计算周期数——阶段转移的数量就是周期数。
  • dummy read 是真实的:零页索引的那次“没用的“读取确实发生在总线上,穿过 Bus::read,触发设备副作用。

阶段转移:以绝对索引寻址为例

Fetch → Low → High → Indexed → Memory
                        │
                        ├── 未跨页 + 读操作:在 Indexed 完成
                        └── 跨页或写/RMW:继续到 Memory

这自然产生了 6502 著名的“跨页+1 周期“行为:如果索引加法没有进位到高字节,Indexed 阶段读到的就是正确数据,可以直接完成。否则需要额外一个周期在 Memory 阶段用正确的高字节重读。

Indexed => {
    let access = read(bus, e.intermediate());
    if !e.store() && !e.modify() && !e.crossed() {
        self.read_operation(e.op, access.data);
        done = true;
    } else {
        e.phase = Memory;
    }
    access
}

中断的优雅统一

BRK 指令和硬件中断(IRQ、NMI、RESET)在 6502 内部共享同一条微代码路径。Hesper 直接体现了这一点:

// 中断入口复用 BRK 的操作码微代码
Execution::new(kind, self.registers, Op::Brk, Mode::Imp)

区别在于 StepKind

pub enum StepKind {
    Instruction { opcode: u8 }, // 普通指令(包括 BRK)
    Irq,
    Nmi,
    Reset,
}

这意味着 BRK 的压栈、向量读取、I 标志设置逻辑只写一次,中断入口自然复用。唯一的差异在压栈阶段:

// BRK 的状态字带 B=1,硬件中断带 B=0
self.registers.status.bits()
    | if matches!(e.kind, StepKind::Instruction { .. }) {
        0x10  // B 位
    } else {
        0
    }

RESET 更巧妙——它走相同的三次“压栈“阶段,但把写变成了读:

let access = if e.kind == StepKind::Reset {
    read(bus, e.stack_address())  // RESET:读取(不写入栈)
} else {
    write(bus, e.stack_address(), value) // BRK/IRQ/NMI:写入栈
};

这精确还原了真实硬件行为:RESET 期间 R/W 线保持高电平(读),但地址总线上确实出现了三个栈地址。

NMI 抢占向量

6502 有一个著名的微妙行为:如果 NMI 在 BRK/IRQ 的向量读取之前到达,CPU 会改用 NMI 向量。Hesper 在向量选择时自然地表达了这一点:

e.address = if e.kind == StepKind::Reset {
    0xfffc
} else if e.kind == StepKind::Nmi || self.nmi_edge {
    0xfffa  // NMI 向量抢占
} else {
    0xfffe
};

拉取式观察 vs. 回调注入

很多模拟器用回调注册观察者。Hesper 选择了拉取式(pull-based):每个 cycle() 调用返回一个不可变的 Cycle 值,宿主决定如何使用它。

pub struct Cycle {
    pub bus: BusCycle,      // 这个周期的总线活动
    pub stalled: bool,       // RDY 是否阻止了推进
    pub completed: Option<Step>, // 指令/入口是否完成
}

pub struct BusCycle {
    pub address: u16,
    pub data: u8,
    pub direction: Direction,
    pub sync: bool,  // 这是一次取指吗?
}

debug_state() 提供无副作用的快照——它不读总线、不改状态、不分配内存:

pub fn debug_state(&self) -> DebugState {
    DebugState {
        registers: self.registers,
        next_clock_phase: self.next_clock_phase(),
        execution: self.execution.as_ref().map(|e| ExecutionState { ... }),
        pins: InputPins { ... },
        latches: PinLatches { ... },
        ...
    }
}

这意味着诊断永远不会干扰模拟——不会产生额外的总线访问,不会改变 memory-mapped 设备的状态。

三层驱动接口

宿主可以选择精度:

// 1. 指令级:最简单
let step = cpu.step(&mut bus)?;

// 2. 周期级:看到每次总线活动
let cycle = cpu.cycle(&mut bus)?;
if let Some(step) = cycle.completed { ... }

// 3. 半周期级:在 phi1 和 phi2 之间注入引脚变化
cpu.set_nmi_line(true);  // 在两个半周期之间改变引脚
let result = cpu.half_cycle(&mut bus)?;

step 不是独立的实现——它是 cycle 的有预算循环:

pub fn step(&mut self, bus: &mut dyn Bus) -> Result<Step, CpuError> {
    self.step_with_cycle_budget(bus, 7)
}

pub fn step_with_cycle_budget(
    &mut self, bus: &mut dyn Bus, budget: u64,
) -> Result<Step, CpuError> {
    for _ in 0..budget {
        if let Some(step) = self.cycle(bus)?.completed {
            return Ok(step);
        }
    }
    Err(CpuError::CycleBudgetExceeded { ... })
}

一个引擎,三种粒度。不可能出现 stepcycle 产生不同结果的情况。

可运行示例

示例 1:执行一条 LDA 指令

rust

示例 2:观察 RMW 指令的双重写入

Read-Modify-Write(RMW)指令如 INC 会在同一地址先写回旧值,再写入新值。这对于 memory-mapped 设备来说是可观察的副作用。

rust

示例 3:跨页分支的额外周期

rust

示例 4:NMOS 十进制模式的诡异标志

NMOS 6502 的 BCD 算术有著名的标志位怪异行为:N 和 V 来自中间二进制结果,Z 来自纯二进制和。这不是 bug,是硅片行为。

rust

示例 5:完整的复位与执行循环

rust

示例 6:用 cycle() 观察 JSR 的六个周期

JSR 是 6502 最复杂的指令之一——它需要读取目标地址、把返回地址压栈、跳转。观察它的逐周期行为可以看到 6502 微架构的精妙细节。

rust

物理 RESET:不是 begin_reset 的马甲

Hesper 区分两种复位:

  1. 宿主复位 begin_reset():立即丢弃执行状态,启动七周期入口。这是测试和初始化用的简便 API。
  2. 物理 RESET set_reset_line(true/false):模拟实际的 RESET 引脚。信号在 phi2 下降沿采样,一个周期后生效。断言不会立即清除执行状态——已经开始的总线传送会继续,只是写入被抑制为读取。释放后 CPU 从 $FFFC/$FFFD 读取新 PC。

物理 RESET 的实现需要保留大量内部状态——数据锁存、ALU 输入、进行中的栈传送——因为 RESET 保持期间,6502 的内部总线仍在运作,产生数据依赖的暂态地址。这些暂态被 Visual6502 的晶体管级模拟所观察到,Hesper 的 419 组 RESET 对照全部通过。

零运行时依赖

hesper-cpu6502Cargo.toml 中没有 [dependencies]

  • 没有 serde(序列化只在测试中使用)
  • 没有日志库
  • 没有内存分配器
  • 没有异步运行时
  • 编译到 wasm32-unknown-unknown 不需要任何适配

CPU 库是纯计算。它取一个 &mut dyn Bus,推进状态,返回值。这使它可以嵌入任何宿主——CLI、浏览器、游戏引擎、嵌入式系统。

指令解码器:151 行显式匹配

pub(crate) fn decode(opcode: u8) -> Option<(Op, Mode, u8)> {
    Some(match opcode {
        0x00 => (Brk, Imp, 7),
        0x01 => (Ora, Izx, 6),
        0x05 => (Ora, Zp,  3),
        0x06 => (Asl, Zp,  5),
        // ... 151 个官方 opcode
        0xfe => (Inc, Abx, 7),
        _ => return None,  // 105 个未定义字节 → 明确错误
    })
}

没有位模式解码器,没有宏生成的表,没有按 aaa-bbb-cc 拆分的通用解码矩阵。151 个 match arm,一目了然。对应的 105 个未定义字节返回 None,CPU 在取指时将其转为 CpuError::UnsupportedOpcode——不会静默当作 NOP,不会 panic。

这种“笨办法“的好处是审计性:任何人都可以逐行核对每个 opcode 的操作、寻址方式和基础周期数,不需要理解解码算法。

验证:证据驱动,不是“看起来对“

Hesper 的验证体系值得单独一段:

验证层规模比较内容
SingleStepTests1,510,000 条寄存器、内存、周期数、逐周期总线序列
Klaus functional30M+ 指令到达成功地址 $3469
Bruce Clark decimal17M+ 指令全部 A/N/V/Z/C 标志
Klaus interrupt1,049 步IRQ/NMI/BRK 顺序计数
Visual6502 pins246 组逐周期地址/数据/RW/SYNC
Visual6502 RESET419 组64 周期的物理 RESET 行为

穷举测试中有一个有趣的细节:测试不仅检查最终结果,还验证了过程——每个周期的总线地址、数据和方向都与参考实现一致。这意味着 dummy read、RMW 的双写、栈操作的地址顺序都被覆盖了。

设计哲学总结

原则实践
CPU 不拥有副作用不做日志、不持有历史、不分配内存
一相一访问阶段转移 = 真实总线操作
类型即文档u8 = 寄存器,u16 = 地址,bool = 标志
拉取而非推送返回值而非回调
显式而非推导151 行 match 而非解码矩阵
复用而非重复BRK/IRQ/NMI/RESET 共享入口管线
故障即值CpuError 而非 panic
可恢复周期预算耗尽后可继续执行

Hesper 的 6502 不是最快的模拟器——它没有 JIT、没有指令批处理、没有投机执行。但它可能是最诚实的之一:每一次总线活动都真实发生,每一个时序细节都有硬件依据,每一个设计决策都直接映射到 MOS 6502 的实际行为。

当你用 cycle() 推进一次时钟,你看到的就是 1975 年那颗芯片在那个周期做的事情。