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