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-компилятора включают:
- Удаление мёртвых флагов (Dead-Flag Elision): Арифметические команды гостя постоянно обновляют флаги состояния (Z, N, C, V), но большинство из них тут же перезаписывается следующей командой без чтения. Анализ живучести переменных (Liveness Analysis) на SSA-графе позволяет выбросить более $80\,\%$ бесполезных вычислений флагов.
- Физическое распределение регистров: Алгоритм Linear Scan удерживает наиболее часто используемые регистры гостя непосредственно в аппаратных регистрах x86-64/ARM64, избавляя от обращений к структуре
CPUStateв памяти. - Сшивка блоков (Block Chaining): Если скомпилированный блок A переходит к уже скомпилированному блоку B, JIT напрямую патчит инструкцию
jmpв конце блока A на нативный адрес блока B, минуя диспетчерский цикл C++. - Перехват самомодифицирующегося кода (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-битной ОС хоста:
- Эмулятор резервирует в 64-битном адресном пространстве хоста непрерывную виртуальную арену размером во всё адресное пространство гостя (например, $4\,\text{ГБ}$ через
mmap/VirtualAllocдля 32-битной консоли) и хранит её базовый указатель в выделенном регистре хоста (rbx). - Страницы обычного ОЗУ и его зеркал отображаются с правами чтения и записи (
PROT_READ | PROT_WRITE). - Все страницы, соответствующие аппаратным регистрам MMIO, намеренно помечаются как недоступные (
PROT_NONE/PAGE_NOACCESS).
Благодаря этому JIT-компилятор транслирует любое чтение гостевой памяти в одну-единственную нативную инструкцию без единого ветвления:
| |
- Быстрый путь ($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 века.
