Если вы давно хотели написать свой эмулятор компьютера, самое время начать. Мы нашли полезную статью, написанную уже давно, но всё ещё актуальную. 

Статья охватывает несколько общих технических вопросов, которые часто задают при написании эмуляторов. Также здесь вы найдёте «схемы» внутреннего устройства эмулятора, которым можно следовать.

Что можно эмулировать?

По сути, всё, что имеет микропроцессор внутри. Конечно, для эмулирования интересны только устройства с более-менее гибкой программой. К ним относятся:

  • Компьютеры

  • Калькуляторы

  • Игровые приставки

  • Аркадные игровые автоматы

  • и т. д.

Вы можете эмулировать любую компьютерную систему, даже очень сложную (например, компьютер Commodore Amiga). Однако производительность такой эмуляции может оказаться очень низкой.

Что такое «эмуляция» и чем она отличается от «симуляции»?

Эмуляция — это попытка имитировать внутреннюю структуру устройства. Симуляция — это попытка имитировать функции устройства. Например, программа, имитирующая игровой автомат Pacman и запускающая на нем настоящее ПЗУ Pacman, является эмулятором. Игра Pacman, написанная для вашего компьютера, но использующая графику, похожую на настоящую аркаду, является симулятором.

Законно ли эмулировать проприетарное оборудование?

Эмулирование проприетарного оборудования является законным, хоть и находится в «серой» зоне. Главное, чтобы информация для эмулирования была получена законным путем. Также следует знать, что распространение системных ПЗУ (BIOS и т. д.) с помощью эмулятора является незаконным, если они защищены авторским правом.

Три способа создания эмуляции

Есть три основные схемы, которые можно использовать для создания эмулятора. Их можно комбинировать для достижения наилучшего результата.

Интерпретация

Эмулятор байт за байтом считывает эмулируемый код из памяти, декодирует его и исполняет соответствующие команды на эмулируемых регистрах, в памяти и вводе-выводе. Общий алгоритм работы такого эмулятора следующий:

while(CPUIsRunning)

{

  Fetch OpCode

  Interpret OpCode

}

К достоинствам этой модели относится простота отладки, переносимость и простота синхронизации (вы можете просто подсчитать количество пройденных тактов и привязать к нему остальную часть вашей эмуляции).

Единственный большой и очевидный недостаток — низкая производительность. Интерпретация занимает много ресурсов процессора, так что для нормальной работы потребуется довольно шустрый компьютер. 

Статическая перекомпиляция

Этот метод заключается в следующем: вы берете программу, написанную на эмулированном коде, и пытаетесь перевести ее в код ассемблера вашего компьютера. В результате получится обычный исполняемый файл, который вы сможете запустить на своем компьютере без дополнительного шаманства. Звучит здорово, но это не всегда возможно. Например, вы не можете статически перекомпилировать самомодифицирующийся код, поскольку невозможно сказать, во что он превратится, пока не запустите его. Чтобы избежать неприятных ситуаций, можно попробовать комбинировать статический рекомпилятор с интерпретатором или динамическим перекомпилятором.

Динамическая перекомпиляция

Динамическая перекомпиляция аналогична статической, но происходит во время выполнения программы. Вместо того, чтобы пытаться перекомпилировать весь код сразу, перекомпиляция делается по ходу дела, когда сталкивается с инструкциями CALL или JUMP. Для увеличения скорости этот метод можно комбинировать со статической перекомпиляцией. Подробнее о динамической перекомпиляции можно прочитать в официальном документе Арди, создателя перекомпилирующего эмулятора Macintosh.

Я хочу написать эмулятор. С чего начать?

Чтобы написать эмулятор, вы должны обладать неплохими общими знаниями в области программирования и цифровой электроники. Также очень пригодится опыт программирования на ассемблере.

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

  2. Найдите всю доступную информацию об эмулируемом оборудовании.

  3. Напишите эмуляцию ЦП или получите существующий код для эмуляции ЦП.

  4. Напишите черновой код для эмуляции остального оборудования, хотя бы частично.

  5. На этом этапе полезно написать небольшой встроенный отладчик, который позволяет остановить эмуляцию и посмотреть, что делает программа. Также может понадобиться дизассемблер языка ассемблера эмулируемой системы. Напишите свой, если такового нет.

  6. Попробуйте запустить программы на вашем эмуляторе.

  7. Используйте дизассемблер и отладчик, чтобы увидеть, как программы используют оборудование, и соответствующим образом скорректируйте свой код.

Какой язык программирования мне следует использовать?

Наиболее очевидными являются C и Assembly. Вот плюсы и минусы каждого из них:

Языки ассемблера

+ Как правило, позволяют создавать более быстрый код.

+ Эмуляция регистров ЦП может использоваться для прямого хранения регистров эмулируемого процессора.

+ Многие опкоды можно эмулировать аналогичными опкодами эмулирующего процессора.

- Код не переносимый, т.е. его нельзя запустить на компьютере с другой архитектурой.

- Трудно отлаживать и поддерживать код.

С

+ Код можно сделать переносимым, чтобы он работал на разных компьютерах и операционных системах.

+ Относительно легко отлаживать и поддерживать код.

+ Можно быстро проверить гипотезы о том, как работает реальное оборудование .

- Обычно медленнее, чем чистый ассемблерный код.

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

Где я могу получить информацию об эмулируемом оборудовании?

Ниже приведен список мест, куда можно заглянуть (значительная часть уже, к сожалению, недоступна).

Новостные группы:

  • comp.emulators.misc
    Это новостная группа для обсуждения компьютерной эмуляции. Её читают многие авторы эмуляторов, хотя много левых обсуждений. Прежде чем публиковать сообщения в этой группе, прочтите FAQ.

  • comp.emulators.game-consoles
    То же, что и comp.emulators.misc, но специализируется на эмуляторах игровых консолей. Прежде чем публиковать сообщения в этой группе, прочтите FAQ.

  • comp.sys./emulated-system/
    Иерархия comp.sys.* содержит новостные группы, предназначенные для конкретных компьютеров. Вы можете получить много полезной технической информации. Примеры:

comp.sys.msx       MSX/MSX2/MSX2+/TurboR computers

comp.sys.sinclair  Sinclair ZX80/ZX81/ZXSpectrum/QL

comp.sys.apple2    Apple ][

etc.

  • alt.folklore.computers

  • rec.games.video.classic

FTP:

Программирование консолей и игр

Аркадные игровые автоматы

История компьютеров и эмуляции

Сайты:

Моя домашняя страница

Репозиторий программирования эмуляции игровых автоматов

Ресурс для программистов эмуляций

Как эмулировать процессор?

Прежде всего, если вам нужно эмулировать стандартный ЦП Z80 или 6502, вы можете использовать один из написанных мной эмуляторов ЦП. Однако для их использования есть определенные условия.

Для тех, кто хочет написать собственное ядро ​​эмуляции ЦП или интересуется, как оно работает, привожу скелет типичного эмулятора ЦП на C. В реальном эмуляторе вы можете пропустить некоторые части и добавить другие самостоятельно.

Counter=InterruptPeriod;

PC=InitialPC;

for(;;)

{

  OpCode=Memory[PC++];

  Counter-=Cycles[OpCode];

  switch(OpCode)

  {

    case OpCode1:

    case OpCode2:

    ...

  }

  if(Counter<=0)

  {

    /* Check for interrupts and do other */

    /* cyclic tasks here                 */

    ...

    Counter+=InterruptPeriod;

    if(ExitRequired) break;

  }

}

Во-первых, мы присваиваем начальные значения счетчику циклов процессора (Counter) и счетчику программ (PC):

Counter=InterruptPeriod;

PC=InitialPC;

Counter содержит количество циклов ЦП, оставшихся до следующего предполагаемого прерывания. Обратите внимание, что прерывание не обязательно должно происходить по истечении этого счетчика: вы можете использовать его для многих других целей, таких как синхронизация таймеров или обновление строк развертки на экране. Подробнее об этом позже. 

PC содержит адрес памяти, из которого наш эмулируемый ЦП будет считывать свой следующий код операции.

После присвоения начальных значений запускаем основной цикл:
Обратите внимание, что этот цикл также может быть реализован как

while(CPUIsRunning)

{

где CPUIsRunning - логическая переменная. Это имеет определенные преимущества, так как вы можете прервать цикл в любой момент, установив CPUIsRunning=0. К сожалению, проверка этой переменной на каждом проходе занимает довольно много процессорного времени, и ее следует по возможности избегать. Не реализуйте этот цикл как

while(1)

{

потому что в этом случае некоторые компиляторы будут генерировать код, проверяющий, является ли 1 true или нет. Вы, конечно, не хотите, чтобы компилятор выполнял эту ненужную работу при каждом проходе цикла.

Теперь, когда мы находимся в цикле, первым делом нужно прочитать следующий код операции и изменить счетчик программ:

OpCode=Memory[PC++];

Хотя это самый простой и быстрый способ чтения из эмулируемой памяти, его не всегда получается реализовать. Есть более универсальный способ доступа к памяти.
После получения опкода мы уменьшаем счетчик циклов ЦП на количество циклов, необходимых для этого опкода:

Counter-=Cycles[OpCode];

Таблица Cycles[] должна содержать количество циклов процессора для каждого опкода. Имейте в виду, что некоторые опкоды (например, условные переходы или вызовы подпрограмм) могут занимать разное количество циклов в зависимости от их аргументов. Однако это можно исправить позже в коде.

Теперь пришло время интерпретировать код операции и выполнить его:

switch(OpCode)

{

Есть распространённое заблуждение, что конструкция switch() неэффективна, так как она компилируется в цепочку операторов if() ... else if() ... Это действительно справедливо для конструкций с небольшим количеством операторов, но большие конструкции (100-200 и более операторов) всегда компилируются в таблицу переходов, что делает их весьма эффективными.

Есть два альтернативных способа интерпретации опкодов. Первый — составить таблицу функций и вызвать соответствующую. Этот метод кажется менее эффективным, чем switch(), поскольку есть расходы на вызовы функций. Второй метод заключается в создании таблицы меток и использовании оператора goto. Хотя этот метод немного быстрее, чем switch(), он будет работать только с компиляторами, поддерживающими «предварительно вычисленные метки». Другие компиляторы не позволят вам создать массив адресов меток.

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

if(Counter<=0)

{  

  /* Check for interrupts and do other hardware emulation here */

  ...

  Counter+=InterruptPeriod;

  if(ExitRequired) break;

}

Эти циклические задачи рассматриваются далее в статье.

Обратите внимание, что мы не просто присваиваем Counter=InterruptPeriod, а делаем Counter+=InterruptPeriod: это делает подсчет циклов более точным, так как в Counter могут присутствовать некоторое отрицательное количество циклов.

Также посмотрите на строку

if(ExitRequired) break;

Поскольку проверять выход при каждом проходе цикла слишком ресурсозатратно, мы делаем это только по истечении срока действия Counter: это все равно приведет к выходу из эмуляции, когда вы установите ExitRequired=1, но это не займет столько ресурсов процессора.

Как обрабатывать доступ к эмулируемой памяти?

Самый простой способ получить доступ к эмулируемой памяти — рассматривать ее как простой массив байтов (слов и т. д.). тогда доступ к нему тривиален:

 Data=Memory[Address1]; /* Read from Address1 */

 Memory[Address2]=Data; /* Write to Address2  */

Однако такой простой доступ к памяти не всегда возможен по следующим причинам:

  • Страничная память
    Адресное пространство может быть фрагментировано на переключаемые страницы. Часто это делается для расширения памяти, когда адресное пространство мало (64 КБ).

  • Зеркальная память
    Область памяти может быть доступна по нескольким разным адресам. Например, данные, которые вы записываете в ячейку $4000, также будут отображаться в $6000 и $8000. ПЗУ также могут быть зеркальными из-за неполного декодирования адреса.

  • Защита ПЗУ
    Некоторое программное обеспечение на основе картриджей (например, игры MSX) пытается записать в свое собственное ПЗУ и отказывается работать, если запись завершается успешно. Часто это делается для защиты от копирования. Чтобы такое программное обеспечение работало на вашем эмуляторе, вы должны отключить запись в ПЗУ.

  • Ввод-вывод с отображением памяти
    В системе могут быть устройства ввода-вывода с отображением памяти. Доступ к таким ячейкам памяти производит «специальные эффекты» и поэтому должен отслеживаться.

Чтобы справиться с этими проблемами, введем пару функций:

Data=ReadMemory(Address1);  /* Read from Address1 */

WriteMemory(Address2,Data); /* Write to Address2  */

Вся специальная обработка, такая как доступ к странице, зеркалирование, обработка ввода-вывода и т. д., выполняется внутри этих функций.

ReadMemory()и WriteMemory()обычно приводят к затратам ресурсов на эмуляцию, потому что они вызываются очень часто. Поэтому они должны быть максимально эффективными. Вот пример этих функций, написанных для доступа к страничному адресному пространству:

static inline byte ReadMemory(register word Address)

{

  return(MemoryPage[Address>>13][Address&0x1FFF]);

}

static inline void WriteMemory(register word Address,register byte Value)

{

  MemoryPage[Address>>13][Address&0x1FFF]=Value;

}

Обратите внимание на inline. Он заставит компилятор встроить функцию в код, а не вызывать ее. Если ваш компилятор не поддерживает inline или _inline, попробуйте сделать function static: некоторые компиляторы (например, WatcomC) оптимизируют короткие статические функции, встраивая их.

Также имейте в виду, что в большинстве случаев ReadMemory()вызывается в несколько раз чаще, чем WriteMemory(). Поэтому стоит реализовать большую часть кода, WriteMemory(), оставив ReadMemory()как можно более коротким и простым.

Небольшое замечание о зеркалировании памяти:

Как было сказано ранее, многие компьютеры имеют зеркалирование ОЗУ, где значение, записанное в одном месте, появится в других. Хотя эта ситуация может быть обработана в ReadMemory(), обычно это нежелательно, так как ReadMemory() вызывается гораздо чаще, чем WriteMemory(). Более эффективным способом было бы реализовать зеркалирование памяти в функции WriteMemory().

Циклические задачи: что это такое?

Циклические задачи — это задачи, которые должны периодически выполняться на эмулируемой машине, например:

  • Обновление экрана

  • Прерывания VBlank и HBlank

  • Обновление таймеров

  • Обновление параметров звука

  • Обновление состояния клавиатуры/джойстиков

  • и т. д.

Чтобы эмулировать такие задачи, вы должны привязать их к соответствующему количеству циклов процессора. Например, если ЦП должен работать на частоте 2,5 МГц, а дисплей использует частоту обновления 50 Гц (стандарт для видео PAL), прерывание VBlank должно происходить каждые

2500000/50 = 50000 циклов процессора

Теперь, если мы предположим, что весь экран (включая VBlank) имеет высоту 256 строк развертки и 212 из них фактически отображаются на дисплее (т.е. остальные 44 попадают в VBlank), мы получаем, что ваша эмуляция должна обновлять строку развертки каждые

50000/256 ~= 195 циклов процессора

После этого следует сгенерировать прерывание VBlank и ничего не делать, пока мы не закончим с VBlank, т.е.

(256-212)*50000/256 = 44*50000/256 ~= 8594 циклов ЦП

Тщательно рассчитайте количество циклов ЦП, необходимых для каждой задачи, затем используйте их наибольший общий делитель для InterruptPeriod и привяжите к нему все остальные задачи (они не обязательно должны выполняться при каждом истечении срока действия Counter).

Как оптимизировать код C?

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

Watcom C++      -oneatx -zp4 -5r -fp3

GNU C++         -O3 -fomit-frame-pointer

Borland C++

Небольшое замечание по поводу развертывания цикла:

Полезной может оказаться опция оптимизатора "раскручивание цикла" (loop unrolling). Эта опция попытается преобразовать короткие циклы в линейные фрагменты кода. Однако мой опыт показывает, что этот вариант не дает никакого прироста производительности. Его включение в некоторых случаях также может привести к поломке вашего кода.

Оптимизация самого кода C немного сложнее, чем выбор параметров компилятора, и обычно зависит от процессора, для которого вы компилируете код. Несколько общих правил применимы ко всем процессорам. Однако не принимайте их за абсолютную истину, поскольку в вашем случае всё может быть иначе:

  • Используйте профайлер!
    Запуск вашей программы под управлением приличного профайлера (сразу приходит на ум GPROF) может выявить множество замечательных вещей, о которых вы никогда раньше не подозревали. Вы можете обнаружить, что кажущиеся незначительными фрагменты кода исполняются намного чаще, чем остальная часть, и замедляют работу всей программы. Оптимизация этих фрагментов кода или их переписывание на ассемблере повысит производительность.

  • Избегайте C++
    Избегайте использования каких-либо конструкций, которые заставят вас компилировать вашу программу с помощью компилятора C++ вместо простого C: компиляторы C++ обычно добавляют дополнительную нагрузку к сгенерированному коду.

  • Размер целых чисел
    Старайтесь использовать только целые числа базового размера, поддерживаемого ЦП, т.е. int единицы , а не short или long. Это уменьшит количество кода, генерируемого компилятором для преобразования между различными длинами целых чисел. Это также может уменьшить время доступа к памяти, поскольку некоторые ЦП работают быстрее всего при чтении/записи данных базового размера.

  • Распределение регистров
    Используйте как можно меньше переменных в каждом блоке и объявляйте наиболее часто используемые как register (хотя большинство новых компиляторов могут автоматически помещать переменные в регистры). Это имеет больше смысла для процессоров с большим количеством регистров общего назначения (PowerPC), чем для процессоров с несколькими выделенными регистрами (Intel 80x86).

  • Развертывание небольших циклов
    Если у вас есть небольшой цикл, который выполняется несколько раз, всегда полезно вручную развернуть его в линейный фрагмент кода. 

  • Сдвиги при умножении/делении
    Всегда используйте сдвиги везде, где вам нужно умножить или разделить на 2^n (J/128==J>>7). Они выполняются быстрее на большинстве процессоров. Кроме того, в таких случаях используйте побитовое И для получения значения по модулю (J%128==J&0x7F).

Что такое низкий/высокий порядок байтов?

Все процессоры обычно делятся на несколько классов в зависимости от того, как они хранят данные в памяти. Хотя есть несколько очень своеобразных экземпляров, большинство процессоров относятся к одному из двух классов:

ЦП с высоким порядком байтов будут хранить данные так, что старшие байты слов всегда будут первыми в памяти. Например, если хранить 0x12345678 на таком процессоре, то память будет выглядеть так:

                      0 1 2 3

                     +--+--+--+--+

                     |12|34|56|78|

                     +--+--+--+--+

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

                      0 1 2 3

                     +--+--+--+--+

                     |78|56|34|12|

                     +--+--+--+--+

Типичными примерами высокопроизводительных процессоров являются 6809, серия Motorola 680x0, PowerPC и Sun SPARC. К процессорам с низким порядком байтов относятся 6502 и его преемник 65816, Zilog Z80, большинство чипов Intel (включая 8080 и 80x86), DEC Alpha и т. д.

При написании эмулятора вы должны знать порядок байтов обоих процессоров (того, который эмулируем и тот, на котором будет работать эмуляция). Допустим, вы хотите эмулировать процессор Z80 с низким порядком байтов. То есть Z80 хранит свои 16-битные слова младшим байтом вперед. Если для этого использовать процессор с низким порядком байтов (например, Intel 80x86), то все происходит естественным образом. Однако, если вы используете процессор с высоким порядком (PowerPC), внезапно возникает проблема с размещением 16-битных данных Z80 в памяти. Хуже того, если ваша программа должна работать на обеих архитектурах, вам нужен какой-то способ сделать её универсальной.

Один из способов решения проблемы приведен ниже:

typedef union

{

  short W;        /* Word access */

  struct          /* Byte access... */

  {

#ifdef LOW_ENDIAN

    byte l,h;     /* ...in low-endian architecture */

#else

    byte h,l;     /* ...in high-endian architecture */

#endif

  } B;

} word;

Как видите, можно получить доступ к слову как к целому, используя W. Однако каждый раз, когда вашей эмуляции требуется доступ к нему как к отдельным байтам, вы используете B.l и B.h, которые сохраняют порядок.

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

 int *T;

  T=(int *)"\01\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0";

  if(*T==1) printf("This machine is high-endian.\n");

  else      printf("This machine is low-endian.\n");

Почему я должен делать программу модульной?

Большинство компьютерных систем состоят из нескольких больших микросхем, каждая из которых выполняет определенную часть системных функций. Таким образом, есть ЦП, видеокарта, звуковая карта. К некоторым из этих микросхем может быть подключена собственная память и другое аппаратное обеспечение.

Типичный эмулятор должен повторять дизайн исходной системы, реализуя функции каждой подсистемы в отдельном модуле. Во-первых, это упрощает отладку, так как все ошибки локализованы в модулях. Во-вторых, модульная архитектура позволяет повторно использовать модули в других эмуляторах. Аппаратное обеспечение компьютера довольно стандартизировано: вы можете найти один и тот же процессор или видеочип во многих разных моделях компьютеров. Гораздо проще эмулировать чип один раз, чем внедрять его снова и снова для каждого компьютера.

Комментарии (5)


  1. huder
    27.07.2023 07:47
    +4

    Это статья 99-2000 года, вы серьезно? Там уже почти все ссылки только в вебархиве доступны


    1. witz
      27.07.2023 07:47

      Я то удивился, упомянули С и даже С++ и ни слова о расте


  1. Wizard_of_light
    27.07.2023 07:47
    +1

    Вот это откопали так откопали. Классика двадцатитрёхлетней давности.


  1. shiru8bit
    27.07.2023 07:47
    +2

    Да-да, самое время заглянуть в новостные группы.


  1. nagayev
    27.07.2023 07:47

    Некоторые вещи неактуальны. Например, используйте сдвиги. Сейчас любой компилятор сделает это за вас.