Раздел 23 · Rust

Архитектура NES

senior~40 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Архитектура NES

Открываем блок из семи уроков. В прошлом проекте ты собрал эмулятор учебного процессора с шиной, периферией и HAL. Теперь берём настоящую приставку 1983 года и доводим эмулятор до точки, где он запускает реальную игру. Приём тот же, что в эмуляторе CPU: сначала карта, потом код. Этот урок не исполняет ни одной инструкции 6502, он расставляет четыре машины по местам, разбирает формат картриджа и собирает скелет, который компилируется и тикает. А в конце ты погоняешь готовую NES прямо в браузере: это тот же Rust, что мы пишем в блоке, собранный в WebAssembly.

Что воспроизводит NES

Nintendo Entertainment System (в Японии Famicom) это не один чип, а маленькая система из нескольких устройств на общей шине. Эмулятор повторяет ровно эту структуру, и каждая часть станет отдельным уроком. Внутри коробки четыре машины.

  • CPU 2A03: ядро 6502 без десятичного режима, плюс встроенный звуковой блок APU. Это урок 43 и половина 47-го.
  • PPU 2C02: графический сопроцессор, рисует фон и спрайты по своему таймингу. Уроки 45 и 46.
  • Картридж: PRG ROM с кодом, CHR ROM с графикой, маппер для переключения банков. Это урок 44.
  • Шина: провода, по которым процессор дотягивается до памяти, регистров PPU, регистров APU, геймпадов и картриджа. Её мы спроектируем сегодня.

PPU и CPU работают параллельно и в разном темпе. Это и есть главная трудность блока: задача не в том, чтобы «выполнить инструкцию», а в том, чтобы синхронизировать две машины такт в такт. К этому мы будем возвращаться в каждом уроке.

CPU 2A03 исполняет код игры. Но сам по себе он умеет ровно одно: читать и писать байты по 16-битным адресам. Что лежит по адресу, процессор не знает и знать не должен. Вот с этого разделения и начнём.

Главная идея: процессор не знает, что снаружи

В уроке про шину учебного CPU мы уже это проходили: ядро параметризовано трейтом шины и общается с миром только через read и write. На NES идея ровно та же, и она несущая для всего блока. Объявим контракт шины.

/// Контракт шины процессора 6502: единственная дверь ядра в мир NES.
pub trait Bus {
    /// Прочитать байт по 16-битному адресу.
    ///
    /// Почему `&mut self`, а не `&self`? Чтение регистра устройства часто имеет
    /// побочный эффект. Например, чтение статуса PPU по адресу 0x2002 сбрасывает
    /// флаг vblank. Значит, даже «просто прочитать» может менять состояние.
    fn read(&mut self, addr: u16) -> u8;

    /// Записать байт по 16-битному адресу.
    fn write(&mut self, addr: u16, value: u8);

    /// Продвинуть всю периферию ровно на один такт процессора. В NES это три
    /// точки PPU и один такт APU. Процессор зовёт `tick` на каждый свой такт,
    /// поэтому PPU и APU идут синхронно с CPU точка в точку.
    fn tick(&mut self) {}

    /// Снять фронт линии NMI. PPU поднимает её в начале vblank. Процессор
    /// опрашивает линию между инструкциями и, если фронт есть, уходит в
    /// обработчик прерывания. По умолчанию фронта нет.
    fn poll_nmi(&mut self) -> bool {
        false
    }
}

NMI и tick это не лишние методы: на них держится синхронизация. Но обрати внимание на приём: у трейта есть методы по умолчанию. Ядро 6502 в уроке 43 будет тестироваться на плоской памяти, которой не нужны ни PPU, ни прерывания, поэтому она реализует только read и write, а tick и poll_nmi берёт по умолчанию. Это прямо идиома из RU2: дать разумные значения по умолчанию, чтобы простой случай оставался простым.

Карта памяти CPU и зеркала

У 6502 ровно 16 адресных линий, значит 64 КБ адресного пространства, от 0x0000 до 0xFFFF. NES раскладывает по нему всё: память, регистры устройств, картридж. Вот карта.

ДиапазонЧто этоРазмер
0x0000 до 0x1FFFвнутренняя RAM, 2 КБ, отражена 4 раза8 КБ окно на 2 КБ
0x2000 до 0x3FFF8 регистров PPU, отражены каждые 8 байт8 КБ окно на 8 байт
0x4000 до 0x4015регистры APU
0x4016 до 0x4017геймпады
0x4014запуск DMA спрайтов
0x4020 до 0xFFFFкартридж: PRG ROM, PRG RAM, через маппер

Сразу видна странность: 2 КБ памяти занимают 8 КБ адресов, а 8 регистров PPU размазаны по целым 8 КБ. Это зеркалирование: на плате не разведены старшие адресные линии, поэтому один и тот же чип отвечает по нескольким адресам. В эмуляторе это одна маска.

  • RAM: физический адрес это addr & 0x07FF (берём младшие 11 бит, 2 КБ).
  • Регистры PPU: номер регистра это addr & 0x0007 (берём младшие 3 бита, 8 штук).

Соберём шину NES. Она и есть карта памяти: по диапазону адреса решает, кому отдать байт. PPU и APU пока заглушки, их наполнят следующие уроки, но маршрутизация настоящая.

/// Карта памяти процессора NES. Это и есть реализация трейта `Bus` для системы.
pub struct SystemBus {
    ram: [u8; 0x800],     // 2 КБ внутренней памяти
    ppu: Ppu,             // заглушка, урок 45
    apu: Apu,             // заглушка, урок 47
    cart: Cartridge,      // PRG и CHR ROM, урок 44
}

impl Bus for SystemBus {
    fn read(&mut self, addr: u16) -> u8 {
        match addr {
            0x0000..=0x1FFF => self.ram[(addr & 0x07FF) as usize],
            0x2000..=0x3FFF => self.ppu.read_register(addr & 0x0007),
            0x4015 => self.apu.read_status(),
            0x4016 => 0, // геймпад 1, урок 48
            0x4017 => 0, // геймпад 2, урок 48
            0x4000..=0x4014 | 0x4018..=0x401F => 0, // прочее: open bus
            _ => self.cart.cpu_read(addr), // 0x4020..=0xFFFF, картридж
        }
    }

    fn write(&mut self, addr: u16, value: u8) {
        match addr {
            0x0000..=0x1FFF => self.ram[(addr & 0x07FF) as usize] = value,
            0x2000..=0x3FFF => self.ppu.write_register(addr & 0x0007, value),
            0x4014 => { /* DMA спрайтов, урок 48 */ }
            0x4000..=0x4013 | 0x4015 | 0x4017 => self.apu.write_register(addr, value),
            0x4016 => { /* strobe геймпадов, урок 48 */ }
            0x4018..=0x401F => {}
            _ => self.cart.cpu_write(addr, value),
        }
    }
}

Open bus это то, что читается с адреса, к которому ничего не подключено. Мы возвращаем 0: для запуска игр этого достаточно. Заметь ещё асимметрию по 0x4017: на запись это регистр APU (frame counter), а на чтение это геймпад 2. Один адрес, два разных устройства в зависимости от направления. На реальном железе такое сплошь и рядом, и карта памяти обязана это учитывать.

Формат iNES: как игра попадает внутрь

Игра приходит файлом .nes в формате iNES. Это образ картриджа: 16 байт заголовка, затем банки PRG ROM (код), затем банки CHR ROM (графика). Заголовок говорит, сколько тех и других, какой номер маппера и как разведено зеркалирование.

iNES разбирается несложно, но в заголовке есть тонкости: номер маппера собран из двух половинок в разных байтах, а зеркалирование закодировано двумя битами. Сначала опишем, что вообще бывает за зеркалирование и какие ошибки возможны.

/// Как картридж разводит зеркалирование таблиц имён PPU. Влияет на скролл,
/// подробности в уроке 45. Сейчас это просто поле, которое едет из заголовка.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mirroring {
    Horizontal,
    Vertical,
    FourScreen,
}

/// Что может пойти не так при разборе образа.
#[derive(Debug, PartialEq, Eq)]
pub enum RomError {
    /// Нет сигнатуры NES\x1a в начале файла: это не iNES.
    BadMagic,
    /// Файл короче, чем обещает заголовок.
    Truncated,
}

Теперь сам разбор. Возвращаем структуру с уже вырезанными срезами PRG и CHR и распознанными полями. Конструирование конкретного маппера по mapper_id это работа урока 44, здесь мы доводим файл до удобного вида и не дальше.

/// Размер банка по формату iNES.
const PRG_BANK: usize = 16 * 1024;
const CHR_BANK: usize = 8 * 1024;

/// Разобранный образ картриджа.
pub struct Rom {
    pub prg_rom: Vec<u8>,
    pub chr_rom: Vec<u8>,
    pub mapper_id: u8,
    pub mirroring: Mirroring,
}

pub fn parse_rom(bytes: &[u8]) -> Result<Rom, RomError> {
    // 1. Сигнатура: байты 'N', 'E', 'S' и символ конца файла 0x1A.
    if bytes.len() < 16 || &bytes[0..4] != b"NES\x1a" {
        return Err(RomError::BadMagic);
    }

    // 2. Размеры в банках и флаги.
    let prg_banks = bytes[4] as usize;
    let chr_banks = bytes[5] as usize;
    let flags6 = bytes[6];
    let flags7 = bytes[7];

    // 3. Номер маппера собран из двух нибблов: старший в flags7, младший в flags6.
    let mapper_id = (flags7 & 0xF0) | (flags6 >> 4);

    // 4. Зеркалирование. Бит 3 это four-screen (перекрывает остальное),
    //    иначе бит 0: 1 это вертикальное, 0 это горизонтальное.
    let mirroring = if flags6 & 0b0000_1000 != 0 {
        Mirroring::FourScreen
    } else if flags6 & 0b0000_0001 != 0 {
        Mirroring::Vertical
    } else {
        Mirroring::Horizontal
    };

    // 5. Опциональный 512-байтный trainer перед PRG: устаревший, просто пропускаем.
    let has_trainer = flags6 & 0b0000_0100 != 0;
    let mut offset = 16 + if has_trainer { 512 } else { 0 };

    // 6. Вырезаем банки. Если файл короче обещанного, это битый образ.
    let prg_len = prg_banks * PRG_BANK;
    let chr_len = chr_banks * CHR_BANK;
    if bytes.len() < offset + prg_len + chr_len {
        return Err(RomError::Truncated);
    }

    let prg_rom = bytes[offset..offset + prg_len].to_vec();
    offset += prg_len;
    let chr_rom = bytes[offset..offset + chr_len].to_vec();

    Ok(Rom { prg_rom, chr_rom, mapper_id, mirroring })
}

Здесь как на ладони вся работа из блока про биты: склейка нибблов через маску и сдвиг, чтение отдельных битов флагов, нарезка байтового буфера на части. Если ты прошёл RU3, парсер iNES это разминка, а не новый материал. Это и есть смысл порядка разделов: к проектам ты приходишь с готовыми инструментами.

Стоит написать на парсер тест прямо сейчас, пока заголовок свежий в голове.

#[test]
fn rejects_garbage() {
    assert_eq!(parse_rom(&[0; 8]), Err(RomError::BadMagic));
}

#[test]
fn reads_header_fields() {
    // Минимальный валидный заголовок: 1 банк PRG, 1 банк CHR, маппер 0.
    let mut bytes = vec![b'N', b'E', b'S', 0x1a, 1, 1, 0, 0];
    bytes.resize(16, 0);
    bytes.resize(16 + PRG_BANK + CHR_BANK, 0xEA); // тело забьём NOP-байтами
    let rom = parse_rom(&bytes).expect("валидный образ");
    assert_eq!(rom.mapper_id, 0);
    assert_eq!(rom.prg_rom.len(), PRG_BANK);
    assert_eq!(rom.mirroring, Mirroring::Horizontal);
}

Тактовые домены: три к одному

Все машины висят на одном кварце, но делят его по-разному. Соотношение жёсткое: на один такт CPU приходится ровно три такта (точки) PPU. APU тикает вместе с CPU. Если эмулятор не держит это соотношение, развалятся скролл и звук: картинка поедет, тон поплывёт.

Самый простой и точный способ удержать соотношение это tick на шине. Процессор зовёт его на каждый свой такт, а шина внутри прокручивает PPU три раза и APU один раз. Покажем на заглушках, как это выглядит, не вдаваясь в реализацию самих машин.

// Заглушки машин. В этом уроке они только считают такты, чтобы показать
// соотношение доменов. Настоящая логика появится в своих уроках.
pub struct Ppu { pub dots: u64 }
pub struct Apu { pub ticks: u64 }
pub struct Cartridge { rom: Rom }

impl Ppu {
    fn tick(&mut self) { self.dots += 1; }
    fn read_register(&mut self, _reg: u16) -> u8 { 0 }
    fn write_register(&mut self, _reg: u16, _value: u8) {}
}
impl Apu {
    fn tick(&mut self) { self.ticks += 1; }
    fn read_status(&mut self) -> u8 { 0 }
    fn write_register(&mut self, _addr: u16, _value: u8) {}
}
impl Cartridge {
    fn cpu_read(&mut self, addr: u16) -> u8 {
        // NROM-подобное отображение для скелета: последние 16 или 32 КБ ROM.
        let len = self.rom.prg_rom.len();
        self.rom.prg_rom[(addr as usize - 0x8000) % len]
    }
    fn cpu_write(&mut self, _addr: u16, _value: u8) {}
}

impl Bus for SystemBus {
    // ... read и write из раздела выше ...

    fn tick(&mut self) {
        // Один такт процессора это три точки PPU и один такт APU.
        self.ppu.tick();
        self.ppu.tick();
        self.ppu.tick();
        self.apu.tick();
    }
}

Теперь видно, зачем tick в трейте шины: ядро 6502 в уроке 43 будет звать его на каждый свой такт, и периферия сама окажется синхронной, без отдельного планировщика. Это та же развилка точность против скорости из архитектуры эмулятора. Мы выбираем потактовую синхронизацию: дороже, чем «прокрутить PPU раз в кадр», но именно она даёт правильные скролл и sprite 0 hit, на которых держатся реальные игры.

Сборку всей системы в один тип Nes и цикл кадра мы соберём в финальном уроке, когда каждая машина перестанет быть заглушкой. Но скелет уже компилируется и тикает, и это важно: у тебя с первого урока есть форма целого, в которую дальше встают настоящие детали. Так же мы строили эмулятор CPU.

Погоняй готовую NES

Виджет ниже это наш эмулятор из этого блока, собранный в WebAssembly: тот самый Rust, что мы пишем по урокам. По умолчанию в него загружен свободный тест-ROM nestest, потому что коммерческих игр в курсе нет и быть не может. Свой .nes можно подгрузить кнопкой ROM.

Что здесь есть и как это связано с уроком.

  • Экран и кнопка Играть запускают кадровый цикл: каждые 1/60 секунды эмулятор прогоняет кадр и рисует его. Управление с клавиатуры: крестовина это стрелки, Z это кнопка A, X это B, Enter это Start, правый Shift это Select.
  • Шаг исполняет одну инструкцию 6502 и замораживает картинку. Справа панель отладчика: регистры PC, A, X, Y, SP, флаги NV-BDIZC и счётчик тактов CYC. Это ровно то состояние процессора, которое мы распишем в уроке 43.
  • Дизассемблер показывает код вокруг PC. Кликни по строке, и встанет точка останова, а кнопка До точки домотает до неё. Это тот же отладчик с брейкпойнтами и golden-трейсом, что закрывал прошлый блок, только для 6502.
  • PPU раскрывает внутренности графики: таблицы паттернов (тайлы из CHR ROM картриджа), 32 ячейки палитры и таблицы имён. Сейчас это магия, но к уроку 45 ты будешь читать эту панель как открытую книгу.

Пощёлкай Шагом по nestest и посмотри на дизассемблер: это настоящий код 6502 из картриджа, проехавший через ровно ту карту памяти, что мы написали выше. Тот байт, который читает процессор, шина достала из cart.cpu_read.

Дальше

Карта есть, картридж разбирается, скелет тикает. Дальше спускаемся в самый низ и пишем сердце системы: полный 6502 под nestest. Когда процессор станет зелёным по эталонному тесту, мы подключим к нему настоящий картридж с мапперами, потом графику, потом звук, и в финале запустим реальную игру.

Домашка