Эмуляция компьютера: интерпретатор CHIP-8

от автора

Меня, по ряду причин, всегда завораживала эмуляция. Программа, которая выполняет другую программу… Мне эта идея кажется невероятно привлекательной. И у меня такое ощущение, что тот, кто напишет подобную программу, не пожалеет ни об одной минуте потраченного на это времени. Кроме того, написание эмулятора — это очень похоже на создание настоящего компьютера программными средствами. Мне было очень интересно разбираться в устройстве компьютерной архитектуры, писать простой HDL-код, но эмуляция — это гораздо более простой способ ощутить себя тем, кто своими руками создаёт компьютер. А ещё, в детстве, когда я впервые увидел игру Super Mario World, я поставил себе цель, которая до сих пор не потеряла для меня ценности. Она заключается в том, чтобы полностью понять то, как именно работает эта игра. Именно поэтому я уже некоторое время подумываю о написании эмулятора SNES/SNS. Недавно я решил, что пришло время сделать первый шаг к этой цели.

Предлагаю поговорить о разработке эмулятора и обсудить простой, но полноценный пример эмуляции CHIP-8.

CHIP-8 — это, на самом деле, язык программирования. И он, кроме того, очень простой: в нём имеется всего 35 кодов операций. Для того чтобы создать интерпретатор для этого языка, пожалуй, достаточно написать программу, которая может выполнять эти 35 инструкций. Аспект эмуляции в подобный проект вносит то, чего обычно нет в интерпретаторах языков программирования. А именно, нам нужны средства для вывода графики, обработки пользовательского ввода, воспроизведения звуков. Нам, кроме того, требуется смоделировать аппаратные механизмы компьютера, на котором выполняется код CHIP-8. При выполнении кода нужно помнить о регистрах и о памяти, необходимо аккуратно обращаться с таймерами.

Проект мы будем писать на C++. Но, если кто-то захочет переписать данную систему на другом языке, сделать это, скорее всего, будет достаточно просто. Если хотите увидеть полный код проекта — загляните в этот репозиторий.

Начнём с простого главного цикла. Пока не будем обращать внимание на эмуляцию временных параметров выполнения кода.

// main.cpp  void Run() {   CpuChip8 cpu;   cpu.Initialize("/path/to/program/file");   bool quit = false;   while (!quit) {     cpu.RunCycle();   } }  int main(int argc, char** argv) {   try {     Run();   } catch (const std::exception& e) {     std::cerr << "ERROR: " << e.what();     return 1;   } } 

Класс CpuChip8 будет инкапсулировать состояние виртуальной машины и интерпретатора. Теперь, если мы реализуем RunCycle и Initialize, в наших руках окажется «скелет» простого эмулятора. Обсудим теперь тот «железный» компьютер, который мы будем эмулировать.

Нашей CHIP-8-системой будет Telmac 1800. В нашем распоряжении окажется 4 Кб памяти, монохромный дисплей с разрешением 64×32 пикселя, а также — возможность воспроизводить звуки. Это очень хорошо. Сам интерпретатор CHIP-8 будет реализован посредством виртуальной машины. Нам понадобится обеспечить функционирование шестнадцати 8-битных регистров общего назначения (V0 — VF), 12-битного индексного регистра (I), счётчика команд, двух 8-битных таймеров и стека на 16 кадров.

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

0x000 |-----------------------|       | Память интерпретатора |       |                       | 0x050 |   Встроенные шрифты   | 0x200 |-----------------------|       |                       |       |                       |       | Память программы      |       | и динамически         |       | выделяемая память     |       |                       | 0xFFF |-----------------------| 

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

// cpu_chip8.h  class CpuChip8 {  public:   public Initialize(const std::string& rom);   void RunCycle();   private:   // Заполняет набор инструкций (instructions_).   void BuildInstructionSet();    using Instruction = std::function<void(void)>;   std::unordered_map<uint16_t, Instruction>> instructions_;    uint16_t current_opcode_;     uint8_t memory_[4096];  // 4K   uint8_t v_register_[16];    uint16_t index_register_;   // Указывает на следующую инструкцию в памяти, которую нужно выполнить.   uint16_t program_counter_;    // Таймеры на 60 Гц.   uint8_t delay_timer_;   uint8_t sound_timer_;    uint16_t stack_[16];   // Указывает на следующую пустую ячейку стека.   uint16_t stack_pointer_;    // 0 если ни одна клавиша не нажата.   uint8_t keypad_state_[16]; }; 

Мы специально используем целочисленные типы. Это позволяет обеспечить правильность обработки ситуаций, связанных с исчезновением значащих разрядов и переполнением. Для 12-битных значений нам нужно использовать 16-битные типы. У нас, кроме того, имеется 16 клавиш, состояние которых (нажата клавиша или нет) тоже хранится в этом классе. Когда мы подключим подсистему обработки ввода, мы найдём способ передачи соответствующих данных в класс между циклами. Работать с кодами операций несложно благодаря тому, что все инструкции CHIP-8 имеют длину, равную 2 байта.

Это даёт нам возможность обрабатывать 0xFFFF (65535) инструкций (хотя многие из них не используются). Мы, на самом деле, можем сохранить все возможные инструкции в контейнере map. И, когда получаем код операции, можем просто тут же выполнить инструкцию, обращаясь к связанной с кодом операции сущности Instruction из instructions_. Мы не привязываем особенно много данных к функциям, в результате весь контейнер map с инструкциями должен поместиться в кеш-памяти.

Функция Initialize — это то место, где осуществляется настройка описанной выше схемы распределения памяти:

// cpu_chip8.cpp  CpuChip8::Initialize(const std::string& rom) {   current_opcode_ = 0;   std::memset(memory_, 0, 4096);   std::memset(v_registers_, 0, 16);   index_register_ = 0;   // Память, предназначенная для программ, начинается с адреса 0x200.   program_counter_ = 0x200;    delay_timer_ = 0;   sound_timer_ = 0;   std::memset(stack_, 0, 16);   stack_pointer_ = 0;   std::memset(keypad_state_, 0, 16);      uint8_t chip8_fontset[80] =   {      0xF0, 0x90, 0x90, 0x90, 0xF0, // 0     0x20, 0x60, 0x20, 0x20, 0x70, // 1     0xF0, 0x10, 0xF0, 0x80, 0xF0, // 2     0xF0, 0x10, 0xF0, 0x10, 0xF0, // 3     0x90, 0x90, 0xF0, 0x10, 0x10, // 4     0xF0, 0x80, 0xF0, 0x10, 0xF0, // 5     0xF0, 0x80, 0xF0, 0x90, 0xF0, // 6     0xF0, 0x10, 0x20, 0x40, 0x40, // 7     0xF0, 0x90, 0xF0, 0x90, 0xF0, // 8     0xF0, 0x90, 0xF0, 0x10, 0xF0, // 9     0xF0, 0x90, 0xF0, 0x90, 0x90, // A     0xE0, 0x90, 0xE0, 0x90, 0xE0, // B     0xF0, 0x80, 0x80, 0x80, 0xF0, // C     0xE0, 0x90, 0x90, 0x90, 0xE0, // D     0xF0, 0x80, 0xF0, 0x80, 0xF0, // E     0xF0, 0x80, 0xF0, 0x80, 0x80  // F   };   // Загрузка встроенного набора шрифтов в адреса 0x050-0x0A0   std::memcpy(memory_ + 0x50, chip8_fontset, 80);    // Загрузка ROM в память, предназначенную для программы.   std::ifstream input(filename, std::ios::in | std::ios::binary);   std::vector<uint8_t> bytes(          (std::istreambuf_iterator<char>(input)),          (std::istreambuf_iterator<char>()));   if (bytes.size() > kMaxROMSize) {     throw std::runtime_error("File size is bigger than max rom size.");   } else if (bytes.size() <= 0) {     throw std::runtime_error("No file or empty file.");   }   std::memcpy(memory_ + 0x200, bytes.data(), bytes.size());    BuildInstructionSet(); } 

Можете не читать код загрузки файла — C++-библиотека iostream устроена довольно странно. Самое главное тут то, что мы всё устанавливаем в 0 и загружаем в память то, что должно быть в неё загружено. Наш набор шрифтов — это последовательность из 16 встроенных спрайтов, к которым, при необходимости, могут обращаться программы. Позже, когда мы будем разбираться с графической составляющей системы, мы поговорим о том, как соответствующие данные, записанные в память, формируют спрайты. Сейчас наша цель заключается в том, чтобы, после того, как работа Initialize завершится, мы были бы готовы к выполнению пользовательской программы.

Создадим простой цикл, RunCycle, что позволит нам лучше разобраться в том, что нам делать с BuildInstructionSet. Если вы можете вспомнить о том, как устроена какая-нибудь простая архитектура компьютера, то вы знаете, что у цикла есть несколько фаз. Сначала осуществляется загрузка инструкции. Потом её декодируют, а после этого — выполняют.

// cpu_chip8.cpp  void CpuChip8::RunCycle() {   // Прочитать слово кода операции в формате big-endian.   current_opcode_ = memory_[program_counter_] << 8 |     memory_[program_counter_ + 1];    auto instr = instructions_.find(current_opcode_);   if (instr != instructions_.end()) {     instr->second();   } else {     throw std::runtime_error("Couldn't find instruction for opcode " +       std::to_string(current_opcode_));   }    // TODO: Обновить таймеры, отвечающие за звук и задержку. } 

Тут, в общем-то, всё устроено очень просто: мы ищем инструкцию, которую надо выполнить. Единственное, что тут может показаться необычным, это то, как выполняется чтение следующего кода операции. CHIP-8 использует формат big-endian. Это означает, что наиболее значимая часть слова идёт первой, а за ней идёт наименее значимая часть слова. В современных системах, основанных на архитектуре x86, используется обратный порядок представления данных (little-endian).

Memory location 0x000: 0xFF  Memory location 0x001: 0xAB  Big endian interpretation:    0xFFAB Little endian interpretation: 0xABFF 

Обратите внимание на то, что в RunCycle мы не изменяем счётчик команд. Это делается в функциях, поэтому мы перекладываем эту задачу на реализацию конкретной инструкции. Кроме того, так как мы решили объявить Instruction в виде указателя на функцию без аргументов, мы собираемся привязать это к самой функции. Нам потребуется выполнить больше работы при первоначальной настройке системы, но это означает, что мы полностью избавимся от фазы декодирования инструкции в RunCycle.

Теперь вплотную займёмся интерпретатором — BuildInstructionSet. Я не буду тут приводить реализацию каждой функции, вы можете найти соответствующий код в репозитории проекта. Я настоятельно рекомендую читать этот код, держа под рукой документацию по инструкциям CHIP-8.

// cpu_chip8.cpp  #define NEXT program_counter_ += 2 #define SKIP program_counter_ += 4  void CpuChip8::BuildInstructionSet() {   instructions_.clear();   instructions_.reserve(0xFFFF);    instructions_[0x00E0] = [this]() { frame_.SetAll(0); NEXT; }; // CLS   instructions_[0x00EE] = [this]() {     program_counter_ = stack_[--stack_pointer_] + 2;  // RET   };    for (int opcode = 0x1000; opcode < 0xFFFF; opcode++) {     uint16_t nnn =  opcode & 0x0FFF;     uint8_t kk =    opcode & 0x00FF;     uint8_t x =     (opcode & 0x0F00) >> 8;     uint8_t y =     (opcode & 0x00F0) >> 4;     uint8_t n =     opcode & 0x000F;     if ((opcode & 0xF000) == 0x1000) {       instructions_[opcode] = GenJP(nnn);     } else if ((opcode & 0xF000) == 0x2000)) {       instructions_[opcode] = GenCALL(nnn);     }     // ... } 

В каждой инструкции могут быть закодированы какие-то параметры, которые мы декодируем и, по мере возникновения необходимости в них, используем. Тут мы, для генерирования функций std::function, можем воспользоваться std::bind, но я, в данном случае, решил объявить функции в виде Gen[INSTRUCTION_NAME], что позволяет возвращать функции в виде лямбда-выражений с привязанными к ним данными.

Рассмотрим ещё некоторые интересные функции.

// cpu_chip8.cpp  CpuChip8::Instruction CpuChip8::GenJP(uint16_t addr) {   return [this, addr]() {  program_counter_ = addr; }; } 

Когда мы выполняем команду перехода на заданный адрес (JP) — мы просто устанавливаем счётчик команд на этот адрес. Это приводит к тому, что в следующем цикле выполняется инструкция, находящаяся по этому адресу.

// cpu_chip8.cpp  CpuChip8::Instruction CpuChip8::GenCALL(uint16_t addr) {   return [this, addr]() {     stack_[stack_pointer_++] = program_counter_;     program_counter_ = addr;   }; } 

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

// cpu_chip8.cpp  CpuChip8::Instruction CpuChip8::GenSE(uint8_t reg, uint8_t val) {   return [this, reg, val]() {     v_registers_[reg] == val ? SKIP : NEXT;   }; } 

SE расшифровывается как «пропустить, если непосредственное значение равно значению, хранящемуся в предоставленном регистре». Инструкция получает регистр общего назначения, выясняет его значение и соответствующим образом устанавливает счётчик команд.

// cpu_chip8.cpp  CpuChip8::Instruction CpuChip8::GenADD(uint8_t reg_x, uint8_t reg_y) {   return [this, reg_x, reg_y]() {     uint16_t res = v_registers_[reg_x] += v_registers_[reg_y];     v_registers_[0xF] = res > 0xFF; // set carry     v_registers_[reg_x] = res;     NEXT;   }; } CpuChip8::Instruction CpuChip8::GenSUB(uint8_t reg_x, uint8_t reg_y) {   return [this, reg_x, reg_y]() {     v_registers_[0xF] = v_registers_[reg_x] > v_registers_[reg_y]; // set not borrow     v_registers_[reg_x] -= v_registers_[reg_y];     NEXT;   }; } 

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

// cpu_chip8.cpp  CpuChip8::Instruction CpuChip8::GenLDSPRITE(uint8_t reg) {   return [this, reg]() {     uint8_t digit = v_registers_[reg];     index_register_ = 0x50 + (5 * digit);     NEXT;   }; } 

Наша функция загрузки спрайтов достаточно проста. Она используется программой для выяснения того, где именно во встроенном наборе шрифтов находится определённый символ. Тут стоит помнить о том, что встроенный набор шрифтов мы сохранили по адресу 0x50, и то, что каждый символ описывается последовательностью из 5 байтов. Поэтому мы и устанавливаем I, пользуясь конструкцией 0x50 + 5 * digit.

// cpu_chip8.cpp  CpuChip8::Instruction CpuChip8::GenSTREG(uint8_t reg) {   return [this, reg]() {     for (uint8_t v = 0; v <= reg; v++) {       memory_[index_register_ + v] = v_registers_[v];     }     NEXT;   }; } CpuChip8::Instruction CpuChip8::GenLDREG(uint8_t reg) {   return [this, reg]() {     for (uint8_t v = 0; v <= reg; v++) {       v_registers_[v] = memory_[index_register_ + v];     }     NEXT;   }; } 

Когда мы напрямую работаем с памятью, пользователь предоставляет максимальный регистр из последовательности регистров, в которые нужно загрузить данные. Например, если надо загрузить данные, последовательно хранящиеся в MEM[I], в регистры V0, V1 и V2, то, после установки I, передаётся регистр V2.

Итоги

Только что мы создали интерпретатор CHIP-8! Конечно, к нему пока не подключены звуковая и графическая подсистемы, но на нём уже можно запустить простые тестовые ROM, в которых соответствующие возможности не используются. Следующая часть этой серии статей посвящена разработке графической подсистемы эмулятора. Вывод графики — это самая сложная из задач, решаемых нашей системой.

ссылка на оригинал статьи https://habr.com/ru/company/ruvds/blog/534118/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *