Imran Nazar: Эмуляция GameBoy на JavaScript: Процессор

Этот перевод открывает серию статьей про эмуляцию GameBoy на JavaScript. Оригинал здесь. Далее авторский текст.

Это первая часть серии статей об эмуляции в JavaScript; восемь частей уже доступно, позже будут ещё.

Исходные коды эмулятора, о котором идёт речь в статьях, находятся здесь: http://github.com/Two9A/jsGB

Обычно говорят, что JavaScript — язык специального назначения, который предоставляет возможность делать интерактивные веб сайты. Тем не менее, JavaScript это полноценный объектно-ориентированный язык, который используется не только в веб-разработке. На JavaScript реализованы виджеты в последних версиях Windows и Mac OS или GUI в Mozilla application suite.

В связи с недавним появлением в HTML тега

1
<canvas>

, возник вопрос о возможности при помощи JavaScript эмулировать устройство, также как настольные приложения эмулируют Commodore 64, GameBoy Advance и другие игровые консоли. Написать такой эмулятор на JavaScript это простейший путь проверить жизнеспособность идеи.

Эта статья ставит перед собой цель реализовать основу эмуляции GameBoy подготовив фундамент для эмуляции каждой части физического устройства. Начнём с процессора.

Модель

Программа, состоящая из инструкций, говорит процессору что требуется делать. Это традиционная модель компьютера. Программа может располагаться как в своей специальной памяти, так и в обычной. Это зависит от компьютера. Каждая инструкция для исполнения требует небольшое количество времени. Все инструкции исполняются по очереди. С точки зрения процессора, как только компьютер включен, начинается цикл. Взять инструкцию из памяти, идентифицировать и исполнить её.

Процессор, для того чтобы отслеживать в каком месте программы он находится, содержит число, которое называется счётчик команд (Program Counter, PC). После того как инструкция получена из памяти, значение PC увеличивается на её размер в байтах.

Рис. 1: Цикл получить-декодировать-исполнить

Процессор на GameBoy — модифицированный Zilog Z80:

  • Z80 это 8-разрядная микросхема, поэтому все внутренние механизмы оперируют одним байтом за раз;
  • Интерфейс памяти может адресовать до 65,536 байт (16-разрядная адресная шина);
  • Программы доступны через ту же самую адресную шину, что и обычная память;
  • Размер инструкции может быть от одного до трёх байт.

В дополнение к PC, внутри процессора существуют другие числа, которые могут быть использованы при расчётах. Их называют регистрами: A, B, C, D, E, H и L. Размер каждого из них равен одному байту, следовательно любой из регистров может хранить значение от 0 до 255. Большинство инструкций Z80 используются для оперирования значениями в этих регистрах: загрузка значения из памяти в регистр, сложение или вычитание значений и так далее.

Поскольку первый байт инструкции может принимать 256 разных значений, то это делает возможным существование 256 различных инструкций в основной таблице. Эта таблица подробно расписана в таблице опкодов Gameboy Z80. (опкод это код операции, подробнее здесь. прим. перев.) Каждая из них может быть смоделирована JavaScript функцией, которая оперирует внутренним представлением регистров и воздействует на внутреннее представление интерфейса памяти. (другими словами оперирует структурами данных эмулятора, которые изображают реальные регистры и память. прим. перев.)

В Z80 есть другие регистры, которые хранят состояние: регистр флагов (Flags, F), о работе которого говорится ниже; и указатель стека (Stack Pointer, SP) который используется вместе с инструкциями PUSH и POP для управления данными по принципу LIFO. (проще говоря при помощи SP, PUSH и POP реализуется стек. прим. перев.) Следовательно, для базовой эмуляции Z80 требуются следующие компоненты:

  • Внутреннее состояние:
    • Структура для хранения текущего состояния регистров;
    • Длительность исполнения последней инструкции;
    • Общее время работы CPU;
  • Функции для моделирования каждой инструкции;
  • Таблица для сопоставления опкода и функции, которая его моделирует;
  • Интерфейс для взаимодействия с эмулируемой памятью.

Внутреннее состояние может храниться следующим образом:

Z80.js: Внутреннее состояние

Z80 = {
    // Таймер: Z80 содержит два типа таймеров (m и t)
    _clock: {m:0, t:0},

    // Набор регистров
    _r: {
        a:0, b:0, c:0, d:0, e:0, h:0, l:0, f:0,    // 8-разрядные регистры
        pc:0, sp:0,                                // 16-разрядные регистры
        m:0, t:0                                   // Таймер для последней инструкции
    }
};

Регистр флагов (F) важен для функционирования процессора: он автоматически рассчитывает некоторые биты или флаги, основываясь на результате последней операции. (не сам регистр, конечно, а процессор, но важно то, что в результате некоторых операций содержимое регистра флагов меняется. прим. перев.) В Gameboy Z80 четыре флага:

  • Zero (0x80): Устанавливается, если результат последней операции 0;
  • Operation (0x40): Устанавливается, если последняя операция была вычитанием;
  • Half-carry (0x20): Устанавливается, если при получении результата последней операции оказалось, что нижняя половина байта переполнена и миновала значение 15. (здесь дело в том, что в двоичном представлении 15 это 0000 1111, а 16 уже 0001 0000 или 31 это 0001 1111, а 32 это 0010 0000. Соответственно, при таком переходе и устанавливается флаг. прим. перев.);
  • Carry (0x10): Устанавливается, если результат последней операции больше 255 (для сложений) или меньше 0 (для вычитаний).

Так как основные регистры для расчётов 8-разрядные, то флаг Carry (переводится как “перенос”. прим. перев.) позволяет программе понять что случилось со значением, если результат вычисления переполнил регистр. Ниже приведено несколько примеров моделирования инструкций с участием флагов. Эти примеры упрощены и не устанавливают флаг Half-carry.

Z80.js: Моделирование инструкций

Z80 = {
    // Внутреннее состояние
    _clock: {m:0, t:0},
    _r: {a:0, b:0, c:0, d:0, e:0, h:0, l:0, f:0, pc:0, sp:0, m:0, t:0}, 

    // Прибавить E к A, результат записать в A (ADD A, E)
    ADDr_e: function() {
        Z80._r.a += Z80._r.e;                      // Выполнить сложение
        Z80._r.f = 0;                              // Очистить флаги
        if(!(Z80._r.a & 255)) Z80._r.f |= 0x80;    // Проверить на 0
        if(Z80._r.a > 255) Z80._r.f |= 0x10;       // Проверить на перенос
        Z80._r.a &= 255;                           // Наложить 8-разрядную маску
        Z80._r.m = 1; Z80._r.t = 4;                // Операция заняла 1 цикл обращений к шине
    } 

    // Сравнить B с A, установить флаги (CP A, B)
    CPr_b: function() {
        var i = Z80._r.a;                          // Создать временную копию A
        i -= Z80._r.b;                             // Вычесть B
        Z80._r.f |= 0x40;                          // Установить флаг Operation
        if(!(i & 255)) Z80._r.f |= 0x80;           // Проверить на 0
        if(i < 0) Z80._r.f |= 0x10;                // Проверить на переполнение
        Z80._r.m = 1; Z80._r.t = 4;                // Операция заняла 1 цикл обращений к шине 

    } 

    // No-operation (NOP) (операция, которая ничего не делает. прим. перев.)
    NOP: function() {
        Z80._r.m = 1; Z80._r.t = 4;                // Операция заняла 1 цикл обращений к шине
    }
};

Взаимодействие с памятью

Чтобы быть полезным процессор должен не только манипулировать регистрами внутри себя, но и уметь записывать значения в память. Другими словами, реализация процессора, приведённая выше, требует интерфейс к эмулируемой памяти; он может быть предоставлен устройством управления памятью (memory management unit, MMU). Эмулируемое устройство будет довольно простым, так как GameBoy не содержит сложного MMU.

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

Z80.js: Интерфейс памяти

MMU = {
    rb: function(addr) { /* Прочитать 8-битный байт по адресу */ },
    rw: function(addr) { /* Прочитать 16-битное слово по адресу */ }, 

    wb: function(addr, val) { /* Записать 8-битный байт по адресу */ },
    ww: function(addr, val) { /* Записать 16-битное слово по адресу */ }
};

Теперь можно смоделировать остальные инструкции CPU. Ещё несколько примеров:

Z80.js: Инструкции для управления памятью

    // Поместить регистры B и C в стек (PUSH BC)
    PUSHBC: function() {
        Z80._r.sp--;                               // Опуститься по стеку
        MMU.wb(Z80._r.sp, Z80._r.b);               // Записать B
        Z80._r.sp--;                               // Опуститься по стеку
        MMU.wb(Z80._r.sp, Z80._r.c);               // Записать C
        Z80._r.m = 3; Z80._r.t = 12;               // Операция заняла 3 цикла обращений к шине
    }, 

    // Восстановить регистры H и L из стека (POP HL)
    POPHL: function() {
        Z80._r.l = MMU.rb(Z80._r.sp);              // Прочитать L
        Z80._r.sp++;                               // Подняться по стеку
        Z80._r.h = MMU.rb(Z80._r.sp);              // Прочитать H
        Z80._r.sp++;                               // Подняться по стеку
        Z80._r.m = 3; Z80._r.t = 12;               // Операция заняла 3 цикла обращений к шине
    } 

    // Прочитать в A байт по абсолютному адресу памяти (LD A, addr)
    LDAmm: function() {
        var addr = MMU.rw(Z80._r.pc);              // Взять адрес из инструкции
        Z80._r.pc += 2;                            // Увеличить PC
        Z80._r.a = MMU.rb(addr);                   // Прочитать значение по адресу
        Z80._r.m = 4; Z80._r.t=16;                 // Операция заняла 4 цикла обращений к шине
    }

Диспетчеризация и сброс

С инструкциями разобрались. Оставшиеся кусочки паззла для процессора это сброс во время старта, и отправка инструкций в процедуры эмуляции. Процедура сброса позволит процессору остановиться и начать работать с самого начала; ниже приведён пример.

Z80.js: Сброс

    reset: function() {
    Z80._r.a = 0; Z80._r.b = 0; Z80._r.c = 0; Z80._r.d = 0;
    Z80._r.e = 0; Z80._r.h = 0; Z80._r.l = 0; Z80._r.f = 0;
    Z80._r.sp = 0;
    Z80._r.pc = 0;      // Начать работу с самого начала

    Z80._clock.m = 0; Z80._clock.t = 0;
    }

Для запуска эмуляции процессор должен эмулировать цикл получить-декодировать-исполнить описанный ранее. Об «исполнить» позаботятся функции эмуляции инструкций, но «получить» и «декодировать» требуют специальный код известный как «цикл диспетчеризации». Этот цикл берёт инструкцию за инструкцией, определяет какая функция отвечает за моделирование, и отправляет туда инструкцию.

Z80.js: Диспетчер

while(true)
{
    var op = MMU.rb(Z80._r.pc++);              // Получить инструкцию
    Z80._map[op]();                            // Вызвать моделирующую функцию
    Z80._r.pc &= 65535;                        // Наложить на PC 16-разрядную маску
    Z80._clock.m += Z80._r.m;                  // Увеличить значения таймеров
    Z80._clock.t += Z80._r.t;
} 

Z80._map = [
    Z80._ops.NOP,
    Z80._ops.LDBCnn,
    Z80._ops.LDBCmA,
    Z80._ops.INCBC,
    Z80._ops.INCr_b,
    ...
];

Использование для эмуляции устройства

Реализация эмуляции ядра Z80 бесполезна без эмулятора, который её запустит. В следующих статьях начинается работа по эмуляции Gameboy: я проведу обзор карты памяти GameBoy и того как образ с игрой может быть загружен в эмулятор

Готовое ядро Z80 доступно здесь: http://imrannazar.com/content/files/jsgb.z80.js; дайте мне знать, если найдёте баги в реализации.

Imran Nazar <tf@imrannazar.com>, Июль 2010.

  • круто :) интересно почитать, спасибо :)

  • semenov

    > вёб

    • Да вроде бы исправил ещё с утра. Может, кэш глючит.

  • Очень занимательно :) Как я понимаю прерывания и работа с портами не реализовано? :) Без этого эмулятор представляет чисто академический интерес :)

    • Автор реализовал и то и другое, но перевод следующих частей (всего их восемь) ещё в процессе.

  • А дальше?