Меня, по ряду причин, всегда завораживала эмуляция. Программа, которая выполняет другую программу… Мне эта идея кажется невероятно привлекательной. И у меня такое ощущение, что тот, кто напишет подобную программу, не пожалеет ни об одной минуте потраченного на это времени. Кроме того, написание эмулятора — это очень похоже на создание настоящего компьютера программными средствами. Мне было очень интересно разбираться в устройстве компьютерной архитектуры, писать простой 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/


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