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

Chip-8

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

22.12.2020 16:08:30 | Автор: admin


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



Подробнее..

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

01.01.2021 18:11:05 | Автор: admin
Недавно мы опубликовали перевод первого материала из серии статей, посвящённой эмуляции компьютера. Автор этих статей подробно рассказывает о написании интерпретатора CHIP-8 на C++. В той публикации мы устроили опрос о целесообразности перевода продолжения цикла. Почти 94% тех, кто принял участие в опросе, продолжение перевода поддержали. Поэтому сегодня мы представляем вашему вниманию второй материал о CHIP-8.



Подготовка к выводу изображений


В прошлый раз мы написали интерпретатор CHIP-8, который способен выполнять все операции за исключением одной Dxyn (DRW Vx, Vy, nibble). Ради упрощения реализации этой инструкции мы инкапсулируем графическую память и код в классе Image. Кадр размером 64x32 пикселя будет представлен в виде единого фрагмента данных в памяти. Каждому пикселю будет соответствовать один байт:

0x000:|--------------------------------------------------------------|0x040:|                               |0x080:|                               |0x0C0:|                               |...0x7C0:|--------------------------------------------------------------|

Для описания этой памяти нам понадобится три значения: количество строк, количество столбцов и начальный адрес (нам его даёт malloc). Если у нас есть этот адрес, указывающий на элемент графической памяти, находящийся в верхнем левом углу вышеприведённой схемы, обращение к отдельным пикселям будет выполняться очень просто. Вот несколько примеров:

img[col=0, row=0] = img[0]img[col=0, row=1] = img[width]img[col=1, row=3] = img[3*width+1]

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

// image.hclass Image {public:// Выделение и освобождение памяти в ctor и dtor.Image(int cols, int rows);~Image();uint8_t* Row(int r);// Возвращает пиксель, который может быть изменён.uint8_t& At(int c, int r);void SetAll(uint8_t value);private:int cols_;int rows_;uint8_t* data_;};

Тут надо обратить внимание на то, что мы динамически выделяем память, владельцем которой будет этот класс. В более крупной системе мы могли бы решить воспользоваться std::unique_ptr вместе с особой функцией для выделения памяти. Но тут мы просто используем malloc в конструкторе и free в деструкторе класса.

// image.cppImage::Image(int cols, int rows) {data_ = static_cast<uint8_t*>(malloc(cols * rows * sizeof(uint8_t)));cols_ = cols;rows_ = rows;}Image::~Image() {free(data_);}uint8_t* Image::Row(int r) {return &data_[r * cols_];}uint8_t& Image::At(int c, int r) {return Row(r)[c];}void Image::SetAll(uint8_t value) {std::memset(data_, value, rows_ * cols_);}void Image::DrawToStdout() {for (int r = 0; r < rows_; r++) {for (int c = 0; c < cols_; c++) {if (At(c,r) > 0) {std::cout << "X";} else {std::cout << " ";}}std::cout << std::endl;}std::cout << std::endl;}

Здесь мне удобнее пользоваться такими именами переменных, как rows_ (строки) и cols_ (столбцы), а не x и y. Дело в том, что имя переменной rows чётко ассоциируется у меня со строками, а вот о том, что такое x, я вполне могу забыть. Функция At возвращает uint8_t&, что даёт нам возможность и получать значения отдельных пикселей, и устанавливать эти значения. Это плохо с точки зрения инкапсуляции, но такой приём часто используется в графических API. Мы, кроме того, предусмотрели тут удобную функцию DrawToStdout, которая позволяет выводить в консоль то, что должно быть отображено на экране, делая это даже тогда, когда подсистема графического вывода эмулятора ещё не реализована. Сейчас мы можем добавить в класс CpuChip8 поле frame_ типа Image и поработать над реализацией соответствующих механизмов.

// cpu_chip8.hclass CpuChip8 {public:constexpr innt kFrameWidth = 64;constexpr innt kFrameHeight = 32;CpuChip8() : frame_(kFrameWidth, kFrameHeight) {}...private:...Image frame_;};

Теперь давайте поговорим о том, как CHIP-8 выполняет вывод графических данных. А именно, рисование спрайта в текущем (и единственном) кадровом буфере выполняется по принципам, используемым в операции XOR. Все спрайты описываются в виде изображений с глубиной цвета в 1 бит (каждый пиксель может быть либо включен, либо выключен). Ширина спрайта равняется 8 битам, высота может меняться. Ограничение на ширину спрайта применяется из-за того, что каждый пиксель спрайта представлен единственным битом. Посмотрим на описание набора шрифтов, присутствующее в предыдущем материале, и попробуем расшифровать одну из цифр.

0xF0, 0x90, 0x90, 0x90, 0xF0, // 00xF0 это 1111 0000 -> XXXX0x90 это 1001 0000 -> X X0x90 это 1001 0000 -> X X0x90 это 1001 0000 -> X X0xF0 это 1111 0000 -> XXXX

Замечательно! Помните, я говорил о том, что вывод графики основан на операции XOR? Так вот, это значит, что единственный способ убрать спрайт с экрана заключается в том, чтобы вывести ещё один спрайт поверх него (фактически тот же самый спрайт), так как 1 1 даёт 0. Именно поэтому при работе с CHIP-8-программами часто заметно мерцание, так как спрайты постоянно выводятся на экран и стираются с него для вывода движущихся объектов.

Итак, мы готовы к тому, чтобы создать функцию для вывода спрайтов. Нам понадобится начальная точка и сам спрайт (область памяти). Так как спрайты могут иметь переменную высоту, мы получаем и соответствующий параметр, описывающий её. Отмечу, что одна особенность интерпретатора CHIP-8 потребовала некоторого времени на её отладку. Она заключается в том, что интерпретатор поддерживает вывод графики за пределами экрана. Когда спрайт выходит за границы экрана, его рисование продолжается на другой стороне экрана. Это поведение проявляется и при указании стартовых координат спрайта (то есть вывод 15 строк в координате 255,255 это совершенно нормально). Кроме того, интерпретатору нужно сообщать о том, был ли при выводе спрайта стёрт какой-нибудь пиксель (это часто используется для обнаружения столкновений объектов, выводимых на экран).

// image.cpp// Возвращает true в том случае, если новое значение стирает пиксель.bool Image::XOR(int c, int r, uint8_t val) {uint8_t& current_val = At(c, r);uint8_t prev_val = current_val;current_val ^= val;return current_val == 0 && prev_val > 0;}bool Image::XORSprite(int c, int r, int height, uint8_t* sprite) {// Переход на другую сторону экрана при выводе спрайта.bool pixel_was_disabled = false;for (int y = 0; y < height; y++) {int current_r = r + y;while (current_r >= rows_) { current_r -= rows_; }uint8_t sprite_byte = sprite[y];for (int x = 0; x < 8; x++) {int current_c = c + x;while (current_c >= cols_) { current_c -= cols_; }// Обратите внимание: Сканирование выполняется от MSbit до LSbituint8_t sprite_val = (sprite_byte & (0x80 >> x)) >> (7-x);pixel_was_disabled |= XOR(current_c, current_r, sprite_val);}}return pixel_was_disabled;}

Нам нужно позаботиться о том, чтобы извлекать биты, представляя их значениями 1 или 0. Так как класс Image поддерживает [0..255], операции XOR, без этого ограничения, могут наделать много беспорядка. Когда же применяется это ограничение, соответствующая инструкция нашего CPU получается очень простой нужно всего лишь извлечь параметры, необходимые для вызова XORSprite.

CpuChip8::Instruction CpuChip8::GenDRAW(uint8_t reg_x, uint8_t reg_y, uint8_t n_rows) {return [this, reg_x, reg_y, n_rows]() {uint8_t x_coord = v_registers_[reg_x];uint8_t y_coord = v_registers_[reg_y];bool pixels_unset = frame_.XORSprite(x_coord, y_coord, n_rows,memory_ + index_register_);v_registers_[0xF] = pixels_unset;NEXT;};}

Итоги


Если вы дошли до этого момента у вас уже должна появиться возможность запускать некоторые ROMы! Поставьте вызов DrawToStdout после цикла выполнения кода и понаблюдайте за тем, что попадает в консоль. Правда, пока на нашем интерпретаторе можно запускать только программы, не ожидающие пользовательского ввода.

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

Если бы вы писали собственный интерпретатор CHIP-8 каким языком программирования вы бы пользовались?

Подробнее..

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

03.01.2021 20:22:24 | Автор: admin
Мы уже создали вполне рабочий эмулятор CHIP-8, но он, к сожалению, получился очень медленным. Почему? Если заглянуть в его главный цикл можно увидеть, что данные на экран выводятся после выполнения каждого шага цикла. При включённом vsync SDL пытается привязать скорость рендеринга к частоте обновления кадров дисплея (возможно это 60 Гц). Для нас это означает то, что метод SDLViewer::Update, почти при каждом его вызове, долго будет находиться в заблокированном состоянии, ожидая сигнала vsync от монитора.



Насколько быстро должен работать наш эмулятор? Точный ответ на этот вопрос дать не так уж и просто. На реальном компьютере на выполнение операций с разными кодами нужно разное время, но известны ориентировочные временные показатели выполнения различных операций. Можно выполнить инструкцию, выяснить то, сколько времени её выполнение должно занимать на реальном аппаратном обеспечении, а после этого усыпить программу до того момента, когда можно будет продолжать работу. Но тут есть одна проблема, которая заключается в том, что у нас, при таком подходе, нет доступа к временным параметрам работы CPU. На выполнение большинства этих инструкций должна уходить пара микросекунд, но на современных системах усыплять программы можно, как минимум, на одну миллисекунду.

Правда, мы можем поступить иначе. Известно, что в течение секунды должно быть выполнено, в среднем, 540 операций. Конечно, это будет не так в том случае, если каждая из инструкций будет представлять собой что-то сложное, вроде вывода графики, но в реальных программах такой подход жизнеспособен. Мы, кроме того, знаем о том, что CHIP-8-компьютеры рассчитаны на частоту обновления экрана в 60 Гц. Это означает, что наш эмулятор должен ждать до следующего vsync столько времени, сколько нужно на выполнение 9 (540/60) инструкций.

В результате нам нужно переписать главный цикл так, чтобы перед каждым вызовом SDLViewer::Update выполнялись бы 9 инструкций. А затем мы будем использовать нашу собственную частоту обновления экрана для того чтобы подкорректировать временные параметры работы эмулятора. Попробуем это сделать:

// main.cppvoid Run() {...while (!quit) {for (int i = 0; i < 9; i++) {cpu.RunCycle();}cpu.GetFrame()->CopyToRGB24(rgb24, /*r=*/255, /*g=*/0, /*b=*/0);viewer.SetFrameRGB24(rgb24, height);auto events = viewer.Update();...}}

Благодаря использованию вышеприведённого кода весьма велики шансы того, что эмулятор теперь выполняет 540 инструкций в секунду и выводит графику с частотой 60 Гц!

Правда, если попытаться запустить эмулятор на компьютере, частота обновления экрана которого больше 60 Гц, характеристики аппаратного обеспечения уже не будут соответствовать параметрам эмулятора. Это приведёт к тому, что эмулятор будет работать слишком быстро. Если вы столкнулись с этой проблемой вы можете сэмулировать и vsync. Известно, что выполнение 9 инструкций и рендеринг кадра должны занимать 16,67 мс (1000/60). Поэтому можно выполнить данные операции, измерить время, необходимое на их выполнение, а после этого усыпить программу на столько, сколько понадобится. Так как это время не удастся вычислить достаточно точно, можно измерить и время выполнения 540 операций (60 сэмулированных циклов обновления экрана) и усыпить программу до наступления новой секунды для того чтобы внести необходимые коррективы в длительность vsync-сна. Именно это я и сделал в исходной версии эмулятора, на которой основана эта серия статей. В том проекте, кроме того, для эмуляции CPU использовался отдельный поток. В этом, вероятно, необходимости нет, но получилось, всё равно, очень интересно.

Теперь, когда эмулятор выполняет 540 инструкций в секунду, поддержка CPU-таймеров реализуется весьма просто. В CHIP-8 имеется два таймера: таймер задержки и звуковой таймер. Значения обоих таймеров 60 раз в секунду уменьшаются на 1. Компьютер издаёт звуки до тех пор, пока звуковой таймер не равен 0. Теперь можно просто, каждый раз, когда выполнено 9 инструкций, уменьшать значения, хранящиеся в таймерах, на 1. Это даст нам нужную скорость работы таймеров:

// cpu_chip8.cppvoid CpuChip8::RunCycle() {...// Обновление значений, хранящихся в таймерахnum_cycles_++;if (num_cycles_ % 9 == 0) {if (delay_timer_ > 0) delay_timer_--;if (sound_timer_ > 0) {std::cout << "BEEPING" << std::endl;sound_timer_--;}}}

Нам осталось поговорить лишь о вводе данных в систему. Это простая задача, которая сводится к обработке событий, возвращённых из SDLViewer::Update. Как уже было сказано, в CHIP-8 используется 16-клавишная клавиатура. Эти клавиши можно привязать к любым кнопкам обычной клавиатуры. Вот фрагмент кода, отвечающий за обработку ввода:

// main.cpp...auto events = viewer.Update();uint8_t cpu_keypad[16];for (const auto& e : events) {if (e.type == SDL_KEYDOWN || e.type == SDL_KEYUP) {if (e.key.keysym.sym == SDLK_1) {cpu_keypad[0] = e.type == SDL_KEYDOWN;} else if (e.key.keysym.sym == SDLK_2) {cpu_keypad[1] = e.type == SDL_KEYDOWN;} else if (e.key.keysym.sym == SDLK_3) {cpu_keypad[2] = e.type == SDL_KEYDOWN;} else if (e.key.keysym.sym == SDLK_4) {cpu_keypad[3] = e.type == SDL_KEYDOWN;} else if (e.key.keysym.sym == SDLK_q) {cpu_keypad[4] = e.type == SDL_KEYDOWN;}...}}cpu.SetKeypad(cpu_keypad);// cpu_chip8.cppvoid CpuChip8::SetKeypad(uint8_t* keys) {std::memcpy(keypad_state_, keys, 16);}

Итоги


Наш эмулятор готов. Мы создали полноценный интерпретатор CHIP-8. Он поддерживает монохромный кадровый буфер и систему, преобразующую данные, хранящиеся в этом буфере, в RGB-изображения, которые передаются на GPU в виде текстур. А только что мы настроили временные параметры работы эмулятора и подключили к нему подсистему для обработки ввода.

Не знаю, как вы, а о себе могу сказать, что я, работая над этим проектом, узнал много нового. Мне очень понравились ощущения, которые испытываешь, создавая что-то с нуля и доводя до завершения. А этот проект особенно хорош тем, что, окончив работу над эмулятором, я смог запускать на нём настоящие программы для CHIP-8. Завершив работу над этим проектом я ещё на шаг приблизился к пониманию того, что происходит в недрах Super Mario World.

Планируете ли вы написать собственный интерпретатор CHIP-8?

Подробнее..

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

10.01.2021 16:05:45 | Автор: admin
В прошлый раз мы остановились на том, что создали интерпретатор CHIP-8 и оснастили его системой для формирования кадров. Видеть то, что должно попасть на экран, можно в консоли. Теперь же мы собираемся взять то, что формирует интерпретатор, вынести это за пределы консоли и показать на экране.

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



Есть много способов вывести что-либо на экран с использованием SDL. В играх, в основном, изображения не формируются, как в нашем случае, средствами CPU. Но при эмуляции и (что встречается чаще) при воспроизведении видео изображение (вполне возможно сжатое) готовится к выводу средствами CPU. Такое изображение, для вывода его на экране, нужно загрузить в GPU. После того, как изображение попадёт в GPU, мы называем его текстурой, а весь этот процесс называют стримингом текстур.

Изображение, формируемое средствами класса Image, представлено в некоем графическом формате. Но SDL понимает лишь определённый набор пиксельных форматов. Если взглянуть на эти форматы, то окажется, что нам вполне может подойти SDL_PIXELFORMAT_RGB24. Настроим класс SDLViewer, который будет стримить изображения в этом формате, а чуть позже поразмыслим о том, как преобразовать данные нашего кадрового буфера в RGB24.

// sdl_viewer.h// SDL-окно RAII с поддержкой аппаратного ускорения.// Оптимизировано для стриминга RGB24-текстур.class SDLViewer {public:// Ширина и высота должны быть равны параметрам изображения, загружаемого// через SetFrameRGB24.SDLViewer(const std::string& title, int width, int height, int window_scale = 1);~SDLViewer();// Рендеринг текущего кадра, возврат списка событий.std::vector<SDL_Event> Update();// Предполагается, что это - 8-битное RGB-изображение, ширина которого в байтах равна его ширине в пикселях (без необходимости использовать заполнители).void SetFrameRGB24(uint8_t* rgb24, int height);private:SDL_Window* window_ = nullptr;SDL_Renderer* renderer_ = nullptr;SDL_Texture* window_tex_ = nullptr;};

Мы планируем использовать этот класс как SDL-окно RAII, которое получает актуальные сведения о текстурах и выполняет рендеринг. Конструктор принимает показатель масштабирования окна, так как если попытаться вывести на экран изображение размером 64x32 пикселя без масштабирования, оно окажется очень маленьким.

SDLViewer::SDLViewer(const std::string& title, int width, int height, int window_scale) :title_(title) {if(SDL_Init(SDL_INIT_VIDEO) < 0) {throw std::runtime_error(SDL_GetError());}// Создание SDL-окна с учётом коэффициента масштабирования.window_ = SDL_CreateWindow(title.c_str(), SDL_WINDOWPOS_UNDEFINED,SDL_WINDOWPOS_UNDEFINED, width * window_scale, height * window_scale, SDL_WINDOW_SHOWN);// Настройка аппаратной системы рендеринга и текстуры, которую мы будем стримить.renderer_ = SDL_CreateRenderer(window_, -1, SDL_RENDERER_ACCELERATED | SDL_RENDERER_PRESENTVSYNC);SDL_SetRenderDrawColor(renderer_, 0xFF, 0xFF, 0xFF, 0xFF);window_tex_ = SDL_CreateTexture(renderer_, SDL_PIXELFORMAT_RGB24,SDL_TEXTUREACCESS_STREAMING, width, height);}SDLViewer::~SDLViewer() {SDL_DestroyTexture(window_tex_);SDL_DestroyRenderer(renderer_);SDL_DestroyWindow(window_);SDL_Quit();}std::vector<SDL_Event> SDLViewer::Update() {std::vector<SDL_Event> events;SDL_Event e;while (SDL_PollEvent(&e)) { events.push_back(e); }// Рендеринг текстуры.SDL_RenderCopy(renderer_, window_tex_, NULL, NULL );SDL_RenderPresent(renderer_);return events;}void SDLViewer::SetFrameRGB24(uint8_t* rgb24, int height) {void* pixeldata;int pitch;// Блокировка текстуры и загрузка изображения в GPU.SDL_LockTexture(window_tex_, nullptr, &pixeldata, &pitch);std::memcpy(pixeldata, rgb24, pitch * height);SDL_UnlockTexture(window_tex_);}

Тут нужно выполнить некоторые стандартные процедуры по инициализации SDL-механизмов в конструкторе класса и по освобождению ресурсов в деструкторе. Метод Update будет представлять свежее изображение, отправленное SDLViewer. Он, кроме того, отвечает за приём событий, связанных с вводом данных.

Загрузка текстуры в GPU выполняется в SetFrameRGB24. Функция принимает сведения о фрагменте памяти, в котором хранится изображение в нужном формате, а так же сведения о высоте изображения. SDL_LockTexture возвращает CPU-память для копирования графических данных. Ещё эта функция возвращает длину строки изображения в байтах. После того, как изображение скопировано в выделенный участок памяти, мы вызываем функцию SDL_UnlockTexture, которая выгружает изображение в GPU в виде новой текстуры.

Теперь нам надо отредактировать код главного цикла, сделав так, чтобы в нём использовалось бы новое окно.

// main.cppvoid Run() {int width = 64;int height = 32;SDLViewer viewer("CHIP-8 Emulator", width, height, /*window_scale=*/8);uint8_t* rgb24 = static_cast<uint8_t*>(std::calloc(width * height * 3, sizeof(uint8_t)));viewer.SetFrameRGB24(rgb24, height);CpuChip8 cpu;cpu.Initialize("/path/to/program/file");bool quit = false;while (!quit) {cpu.RunCycle();cpu.GetFrame()->CopyToRGB24(rgb24, /*r=*/255, /*g=*/0, /*b=*/0);viewer.SetFrameRGB24(rgb24, height);auto events = viewer.Update();for (const auto& e : events) {if (e.type == SDL_QUIT) {quit = true;}}}}

Мы инициализируем RGB24-картинку пустым изображением (нулями, чёрным цветом). Обратите внимание на то, что размер этого изображения вычисляется не как width * height (ширина * высота), а как width * height * 3 (ширина * высота * 3). Мы ведь работаем с RGB-изображением, имеющим 3 цветовых канала. Загрузка текстуры и вывод её на экран выполняются в каждом цикле. Из-за использования vsync оказывается, что эмулятор работает очень медленно. Но мы это исправим, добравшись до настройки временных параметров работы эмулятора. Теперь нам осталось лишь разобраться в том, что собой представляет графический формат RGB24, и реализовать Image::CopyToRGB24.

При создании RGB-изображений данные красного (red), зелёного (green) и синего (blue) цветовых каналов каждого пикселя часто идут в памяти друг за другом. Поэтому простое добавление 1 к адресу памяти уже необязательно позволит нам получить значение, соответствующее следующему пикселю.

0x000 :|RGBRGBRGB...----------------------------------------|0x040*3:|RGBRGBRGB...                    |0x080*3:|RGBRGBRGB...                    |..0x7C0*3:|RGBRGBRGB...----------------------------------------|

Нам, прежде чем мы сможем это обсудить, понадобится ввести некоторые новые термины. То, что называется stride или pitch, представляет собой ширину строки изображения в байтах. В данном случае это 3 * width_px (3 * ширина в пикселях). Мы можем говорить о байтовой ширине строки изображения и в смысле её отношения к цветовым каналам. Для того чтобы перейти от одного значения красного цвета (канала) в некоем пикселе к такому же значению для следующего пикселя, мы должны прибавить к адресу этого первого значения 3 (это называется 0-dimension stride). То же самое справедливо и для синего, и для зелёного каналов. При этом каждое отдельное значение, как и прежде, представлено 8 битами (значение может находиться в диапазоне от 0 до 255), но для описания каждого пикселя теперь нужно 3 значения (число 24 в названии RGB24, в результате, означает результат умножения 3 каналов на 8 битов). Собственно говоря, теперь у нас, похоже, есть всё необходимое для того чтобы сгенерировать изображение нужного формата на основе нашего монохромного изображения.

// image.cppvoid Image::CopyToRGB24(uint8_t* dst, int red_scale, int green_scale, int blue_scale) {int cols = Cols();for (int row = 0; row < Rows(); row++) {for (int col = 0; col < cols; col++) {dst[(row * cols + col) * 3] = At(col, row) * red_scale;dst[(row * cols + col) * 3 + 1] = At(col, row) * green_scale;dst[(row * cols + col) * 3 + 2] = At(col, row) * blue_scale;}}}

Тут мы перебираем данные исходного изображения и создаём его эквивалент в dst. Каждый пиксель исходного изображения представляем в виде трёх байт нового изображения. Каждый из этих байтов соответствует одному из цветовых каналов. Здесь мы пользуемся знанием того, что пиксели исходного изображения могут пребывать либо в состоянии выключено, либо в состоянии включено, применяя коэффициенты при задании значений цветовых каналов и, таким образом, получая готовое изображение, окрашенное в какой-то цвет.

Итоги


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

Как вы думаете, почему эмулятор CHIP-8 столь популярен?
Подробнее..

Категории

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

  • Имя: Макс
    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