Ядро 6502
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Ядро 6502
Самый длинный урок блока и самый важный. Учебный процессор из прошлого проекта мы рисовали под себя: фиксированная ширина инструкции, чистая таблица опкодов. 6502 другой: переменная длина, тринадцать режимов адресации, такты, которые зависят от пересечения страницы, фиктивные чтения. Зато на него есть эталонный тест nestest с побайтовым логом, и наша цель прямая: гонять его, пока трейс не совпадёт с золотым до последнего флага и такта.
Это первый урок, где ты пишешь код, а не читаешь. Дальше по блоку мы строим эмулятор NES по имени our-nes с нуля, файл за файлом, и каждый урок добавляет один кусок. Сегодня закладываем проект и собираем процессор целиком: к концу урока у тебя зелёный nestest, значит ядро считает правильно.
Заводим проект
Создай библиотечный крейт. Бинарь и crate-type мы пропишем руками в Cargo.toml, поэтому стартуем именно как либу.
cargo new --lib our-nes
cd our-nes
Теперь замени сгенерированный Cargo.toml на полный. Тут две поверхности у одного кода: rlib для уроков и тестов, cdylib для будущей сборки в wasm (движок виджета в уроке 48). Нативный раннер с окном и звуком объявлен бинарём, а его зависимости (minifb, cpal) висят под target-cfg, чтобы не лезть в wasm-сборку. На уроке 43 эти зависимости нам ещё не нужны, но Cargo стянет их при первой сборке нативного бинаря; если хочешь только гонять тесты, бинарь можно временно не создавать.
Создай файл Cargo.toml:
[package]
name = "our-nes"
version = "0.1.0"
edition = "2021"
description = "our-nes: учебный эмулятор NES (6502, PPU, APU, мапперы), эталон блока RU7"
license = "MIT"
publish = false
[lib]
# rlib для уроков и тестов, cdylib для сборки в wasm32 (движок виджета урока 48).
# Тот же код, две поверхности: feature "wasm" включает плоскую C-ABI обёртку,
# ровно как в эталоне RU6 (examples/our-cpu).
name = "our_nes"
crate-type = ["rlib", "cdylib"]
[features]
# Включает модуль `wasm` с экспортами extern "C" для браузера.
wasm = []
[[bin]]
# Нативный раннер: `cargo run -- путь/к/игре.nes`. Это тонкая оболочка вокруг либы.
name = "our-nes"
path = "src/main.rs"
[dependencies]
# minifb (окно и фреймбуфер раннера) нужен только для нативной сборки. Для wasm32 он
# не компилируется, поэтому зависимость объявлена через target-cfg: в сборке движка
# виджета (`--target wasm32-unknown-unknown`) его в графе нет, а bin там пустой.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
minifb = "0.27"
# cpal даёт кросс-платформенный звук (CoreAudio на macOS). Раннер скармливает ему
# аудиосэмплы APU. Как и minifb, только для нативной сборки, не для wasm.
cpal = "0.15"
[profile.release]
opt-level = 3
lto = true
Дальше точка входа библиотеки. src/lib.rs объявляет модули, которые существуют после текущего урока. На уроке 43 это всего два: шина и процессор. Каждый следующий урок дописывает сюда одну строку pub mod (картридж, PPU, APU, интеграция), так что финальный полный lib.rs соберётся к уроку 48. Пока он короткий:
Создай файл src/lib.rs:
//! our-nes: учебный эмулятор NES. Каждый урок блока добавляет один модуль.
pub mod bus;
pub mod cpu;
// Урок 44 добавит `pub mod cartridge;`, урок 45 `pub mod ppu;`, и так далее
// до полного `lib.rs` в уроке 48.
Процессор это модуль-папка: создай каталог src/cpu/ и три файла внутри, src/cpu/mod.rs, src/cpu/opcodes.rs, src/cpu/status.rs. Шина живёт одним файлом рядом, src/bus.rs. С неё и начнём: она самая маленькая и от неё зависит всё остальное.
Шина: единственная дверь процессора
6502 ничего не знает о том, что лежит по адресу. Он умеет читать и писать байт по 16-битному адресу, а маршрутизацию (ОЗУ, регистры PPU, регистры APU, картридж) берёт на себя тот, кто реализует трейт Bus. В nestest это плоская память, а в полной NES это системная шина из прошлого урока. Тот же приём, что в учебном процессоре RU6: ядро параметризовано шиной.
Создай файл src/bus.rs:
//! Шина процессора: единственная дверь 6502 в мир NES.
//!
//! Урок 42, 43. Как и в эталоне RU6 (`examples/our-cpu`), ядро ничего не знает о
//! том, что лежит по адресу. Оно умеет читать и писать байт по 16-битному адресу,
//! а маршрутизацию (ОЗУ, регистры PPU, регистры APU, картридж) берёт на себя тот,
//! кто реализует [`Bus`]. В NES это [`crate::nes::SystemBus`]; в тестах CPU это
//! плоская память.
//!
//! Линии прерываний (`NMI` от PPU, `IRQ` от APU и маппера) тоже идут через шину:
//! процессор только опрашивает их между инструкциями.
/// Контракт шины процессора 6502.
pub trait Bus {
/// Прочитать байт. `&mut self`, потому что чтение регистра устройства часто
/// имеет побочный эффект (например, чтение `$2002` сбрасывает vblank-флаг PPU).
fn read(&mut self, addr: u16) -> u8;
/// Записать байт.
fn write(&mut self, addr: u16, value: u8);
/// Продвинуть периферию ровно на один такт процессора. В NES это три точки PPU
/// и один такт APU. Процессор зовёт `tick` на каждый свой такт (и на доступ к
/// памяти, и на внутренний), поэтому PPU и APU идут синхронно с CPU точка в
/// точку. Плоская память тестов это игнорирует.
fn tick(&mut self) {}
/// Сколько дополнительных тактов украл DMA спрайтов на последней записи (513
/// при запуске через `$4014`). Процессор добавляет их к своему счётчику и
/// прокручивает на них периферию. Обычная запись возвращает 0.
fn dma_cycles_taken(&mut self) -> u32 {
0
}
/// Снять фронт линии NMI. `true` ровно один раз на каждый поднятый фронт
/// (PPU поднимает NMI в начале vblank). Возврат `true` обязывает процессор
/// обработать прерывание перед следующей инструкцией.
fn poll_nmi(&mut self) -> bool {
false
}
/// Уровень линии IRQ прямо сейчас. В отличие от NMI это именно уровень, а не
/// фронт: пока источник держит линию и флаг `I` снят, IRQ повторяется.
fn irq_pending(&self) -> bool {
false
}
}
Методы с телом по умолчанию (tick, dma_cycles_taken, poll_nmi, irq_pending) шина nestest просто не переопределяет: плоской памяти периферия не нужна. Обязательны только read и write. Обрати внимание на &mut self в read: чтение регистра устройства это не чистая функция, оно может что-то поменять в железе, и тип это честно отражает.
Регистр флагов P
Теперь процессор. Начнём с самого мелкого кусочка: регистра флагов. Это восемь бит, но программно видно семь. Держим их отдельными булевыми полями: так код операций читается как проза (self.p.zero = result == 0), а в байт упаковываем только на границе (push в стек, трейс).
Создай файл src/cpu/status.rs:
//! Регистр состояния `P` процессора 6502.
//!
//! Урок 43. Восемь бит, но программно видно семь флагов. Держим их отдельными
//! полями: так код операций читается как проза (`cpu.p.zero = result == 0`), а в
//! байт упаковываем только на границе (push в стек, трейс, регистр для нестест).
//!
//! Раскладка байта (бит 7 слева):
//!
//! ```text
//! N V 1 B D I Z C
//! 7 6 5 4 3 2 1 0
//! ```
//!
//! Бит 5 в железе подтянут к единице и читается как 1 всегда. Бита `B` как
//! отдельного флага не существует: это значение бита 4 в *копии*, которую кладут
//! в стек, и оно различает источник (`PHP`/`BRK` кладут 1, аппаратные `IRQ`/`NMI`
//! кладут 0). Поэтому `B` тут не поле, а параметр упаковки.
/// Флаги состояния как набор булевых полей.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Status {
/// `C`, перенос или заём.
pub carry: bool,
/// `Z`, результат равен нулю.
pub zero: bool,
/// `I`, запрет маскируемых прерываний (IRQ).
pub interrupt_disable: bool,
/// `D`, десятичный режим. На 2A03 он есть в байте, но игнорируется ALU.
pub decimal: bool,
/// `V`, знаковое переполнение.
pub overflow: bool,
/// `N`, старший бит результата (знак).
pub negative: bool,
}
impl Status {
/// Состояние после сброса: установлен только `I` (прерывания запрещены).
/// В байте это `0x24` (с учётом всегда-единичного бита 5).
pub fn at_reset() -> Self {
Status {
interrupt_disable: true,
..Status::default()
}
}
/// Собрать байт для трейса или для push в стек. `b` это значение бита 4:
/// `true` для `PHP`/`BRK`, `false` для аппаратного входа в прерывание.
pub fn to_byte(self, b: bool) -> u8 {
let mut byte = 0b0010_0000; // бит 5 всегда 1
if self.carry {
byte |= 1 << 0;
}
if self.zero {
byte |= 1 << 1;
}
if self.interrupt_disable {
byte |= 1 << 2;
}
if self.decimal {
byte |= 1 << 3;
}
if b {
byte |= 1 << 4;
}
if self.overflow {
byte |= 1 << 6;
}
if self.negative {
byte |= 1 << 7;
}
byte
}
/// Разобрать байт обратно во флаги (например, `PLP`/`RTI`). Биты 4 и 5 при
/// этом отбрасываются: в полях их нет.
pub fn from_byte(byte: u8) -> Self {
Status {
carry: byte & (1 << 0) != 0,
zero: byte & (1 << 1) != 0,
interrupt_disable: byte & (1 << 2) != 0,
decimal: byte & (1 << 3) != 0,
overflow: byte & (1 << 6) != 0,
negative: byte & (1 << 7) != 0,
}
}
/// Выставить `Z` и `N` по результату операции. Это делает почти каждый опкод,
/// поэтому вынесено в один помощник.
pub fn set_zero_negative(&mut self, value: u8) {
self.zero = value == 0;
self.negative = value & 0x80 != 0;
}
}
Раскладка байта (бит 7 слева): N V 1 B D I Z C. Тут две ловушки, на которых эмуляторы спотыкаются чаще всего.
Во-первых, бита 5 нет как флага: в железе он подтянут к единице и читается как 1 всегда. Видишь 0b0010_0000 в начале to_byte: это и есть тот единичный бит 5. Во-вторых, бита B тоже нет как флага. Это значение бита 4 в той копии регистра, которую кладут в стек, и оно различает источник: PHP и BRK кладут 1, а аппаратные IRQ и NMI кладут 0. Поэтому B это не поле структуры, а параметр упаковки b.
Флаги NV-BDIZC почти каждая инструкция трогает по одной схеме: выставляет Z и N по результату. Это вынесено в единственный помощник set_zero_negative, чтобы не повторять его в каждом опкоде. Обратный разбор байта во флаги (from_byte) нужен для PLP и RTI: они достают регистр со стека и при этом отбрасывают биты 4 и 5, потому что полей под них нет.
Декодирование: один байт это тройка
Учебный процессор из RU6 декодировал инструкцию вручную, разбирая поля опкода. У 6502 проще всего таблица на 256 строк: байт опкода это индекс, а в строке лежит тройка «мнемоника, режим адресации, базовые такты». Та же таблица питает и исполнитель, и дизассемблер из урока 48: декодирование живёт в одном месте.
Создай файл src/cpu/opcodes.rs. Тут полная таблица на 256 опкодов, не сокращай её: nestest гоняет почти весь набор, и любая пропущенная строка разъедет трейс.
//! Таблица опкодов 6502: декодирование без исполнения.
//!
//! Урок 43. Один байт опкода задаёт тройку: что делать (мнемоника), откуда брать
//! операнд (режим адресации) и сколько это стоит в тактах. Держим это таблицей на
//! 256 строк, а не россыпью `if`. Та же таблица питает и исполнитель, и
//! дизассемблер (урок 48): декодирование живёт в одном месте.
//!
//! В таблице есть и неофициальные опкоды. Реальные картриджи на них не
//! полагаются, но `nestest` гоняет стабильные illegal-опкоды (`SLO`, `LAX`, `DCP`,
//! `ISC` и компанию), и без них до зелёного не дойти. Нестабильные (`XAA`, `AHX`,
//! `TAS`, ...) реализованы по общепринятым формулам, но `nestest` их не трогает.
#![allow(clippy::upper_case_acronyms)]
use AddrMode::*;
use Mnemonic::*;
/// Режим адресации: как из байтов после опкода получить адрес операнда.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AddrMode {
/// Без операнда (`INX`, `NOP`).
IMP,
/// Операнд это сам аккумулятор (`ASL A`).
ACC,
/// Непосредственное значение (`LDA #$10`).
IMM,
/// Нулевая страница: один байт адреса `$00xx`.
ZP0,
/// Нулевая страница со смещением по X.
ZPX,
/// Нулевая страница со смещением по Y.
ZPY,
/// Относительный переход (ветвления).
REL,
/// Абсолютный 16-битный адрес.
ABS,
/// Абсолютный со смещением по X.
ABX,
/// Абсолютный со смещением по Y.
ABY,
/// Косвенный (только `JMP ($addr)`), с багом переноса страницы.
IND,
/// Косвенный с предындексацией X: `($addr,X)`.
IZX,
/// Косвенный с постиндексацией Y: `($addr),Y`.
IZY,
}
/// Мнемоника инструкции. Включает официальные и неофициальные опкоды.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[rustfmt::skip]
pub enum Mnemonic {
// Загрузка и сохранение.
LDA, LDX, LDY, STA, STX, STY,
// Пересылки между регистрами.
TAX, TAY, TXA, TYA, TSX, TXS,
// Стек.
PHA, PHP, PLA, PLP,
// Арифметика и логика.
ADC, SBC, AND, ORA, EOR, CMP, CPX, CPY, BIT,
// Инкременты и декременты.
INC, INX, INY, DEC, DEX, DEY,
// Сдвиги.
ASL, LSR, ROL, ROR,
// Управление потоком.
JMP, JSR, RTS, RTI, BRK,
// Ветвления.
BCC, BCS, BEQ, BNE, BMI, BPL, BVC, BVS,
// Флаги.
CLC, SEC, CLI, SEI, CLD, SED, CLV,
// Официальный простой.
NOP,
// Неофициальные, стабильные (нужны для nestest).
SLO, RLA, SRE, RRA, SAX, LAX, DCP, ISC, ANC, ALR, ARR, AXS,
// Неофициальные, нестабильные (nestest их не трогает).
XAA, AHX, TAS, SHY, SHX, LAS,
/// Зависание процессора (illegal `KIL`/`JAM`).
JAM,
}
/// Декодированный опкод: всё, что нужно и исполнителю, и дизассемблеру.
#[derive(Clone, Copy, Debug)]
pub struct OpInfo {
pub mnemonic: Mnemonic,
pub mode: AddrMode,
/// Базовая стоимость в тактах.
pub cycles: u8,
/// Добавлять ли такт при переходе через границу страницы (только для
/// читающих `ABX`/`ABY`/`IZY`; у записи и RMW штраф уже зашит в базу).
pub page_penalty: bool,
/// Официальный ли это опкод.
pub official: bool,
}
const fn op(mnemonic: Mnemonic, mode: AddrMode, cycles: u8, page_penalty: bool) -> OpInfo {
OpInfo {
mnemonic,
mode,
cycles,
page_penalty,
official: true,
}
}
const fn ill(mnemonic: Mnemonic, mode: AddrMode, cycles: u8, page_penalty: bool) -> OpInfo {
OpInfo {
mnemonic,
mode,
cycles,
page_penalty,
official: false,
}
}
/// Декодировать байт опкода.
pub fn decode(opcode: u8) -> OpInfo {
OPCODES[opcode as usize]
}
/// Полная таблица 256 опкодов, индекс это байт опкода.
#[rustfmt::skip]
static OPCODES: [OpInfo; 256] = [
/* 00 */ op(BRK,IMP,7,false), op(ORA,IZX,6,false), ill(JAM,IMP,2,false), ill(SLO,IZX,8,false),
/* 04 */ ill(NOP,ZP0,3,false), op(ORA,ZP0,3,false), op(ASL,ZP0,5,false), ill(SLO,ZP0,5,false),
/* 08 */ op(PHP,IMP,3,false), op(ORA,IMM,2,false), op(ASL,ACC,2,false), ill(ANC,IMM,2,false),
/* 0C */ ill(NOP,ABS,4,false), op(ORA,ABS,4,false), op(ASL,ABS,6,false), ill(SLO,ABS,6,false),
/* 10 */ op(BPL,REL,2,true), op(ORA,IZY,5,true), ill(JAM,IMP,2,false), ill(SLO,IZY,8,false),
/* 14 */ ill(NOP,ZPX,4,false), op(ORA,ZPX,4,false), op(ASL,ZPX,6,false), ill(SLO,ZPX,6,false),
/* 18 */ op(CLC,IMP,2,false), op(ORA,ABY,4,true), ill(NOP,IMP,2,false), ill(SLO,ABY,7,false),
/* 1C */ ill(NOP,ABX,4,true), op(ORA,ABX,4,true), op(ASL,ABX,7,false), ill(SLO,ABX,7,false),
/* 20 */ op(JSR,ABS,6,false), op(AND,IZX,6,false), ill(JAM,IMP,2,false), ill(RLA,IZX,8,false),
/* 24 */ op(BIT,ZP0,3,false), op(AND,ZP0,3,false), op(ROL,ZP0,5,false), ill(RLA,ZP0,5,false),
/* 28 */ op(PLP,IMP,4,false), op(AND,IMM,2,false), op(ROL,ACC,2,false), ill(ANC,IMM,2,false),
/* 2C */ op(BIT,ABS,4,false), op(AND,ABS,4,false), op(ROL,ABS,6,false), ill(RLA,ABS,6,false),
/* 30 */ op(BMI,REL,2,true), op(AND,IZY,5,true), ill(JAM,IMP,2,false), ill(RLA,IZY,8,false),
/* 34 */ ill(NOP,ZPX,4,false), op(AND,ZPX,4,false), op(ROL,ZPX,6,false), ill(RLA,ZPX,6,false),
/* 38 */ op(SEC,IMP,2,false), op(AND,ABY,4,true), ill(NOP,IMP,2,false), ill(RLA,ABY,7,false),
/* 3C */ ill(NOP,ABX,4,true), op(AND,ABX,4,true), op(ROL,ABX,7,false), ill(RLA,ABX,7,false),
/* 40 */ op(RTI,IMP,6,false), op(EOR,IZX,6,false), ill(JAM,IMP,2,false), ill(SRE,IZX,8,false),
/* 44 */ ill(NOP,ZP0,3,false), op(EOR,ZP0,3,false), op(LSR,ZP0,5,false), ill(SRE,ZP0,5,false),
/* 48 */ op(PHA,IMP,3,false), op(EOR,IMM,2,false), op(LSR,ACC,2,false), ill(ALR,IMM,2,false),
/* 4C */ op(JMP,ABS,3,false), op(EOR,ABS,4,false), op(LSR,ABS,6,false), ill(SRE,ABS,6,false),
/* 50 */ op(BVC,REL,2,true), op(EOR,IZY,5,true), ill(JAM,IMP,2,false), ill(SRE,IZY,8,false),
/* 54 */ ill(NOP,ZPX,4,false), op(EOR,ZPX,4,false), op(LSR,ZPX,6,false), ill(SRE,ZPX,6,false),
/* 58 */ op(CLI,IMP,2,false), op(EOR,ABY,4,true), ill(NOP,IMP,2,false), ill(SRE,ABY,7,false),
/* 5C */ ill(NOP,ABX,4,true), op(EOR,ABX,4,true), op(LSR,ABX,7,false), ill(SRE,ABX,7,false),
/* 60 */ op(RTS,IMP,6,false), op(ADC,IZX,6,false), ill(JAM,IMP,2,false), ill(RRA,IZX,8,false),
/* 64 */ ill(NOP,ZP0,3,false), op(ADC,ZP0,3,false), op(ROR,ZP0,5,false), ill(RRA,ZP0,5,false),
/* 68 */ op(PLA,IMP,4,false), op(ADC,IMM,2,false), op(ROR,ACC,2,false), ill(ARR,IMM,2,false),
/* 6C */ op(JMP,IND,5,false), op(ADC,ABS,4,false), op(ROR,ABS,6,false), ill(RRA,ABS,6,false),
/* 70 */ op(BVS,REL,2,true), op(ADC,IZY,5,true), ill(JAM,IMP,2,false), ill(RRA,IZY,8,false),
/* 74 */ ill(NOP,ZPX,4,false), op(ADC,ZPX,4,false), op(ROR,ZPX,6,false), ill(RRA,ZPX,6,false),
/* 78 */ op(SEI,IMP,2,false), op(ADC,ABY,4,true), ill(NOP,IMP,2,false), ill(RRA,ABY,7,false),
/* 7C */ ill(NOP,ABX,4,true), op(ADC,ABX,4,true), op(ROR,ABX,7,false), ill(RRA,ABX,7,false),
/* 80 */ ill(NOP,IMM,2,false), op(STA,IZX,6,false), ill(NOP,IMM,2,false), ill(SAX,IZX,6,false),
/* 84 */ op(STY,ZP0,3,false), op(STA,ZP0,3,false), op(STX,ZP0,3,false), ill(SAX,ZP0,3,false),
/* 88 */ op(DEY,IMP,2,false), ill(NOP,IMM,2,false), op(TXA,IMP,2,false), ill(XAA,IMM,2,false),
/* 8C */ op(STY,ABS,4,false), op(STA,ABS,4,false), op(STX,ABS,4,false), ill(SAX,ABS,4,false),
/* 90 */ op(BCC,REL,2,true), op(STA,IZY,6,false), ill(JAM,IMP,2,false), ill(AHX,IZY,6,false),
/* 94 */ op(STY,ZPX,4,false), op(STA,ZPX,4,false), op(STX,ZPY,4,false), ill(SAX,ZPY,4,false),
/* 98 */ op(TYA,IMP,2,false), op(STA,ABY,5,false), op(TXS,IMP,2,false), ill(TAS,ABY,5,false),
/* 9C */ ill(SHY,ABX,5,false), op(STA,ABX,5,false), ill(SHX,ABY,5,false), ill(AHX,ABY,5,false),
/* A0 */ op(LDY,IMM,2,false), op(LDA,IZX,6,false), op(LDX,IMM,2,false), ill(LAX,IZX,6,false),
/* A4 */ op(LDY,ZP0,3,false), op(LDA,ZP0,3,false), op(LDX,ZP0,3,false), ill(LAX,ZP0,3,false),
/* A8 */ op(TAY,IMP,2,false), op(LDA,IMM,2,false), op(TAX,IMP,2,false), ill(LAX,IMM,2,false),
/* AC */ op(LDY,ABS,4,false), op(LDA,ABS,4,false), op(LDX,ABS,4,false), ill(LAX,ABS,4,false),
/* B0 */ op(BCS,REL,2,true), op(LDA,IZY,5,true), ill(JAM,IMP,2,false), ill(LAX,IZY,5,true),
/* B4 */ op(LDY,ZPX,4,false), op(LDA,ZPX,4,false), op(LDX,ZPY,4,false), ill(LAX,ZPY,4,false),
/* B8 */ op(CLV,IMP,2,false), op(LDA,ABY,4,true), op(TSX,IMP,2,false), ill(LAS,ABY,4,true),
/* BC */ op(LDY,ABX,4,true), op(LDA,ABX,4,true), op(LDX,ABY,4,true), ill(LAX,ABY,4,true),
/* C0 */ op(CPY,IMM,2,false), op(CMP,IZX,6,false), ill(NOP,IMM,2,false), ill(DCP,IZX,8,false),
/* C4 */ op(CPY,ZP0,3,false), op(CMP,ZP0,3,false), op(DEC,ZP0,5,false), ill(DCP,ZP0,5,false),
/* C8 */ op(INY,IMP,2,false), op(CMP,IMM,2,false), op(DEX,IMP,2,false), ill(AXS,IMM,2,false),
/* CC */ op(CPY,ABS,4,false), op(CMP,ABS,4,false), op(DEC,ABS,6,false), ill(DCP,ABS,6,false),
/* D0 */ op(BNE,REL,2,true), op(CMP,IZY,5,true), ill(JAM,IMP,2,false), ill(DCP,IZY,8,false),
/* D4 */ ill(NOP,ZPX,4,false), op(CMP,ZPX,4,false), op(DEC,ZPX,6,false), ill(DCP,ZPX,6,false),
/* D8 */ op(CLD,IMP,2,false), op(CMP,ABY,4,true), ill(NOP,IMP,2,false), ill(DCP,ABY,7,false),
/* DC */ ill(NOP,ABX,4,true), op(CMP,ABX,4,true), op(DEC,ABX,7,false), ill(DCP,ABX,7,false),
/* E0 */ op(CPX,IMM,2,false), op(SBC,IZX,6,false), ill(NOP,IMM,2,false), ill(ISC,IZX,8,false),
/* E4 */ op(CPX,ZP0,3,false), op(SBC,ZP0,3,false), op(INC,ZP0,5,false), ill(ISC,ZP0,5,false),
/* E8 */ op(INX,IMP,2,false), op(SBC,IMM,2,false), op(NOP,IMP,2,false), ill(SBC,IMM,2,false),
/* EC */ op(CPX,ABS,4,false), op(SBC,ABS,4,false), op(INC,ABS,6,false), ill(ISC,ABS,6,false),
/* F0 */ op(BEQ,REL,2,true), op(SBC,IZY,5,true), ill(JAM,IMP,2,false), ill(ISC,IZY,8,false),
/* F4 */ ill(NOP,ZPX,4,false), op(SBC,ZPX,4,false), op(INC,ZPX,6,false), ill(ISC,ZPX,6,false),
/* F8 */ op(SED,IMP,2,false), op(SBC,ABY,4,true), ill(NOP,IMP,2,false), ill(ISC,ABY,7,false),
/* FC */ ill(NOP,ABX,4,true), op(SBC,ABX,4,true), op(INC,ABX,7,false), ill(ISC,ABX,7,false),
];
impl Mnemonic {
/// Имя для дизассемблера. Неофициальные помечаются `*` в трейсе nestest.
pub fn name(self) -> &'static str {
match self {
LDA => "LDA",
LDX => "LDX",
LDY => "LDY",
STA => "STA",
STX => "STX",
STY => "STY",
TAX => "TAX",
TAY => "TAY",
TXA => "TXA",
TYA => "TYA",
TSX => "TSX",
TXS => "TXS",
PHA => "PHA",
PHP => "PHP",
PLA => "PLA",
PLP => "PLP",
ADC => "ADC",
SBC => "SBC",
AND => "AND",
ORA => "ORA",
EOR => "EOR",
CMP => "CMP",
CPX => "CPX",
CPY => "CPY",
BIT => "BIT",
INC => "INC",
INX => "INX",
INY => "INY",
DEC => "DEC",
DEX => "DEX",
DEY => "DEY",
ASL => "ASL",
LSR => "LSR",
ROL => "ROL",
ROR => "ROR",
JMP => "JMP",
JSR => "JSR",
RTS => "RTS",
RTI => "RTI",
BRK => "BRK",
BCC => "BCC",
BCS => "BCS",
BEQ => "BEQ",
BNE => "BNE",
BMI => "BMI",
BPL => "BPL",
BVC => "BVC",
BVS => "BVS",
CLC => "CLC",
SEC => "SEC",
CLI => "CLI",
SEI => "SEI",
CLD => "CLD",
SED => "SED",
CLV => "CLV",
NOP => "NOP",
SLO => "SLO",
RLA => "RLA",
SRE => "SRE",
RRA => "RRA",
SAX => "SAX",
LAX => "LAX",
DCP => "DCP",
ISC => "ISC",
ANC => "ANC",
ALR => "ALR",
ARR => "ARR",
AXS => "AXS",
XAA => "XAA",
AHX => "AHX",
TAS => "TAS",
SHY => "SHY",
SHX => "SHX",
LAS => "LAS",
JAM => "JAM",
}
}
}
Сама таблица собрана двумя короткими конструкторами: op (официальный опкод) и ill (неофициальный). Один и тот же ORA живёт в восьми режимах, а между ними рассыпаны неофициальные опкоды. Колонка page_penalty это true только у читающих индексных режимов (ABX, ABY, IZY) и у ветвлений: у записи и RMW штраф за страницу уже зашит в базовое число тактов.
Неофициальные опкоды в таблице помечены конструктором ill. Реальные картриджи на них почти не полагаются, но nestest гоняет стабильные (SLO, LAX, DCP, ISC и компанию), и без них до зелёного не дойти. Нестабильные (XAA, AHX, TAS) реализованы по общепринятым формулам, но nestest их не трогает: их поведение зависит от температуры и экземпляра чипа.
Состояние процессора и доступ к памяти
Теперь главный файл, src/cpu/mod.rs. Он большой (около 800 строк), но почти весь это длинный match исполнения, который читается линейно. Создавай его целиком, ниже разберём ключевые куски по порядку: состояние, доступ к памяти, цикл шага, разрешение адреса, исполнение, ALU.
Создай файл src/cpu/mod.rs:
//! Ядро 6502 (точнее, его вариант 2A03 в NES: тот же набор команд, десятичный
//! режим ALU отключён).
//!
//! Урок 43. Процессор это маленькая машина состояний: регистры плюс цикл
//! fetch-decode-execute. Декодирование вынесено в [`opcodes`], флаги в [`status`].
//! Здесь живёт состояние, разрешение адреса операнда (с точными тактовыми
//! штрафами) и исполнение каждой мнемоники.
//!
//! Процессор параметризован трейтом [`Bus`], как и в эталоне RU6: одно ядро
//! работает и поверх плоской памяти (тест nestest), и поверх полной шины NES.
pub mod opcodes;
pub mod status;
use crate::bus::Bus;
use opcodes::{decode, AddrMode, Mnemonic, OpInfo};
use status::Status;
/// Вектор немаскируемого прерывания (PPU vblank).
pub const VECTOR_NMI: u16 = 0xFFFA;
/// Вектор сброса.
pub const VECTOR_RESET: u16 = 0xFFFC;
/// Вектор маскируемого прерывания и `BRK`.
pub const VECTOR_IRQ: u16 = 0xFFFE;
/// Как инструкция трогает операнд. От этого зависят фиктивные чтения (dummy
/// reads): процессор 6502 при индексной адресации сначала читает по
/// неисправленному адресу, и для пишущих инструкций это происходит всегда, а для
/// читающих только при переходе через страницу.
#[derive(Clone, Copy, PartialEq, Eq)]
enum AccessKind {
Read,
Write,
ReadModifyWrite,
}
/// Куда указывает разрешённый операнд.
#[derive(Clone, Copy)]
enum Operand {
/// Нет операнда (`INX`).
Implied,
/// Аккумулятор (`ASL A`).
Accumulator,
/// Непосредственное значение.
Immediate(u8),
/// Адрес в памяти.
Memory(u16),
}
/// Состояние процессора 6502.
#[derive(Clone, Debug)]
pub struct Cpu {
/// Аккумулятор.
pub a: u8,
/// Индексный регистр X.
pub x: u8,
/// Индексный регистр Y.
pub y: u8,
/// Указатель стека (стек живёт на странице `$0100`).
pub sp: u8,
/// Программный счётчик.
pub pc: u16,
/// Регистр состояния.
pub p: Status,
/// Накопленные такты с момента сброса (цикл-бюджет, нужен для синхронизации
/// с PPU и для трейса nestest).
pub cycles: u64,
/// Процессор завис на illegal `JAM`.
pub jammed: bool,
/// Добавочные такты текущей инструкции, которые не пришлись на доступ к памяти
/// (взятое ветвление, переход через страницу). Добиваются в конце шага.
extra_cycles: u8,
/// Прерывание опрошено в конце прошлой инструкции и обслуживается перед
/// следующей. Так смена флага `I` через `CLI`/`SEI` видна с задержкой на одну
/// инструкцию, как в железе.
service_nmi: bool,
service_irq: bool,
}
impl Default for Cpu {
fn default() -> Self {
Self::new()
}
}
impl Cpu {
/// Свежий процессор до сброса. Реальные значения регистров задаёт [`reset`].
pub fn new() -> Self {
Cpu {
a: 0,
x: 0,
y: 0,
sp: 0xFD,
pc: 0,
p: Status::at_reset(),
cycles: 0,
jammed: false,
extra_cycles: 0,
service_nmi: false,
service_irq: false,
}
}
/// Аппаратный сброс: читает вектор сброса и встаёт на него. Регистры данных
/// аппаратно не обнуляются, а `sp` уезжает на `0xFD` (три фиктивных pop).
pub fn reset(&mut self, bus: &mut impl Bus) {
self.sp = 0xFD;
self.p = Status::at_reset();
self.pc = bus.read(VECTOR_RESET) as u16 | (bus.read(VECTOR_RESET + 1) as u16) << 8;
self.cycles = 7;
self.jammed = false;
}
/// Поставить процессор на конкретный адрес (используется тестом nestest,
/// который стартует с `$C000` в автоматическом режиме).
pub fn set_pc(&mut self, pc: u16) {
self.pc = pc;
}
// --- доступ к памяти через шину ---
//
// Каждый доступ к памяти это один такт процессора. Перед самим доступом мы
// тикаем шину (PPU успевает три точки, APU один такт), затем считываем или
// пишем. Так PPU и CPU идут точка в точку: чтение `$2002` видит ровно то
// состояние vblank, что и железо.
/// Внутренний такт без обращения к памяти (индексация, фиктивные циклы, добивка
/// до табличной длины инструкции).
fn tick(&mut self, bus: &mut impl Bus) {
bus.tick();
self.cycles += 1;
}
fn read(&mut self, bus: &mut impl Bus, addr: u16) -> u8 {
self.tick(bus);
bus.read(addr)
}
fn write(&mut self, bus: &mut impl Bus, addr: u16, value: u8) {
self.tick(bus);
bus.write(addr, value);
// DMA спрайтов крадёт сотни тактов: прокручиваем на них периферию.
let stall = bus.dma_cycles_taken();
for _ in 0..stall {
self.tick(bus);
}
}
fn fetch(&mut self, bus: &mut impl Bus) -> u8 {
let byte = self.read(bus, self.pc);
self.pc = self.pc.wrapping_add(1);
byte
}
fn fetch16(&mut self, bus: &mut impl Bus) -> u16 {
let lo = self.fetch(bus) as u16;
let hi = self.fetch(bus) as u16;
lo | (hi << 8)
}
/// Прочитать слово с багом переноса страницы 6502: если младший байт адреса
/// `0xFF`, старший байт читается из начала той же страницы, а не следующей.
/// Этим багом славится косвенный `JMP ($xxFF)`.
fn read16_bug(&mut self, bus: &mut impl Bus, addr: u16) -> u16 {
let lo = self.read(bus, addr) as u16;
let hi_addr = (addr & 0xFF00) | ((addr + 1) & 0x00FF);
let hi = self.read(bus, hi_addr) as u16;
lo | (hi << 8)
}
/// Прочитать слово из нулевой страницы с заворотом внутри неё.
fn read16_zp(&mut self, bus: &mut impl Bus, zp: u8) -> u16 {
let lo = self.read(bus, zp as u16) as u16;
let hi = self.read(bus, zp.wrapping_add(1) as u16) as u16;
lo | (hi << 8)
}
// --- стек на странице $0100 ---
fn push(&mut self, bus: &mut impl Bus, value: u8) {
self.write(bus, 0x0100 | self.sp as u16, value);
self.sp = self.sp.wrapping_sub(1);
}
fn pull(&mut self, bus: &mut impl Bus) -> u8 {
self.sp = self.sp.wrapping_add(1);
self.read(bus, 0x0100 | self.sp as u16)
}
fn push16(&mut self, bus: &mut impl Bus, value: u16) {
self.push(bus, (value >> 8) as u8);
self.push(bus, (value & 0xFF) as u8);
}
fn pull16(&mut self, bus: &mut impl Bus) -> u16 {
let lo = self.pull(bus) as u16;
let hi = self.pull(bus) as u16;
lo | (hi << 8)
}
// --- прерывания ---
/// Войти в обработчик немаскируемого прерывания (NMI). Дёргается шиной, когда
/// PPU поднимает vblank-фронт.
pub fn nmi(&mut self, bus: &mut impl Bus) {
self.push16(bus, self.pc);
self.push(bus, self.p.to_byte(false));
self.p.interrupt_disable = true;
self.pc = self.read16_bug(bus, VECTOR_NMI);
}
/// Войти в обработчик маскируемого прерывания (IRQ), если они не запрещены.
fn irq(&mut self, bus: &mut impl Bus) {
self.push16(bus, self.pc);
self.push(bus, self.p.to_byte(false));
self.p.interrupt_disable = true;
self.pc = self.read16_bug(bus, VECTOR_IRQ);
}
/// Один полный шаг: проверка прерываний, затем fetch-decode-execute одной
/// инструкции. Возвращает, сколько тактов это стоило.
pub fn step(&mut self, bus: &mut impl Bus) -> u8 {
if self.jammed {
return 0;
}
// Прерывание, опрошенное в конце прошлой инструкции, обслуживается сейчас.
// Вход стоит 7 тактов: часть набегает на доступы (push, чтение вектора),
// остаток добиваем тиками до семи. NMI приоритетнее IRQ.
if self.service_nmi {
self.service_nmi = false;
self.service_irq = false;
let start = self.cycles;
self.nmi(bus);
self.pad_to(bus, start, 7);
return (self.cycles - start) as u8;
}
if self.service_irq {
self.service_irq = false;
let start = self.cycles;
self.irq(bus);
self.pad_to(bus, start, 7);
return (self.cycles - start) as u8;
}
let start = self.cycles;
self.extra_cycles = 0;
// Флаг I фиксируем до инструкции: смена через CLI/SEI/PLP повлияет на опрос
// прерываний только со следующей инструкции (задержка как в железе).
let interrupts_disabled_before = self.p.interrupt_disable;
let opcode = self.fetch(bus);
let info = decode(opcode);
let kind = access_kind(info.mnemonic);
let (operand, page_crossed) = self.resolve(bus, info.mode, kind);
let mut target = info.cycles as u64;
if info.page_penalty
&& matches!(info.mode, AddrMode::ABX | AddrMode::ABY | AddrMode::IZY)
&& page_crossed
{
target += 1;
}
self.execute(bus, info, operand, page_crossed);
target += self.extra_cycles as u64;
// Доступы уже натикали часть; добиваем внутренние такты до табличной длины.
self.pad_to(bus, start, target);
// Опрос прерываний в конце инструкции. Задержку флага I дают только
// CLI/SEI/PLP: они меняют `I` на последнем такте, уже после опроса, поэтому
// тут берётся значение до инструкции. RTI восстанавливает `I` раньше опроса,
// поэтому учитывается сразу (для него и всех прочих берём текущее значение).
let poll_disabled =
if matches!(info.mnemonic, Mnemonic::CLI | Mnemonic::SEI | Mnemonic::PLP) {
interrupts_disabled_before
} else {
self.p.interrupt_disable
};
if bus.poll_nmi() {
self.service_nmi = true;
}
if bus.irq_pending() && !poll_disabled {
self.service_irq = true;
}
(self.cycles - start) as u8
}
/// Добить шину тиками, пока с момента `start` не пройдёт ровно `target` тактов.
fn pad_to(&mut self, bus: &mut impl Bus, start: u64, target: u64) {
while self.cycles - start < target {
self.tick(bus);
}
}
/// Разрешить операнд по режиму адресации. Возвращает операнд и флаг перехода
/// через границу страницы. `kind` управляет фиктивными чтениями: при индексной
/// адресации процессор читает по неисправленному адресу, и для записи это всегда
/// один такт, а для чтения только при переходе через страницу.
fn resolve(&mut self, bus: &mut impl Bus, mode: AddrMode, kind: AccessKind) -> (Operand, bool) {
use AddrMode::*;
match mode {
IMP => (Operand::Implied, false),
ACC => (Operand::Accumulator, false),
IMM => {
let value = self.fetch(bus);
(Operand::Immediate(value), false)
}
ZP0 => {
let addr = self.fetch(bus) as u16;
(Operand::Memory(addr), false)
}
ZPX => {
let base = self.fetch(bus);
// Фиктивное чтение по неиндексированному адресу (такт индексации).
self.read(bus, base as u16);
(Operand::Memory(base.wrapping_add(self.x) as u16), false)
}
ZPY => {
let base = self.fetch(bus);
self.read(bus, base as u16);
(Operand::Memory(base.wrapping_add(self.y) as u16), false)
}
ABS => {
let addr = self.fetch16(bus);
(Operand::Memory(addr), false)
}
ABX => {
let base = self.fetch16(bus);
let addr = base.wrapping_add(self.x as u16);
self.indexed_dummy_read(bus, base, addr, kind);
(Operand::Memory(addr), page_crossed(base, addr))
}
ABY => {
let base = self.fetch16(bus);
let addr = base.wrapping_add(self.y as u16);
self.indexed_dummy_read(bus, base, addr, kind);
(Operand::Memory(addr), page_crossed(base, addr))
}
IND => {
let ptr = self.fetch16(bus);
let addr = self.read16_bug(bus, ptr);
(Operand::Memory(addr), false)
}
IZX => {
let pointer = self.fetch(bus);
// Фиктивное чтение исходного указателя до прибавления X.
self.read(bus, pointer as u16);
let zp = pointer.wrapping_add(self.x);
let addr = self.read16_zp(bus, zp);
(Operand::Memory(addr), false)
}
IZY => {
let zp = self.fetch(bus);
let base = self.read16_zp(bus, zp);
let addr = base.wrapping_add(self.y as u16);
self.indexed_dummy_read(bus, base, addr, kind);
(Operand::Memory(addr), page_crossed(base, addr))
}
REL => {
let offset = self.fetch(bus) as i8;
let addr = self.pc.wrapping_add(offset as u16);
(Operand::Memory(addr), page_crossed(self.pc, addr))
}
}
}
/// Фиктивное чтение индексной адресации по неисправленному адресу (старший байт
/// ещё не учёл перенос). Для записи и RMW происходит всегда, для чтения только
/// при переходе через страницу.
fn indexed_dummy_read(&mut self, bus: &mut impl Bus, base: u16, addr: u16, kind: AccessKind) {
let crossed = page_crossed(base, addr);
if kind != AccessKind::Read || crossed {
let unfixed = (base & 0xFF00) | (addr & 0x00FF);
self.read(bus, unfixed);
}
}
/// Прочитать значение операнда (для читающих и RMW инструкций).
fn load(&mut self, bus: &mut impl Bus, operand: Operand) -> u8 {
match operand {
Operand::Immediate(value) => value,
Operand::Accumulator => self.a,
Operand::Memory(addr) => self.read(bus, addr),
Operand::Implied => 0,
}
}
/// Записать значение обратно (для store и RMW).
fn store(&mut self, bus: &mut impl Bus, operand: Operand, value: u8) {
match operand {
Operand::Accumulator => self.a = value,
Operand::Memory(addr) => self.write(bus, addr, value),
_ => {}
}
}
fn addr_of(operand: Operand) -> u16 {
match operand {
Operand::Memory(addr) => addr,
_ => 0,
}
}
/// Запись нестабильного store-опкода. При переходе через страницу старший байт
/// целевого адреса подменяется записываемым значением (квирк железа).
fn store_unstable(&mut self, bus: &mut impl Bus, base: u16, addr: u16, value: u8) {
let target = if page_crossed(base, addr) {
(addr & 0x00FF) | ((value as u16) << 8)
} else {
addr
};
self.write(bus, target, value);
}
/// Исполнить декодированную инструкцию.
fn execute(&mut self, bus: &mut impl Bus, info: OpInfo, operand: Operand, page_crossed: bool) {
use Mnemonic::*;
match info.mnemonic {
// --- загрузка и сохранение ---
LDA => {
self.a = self.load(bus, operand);
self.p.set_zero_negative(self.a);
}
LDX => {
self.x = self.load(bus, operand);
self.p.set_zero_negative(self.x);
}
LDY => {
self.y = self.load(bus, operand);
self.p.set_zero_negative(self.y);
}
STA => self.store(bus, operand, self.a),
STX => self.store(bus, operand, self.x),
STY => self.store(bus, operand, self.y),
// --- пересылки ---
TAX => {
self.x = self.a;
self.p.set_zero_negative(self.x);
}
TAY => {
self.y = self.a;
self.p.set_zero_negative(self.y);
}
TXA => {
self.a = self.x;
self.p.set_zero_negative(self.a);
}
TYA => {
self.a = self.y;
self.p.set_zero_negative(self.a);
}
TSX => {
self.x = self.sp;
self.p.set_zero_negative(self.x);
}
TXS => self.sp = self.x,
// --- стек ---
PHA => self.push(bus, self.a),
PHP => self.push(bus, self.p.to_byte(true)),
PLA => {
self.a = self.pull(bus);
self.p.set_zero_negative(self.a);
}
PLP => {
let byte = self.pull(bus);
self.p = Status::from_byte(byte);
}
// --- арифметика и логика ---
ADC => {
let value = self.load(bus, operand);
self.adc(value);
}
SBC => {
let value = self.load(bus, operand);
self.sbc(value);
}
AND => {
self.a &= self.load(bus, operand);
self.p.set_zero_negative(self.a);
}
ORA => {
self.a |= self.load(bus, operand);
self.p.set_zero_negative(self.a);
}
EOR => {
self.a ^= self.load(bus, operand);
self.p.set_zero_negative(self.a);
}
CMP => {
let value = self.load(bus, operand);
self.compare(self.a, value);
}
CPX => {
let value = self.load(bus, operand);
self.compare(self.x, value);
}
CPY => {
let value = self.load(bus, operand);
self.compare(self.y, value);
}
BIT => {
let value = self.load(bus, operand);
self.p.zero = (self.a & value) == 0;
self.p.overflow = value & 0x40 != 0;
self.p.negative = value & 0x80 != 0;
}
// --- инкременты и декременты ---
INC => {
let value = self.load(bus, operand).wrapping_add(1);
self.p.set_zero_negative(value);
self.store(bus, operand, value);
}
DEC => {
let value = self.load(bus, operand).wrapping_sub(1);
self.p.set_zero_negative(value);
self.store(bus, operand, value);
}
INX => {
self.x = self.x.wrapping_add(1);
self.p.set_zero_negative(self.x);
}
INY => {
self.y = self.y.wrapping_add(1);
self.p.set_zero_negative(self.y);
}
DEX => {
self.x = self.x.wrapping_sub(1);
self.p.set_zero_negative(self.x);
}
DEY => {
self.y = self.y.wrapping_sub(1);
self.p.set_zero_negative(self.y);
}
// --- сдвиги ---
ASL => {
let value = self.load(bus, operand);
let result = self.asl(value);
self.store(bus, operand, result);
}
LSR => {
let value = self.load(bus, operand);
let result = self.lsr(value);
self.store(bus, operand, result);
}
ROL => {
let value = self.load(bus, operand);
let result = self.rol(value);
self.store(bus, operand, result);
}
ROR => {
let value = self.load(bus, operand);
let result = self.ror(value);
self.store(bus, operand, result);
}
// --- управление потоком ---
JMP => self.pc = Self::addr_of(operand),
JSR => {
// На стек кладётся адрес последнего байта инструкции (pc-1).
self.push16(bus, self.pc.wrapping_sub(1));
self.pc = Self::addr_of(operand);
}
RTS => self.pc = self.pull16(bus).wrapping_add(1),
RTI => {
let byte = self.pull(bus);
self.p = Status::from_byte(byte);
self.pc = self.pull16(bus);
}
BRK => {
// BRK двухбайтный: пропускаем байт-заполнитель.
self.pc = self.pc.wrapping_add(1);
self.push16(bus, self.pc);
self.push(bus, self.p.to_byte(true));
self.p.interrupt_disable = true;
self.pc = self.read16_bug(bus, VECTOR_IRQ);
}
// --- ветвления ---
BCC => self.branch(operand, page_crossed, !self.p.carry),
BCS => self.branch(operand, page_crossed, self.p.carry),
BEQ => self.branch(operand, page_crossed, self.p.zero),
BNE => self.branch(operand, page_crossed, !self.p.zero),
BMI => self.branch(operand, page_crossed, self.p.negative),
BPL => self.branch(operand, page_crossed, !self.p.negative),
BVC => self.branch(operand, page_crossed, !self.p.overflow),
BVS => self.branch(operand, page_crossed, self.p.overflow),
// --- флаги ---
CLC => self.p.carry = false,
SEC => self.p.carry = true,
CLI => self.p.interrupt_disable = false,
SEI => self.p.interrupt_disable = true,
CLD => self.p.decimal = false,
SED => self.p.decimal = true,
CLV => self.p.overflow = false,
NOP => {}
// --- неофициальные стабильные ---
LAX => {
let value = self.load(bus, operand);
self.a = value;
self.x = value;
self.p.set_zero_negative(value);
}
SAX => self.store(bus, operand, self.a & self.x),
DCP => {
let value = self.load(bus, operand).wrapping_sub(1);
self.store(bus, operand, value);
self.compare(self.a, value);
}
ISC => {
let value = self.load(bus, operand).wrapping_add(1);
self.store(bus, operand, value);
self.sbc(value);
}
SLO => {
let value = self.load(bus, operand);
let result = self.asl(value);
self.store(bus, operand, result);
self.a |= result;
self.p.set_zero_negative(self.a);
}
RLA => {
let value = self.load(bus, operand);
let result = self.rol(value);
self.store(bus, operand, result);
self.a &= result;
self.p.set_zero_negative(self.a);
}
SRE => {
let value = self.load(bus, operand);
let result = self.lsr(value);
self.store(bus, operand, result);
self.a ^= result;
self.p.set_zero_negative(self.a);
}
RRA => {
let value = self.load(bus, operand);
let result = self.ror(value);
self.store(bus, operand, result);
self.adc(result);
}
ANC => {
self.a &= self.load(bus, operand);
self.p.set_zero_negative(self.a);
self.p.carry = self.p.negative;
}
ALR => {
self.a &= self.load(bus, operand);
self.a = self.lsr(self.a);
}
ARR => {
self.a &= self.load(bus, operand);
self.a = (self.a >> 1) | ((self.p.carry as u8) << 7);
self.p.set_zero_negative(self.a);
self.p.carry = self.a & 0x40 != 0;
self.p.overflow = ((self.a >> 6) ^ (self.a >> 5)) & 1 != 0;
}
AXS => {
let value = self.load(bus, operand);
let result = (self.a & self.x) as u16;
let diff = result.wrapping_sub(value as u16);
self.p.carry = result >= value as u16;
self.x = diff as u8;
self.p.set_zero_negative(self.x);
}
// --- неофициальные нестабильные (nestest их не запускает) ---
XAA => {
self.a = self.x & self.load(bus, operand);
self.p.set_zero_negative(self.a);
}
LAS => {
let value = self.load(bus, operand) & self.sp;
self.a = value;
self.x = value;
self.sp = value;
self.p.set_zero_negative(value);
}
// Нестабильные store-опкоды: пишут `reg & (старший_байт_базы + 1)`, а
// при переходе через страницу подменяют старший байт целевого адреса
// этим же значением. `base` это абсолютный операнд до индексации.
AHX => {
let addr = Self::addr_of(operand);
let base = addr.wrapping_sub(self.y as u16);
let value = self.a & self.x & ((base >> 8) as u8).wrapping_add(1);
self.store_unstable(bus, base, addr, value);
}
TAS => {
self.sp = self.a & self.x;
let addr = Self::addr_of(operand);
let base = addr.wrapping_sub(self.y as u16);
let value = self.sp & ((base >> 8) as u8).wrapping_add(1);
self.store_unstable(bus, base, addr, value);
}
SHY => {
let addr = Self::addr_of(operand);
let base = addr.wrapping_sub(self.x as u16);
let value = self.y & ((base >> 8) as u8).wrapping_add(1);
self.store_unstable(bus, base, addr, value);
}
SHX => {
let addr = Self::addr_of(operand);
let base = addr.wrapping_sub(self.y as u16);
let value = self.x & ((base >> 8) as u8).wrapping_add(1);
self.store_unstable(bus, base, addr, value);
}
JAM => self.jammed = true,
}
}
// --- помощники ALU ---
fn adc(&mut self, value: u8) {
let sum = self.a as u16 + value as u16 + self.p.carry as u16;
let result = sum as u8;
self.p.carry = sum > 0xFF;
// Переполнение знака: одинаковый знак входов и другой знак результата.
self.p.overflow = (self.a ^ result) & (value ^ result) & 0x80 != 0;
self.a = result;
self.p.set_zero_negative(self.a);
}
fn sbc(&mut self, value: u8) {
// Вычитание это сложение с дополнительным кодом: a + !value + carry.
self.adc(!value);
}
fn compare(&mut self, register: u8, value: u8) {
let diff = register.wrapping_sub(value);
self.p.carry = register >= value;
self.p.set_zero_negative(diff);
}
fn asl(&mut self, value: u8) -> u8 {
self.p.carry = value & 0x80 != 0;
let result = value << 1;
self.p.set_zero_negative(result);
result
}
fn lsr(&mut self, value: u8) -> u8 {
self.p.carry = value & 0x01 != 0;
let result = value >> 1;
self.p.set_zero_negative(result);
result
}
fn rol(&mut self, value: u8) -> u8 {
let carry_in = self.p.carry as u8;
self.p.carry = value & 0x80 != 0;
let result = (value << 1) | carry_in;
self.p.set_zero_negative(result);
result
}
fn ror(&mut self, value: u8) -> u8 {
let carry_in = self.p.carry as u8;
self.p.carry = value & 0x01 != 0;
let result = (value >> 1) | (carry_in << 7);
self.p.set_zero_negative(result);
result
}
fn branch(&mut self, operand: Operand, page_crossed: bool, take: bool) {
if !take {
return;
}
// Взятое ветвление стоит +1 такт, переход через страницу ещё +1.
// Это внутренние такты (без доступа к памяти), добьются в конце шага.
self.extra_cycles += 1;
if page_crossed {
self.extra_cycles += 1;
}
self.pc = Self::addr_of(operand);
}
}
/// Перешёл ли адрес через границу страницы относительно базы (старший байт изменился).
fn page_crossed(base: u16, addr: u16) -> bool {
(base & 0xFF00) != (addr & 0xFF00)
}
/// Как инструкция трогает память: читает, пишет или читает-меняет-пишет. Определяет
/// фиктивные чтения при индексной адресации.
fn access_kind(mnemonic: Mnemonic) -> AccessKind {
use Mnemonic::*;
match mnemonic {
STA | STX | STY | SAX | SHX | SHY | AHX | TAS => AccessKind::Write,
ASL | LSR | ROL | ROR | INC | DEC | SLO | RLA | SRE | RRA | DCP | ISC => {
AccessKind::ReadModifyWrite
}
_ => AccessKind::Read,
}
}
Дальше разберём этот файл по кускам, чтобы каждый квирк стал понятен.
Состояние и сброс
Три регистра данных (a, x, y) это та же тройка, что у любого учебного процессора, а ниже начинаются особенности железа. cycles это бюджет тактов: на нём держится синхронизация с PPU из прошлого урока. extra_cycles копит такты, которых не было в таблице (взятое ветвление, переход через страницу). А service_nmi и service_irq это отложенные прерывания: 6502 опрашивает линии прерываний не сразу, а в конце инструкции, и обслуживает на следующем шаге. Из-за этого смена флага запрета прерываний видна с задержкой ровно в одну инструкцию, как в кремнии.
Сброс не обнуляет регистры данных. Он только читает вектор сброса и встаёт на него, а указатель стека уезжает на 0xFD. Векторы лежат в самом верху адресного пространства, и картридж заполняет их в уроке про мапперы. Адрес собирается в порядке младший байт, потом старший: это little-endian из RU3, 6502 хранит слова именно так.
Каждый доступ это такт
Главный приём всего ядра: каждый доступ к памяти это один такт процессора. Метод read сначала зовёт tick (который тикает шину и наращивает cycles), и только потом читает байт. Так PPU и CPU идут точка в точку: чтение $2002 видит ровно то состояние vblank, что и железо. На этом построена вся точность тактов: мы не считаем «сколько раз дёрнули шину», мы тикаем шину на каждый доступ и в конце добиваем остаток до табличной длины.
Декодирование и режимы адресации
Шаг читает опкод, декодирует его в OpInfo через таблицу из opcodes.rs, потом зовёт resolve: тот по режиму адресации вычисляет, где лежит операнд, и попутно решает, был ли переход через страницу. У 6502 тринадцать режимов, и resolve это самая хитрая часть ядра, потому что здесь живут точные такты.
Обрати внимание на self.read(bus, base as u16) внутри ZPX: это чтение, результат которого нам не нужен. Зачем оно? Затем, что в железе оно происходит. 6502 сначала читает по неисправленному адресу, и только потом по правильному.
Фиктивное чтение важно не из-за тактов (хотя и из-за них тоже), а из-за побочных эффектов. Если фиктивный адрес попал на регистр PPU, лишнее чтение что-то меняет в железе, и эмулятор обязан его воспроизвести. Логика, когда оно случается, вынесена в метод indexed_dummy_read: для записи и RMW читаем по неисправленному адресу всегда, для чтения только при переходе через страницу.
Страничный переход определяется одной строкой page_crossed в конце файла: совпадает ли старший байт базы и итогового адреса.
Цикл шага
Теперь видно, как всё собирается в один шаг. step проверяет отложенные прерывания, читает опкод, декодирует, разрешает операнд, исполняет, добивает такты до табличной длины и в конце опрашивает линии прерываний на будущее. Базовые такты берутся из таблицы, плюс единица штрафа за страницу, но только для читающих индексных режимов (ABX, ABY, IZY), как и обещала колонка page_penalty.
Ключевой приём: такты считаются не «сколько раз дёрнули шину», а «до табличной длины». Каждый доступ к памяти тикает шину сам, а в конце pad_to добивает оставшиеся внутренние такты. Так одна инструкция отдаёт ровно столько тактов, сколько обещала таблица, плюс честные штрафы за страницу и взятое ветвление. Именно на это число PPU прокрутится в финальном уроке.
Само исполнение это большой match по мнемонике. Логические опкоды выглядят так:
AND => {
self.a &= self.load(bus, operand);
self.p.set_zero_negative(self.a);
}
ORA => {
self.a |= self.load(bus, operand);
self.p.set_zero_negative(self.a);
}
Чистая проза: загрузили операнд, применили операцию, выставили Z и N. Большинство опкодов такие же короткие. Сложность не в них, а в арифметике и в тактах.
ADC и флаг переполнения
Сложение с переносом, ADC, это место, где новички теряют флаг V чаще всего. Вот тот же помощник adc из файла, отдельно.
fn adc(&mut self, value: u8) {
let sum = self.a as u16 + value as u16 + self.p.carry as u16;
let result = sum as u8;
self.p.carry = sum > 0xFF;
// Переполнение знака: одинаковый знак входов и другой знак результата.
self.p.overflow = (self.a ^ result) & (value ^ result) & 0x80 != 0;
self.a = result;
self.p.set_zero_negative(self.a);
}
Флаг C простой: сумма не влезла в 8 бит. А V это про знаковую арифметику. Он взводится, когда оба слагаемых одного знака, а результат вышел другого: значит, знаковое сложение переполнилось. Маска (self.a ^ result) & (value ^ result) & 0x80 ловит ровно это. Вычитание SBC не пишут отдельно: это сложение с дополнительным кодом, self.adc(!value).
Покрути сложение в виджете ниже: меняй A, операнд и входной перенос, смотри, как загораются C, Z, N, V и почему. Особенно поучительны пресеты, где сумма влезает в байт, но знаковое переполнение всё равно случилось.
Два самых коварных бага
Когда трейс начинает расходиться с эталоном, в девяти случаях из десяти виноват один из двух квирков железа.
Первый это баг косвенного JMP. Инструкция JMP ($xxFF) должна прочитать адрес перехода из $xxFF и $xx00 + 1, но 6502 не переносит единицу в старший байт указателя: старший байт берётся из начала той же страницы. Это не наша оплошность, это поведение чипа, и эмулятор обязан его повторить. Этот квирк живёт в методе read16_bug из файла выше:
fn read16_bug(&mut self, bus: &mut impl Bus, addr: u16) -> u16 {
let lo = self.read(bus, addr) as u16;
let hi_addr = (addr & 0xFF00) | ((addr + 1) & 0x00FF);
let hi = self.read(bus, hi_addr) as u16;
lo | (hi << 8)
}
Строка (addr & 0xFF00) | ((addr + 1) & 0x00FF) это и есть баг: к младшему байту прибавили единицу с заворотом внутри страницы, а старший байт оставили тем же. Тот же метод читает векторы NMI, IRQ и косвенный JMP.
Второй это флаг V в ADC и SBC, который мы только что разобрали: легко посчитать перенос и забыть про знаковое переполнение, и тогда тест разойдётся ровно на первой арифметике с граничными числами.
Зелёный nestest
nestest это ROM, который прогоняет почти все опкоды (официальные и стабильные неофициальные) и пишет результат в память. Рядом лежит эталонный построчный лог известного эмулятора Nintendulator. Запускаем nestest с pc = 0xC000 в автоматическом режиме, на каждом шаге собираем строку состояния и сверяем с золотой. Первая разошедшаяся строка показывает точный адрес бага.
Создай файл tests/nestest.rs. Шина тут плоская: PPU и APU не нужны, автоматический режим nestest их не трогает.
//! Golden-тест ядра 6502 против `nestest.nes` и эталонного трейса `nestest.log`.
//!
//! Урок 43. `nestest` это канонический тест процессора NES: он прогоняет все
//! опкоды (официальные и стабильные неофициальные) и пишет результат в RAM, а
//! референсный эмулятор Nintendulator оставил построчный лог состояния. Мы стартуем
//! с `$C000` в автоматическом режиме и на каждой инструкции сверяем PC, регистры,
//! флаги и счётчик тактов. Расхождение это точный адрес первого бага.
use our_nes::bus::Bus;
use our_nes::cpu::Cpu;
/// Плоская шина под nestest: 2 KiB ОЗУ с зеркалами и 16 KiB PRG ROM (NROM-128),
/// отражённые в `$8000` и `$C000`. PPU и APU не нужны: автоматический режим
/// nestest их не трогает.
struct NromBus {
ram: [u8; 0x800],
prg: Vec<u8>,
}
impl NromBus {
fn new(prg: Vec<u8>) -> Self {
NromBus {
ram: [0; 0x800],
prg,
}
}
}
impl Bus for NromBus {
fn read(&mut self, addr: u16) -> u8 {
match addr {
0x0000..=0x1FFF => self.ram[(addr & 0x07FF) as usize],
0x8000..=0xFFFF => {
// 16 KiB PRG зеркалируется на оба банка $8000 и $C000.
let index = (addr - 0x8000) as usize % self.prg.len();
self.prg[index]
}
_ => 0xFF, // открытая шина для незанятых адресов
}
}
fn write(&mut self, addr: u16, value: u8) {
if let 0x0000..=0x1FFF = addr {
self.ram[(addr & 0x07FF) as usize] = value;
}
}
}
/// Одна разобранная строка эталонного лога: то, что мы умеем сверить.
#[derive(Debug, PartialEq, Eq)]
struct LogLine {
pc: u16,
a: u8,
x: u8,
y: u8,
p: u8,
sp: u8,
cyc: u64,
}
/// Достать значение из строки по префиксу вроде `A:`, `SP:`, `CYC:`.
fn field<'a>(line: &'a str, key: &str) -> &'a str {
let start = line.find(key).expect("ключ есть в строке") + key.len();
let rest = &line[start..];
let end = rest.find([' ', ',']).unwrap_or(rest.len());
&rest[..end]
}
fn parse_log_line(line: &str) -> LogLine {
LogLine {
pc: u16::from_str_radix(&line[0..4], 16).unwrap(),
a: u8::from_str_radix(field(line, "A:"), 16).unwrap(),
x: u8::from_str_radix(field(line, "X:"), 16).unwrap(),
y: u8::from_str_radix(field(line, "Y:"), 16).unwrap(),
p: u8::from_str_radix(field(line, "P:"), 16).unwrap(),
sp: u8::from_str_radix(field(line, "SP:"), 16).unwrap(),
cyc: field(line, "CYC:").parse().unwrap(),
}
}
fn cpu_line(cpu: &Cpu) -> LogLine {
LogLine {
pc: cpu.pc,
a: cpu.a,
x: cpu.x,
y: cpu.y,
p: cpu.p.to_byte(false), // в логе бит B всегда 0, бит5 всегда 1
sp: cpu.sp,
cyc: cpu.cycles,
}
}
#[test]
fn nestest_matches_golden_log() {
let rom = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/roms/nestest.nes"))
.expect("nestest.nes лежит в roms/");
let log = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/roms/nestest.log"))
.expect("nestest.log лежит в roms/");
// iNES: 16 байт заголовка, затем PRG банками по 16 KiB. nestest это NROM-128.
assert_eq!(&rom[0..4], b"NES\x1a", "это iNES");
let prg_banks = rom[4] as usize;
let prg = rom[16..16 + prg_banks * 16384].to_vec();
let mut cpu = Cpu::new();
let mut bus = NromBus::new(prg);
// Автоматический режим nestest: старт с $C000, флаги $24, sp $FD, cyc 7.
cpu.set_pc(0xC000);
cpu.cycles = 7;
for (index, raw) in log.lines().enumerate() {
let expected = parse_log_line(raw);
let actual = cpu_line(&cpu);
assert_eq!(
actual,
expected,
"\nрасхождение на строке {} лога:\n ждали: {:04X?}\n получили: {:04X?}\n лог: {}",
index + 1,
expected,
actual,
raw
);
cpu.step(&mut bus);
}
// nestest пишет код результата в $0002 (0x00 значит все тесты прошли).
assert_eq!(
bus.read(0x0002),
0x00,
"$02 должен быть 0x00 после прохождения"
);
assert_eq!(
bus.read(0x0003),
0x00,
"$03 должен быть 0x00 после прохождения"
);
}
Тесту нужны два файла рядом: roms/nestest.nes (сам ROM) и roms/nestest.log (эталонный трейс Nintendulator). Создай в корне крейта папку roms/ и положи туда оба. Взять их можно со страницы Emulator tests на NESdev, она же есть в ресурсах урока. Путь в тесте собирается через CARGO_MANIFEST_DIR, так что папка должна лежать рядом с Cargo.toml, а не где-то ещё.
Сравниваемая строка собирается функцией cpu_line из регистров в формате лога: PC, A, X, Y, упакованный байт флагов, SP, CYC. В байте флагов бит B всегда 0, поэтому to_byte(false). Шина NromBus зеркалит 16 KiB PRG на оба банка $8000 и $C000, потому что nestest это NROM-128. Если файлов нет, тест честно упадёт на expect с понятным сообщением.
Запускай так:
cargo test --test nestest
Это контрольная точка урока. Зелёный nestest значит, что ядро считает правильно: все опкоды, все флаги, все такты. Это тот же golden-трейс, что закрывал прошлый блок, только эталон не наш, а общий для всех эмуляторов NES. Падение на конкретной строке указывает на точный адрес и инструкцию, где трейс разошёлся: с этого адреса и начинай искать баг.
Дальше
Зелёный nestest значит, что ядро считает правильно: все опкоды, все флаги, все такты. Дальше подключаем к нему реальную игру через картридж и мапперы: научим шину отдавать PRG ROM и переключать банки.