Создаем эмулятор приставки
Вероятно, многие программисты если и не мечтали, то хотя бы задумывались о написании собственного эмулятора какого-либо процессора. Возможно, некоторые даже экспериментировали с чем-то вроде Z80. Но не многие дошли до финальной реализации эмулятора.
В этой заметке я хотел бы поговорить о создании простого эмулятора игровой платформы CHIP-8 из далеких 70-х. Во-первых, мы прикоснемся к истории, а во-вторых, эта платформа из за своей простоты позволит создать полностью функциональный эмулятор даже начинающим программистам.
Конец
Как бы это странно не было, а начну я с конца. Вот такая программа
OPTION BINARY ; We want a binary file, not an HP48 one.
ALIGN OFF ; And we don't want auto alignement, as some
; data can be made of bytes instead of words.
LD V0, 0
LD V1, 0
LOOP:
LD I, LEFT ; We draw a left line by default, as the random number
; is 0 or 1. If we suppose that it will be 1, we keep
; drawing the left line. If it is 0, we change register
; I to draw a right line.
RND V2, 1 ; Load in V2 a 0...1 random number
SE V2, 1 ; It is 1 ? If yes, I still refers to the left line
; bitmap.
LD I, RIGHT ; If not, we change I to make it refer the right line
; bitmap.
DRW V0, V1, 4 ; And we draw the bitmap at V0, V1.
ADD V0, 4 ; The next bitmap is 4 pixels right. So we update
; V0 to do so.
SE V0, 64 ; If V0==64, we finished drawing a complete line, so we
; skip the jump to LOOP, as we have to update V1 too.
JP LOOP ; We did not draw a complete line ? So we continue !
LD V0, 0 ; The first bitmap of each line is located 0, V1.
ADD V1, 4 ; We update V1. The next line is located 4 pixels doan.
SE V1, 32 ; Have we drawn all the lines ? If yes, V1==32.
JP LOOP ; No ? So we continue !
FIN: JP FIN ; Infinite loop...
RIGHT: ; 4*4 bitmap of the left line
DB $1.......
DB $.1......
DB $..1.....
DB $...1....
LEFT: ; 4*4 bitmap of the right line
; And YES, it is like that...
DB $..1.....
DB $.1......
DB $1.......
DB $...1....
занимающая 38 байт и в скомпилированном виде выглядящая так
должна в конечном итоге выполниться в нашем эмуляторе и вывести на экран примерно такую картинку:
С концом покончили, переходим к немного нудной, но необходимой теории.
Архитектура
Итак, что же представляет собой игровая платформа CHIP-8? Владеющие английским языком могут ознакомиться с подробной статьей в википедии, а я же попробую пересказать основные моменты своими словами.
CHIP-8 – это интерпретируемый язык программирования, созданный в середине 70-х годов для игровых приставок COSMAC VIP и Telmac 1800. Программы, написанные и скомпилированные для CHIP-8, выполняются на самих приставках в виртуальных машинах. Ну, по современной аналогии это что-то вроде Java байт-кода. Я же вообще советую забыть на время создания эмулятора о том, что это интерпретируемый язык, и считать, что мы эмулируем железную платформу – некий процессор со своим набором команд. Далее, когда я буду говорить “приставка”, я буду подразумевать CHIP-8.
Наша приставка имеет память, процессор, устройство видео вывода, звук и конечно устройство ввода. Рассмотрим все компоненты подробнее:
Память
Приставка имеет 4Kb основной памяти (RAM). Память начинается со смещения 200h и заканчивается смещением FFFh соответственно. Почему память для программ начинается со смещения 200h? Все очень просто – первые 512 байт памяти в оригинальных приставках как раз занимает интерпретатор языка CHIP-8 в машинных кодах того процессора, на котором построена приставка.
Регистры
В CHIP-8 существует шестнадцать 8-битных регистров данных с именами V0… VF. Регистр VF отвечает за флаг переноса (carry flag) при операциях сложения/вычитания. Также в приставке имеется 16-битный адресный регистр I.
Стек
Стек используется для сохранения адреса возврата, когда завершается выполнение подпрограммы. У оригинальной версии приставки размер стека составляет 48 байт, что соответствует двенадцати уровням вложения подпрограмм. Поскольку мы не ограничены в ресурсах, мы будем использовать 16 уровней вложений. Так делает большинство CHIP-8 эмуляторов.
Таймеры
В приставке присутствуют два 8-битных таймера, они оба уменьшаются с частотой 60 Гц, пока не достигнут нуля.
Delay timer: Этот таймер используется для различных задержек в играх, его значение можно читать/изменять с помощью команд.
Sound timer: Когда значение таймера отлично от нуля, выводится пищащий звук.
Устройство ввода
Ввод осуществляется с помощью 16 клавиш. В оригинальной приставке клавиши имеют коды от 0h до Fh.Если мы эмулируем на компьютере, то удобнее всего использовать правую NumPad часть клавиатуры, ту, где находятся цифры 0-9 и NumLock. Клавиши '8', '4', '6', и '2' обычно используются для перемещения, хотя и не всегда так. Это зависит от игры.
Графика и звук
В нашей приставке разрешение экрана 64x32 пикселя, один цвет (монохром). Вывод реализован с помощью спрайтов, которые всегда имеют ширину 8 пикселей и могут иметь длину от 1 до 15 пикселей. Если при рисовании спрайт накладывается на другой спрайт, то в точке наложения цвет инвертируется, а регистр VF (carry flag) принимает значение 1. Иначе он принимает значение 0.
Как выше уже было замечено, играется противный пищащий звук, если значение Sound timer отлично от нуля. Я думаю, звук мы реализовывать вообще не будем, не люблю эти бипы.
Команды
Наш процессор (CHIP-8 на самом деле) имеет ровно 35 команд, каждая команда всегда имеет длину два байта. Здесь таблицу команд не буду перепечатывать, она есть в википедии. Можно разобрать несколько примеров оттуда, например:
00E0 Clears the screen. – когда встретим в коде 00E0, просто очистим экран.
6XNN Sets VX to NN. – установить регистр VX в значение NN. Например, если встретили команду 635A, значит нужно в регистр V3 записать значение 5Ah.
Практика
Из рассмотренного выше видно, что эта платформа как нельзя лучше подходит для начала изучения принципов работы эмуляторов. Здесь у нас отсутствуют хитрые маскируемые и не маскированные прерывания, нет кучи периферии с портами ввода-вывода, нет сложных таймеров и так далее. Знай, читай себе команды по два байта из файла, сравнивай их с опкодами да и выполняй что требуется. Да и команд то всего ничего – 35 штук. Есть и подводные камни, а куда без них? Ну что ж, давайте начнем. А начнем мы пожалуй с памяти.
Понятно, что первым делом при запуске эмулятора мы должны проинициализировать нашу виртуальную машину. То есть очистить память, стек, регистры и видеопамять. Как я уже писал выше, смещение, по которому мы будем загружать нашу эмулируемую программу равно 200h. До этого, то есть со смещения 000h до 1FFh, должен находиться оригинальный интерпретатор. В нем, помимо всего прочего, присутствует маленький шрифт, который начинается со смещения 000h и до 050h и занимает 80 байт. Его можно увидеть в исходных кодах моего эмулятора. Да, прошу прощения за свой
Display : Array [0..64*32-1] of Byte; //video memory
Memory : Array [0..4095] of Byte; //RAM memory
Stack : Array [0..15] of Word; //stack
Registers : Array [0..15] of Byte; //registers
rI : Word = $200; // I register
SP : Byte = 0; // stack counter
PC : Word = $200; // mem offset counter
delay_timer : Byte = 255; // delay timer;
sound_timer : Byte = 255; // sound timer;
Итак, в начале мы заполняем нулями все массивы, затем копируем шрифт (Font: array [1..80] of byte) в массив Memory начиная с нуля и инициализируем все значения:
FillChar(Memory,4096,0); // очищаем основную память
Move(Font,Memory,80); // копируем в нее шрифт по смещению 000h
FillChar(Stack,16,0); // очищаем стек
FillChar(Registers,16,0); // сбрасываем регистры в ноль
rI := $200; // адресный регистр I на начало программы
PC := $200; // смещение массива
SP := 0; // счетчик стека
delay_timer := 0; // таймеры в нули
sound_timer := 0;
Теперь все подготовлено, можно прочитать в память эмулируемую программу по смещению 200h и браться за интерпретацию кодов. Здесь придется немножко вспомнить, кто такие биты, и как их извлекать из байтов и слов (word). Для простоты я создал процедуру ExecuteOpcode(opcode: word), в которую передается опкод из двух байт, интерпретируется и выполняется. Чтобы понять смысл, можно сверятся с таблицей команд из википедии.
Procedure ExecuteOpcode(opcode :word);
Begin
case (op_code and $F000) shr 12 of // выделяем из опкода первые 4 бита
$00: Begin // опкод начался с нуля
Case op_code and $00FF of
// Это у нас опкод 00E0 - очистка экрана
$E0: Begin
//Делаем дела, то есть тупо очищаем экран
exit;
End;
// А это - 00EE - выход из процедуры
$EE: Begin
// Восстанавливаем из стека адрес, прыгаем на него
exit;
End;
End;
// А сюда попадем, если опкод начался с нуля, но не закончился ни E0, ни EE
// Поэтому либо трапаемся, либо выводим сообщение Invalid Opcode
exit;
End; //конец проверка на нулевой опкод
$01: Begin // первые четыре бита опкода равно 1 (опкод начался с единицы)
// Это JMP, jump. Прыгаем на нужный адрес
PC := op_code and $0FFF;
exit;
End;
$02: Begin // первые четыре бита опкода равны 2 (опкод начался с двойки)
// Вызываем подпрограмму.
// увеличиваем указатель стека
// заносим в стек текущий адрес
// и пыгаем на подпрограмму
End;
//
// Так продолжается до опкода, который начинается с 7.
//
$08: Begin // опкод начался с 8. Здесь нужно смотреть на 4 последних бита
case op_code and $000F of // последние 4 бита опкода
// mov vx, vy
$00: Begin
// Занесем в регистр VX значение VY
exit;
End;
// or vx, vy
$01: Begin
// VX = VX or VY
exit;
End;
//
// так продолжается до 0E
//
End; // конец проверки последних 4 бит опкода
// сюда попадаем, если Invalid Opcode
exit;
End; // конец проверки, если опкод начался на 8
И так далее, думаю идея должна быть более-менее понятна. Во время написания интерпретатора можно пользоваться заглушками для каких-то команд. Теперь, когда мы реализуем основные команды процессора, останется сделать вывод на экран и реализовать устройство ввода. За вывод на экран отвечает команда DXYN. В регистре VX находится координата X, в регистре VY находится координата Y с которых мы должны начать рисовать спрайт. Адресный регистр I в это время указывает на битовый образ спрайта. Я не буду прилагать реализацию рисования графики, думаю тут не должно возникнуть сложностей, тем более всегда можно посмотреть в исходнике в конце данного поста. Так же и с клавиатурой.
Заключение
Конечно все детали реализации я не смог упомянуть в данной заметке. Цель — просто натолкнуть на мысль и показать разбор опкодов. Если кому-то интересно, можно посмотреть мою реализацию эмулятора на Delphi, или найти другие реализации эмуляторов в интернете. Как модно говорить, тысячи их. Начиная от Visual Basic и заканчивая железными решениями.
Заранее прошу прощения за мой код, я не приводил его в порядок — вылил как есть. Основной интересный файл там — hchip.pas, в нем реализована вся эмуляция.
Так же существует неплохой англоговорящий форум EmuTalk, в котором специально выделена ветка посвященная эмуляции Chip-8.
Страница, на которой можно скачать наверное один из самых лучших эмуляторов chip8 и игры под него.
Да и вообще, по запросу в гугле «chip-8» можно найти все что нужно.
Что еще можно сделать? Можно немного модифицировать наш эмулятор для поддержки Super chip-8 инструкций и спрайтов. Да много еще чего можно.
Удачного всем дня.