Вероятно, многие программисты если и не мечтали, то хотя бы задумывались о написании собственного эмулятора какого-либо процессора. Возможно, некоторые даже экспериментировали с чем-то вроде 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 байт. Его можно увидеть в исходных кодах моего эмулятора. Да, прошу прощения за свой французский Delphi, но программирую я на нем, не обессудьте. Для простоты я создал такую структуру:

      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 инструкций и спрайтов. Да много еще чего можно.

Удачного всем дня.