[Перевод] Эмуляция компьютера: интерпретатор 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;
std::unordered_map> 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 bytes(
(std::istreambuf_iterator(input)),
(std::istreambuf_iterator()));
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, в которых соответствующие возможности не используются. Следующая часть этой серии статей посвящена разработке графической подсистемы эмулятора. Вывод графики — это самая сложная из задач, решаемых нашей системой.