Архитектура NES
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Архитектура 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 до 0x3FFF | 8 регистров 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. Когда процессор станет зелёным по эталонному тесту, мы подключим к нему настоящий картридж с мапперами, потом графику, потом звук, и в финале запустим реальную игру.