Русский
Русский
English
Статистика
Реклама

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



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

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



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

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

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

// main.cppvoid 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 Кб памяти, монохромный дисплей с разрешением 64x32 пикселя, а также возможность воспроизводить звуки. Это очень хорошо. Сам интерпретатор CHIP-8 будет реализован посредством виртуальной машины. Нам понадобится обеспечить функционирование шестнадцати 8-битных регистров общего назначения (V0 VF), 12-битного индексного регистра (I), счётчика команд, двух 8-битных таймеров и стека на 16 кадров.

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

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

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

// cpu_chip8.hclass 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]; // 4Kuint8_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.cppCpuChip8::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, // 00x20, 0x60, 0x20, 0x20, 0x70, // 10xF0, 0x10, 0xF0, 0x80, 0xF0, // 20xF0, 0x10, 0xF0, 0x10, 0xF0, // 30x90, 0x90, 0xF0, 0x10, 0x10, // 40xF0, 0x80, 0xF0, 0x10, 0xF0, // 50xF0, 0x80, 0xF0, 0x90, 0xF0, // 60xF0, 0x10, 0x20, 0x40, 0x40, // 70xF0, 0x90, 0xF0, 0x90, 0xF0, // 80xF0, 0x90, 0xF0, 0x10, 0xF0, // 90xF0, 0x90, 0xF0, 0x90, 0x90, // A0xE0, 0x90, 0xE0, 0x90, 0xE0, // B0xF0, 0x80, 0x80, 0x80, 0xF0, // C0xE0, 0x90, 0x90, 0x90, 0xE0, // D0xF0, 0x80, 0xF0, 0x80, 0xF0, // E0xF0, 0x80, 0xF0, 0x80, 0x80 // F};// Загрузка встроенного набора шрифтов в адреса 0x050-0x0A0std::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.cppvoid 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: 0xFFMemory location 0x001: 0xABBig endian interpretation:  0xFFABLittle endian interpretation: 0xABFF

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

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

// cpu_chip8.cpp#define NEXT program_counter_ += 2#define SKIP program_counter_ += 4void CpuChip8::BuildInstructionSet() {instructions_.clear();instructions_.reserve(0xFFFF);instructions_[0x00E0] = [this]() { frame_.SetAll(0); NEXT; }; // CLSinstructions_[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.cppCpuChip8::Instruction CpuChip8::GenJP(uint16_t addr) {return [this, addr]() { program_counter_ = addr; };}

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

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

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

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

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

// cpu_chip8.cppCpuChip8::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 carryv_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 borrowv_registers_[reg_x] -= v_registers_[reg_y];NEXT;};}

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

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



Источник: habr.com
К списку статей
Опубликовано: 22.12.2020 16:08:30
0

Сейчас читают

Комментариев (0)
Имя
Электронная почта

Блог компании ruvds.com

Программирование

C++

Chip-8

Разработка

Ruvds_перевод

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru