Featured image of post Почему старые игровые консоли можно воссоздать программно: Архитектура эмуляторов — интерпретаторы, JIT Dynarec, Fastmem и потактовая точность

Почему старые игровые консоли можно воссоздать программно: Архитектура эмуляторов — интерпретаторы, JIT Dynarec, Fastmem и потактовая точность

Как специализированные кремниевые консоли вроде NES, SNES, PlayStation 2 и GameCube работают с побитовой точностью на современных процессорах x86-64 и ARM64? От универсальной машины Тьюринга и ошибок предсказания ветвлений в интерпретаторах до динамической двоичной трансляции JIT (Dynarec), перехвата MMU через Fastmem и потактовой синхронизации сопрограмм.

1. От кремния к математике: Универсальная машина Тьюринга и изоморфизм конечных автоматов

Nintendo Entertainment System (1983), Super Nintendo (1990), PlayStation 2 (2000) и GameCube (2001) не были универсальными компьютерами — они представляли собой ансамбли узкоспециализированных заказных микросхем (ASIC и кастомных БИС/LSI). Тем не менее сегодня мы можем запускать их оригинальные образы ПЗУ (ROM) на современных ПК и смартфонах с архитектурой x86-64 или ARM64 с побитовой точностью. Программная дисциплина, делающая это возможным, называется аппаратной эмуляцией.

Как чистая программа на совершенно чужеродной архитектуре способна в точности воспроизвести физическое поведение миллионов транзисторов и кварцевых резонаторов? Теоретический фундамент этого явления заложил Алан Тьюринг в 1936 году, доказав существование Универсальной машины Тьюринга (UTM):

$$U(\langle M \rangle, x) = M(x)$$

В сочетании с тезисом Чёрча — Тьюринга это равенство означает: если $M$ — физическое оборудование консоли, $\langle M \rangle$ — исходный код эмулятора, описывающий логику цепей $M$, а $x$ — данные картриджа/диска и нажатия кнопок геймпада, то любой тьюринг-полный хост-компьютер $U$, исполняющий $\langle M \rangle$, гарантированно породит ту же самую последовательность состояний и аудиовизуальных выходов $M(x)$, что и оригинальная приставка.

С точки зрения цифровой схемотехники любая синхронная цифровая схема (CPU, графический процессор PPU, звуковой процессор APU), тактируемая главным генератором, математически сводится к детерминированному конечному автомату (FSM). Пусть $S_t \in \mathcal{S}$ — полный вектор состояния всех триггеров, регистров, флагов, видеопамяти VRAM и ОЗУ WRAM в дискретный такт $t$, а $I_t \in \mathcal{I}$ — вектор внешних входов. Тогда между тактами $t$ и $t+1$ комбинационная сеть логических вентилей вычисляет две чистые функции:

$$S_{t+1} = \delta(S_t, I_t), \qquad O_t = \lambda(S_t, I_t)$$

где $\delta$ — функция переходов состояний, а $\lambda$ — функция выхода, формирующая пиксели RGB и аудиоотсчёты PCM. Булева алгебра, физически реализуемая переключением напряжений ($0\,\text{В}$ и $3{,}3\,\text{В}/5\,\text{В}$) в КМОП-транзисторах, математически изоморфна побитовым операциям (&, |, ^, ~, <<), которые процессор хоста выполняет над переменными uint32_t и uint64_t.

Следовательно, главная проблема эмуляции — не вычислимость, а вычислительная сложность в жёстком реальном времени. В NTSC-консоли Super Nintendo с частотой мастер-кварца $21{,}477272\,\text{МГц}$ кадр состоит из $262$ строк развёртки (scanlines) по $1364$ мастер-такта:

$$f_{\text{frame}} = \frac{21\,477\,272\,\text{Гц}}{262 \times 1364} \approx 60{,}0988\,\text{Гц} \implies T_{\text{frame}} \approx 16{,}639\,\text{мс}$$

В реальном «железе» миллионы вентилей вычисляют $\delta(S_t, I_t)$ одновременно (пространственно параллельно) каждую наносекунду, тогда как эмулятор обязан сериализовать этот параллелизм в последовательный поток команд хост-CPU и уложиться в жёсткий лимит $16{,}639\,\text{миллисекунды}$ на кадр.


2. Основы эмуляции CPU: От Switch-интерпретатора к динамической рекомпиляции (JIT / Dynarec)

2.1 Классический Switch-интерпретатор и штрафы предсказателя ветвлений

Самый очевидный способ эмулировать гостевой процессор — воспроизвести цикл Выборка (Fetch) $\to$ Декодирование (Decode) $\to$ Исполнение (Execute) внутри цикла while с гигантским оператором switch (opcode).

Однако на современных суперскалярных процессорах такой интерпретатор упирается в микроархитектурное бутылочное горлышко: весь switch компилируется в одну-единственную инструкцию косвенного перехода (jmp *[rax + rdx*8]). Поскольку гостевые опкоды постоянно меняются, предсказатель косвенных переходов (BTB) хост-процессора непрерывно ошибается в этой точке, вызывая сброс (flush) 14–20-ступенчатого конвейера и потерю десятков тактов на каждую команду. Переход к Direct Threaded Code через вычисляемые метки (goto *dispatch_table[opcode]) распределяет косвенные переходы по концам обработчиков, но не устраняет накладные расходы на пошаговое декодирование.

2.2 Динамическая двоичная трансляция (JIT / Dynarec) и удаление мёртвых флагов

Высокопроизводительные эмуляторы (Dolphin, PCSX2, RPCS3, QEMU TCG, Apple Rosetta 2) используют динамическую рекомпиляцию (JIT / Dynarec): они переводят целые базовые блоки (Basic Blocks) гостевого машинного кода в нативный машинный код хоста (x86-64 / ARM64) прямо во время выполнения и запускают его из кэша кода.

  flowchart TD
    G["Базовый блок гостевого кода (MIPS / PowerPC / 65816)"] --> L["1. Поднятие в SSA IR (Instruction Lifting)<br/>Декомпозиция гостевых команд в архитектурно-независимое представление"]
    L --> O["2. Оптимизирующие проходы IR (Optimization Passes)<br/>Свёртка констант и удаление мёртвых флагов (Dead-Flag Elision)"]
    O --> R["3. Распределение регистров (Register Allocation)<br/>Linear Scan отображает горячие гостевые регистры на физические регистры хоста"]
    R --> E["4. Генерация кода и сшивка блоков (Block Chaining)<br/>Запись в исполняемую память и прямой патчинг нативных переходов jmp"]
    E --> X["5. Нативное исполнение на полной скорости хост-CPU<br/>Ловушка защиты страниц перехватывает самомодифицирующийся код (SMC)"]

Ключевые оптимизации промышленного JIT-компилятора включают:

  1. Удаление мёртвых флагов (Dead-Flag Elision): Арифметические команды гостя постоянно обновляют флаги состояния (Z, N, C, V), но большинство из них тут же перезаписывается следующей командой без чтения. Анализ живучести переменных (Liveness Analysis) на SSA-графе позволяет выбросить более $80\,\%$ бесполезных вычислений флагов.
  2. Физическое распределение регистров: Алгоритм Linear Scan удерживает наиболее часто используемые регистры гостя непосредственно в аппаратных регистрах x86-64/ARM64, избавляя от обращений к структуре CPUState в памяти.
  3. Сшивка блоков (Block Chaining): Если скомпилированный блок A переходит к уже скомпилированному блоку B, JIT напрямую патчит инструкцию jmp в конце блока A на нативный адрес блока B, минуя диспетчерский цикл C++.
  4. Перехват самомодифицирующегося кода (SMC): Когда игра перезаписывает собственный код в ОЗУ, эмулятор фиксирует запись через программные маски или аппаратную защиту страниц от записи (mprotect / VirtualProtect) и мгновенно инвалидирует устаревшие JIT-блоки.
Критерий сравненияSwitch-интерпретаторThreaded-интерпретаторДинамический рекомпилятор (JIT / Dynarec)
Затраты на 1 команду гостя$20 \sim 80+$ команд хоста$12 \sim 35$ команд хоста$1 \sim 4$ нативные команды хоста
Предсказание ветвленийКрайне плохое (одна точка отказа BTB)Среднее (распределённые переходы)Отличное (прямые нативные jmp и сшивка)
Удаление мёртвых флаговНевозможно (нет контекста между опкодами)Весьма ограниченноПолное (глобальный анализ на SSA IR)
Сложность и переносимостьПростейшая, чистый стандартный C/RustТребует расширений компилятораОгромная (нужен бэкенд под каждую ISA)
Потактовая точностьОчень легко (прерывание на любом такте)ЛегкоСложно (учёт тактов на границах блоков)

3. Шина памяти и Fastmem: Перехват аппаратного MMU хоста

В консолях центральный процессор управляет графикой, звуком и DMA через регистры отображаемого в память ввода-вывода (MMIO). Если при каждом чтении или записи программно проверять через if-else, попадает ли адрес в ОЗУ или в область MMIO, быстродействие JIT упадёт в разы.

Технология Fastmem решает эту проблему с помощью аппаратного блока управления памятью (MMU) 64-битной ОС хоста:

  1. Эмулятор резервирует в 64-битном адресном пространстве хоста непрерывную виртуальную арену размером во всё адресное пространство гостя (например, $4\,\text{ГБ}$ через mmap / VirtualAlloc для 32-битной консоли) и хранит её базовый указатель в выделенном регистре хоста (rbx).
  2. Страницы обычного ОЗУ и его зеркал отображаются с правами чтения и записи (PROT_READ | PROT_WRITE).
  3. Все страницы, соответствующие аппаратным регистрам MMIO, намеренно помечаются как недоступные (PROT_NONE / PAGE_NOACCESS).

Благодаря этому JIT-компилятор транслирует любое чтение гостевой памяти в одну-единственную нативную инструкцию без единого ветвления:

1
2
; rbx = база 4 ГБ виртуальной арены, edi = 32-битный гостевой адрес
mov eax, dword ptr [rbx + rdi]   ; Трансляция адреса и чтение за 1 команду!
  • Быстрый путь ($99{,}9\,\%$ обращений к ОЗУ): Аппаратные MMU и TLB процессора хоста выполняют загрузку за 3–4 такта при нулевых программных накладных расходах.
  • Медленный путь ($0{,}1\,\%$ обращений к MMIO): При обращении к странице PROT_NONE аппаратный MMU мгновенно генерирует страничное прерывание (SIGSEGV в POSIX или векторное исключение STATUS_ACCESS_VIOLATION в Windows). Обработчик сигналов эмулятора перехватывает исключение, декодирует инструкцию, вызывает C++-функцию mmio_read/write(), записывает результат в регистр и сдвигает указатель команд RIP.

4. Потактовая точность (Cycle Accuracy) и синхронизация сопрограмм

Почему легендарный создатель эмуляторов Near (byuu) утверждал, что для 100%-но потактово точной эмуляции Super Nintendo с частотой всего $3{,}58\,\text{МГц}$ требовался современный процессор с частотой $3\,\text{ГГц}$?

Ранние построчные (Scanline-based) эмуляторы сначала выполняли CPU на протяжении всей строки развёртки ($228$ тактов), а затем PPU разом отрисовывал все 256 пикселей этой строки. Однако многие игры изменяют видеорегистры (палитру, скроллинг или матрицы Mode 7) прямо посреди отрисовки строки (Mid-Scanline) с помощью CPU или HDMA. Без синхронизации с точностью до пиксельного такта возникают графические артефакты или зависания.

  flowchart TD
    subgraph Inaccurate["Построчная эмуляция (Быстрая, но ломает эффекты внутри строки)"]
        S1["1. CPU выполняет все 228 тактов строки подряд<br/>(меняет регистр палитры на 112-м такте)"] --> S2["2. PPU задним числом рисует все 256 пикселей строки<br/>(ошибочно применяя новую палитру с самого 0-го пикселя!)"]
    end

    subgraph CycleAcc["Потактовая синхронизация сопрограмм (Cycle-Accurate Catch-up)"]
        C1["1. CPU выполняется ровно до такта t = 112<br/>и пишет в MMIO-регистр палитры PPU"] --> C2["2. Мгновенное переключение в сопрограмму PPU<br/>PPU догоняет время (Catch-up), рисуя пиксели с 0 по 111"]
        C2 --> C3["3. Новая палитра применяется точно на такте t = 112<br/>PPU дорисовывает пиксели 112–255 без малейших искажений"]
    end

В потактово точных эмуляторах вроде bsnes / higan / Ares чипы CPU, PPU, SMP и DSP работают как независимые кооперативные сопрограммы пользовательского пространства (Coroutines / Fibers, libco). Как только один чип совершает действие, видимое другому, co_switch() переключает контекст всего за ~10 ассемблерных команд, позволяя целевому чипу догнать (Catch-up) эталонное время.

Наконец, современная инженерия эмуляции решает ещё три фундаментальные задачи:

  • Нестандартная арифметика с плавающей запятой (Non-IEEE-754): Процессор Emotion Engine и векторные блоки PS2 не поддерживают Inf и NaN, а денормализованные числа принудительно обнуляют. Эмулятору PCSX2 приходится маскировать и ограничивать SIMD-регистры хоста, чтобы физика в играх не «взрывалась».
  • Упреждающее выполнение Run-Ahead: Скрытно просчитывая $N$ кадров вперёд в фоновом режиме и мгновенно загружая снимки состояния (Savestates) в ОЗУ, эмулятор устраняет внутреннюю буферную задержку самой игры, обеспечивая отклик быстрее оригинальной приставки на ЭЛТ-телевизоре.
  • FPGA и сохранение цифрового наследия: Наряду с аппаратным воссозданием вентильных схем на базе FPGA (MiSTer) на языках Verilog/VHDL, открытый код точных эмуляторов служит исполняемой аппаратной документацией, спасающей интерактивное искусство человечества от необратимого физического старения кремниевых чипов XX века.
comments powered by Disqus