company_banner

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

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



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

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

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

// main.cpp

void 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.cpp

void 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.cpp
void CpuChip8::SetKeypad(uint8_t* keys) {
  std::memcpy(keypad_state_, keys, 16);
}

Итоги


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

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

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

RUVDS.com
VDS/VPS-хостинг. Скидка 10% по коду HABR

Комментарии 2

    0
    Мне, к примеру, понравился цикл в расте с паттерн матчингом: код.
    На плюсах вышло как-то так: код.

        match_opcode(opcode
            , []() { assert(false && "Unknown opcode."); }
            , code(0x0, 0x0, 0xE, 0x0, [&]()
            { /*CLS*/
                clear_display(); })
            , code(0x0, 0x0, 0xE, 0xE, [&]()
            { /*RET*/
                assert(sp_ > 0);
                --sp_;
                assert(sp_ < std::size(stack_));
                pc_ = stack_[sp_]; })
            , code(0x0, _n, _n, _n, [](std::uint16_t)
            { /* SYS addr. This instruction is only used on the old computers
                on which Chip-8 was originally implemented.
                It is ignored by modern interpreters. */ })
            , code(0x1, _n, _n, _n, [&](std::uint16_t nnn)
            { /* JP addr. */
                pc_ = nnn; })
            , code(0x2, _n, _n, _n, [&](std::uint16_t nnn)
            { /* CALL addr. */
                assert(sp_ < std::size(stack_));
                stack_[sp_] = pc_;
                ++sp_;
                pc_ = nnn; })
            , code(0x3, _x, _k, _k, [&](std::uint8_t x, std::uint8_t kk)
            { /* SE Vx, byte. */
                pc_ += ((V_[x] == kk) ? 2 : 0); })
    


    Что в плане компактности и «декларативности» мне очень нравится.
      0
      Как бы теперь это все дело активировать

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое