5.2. Написание программ-эмуляторов При разработке программного обеспечения игровых приставок широко используются эмуляторы. Вопросы их создания представляют отдельную и весьма обширную область, так что здесь мы приведем лишь краткий обзор применяемых технологий. 5.2.1. Что и как эмулировать Эмуляция - это моделирование-работы какого-либо программируемого устройства на другом компьютере, возможно отличающемся по архитектуре, с применением специальной программы-эмулятора, но без изменения исходного кода выполняемой программы. Эмулировать можно практически любое устройство, внутри которого установлен микропроцессор и которое работает в соответствии с заданной программой. К таким устройствам относятся компьютеры, калькуляторы, игровые приставки и автоматы, микроконтроллеры и многое другое. Ниже мы расскажем о некоторых вопросах эмуляции игровых приставок и компьютеров. Существуют два основных направления эмуляции - интерпретация кода и рекомпиляция. Интерпретация кода При интерпретации программа-эмулятор последовательно читает из памяти коды команд программы и выполняет их. Главный цикл работы эмулятора выглядит так: while (state == EMU_RUN) { Read(OpCode); // Чтение кода // команды. Execute (OpCode); // Исполнение // команды. } Работа подобных эмуляторов проста и понятна. Их положительными чертами являются легкость встраивания отладочных функций, возможность гибкой коррекции скорости работы и легкость модификации. Главный, и весьма большой, недостаток - низкая производительность. Как и любой интерпретатор, такой эмулятор занимает много времени центрального процессора и требует применения мощного компьютера для достижения приемлемой скорости выполнения эмулируемой программы. Рекомпиляция кода При использовании статической рекомпиляции программа-эмулятор обрабатывает сразу всю исходную программу и формирует программу, которую можно будет запустить на другом компьютере. Код, получающийся в результате статической рекомпиляции, функционирует очень быстро. Однако существуют и ограничения: например, данный метод неприменим к программам, которые во время исполнения изменяют собственный код. При использовании динамической рекомпиляции программа-эмулятор преобразует фрагменты исполняемой программы в код, который может быть выполнен на другом компьютере, непосредственно во время ее работы. Как обычно, наилучшие результаты достигаются при разумном совмещении всех трех методов. В настоящей главе мы ограничимся вопросами создания эмуляторов, использующих метод интерпретации кода. 5.2.2. Подготовка к написанию эмулятора Если вы решили написать эмулятор, подготовьтесь к этому и оцените свои возможности, а также производительность системы, которую вы собираетесь эмулировать, и системы, на которой будет работать программа-эмулятор. Не пытайтесь написать эмулятор Dreamcast, который бы функционировал на компьютере с процессором i486. Помните, что эмулятор - это сложный проект, требующий тщательной оптимизации кода для достижения наивысшей производительности. Здесь потребуется опыт программирования и хорошее знание цифровой электроники, а также языка ассемблера - и для системы, которую нужно эмулировать, и для системы, на которой будет выполняться программа-эмулятор. Шаг 1. Поиск сведений об эмулируемой системе Для начала следует собрать все возможные сведения о той системе, для которой вы собрались написать эмулятор. К этим сведениям относится полная информация о центральном процессоре, видеопроцессоре, портах ввода/вывода, распределении адресного пространства и многое другое. Часть необходимой информации представлена в этой книге, остальное доступно через Internet. Весьма приветствуется наличие рабочей эмулируемой системы, что позволит сразу же проверить появляющиеся в процессе разработки вопросы. Чем больше данных вы соберете, тем лучше. Затем тщательно изучите их и переходите ко второму шагу. Шаг 2. Выбор языка программирования Хотя существуют примеры эмуляторов, написанных на таких языках программирования, как Java и Visual Basic, из-за серьезных требований к быстродействию программы реально Можно рассматривать только две альтернативы: С и ассемблер. Конечно, если вы пишете эмулятор любимого калькулятора «Электроника», который будет работать на Pentium III, вас устроит и Visual Basic. Но мы рассматриваем ситуацию, приближенную к реальности, поэтому ограничимся выбором из двух языков. Сначала рассмотрим ассемблер. Преимуществами ассемблера являются: * создание максимально быстродействующей программы; * применение при эмуляции регистров процессора, что приближает систему к рекомпилирующему эмулятору; * при использовании регистров процессора в качестве регистров эмулируемой системы многие команды могут быть напрямую заменены единственной командой центрального процессора компьютера, на котором работает эмулятор. Конечно же, кроме положительных моментов есть и отрицательные: * программы на ассемблере имеют большой объем и достаточно трудны для понимания, модификации и отладки; * при разработке аналогичного эмулятора, но для другого компьютера программу придется полностью переписывать; * поиск ошибок и правка текста занимают много времени. Теперь пришла очередь языка С. Его достоинства таковы: * программы легко понимаются и отлаживаются; * при разработке эмулятора для другого компьютера не требуется полностью переписывать программу; * программу легко модифицировать, добавив эмуляцию различных дополнительных устройств. А вот и недостатки: * размер скомпилированной программы по сравнению с ассемблером увеличивается, а быстродействие уменьшается. Наилучшего результата можно добиться при совместном использовании обоих языков: интерфейс пользователя пишется на С, а критичные по времени исполнения процедуры эмуляции - на ассемблере. В любом случае необходимо хорошее знание используемого языка. Если вы разрабатываете эмулятор простой системы, например DENDY, несложно удовольствоваться одним только С. Если же ваша цель - эмулятор PLAYSTATION или NINTENDO 64, то без ассемблера вам не обойтись. Наиболее разумным представляется следующий подход: сначала программа-эмулятор пишется полностью на С, затем отлаживается, и часть, отвечающая за эмуляцию, переписывается на ассемблере. Шаг З. Разработка проекта После сбора информации об эмулируемой системе и выбора языка программирования начинается разработка проекта, который лучше разделить на несколько файлов. ОСНОВНАЯ ПРОГРАММА реализует интерфейс пользователя, загружает исполняемую программу, запускает непосредственно эмулятор и при необходимости вызывает встроенный отладчик. МОДУЛЬ ЭМУЛЯЦИИ ЦЕНТРАЛЬНОГО ПРОЦЕССОРА выполняет команды центрального процессора эмулируемой системы. Если вы эмулируете стандартный процессор, например Z80 или 6502, можно воспользоваться одним из свободно распространяемых эмуляторов данного процессора, которые доступны в Internet. МОДУЛЬ ЭМУЛЯЦИИ ПАМЯТИ обрабатывает обращения любых устройств к памяти системы. МОДУЛЬ ЭМУЛЯЦИИ ВИДЕОПРОЦЕССОРА формирует изображение на экране, а также работает со спрайтами, устанавливает флаги синхронизации и генерирует сигналы прерывания для модуля эмуляции центрального процессора. Эта часть сложна для написания и при выполнении занимает достаточно много времени центрального процессора. МОДУЛЬ ЭМУЛЯЦИИ ВВОДА/ВЫВОДА позволяет вводить информацию, например с клавиатуры, и преобразует ее в данные, которые для эмулятора процессора аналогичны сигналам от различных внешних устройств, таких как пульты, джойстики, световые пистолеты и т.д. МОДУЛЬ ЭМУЛЯЦИИ ЗВУКОВОГО ПРОЦЕССОРА формирует звуковое сопровождение. Эта часть программы наиболее критична к качеству синхронизации работы эмулятора и сложна в отладке. ВСТРОЕННЫЙ ОТЛАДЧИК позволяет в любой момент остановить выполнение программы, проверить состояние регистров процессора и памяти. Модуль необходим при отладке программы-эмулятора. Если вы пишете эмулятор для последующего создания программного обеспечения, следует уделить особенное внимание удобству работы с отладчиком и предоставляемым возможностям. Также очень полезными будут кросс-ассемблер и дизассемблер для эмулируемой системы. При желании вы можете написать и их, но проще воспользоваться уже готовыми, которые доступны в Internet. 5.2.3. Программирование эмулятора Теперь мы готовы к созданию эмулятора. В качестве примера рассмотрим простейший эмулятор игровой приставки DENDY, описание которой приведено в первой главе. Шаг 1. Необходимые данные об эмулируемой системе приведены в главе 1. Эмулятор будет работать на компьютере IBM PC с видеокартой VGA. Шаг 2. Эмулятор пишется полностью на С, без применения ассемблера. ШАГ 3. В проект войдут следующие файлы: main.c - основная программа; cup.с - эмулятор центрального процессора; memory.c - эмулятор памяти; screen.c - эмулятор видеопроцессора; gamepad.c - эмулятор игрового пульта. Мы не включили в проект эмуляцию звукового процессора и встроенный отладчик, поскольку вопросы создания качественных эмуляторов заслуживают отдельной книги. При желании можно дописать эти модули самостоятельно. Эмуляция центрального процессора Собственно говоря, мы уже познакомились с простейшим эмулятором центрального процессора. Опишем его еще раз и попробуем изменить для наших целей. #define EMU_RUN 1 BYTE state, OpCode; main() { state = EMU_RUN; while (state == EMU_RUN) { OpCode = ReadMemory(); // Чтение // кода / / команды. Execute (OpCode) // Исполнение // команды. } Сначала нужно задать константу EMU_RUN посредством стандартной директивы препроцессора языка С #define. Затем указываются две переменные: state и OpCode. В переменной state будет храниться код, определяющий режим работы эмулятора процессора. Далее следует цикл выполнения команд. В начале цикла переменной OpCode присваивается значение кода команды, считанного из памяти функцией ReadMemory (). Затем функция Execute выполняет считанную команду, и цикл повторяется. Однако в результате выполнения этой программы мы ничего не сможем увидеть и ввести, поскольку для взаимодействия эмулятора с экраном и клавиатурой не предусмотрено никаких функций. Кроме того, отсутствует эмуляция подачи сигнала сброса и сигнала запроса на прерывание. Итак, следует определить, какие функции нам потребуются. Во-первых, ResetCPU, эмулирующая сброс микропроцессора. Затем IrqCPU и NmiCPU, которые будут выполнять действия, необходимые при поступлении запросов на прерывание. StepCPU исполняет одну команду процессора. Главной функцией модуля станет RunCPU, задачей которой является запуск программы и вызов всех остальных функций эмулятора. Также нужно создать переменные, эмулирующие регистры центрального процессора. Начнем работу с указания необходимых структур данных. Центральный процессор игровой приставки DENDY содержит пять 8-разрядных регистров и один 16-разрядный: struct REG_6502 { byte а; /* Аккумулятор. */ byte р; /* Регистр флагов. */ byte х,у; /* Индексные регистры. */ byte s; /* Указатель стека. */ union /* 16-разрядный счетчик команд. */ { struct {byte 1,h;} В; word W; } pc; } cpu_reg; int iPeriod, iTact; Теперь, чтобы обратиться, например, к переменной, хранящей значение регистра X эмулируемого процессора, нужно задать cpu_reg. х. Особого внимания заслуживает реализация 16-разрядного счетчика команд. Приведенное выше описание позволяет обращаться как ко всему регистру в целом (cpu_reg.pc.W), так и отдельно к старшему (cpu_reg.pc.В.h) или младшему (cpu_reg. pc.В.1) байту этого регистра. Кроме данной структуры, описывающей регистры эмулируемого процессора, для эмуляции системы прерываний используются две переменные: iPeriod и iTact. Каждая команда процессора выполняется за определенное число тактов, и, поскольку частота кадровой развертки телевизора известна заранее (не забудьте, что она различна для систем PAL и NTSC), можно рассчитать количество команд, выполняемых процессором в течение формирования одного кадра. Полученное число записывается в переменную iPeriod. Когда функция StepCPU обработает команду, в качестве результата она вернет число тактов iTact, за которое команда была бы выполнена реальным процессором. Это число вычитается из переменной iPeriod; как только она станет меньше или равна 0, программаэмулятор обновит изображение на экране. Теперь перейдем к рассмотрению отдельных функций. Функция ResetCPU эмулирует действия, которые происходят при нажатии на кнопку RESET void ResetCPU(void) { /* Инициализация аккумулятора и индексных регистров. */ cpu_reg.a=cpu_reg.x=cpu_reg.у=0; /* Инициализация регистра флагов: разряд D5 всегда равен 1, флаг нулевого результата Z (D1) установлен, поскольку в регистре А записан 0. */ cpu_reg.p = 0x22; /* Инициализация указателя стека. */ cpu_reg.s = OxFF; /* Инициализация счетчика команд. После поступления сигнала сброса в счетчик команд записывается адрес из ячеек памяти с адресами FFFCh и FFFDh. */ cpu_reg.pc.В.l=ReadMemory(0xFFFC); cpu_reg.pc.В.h=ReadMemory(0xFFFD); /* Инициализация счетчика обновления экрана. */ iPeripd = FRAME_PERIOD; } Назначение практически всех операторов данной функции ясно из приведенных комментариев. Функция ReadMemory будет добавлена позже, при создании модуля эмуляции памяти. Ее задача -эмуляция чтения из памяти и возвращение прочитанного байта. В качестве параметра функции передается адрес требуемого байта. Константа FRAME_PERIOD - это число тактов процессора за время развертки одного кадра. Ее значение зависит от тактовой частоты процессора эмулируемой системы и формата выходного видеосигнала (PAL или NTSC). Теперь можно написать функцию NmiCPU, имитирующую реакцию процессора на поступление запроса немаскируемого прерывания. void NmiCPU() { /* Счетчик команд и регистр флагов запоминаются в стеке. */ WriteMemory (0x100 | cpu_reg.s, cpu_reg.pc.B.h); --cpu_reg.s; WriteMemory (0x100 | cpu_reg.s, cpu_reg.pc.В.1); --cpu_reg.s; WriteMemory (0x100 | cpu_reg.s, cpu_reg.p); --cpu_reg.s; /* После поступления сигнала запроса немаскируемого прерывания в счетчик команд записывается адрес из ячеек памяти с адресами FFFAh и FFFBh. */ cpu_reg.рс.В.l=ReadMemory(0xFFFA); cpu_reg.pc.В.h=ReadMemory(0xFFFB); /* Обработка запроса на прерывание занимает семь тактов процессора, так что необходимо соответственно скорректировать счетчик. */ iPeriod -= 7 ; } Эта функция также достаточно проста и не требует дополнительных пояснений. Функция WriteMemory эмулирует запись в память; первый параметр — адрес записи, второй - сохраняемое значение. В качестве упражнения можно создать функцию IrqCPU, имитирующую действия процессора при поступлении запроса на маскируемое прерывание. Рассмотрим основную функцию эмуляции процессора - StepCPU, выполняющую при каждом вызове одну команду процессора и возвращающую число тактов, за которое эта команда осуществляется на реальном процессоре. Большая часть этой функции представляет собой один оператор switch с вариантом выбора для каждой команды: Byte StepCPU() { /* Чтение кода команды из памяти и увеличение значения счетчика команд. */ Opcode = ReadMemory(cpu_reg.pc.W++); switch (OpCode) { case 0x00: /* BRK. */ /* Здесь располагается программа эмуляции команды процессора с кодом 00h. */ return tact[0x00]; case 0x01: /* ORA (a8,X). */ /* Здесь располагается программа эмуляции команды процессора с кодом 01h. */ return tact[0x01]; case 0x09: /* ORA #d8. */ /* Выполнение команды. */ cpu_reg.a = cpu_reg.a | ReadMemory(cpu_reg.pc.W++); /* Установка флагов. */ cpu_reg.p &= 0x7D; /* N=0; Z=0. */ if (cpu_reg.a == 0) /* Если A=0, то Z=l. */ cpu_reg.p I = 2 ; /* Установка флага знака. */ if (cpu_reg.a > 127) cpu_reg.p |= 0x80; /* Возврат количества тактов. */ return tact[0x09]; case 0x48: /* PHA. */ /* Выполнение команды. */ WriteMemory (0x100 I cpu_reg.s, cpu_reg.a); --cpu_reg.s; /* Возврат количества тактов. */ return tact[0x48]; case 0x9A: /* TXS */ /* Выполнение команды. */ cpu_reg.s = cpu_reg.x; /* Возврат количества тактов. */ return tact[0x9A]; default: /* Неизвестная команда. */ printf ("Неизвестный код команды %х", OpCode); state = EMU_STOP; /* Остановка */ /* эмулятора. */ return 0; } } Поскольку полный текст программы, обрабатывающей все команды центрального процессора, занял бы около десяти страниц, здесь приводится лишь фрагмент, поясняющий структуру функции и обслуживающий только три команды. Команда ORA #d8 выполняет логическую операцию ИЛИ над содержимым аккумулятора и байта, записанного в памяти сразу за кодом команды. Результат сохраняется в аккумуляторе, и в зависимости от него изменяется состояние флагов Z и N в регистре состояния процессора. Команда РНА сохраняет содержимое аккумулятора в стеке, а команда TXS копирует содержимое регистра X в регистр указателя стека S. На состояние флагов эти команды не влияют. Сгруппируем написанные ранее блоки в единый модуль эмуляции процессора. void RunCPU () { ResetCPU (); /* Сброс процессора. */ while (state == EMU_RUN) { /* Выполнение команды. */ iTact = StepCPU() ; iPeriod -= iTact; /* Проверка, не пора ли обновлять экран. */ if (iPeriod >13){ case 0: RAM[A&0x7FF]=V; /* Запись во */ /* внутреннее ОЗУ. */ break; case 3 : WRAM[A&Ox1FFF]=V; /* Запись в ОЗУ */ /* картриджа. */ break; case 1: case 2: IO_write (A, V) ; /* Запись в регистры */ /* видеопроцессора */ break; /* или звукового */ /* процессора. */ default: MMC_write (А, V) ; /* Запись в регистр */ /* контроллера */ break; /* страниц памяти */ /* в картридже. */ } } Как видим, области ОЗУ игровой приставки в программе представляются просто массивами соответствующей размерности. В частности, встроенное ОЗУ приставки в тексте программы описывается следующей строкой: byte RAM[0x800]; Работа с портами ввода/вывода видеопроцессора, звукового процессора и контроллера страниц памяти, который устанавливается в картридже, оформляется в виде отдельных функций. Завершая раздел, посвященный эмуляции памяти, приведем текст функции и ReadMemory: byte ReadMemory (word A) { switch(А>>13){ case 0: /* Чтение из внутреннего ОЗУ. */ return RAM[A&0x7FF] ; case 3: /* Чтение из ОЗУ картриджа. */ return WRAM[A&0x1FFF] ; case 1: case 2: /* Чтение из порта ввода/вывода. */ return IO_read(A); default: /* Чтение из ПЗУ картриджа. */ return Page [А»13] [А]; } } Эмуляция видеопроцессора Теперь приступим к созданию модуля эмулятора видеопроцессора, который будет формировать выводимое на экран изображение. Написание этого модуля является достаточно сложной задачей, поскольку требуется жесткая синхронизация формирования изображения и эмуляции работы центрального процессора. Кроме того, в игровых приставках обращение к видеопамяти разрешено, как правило, только во время кадровых и строчных синхроимпульсов, а в любое другое время она недоступна. Зная строчную и кадровую частоту выходного сигнала игровой приставки, несложно рассчитать время формирования одного кадра и каждой строки кадра, а также длительность кадрового и строчного синхроимпульсов. Затем на основе значения тактовой частоты центрального процессора вычисляется длительность всех названных временных промежутков (в тактах процессора). Далее необходимо создать несколько счетчиков, например счетчик строк и счетчик точек в строке. Тогда запись в видеопамять станет возможна только при определенных состояниях этих счетчиков. Пример реализации такого способа синхронизации был приведен в эмуляторе центрального процессора, когда число в счетчике iPeriod после выполнения каждой команды уменьшалось на число тактов процессора, а при достижении нулевого значения эмулировалось поступление сигнала NMI. Отображать картинку на экране можно несколькими способами. Допустимо сразу формировать все изображение и выводить его на экран. В приведенном выше эмуляторе центрального процессора это производится вызовом функции UpdateScreen, которая должна рисовать на экране фоновое изображение и спрайты для всего кадра, используя данные из массива, эмулирующего память видеопроцессора. Такой подход является самым простым, но имеет очевидный недостаток - невозможность эмуляции различных эффектов, которые на реальной приставке реализуются изменением содержимого видеопамяти за время формирования кадра. Примером подобного эффекта служит переключение используемого знакогенератора при достижении какой-либо строки экрана. Чтобы устранить этот изъян, можно отображать не весь экран сразу, а разделив его на небольшие фрагменты, например на отдельные строки. Когда счетчик строчной развертки покажет, что процессор отработал столько тактов, сколько занимает вывод на экран одной строки, будет вызываться функция UpdateLine, которая в специально выделенной области памяти сформирует изображение очередной строки и сдвинет указатель на начало следующей. В таком случае функция UpdateScreen копирует содержимое выделенной области, где уже имеется новый кадр, в видеопамять компьютера и устанавливает указатель на начало области, подготавливая ее к получению нового кадра. Третий способ является наиболее сложным и требует наличия многозадачной среды. Программа эмуляции видеопроцессора представляет собой отдельный процесс, работающий независимо от главной программы и постоянно формирующий изображение на экране. Основная программа управляет этим процессом путем записи значений в переменные, являющиеся аналогами регистров реального видеопроцессора. Эмуляция ввода/вывода В качестве примера эмуляции ввода/вывода мы рассмотрим принципы эмуляции игровых пультов приставок. Если программа пишется для компьютера, при управлении, вероятно, будет применяться не пульт, а клавиатура. Следовательно, необходимо обеспечить эмуляцию пульта с использованием клавиатуры. Определение состояния пульта представляет собой работу с портами ввода/вывода. Программа, отвечающая за эмуляцию порта, в нашем примере может выглядеть так: void WriteJoyl (byte V) { if ( (PSG[0xl6]&l) && !(V&1)) { joy_readbit = 0; keyscan(); } PSG[0x16]=V; } byte ReadJoyl( ) { ret = (joy_l>>joy_readbit)&1; joy_readbit=(joy_readbit + 1)&7 ; return ret; } Когда выполняется запись информации в порт с адресом 401бh, вызывается функция WriteJoyl. При этом сохраняемое значение запоминается в переменной PSG [ 0x1б]. Если в результате значение в младшем разряде записываемого байта изменилось с 1 на 0, инициализируется переменная joy_readbit, которая определяет номер считываемого бита, после чего функция keyscan сканирует клавиатуру и фиксирует состояние клавиш, играющих роль кнопок пульта, в переменной jоу_1. При чтении из порта с адресом 4016h вызывается функция ReadJoyl, которая выделяет бит, соответствующий считываемой в данный момент кнопке, из переменной joy_l, изменяет номер опрашиваемой клавиши, хранящийся в переменной joy_readbit, и возвращает вычисленное значение.