Пишем эмулятор CHIP-8. Часть 4: Ядро

Jun 30, 2012   #dev  #emulation 

В сегодняшней статье мы перейдем непосредственно к самой важной части - написанию ядра нашего эмулятора.

В общем виде эмуляция CHIP8 выглядит следующим образом:

  1. Мы объявляем переменные, которые будут представлять регистры процессора, массивы которые представляют память, экран, состояние клавиатуры и т.п.
  2. Инициализируем эти переменные начальными значениями, очищаем массивы памяти и экрана, загружаем в память стандартные шрифты CHIP8/SCHIP. После этого загружаем в память игру (копируем ее в массив памяти со смещением 0x200 от начала массива)
  3. Считываем из памяти по адресу PC двухбайтовый опкод, конструкцией switch-case выбираем его и выполняем (здесь мы должны добиться полного соответствия с эмулируемой платформой: если опкод CHIP-8 присваивает регистру I какое-то значение, то и мы присваиваем его нашей переменной I; если опкод сдвигает изображение на экране на несколько пикселей вправо, то и мы сдвигаем данные в массиве представляющем экран, и т.п.). И не забываем увеличивать значение в регистре PC на 2 (необходимо для выполнения следующего опкода). Конструкция выглядит примерно так: opcode = memory[PC]; PC += 2; switch(opcode) { case opcode1: выполняем опкод; break; case opcode2: выполняем опкод; break; .. case opcodeN: выполняем опкод; break; }
  4. Обновляем значения таймеров 60 раз в секунду, рисуем экран, считываем состояние клавиатуры и снова выполняем пункт 3.

Теперь приступим к написанию кода. Создаем файл emu_chip.h со следующим содержимым:

#ifndef CHIP_EMU_H
#define CHIP_EMU_H
 
#include <string>
 
class ChipEmu
{
private:
        unsigned short opcode;          // двухбайтовый опкод
        unsigned char V[16];            // регистры общего назначения и флаг переноса VF  
        unsigned short I;               // адрессный регистр
        unsigned short PC;              // указатель кода
        unsigned char SP;               // указаель стека
       
        unsigned short stack[16];       // массив хранящий в себе стек
       
        unsigned char memory[4096];     // память, в нее загружаем игру
 
        unsigned char delay_timer;      // таймеры задержки
        unsigned char sound_timer;      // и звука
 
        // функция рисующая спрайт в массив screen
        void drawSprite(unsigned char X, unsigned char Y, unsigned char N);
 
        unsigned char hp48_flags[8];    // флаги, необходимы для опкодов Fx75, Fx85
 
public:
        int mode;                       // переменная определяющая текущий режим:
                                        // 0 - CHIP-8
                                        // 1 - SCHIP
 
        unsigned char screen[128][64];  // массив представляющий экран
 
        unsigned char key[16];          // массив содержащий состояние клавиатуры:
                                        // 0 - клавиша отпущена
                                        // 1 - клавиша нажата
 
        bool stop;                      // переменная для опкода 00FD
 
        ChipEmu();
 
        // инициализация переменных, загрузка шрифтов
        void init();
 
        // функция загрузки игры в память
        bool loadGame(const char *filename);
 
        // функция выполняющая опкод
        void executeNextOpcode();
 
        // функция уменьшающая значения таймеров
        void decreaseTimers();
};
#endif

Файл emu_chip.cpp я выложил на http://pastebin.com/iiAz3Hfk Рассмотрим в нем ключевые моменты.

В функции init() мы обнуляем регистры, очищаем массивы памяти и экрана, а так же загружаем шрифты: маленький грузим в начало массива памяти, большой со смешением в 80 байт от начала. PC устанавливаем в 0x200, так как игра начинает выполняться с этого адреса.

Переменная mode нужна для того, что бы функция drawSprite() знала в каком режиме ей рисовать спрайты: CHIP-8 или SUPER CHIP. По-умолчанию это режим CHIP-8.

Функция loadGame() загружает игру в массив memory начиная со смещения 0x200, так как по спецификации CHIP-8 код игры должен начинаться с этого адреса.

Функция decreaseTimers() вызывается 60 раз в секунду для уменьшения таймеров. К ней мы еще вернемся в следующей статье, когда будем говорить о скорости эмуляции.

Теперь рассмотрим функцию executeNextOpcode(). Практически весь процесс эмуляции происходит именно в ней.

В строке 188 мы загружаем двухбайтовое значение из памяти с адресом PC в переменную opcode. Затем увеличиваем PC на 2, что бы при следующей итерации цикла загружались следующие два байта.

Я подразумеваю, что вы понимаете как работают операции сдвига и булева алгебра, иначе сложно будет разобраться как мы находим необходимый опкод и “извлекаем” из него определенные биты.

Попробую объяснить это на примере: Возмем опкод 8XY0. В нем X и Y - любая шестнадцатеричная цифра. Наша задача определить, что в переменной opcode содержится именно этот опкод, а затем “извлечь” из него значения X и Y.

Для начала нам нужно узнать старшие 4 бита опкода. Делаем это так: (opcode & 0xF000) >> 12 В результате выполнения opcode & 0xF000 мы получим значение 8000, которое сдвигаем на 12 бит вправо и получаем число 8.

Точно так же определяем остальные цифры: Младший 0 получаем после операции opcode & 0x000F, и теперь мы точно можем быть уверены, что это опкод 8XY0 Значение X получаем вот так (opcode & 0x0F00) >> 8 Значение Y получаем вот так (opcode & 0x00F0) >> 4

В функции executeNextOpcode() опкод выбирается через вложенные операторы switch-case. Внешний switch определяет старшие 4 бита опкода и, если их недостаточно для идентификации, вложенный switch определяет остальные необходимые биты.

Дальше смотрим список опкодов во второй части этой статьи и реализуем функционал который эти опкоды должны выполнять. К примеру, для опкода 00E0 (который очищает экран) в цикле забиваем нулями массив screen (строки 209-213). Вообще большая часть опкодов реализуется довольно просто, сложности могут возникнуть с опкодами DXYN, FX33, FX55 и FX65 (угадайте почему 2 последних входят в этот список).

Функция drawSprite() реализует поведение опкода DXYN. Она рисует в массив screen спрайт находящийся по смещению I. Координаты спрайта берутся из регистров VX и VY. Один байт по смещению I представляет одну строку спрайта для CHIP-8, и полстроки для SCHIP (при N=0). Каждый бит в этом байте представляет отдельный пиксель. Количество строк N извлекается из опкода. Если N=0, то в спрайте 16 строк. Также не стоит забывать о том, что если мы рисуем пиксель “поверх” уже существующего пикселя, то эта точка экрана очистится, а регистр VF установится в 1. То есть рисуем методом XOR.

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