[Перевод] Создание эмулятора аркадного автомата. Часть 4
Части первая, вторая, третья.
Остальная часть автомата
Написанный нами код для эмуляции процессора 8080 достаточно общий и может быть с лёгкостью адаптирован для запуска на любой машине с компилятором C. Но чтобы поиграть в саму игру, нам нужно сделать большее. Придётся эмулировать оборудование всего аркадного автомата и написать код, приклеивающий специфические особенности нашей вычислительной среды к эмулятору.
(Вам может быть интересно посмотреть на принципиальную схему автомата.)
Тайминги
Игра выполняется на 2-мегагерцовом 8080. Ваш компьютер гораздо быстрее. Чтобы это учесть, нам придётся придумать какой-нибудь механизм.
Прерывания
Прерывания предназначены для того, чтобы процессор мог обрабатывать задачи с точным временем выполнения, например ввод-вывод. Процессор может выполнять программу, а при срабатывании пина прерывания он прекращает выполнять текущую программу и занимается чем-то ещё.
Нам нужно имитировать способ, которым аркадный автомат генерирует прерывания.
Графика
Space Invaders отрисовывает графику в свою память в интервале адресов 0×2400. Настоящий аппаратный видеоконтроллер считывал бы ОЗУ и управлял ЭЛТ-дисплеем. Нашей программе придётся эмулировать это поведение, отрисовывая картинку игры в окне.
Кнопки
У игры есть физические кнопки, которые программа считывает с помощью команды IN процессора 8080. Наш эмулятор должен будет привязать к этим командам IN ввод с клавиатуры.
ROM и RAM
Надо признаться: мы «срезали угол», создав 16-килобайтный буфер памяти, включающий в себя нижние 16 КБ распределения памяти процессора. На самом деле первые 2 КБ распределения памяти являются настоящей ПЗУ (ROM, read-only memory). Нам нужно будет поместить операции записи в память в функцию, чтобы невозможно было выполнять запись в ROM.
Звук
Пока мы ничего не говорили о звуке. У Space Invaders есть милая аналоговая звуковая схема, воспроизводящая один из 8 звуков, управляемых командой OUT, которая передаётся на один из портов. Нам придётся преобразовать эти команды OUT, чтобы воспроизводить сэмплы звука на своей платформе.
Может показаться, что это большой объём работы, но всё не так плохо, и мы можем двигаться постепенно. Первое, что мы хотим сделать — увидеть экран, для чего нам понадобятся прерывания, графика и часть обработки команд IN и OUT.
Дисплеи и обновление
Основы
Вероятно, вам знакомы компоненты системы отображения видео. Где-то в системе есть какая-то ОЗУ, в которой содержится изображение для показа на экране. В случае аналоговых устройств существует оборудование, считывающее это ОЗУ и преобразующее байты в аналоговое напряжение, передаваемое на монитор.
Более глубокое понимание системы поможет нам, когда дело дойдёт анализа назначения распределения памяти и функционала кода.
У аналоговых дисплеев есть требования к частоте обновления и таймингам. В любой момент времени на дисплее есть обновляемые конкретный пиксель. Передаваемое на экран изображение заполняется точка за точкой, начиная с верхнего левого угла и до верхнего правого, затем первая точка второй строки, последняя точка второй строки и т.д. После отрисовки последней строки на экране видеоконтроллер может сгенерировать прерывание Vertical Blank Interrupt (также известное как VBI или VBL).
Для обеспечения плавности анимации изображение в ОЗУ, обрабатываемое видеоконтроллером, не может изменяться. Если обновление ОЗУ произошло посередине кадра, то зритель увидит части двух изображений. Это приводит к эффекту «разрыва», когда в верхней части экрана показывается кадр, отличающийся от кадра в нижней. Если вы когда-нибудь видели разрыв строк, то знаете, как он выглядит.
Чтобы избежать разрывов, ПО должно что-то сделать, чтобы избежать передачи места обновления экрана. И есть единственный способ сделать это.
VBL генерируется после завершения последней строки, и обычно перед повторной отрисовкой первой строки существует некий промежуток времени. (Это время Vertical Blank, и оно может быть около 1 миллисекунды.)
При получении VBL программа начинает отрисовывать экран сверху.
Каждая строка отрисовывается перед процессом обратного хода кадровой развёртки.
ЦП всегда находится впереди обратного хота и поэтому избегает разрыва строк.
Видеосистема Space Invaders
Очень информативная страница сообщает нам, что у Space Invaders есть два видеопрерывания. Одно — для конца кадра, но также он генерирует прерывание и посередине экрана. На странице описывается система обновления экрана — игра отрисовывает графику в верхней половине экрана, когда получает прерывание середины экрана, и отрисовывает графику в нижней части экрана, когда получает прерывание конца кадра. Это довольно умный способ устранения разрывов строк, и хороший пример того, чего можно добиться, когда разрабатываешь железо и ПО одновременно.
Мы должны заставить эмуляцию нашего автомата генерировать такие прерывания. Если мы будем генерировать их с частотой 60ГЦ, как и автомат Space Invaders, то игра будет отрисовываться с правильной частотой.
В следующем разделе мы поговорим о механике прерываний и подумаем, как их эмулировать.
Кнопки и порты
8080 реализует ввод-вывод с помощью инструкций IN и OUT. У него есть 8 отдельных портов IN и OUT — порт определяется байтом данных команды. Например, IN 3
поместит значение порта 3 в регистр A, а OUT 2
отправит A в порт 2.
Информацию о назначении каждого из портов я взял с сайта Computer Archeology. Если бы эта информация была недоступна, нам пришлось бы получать её изучением принципиальной схемы, а также чтением и пошаговым выполнением кода.
Порты:
Чтения 1
БИТ 0 монета (0, когда активен)
1 Кнопка Start второго игрока
2 Кнопка Start первого игрока
3 ?
4 Кнопка стрельбы первого игрока
5 Джойстик влево первого игрока
6 Джойстик вправо первого игрока
7 ?
Чтения 2
БИТ 0,1 DIP-переключатель количества жизней (0:3,1:4,2:5,3:6)
2 «Кнопка» наклона
3 DIP-переключатель бонусной жизни, 1:1000,0:1500
4 Кнопка стрельбы второго игрока
5 Джойстик влево второго игрока
6 Джойстик вправо второго игрока
7 Информация о монете DIP-переключателя, 1: откл,0: вкл
Чтения 3 результат сдвига регистра
Записи 2 смещение результата сдвига регистра (биты 0,1,2)
Записи 3 связан со звуком
Записи 4 заполнение регистра сдвига
Записи 5 связан со звуком
Записи 6 странный «отладочный» порт? Например, в этот порт выполняется запись,
когда выполняется запись текста на экран (0=a,1=b,2=c и т.д.)
(порты записи 3,5,6 можно не эмулировать порты чтения 1=$01 и 2=$00
приводят к запуску игры, но только в режиме привлечения игроков (attract mode))
Существует три способа реализации ввода-вывода в нашем программном стеке (который состоит из эмулятора 8080, кода автомата и кода платформы).
- Встроить знание об автомате в наш эмулятор 8080
- Встроить знаение об эмуляторе 8080 в код автомата
- Изобрести формальный интерфейс между тремя частями кода, чтобы обеспечить возможность обмена информацией через API
Я исключил первый вариант — достаточно очевидно, что эмулятор находится в самом низу этой цепочки вызовов и должен оставаться отдельным. (Представьте, что нужно заново использовать эмулятор для другой игры, и вы поймёте, о чём я.) В общем случае перенос высокоуровневых структур данных на нижние уровни является плохим архитектурным решением.
Я выбрал вариант 2. Позвольте мне сначала показать код:
while (!done)
{
uint8_t opcode = state->memory[state->pc];
if (*opcode == 0xdb) //machine specific handling for IN
{
uint8_t port = opcode[1];
state->a = MachineIN(state, port);
state->pc++;
}
else if (*opcode == 0xd3) //OUT
{
uint8_t port = opcode[1];
MachineOUT(state, port);
state->pc++;
}
else
Emulate8080Op(state);
}
Этот код заново реализует обработку опкодов для IN и OUT в том же слое, который вызывает эмулятор для остальных команд. По моему мнению, это позволяет сделать код более чистым. Это похоже на переопределение или подкласс для этих двух команд, который относится к слою автомата.
Недостаток заключается в том, что мы переносим эмуляцию опкодов в два места. Я не буду винить вас за выбор третьего варианта. Во втором варианте потребуется меньше кода, но вариант 3 более «чистый», однако ценой является увеличение сложности. Это вопрос выбора стиля.
Регистр сдвига
В автомате Space Invaders есть интересное аппаратное решение, реализующее команду битового сдвига. У 8080 есть команды для сдвига на 1 бит, но для реализации многобитного/многобайтного сдвига понадобятся десятки команд 8080. Специальное железо позволяет игре выполнять эти операции всего за несколько инструкций. С его помощью отрисовывается каждый кадр на поле игры, то есть оно используется множество раз за кадр.
Не думаю, что смогу объяснить его лучше, чем превосходный анализ Computer Archeology:
;регистр 16-битного сдвига:
; f 0 бит
; xxxxxxxxyyyyyyyy
;
; Запись в порт 4 сдвигает x на y, а новое значение на x, например:
; $0000,
; write $aa -> $aa00,
; write $ff -> $ffaa,
; write $12 -> $12ff, ..
;
; Запись в порт 2 (биты 0,1,2) задаёт смещение для 8-битного результата, например:
; offset 0:
; rrrrrrrr result=xxxxxxxx
; xxxxxxxxyyyyyyyy
;
; offset 2:
; rrrrrrrr result=xxxxxxyy
; xxxxxxxxyyyyyyyy
;
; offset 7:
; rrrrrrrr result=xyyyyyyy
; xxxxxxxxyyyyyyyy
;
; Считывание из порта 3 возвращает нужный результат.
Для команды OUT запись в порт 2 задаёт величину сдвига, а запись в порт 4 задаёт данные в регистрах сдвига. Считывание с помощью IN 3 возвращает данные, сдвинутые на величину сдвига. В моём автомате это реализовано примерно так:
-(uint8_t) MachineIN(uint8_t port)
{
uint8_t a;
switch(port)
{
case 3:
{
uint16_t v = (shift1<<8) | shift0;
a = ((v >> (8-shift_offset)) & 0xff);
}
break;
}
return a;
}
-(void) MachineOUT(uint8_t port, uint8_t value)
{
switch(port)
{
case 2:
shift_offset = value & 0x7;
break;
case 4:
shift0 = shift1;
shift1 = value;
break;
}
}
Клавиатура
Чтобы получать реакцию автомата, нам нужно привязать к нему клавиатурный ввод. У большинства платформ есть способ получения событий нажатия и отпускания клавиш. Код платформы для кнопок будет похож на следующий:
if(PeekMessage(&msg,NULL,0,0,PM_REMOVE))
{
if (msg.message==WM_KEYDOWN )
{
if ( msg.wParam == VK_LEFT )
MachineKeyDown(LEFT);
}
else if (msg.message==WM_KEYUP )
{
if ( msg.wParam == VK_LEFT )
MachineKeyUp(LEFT);
}
}
Код автомата, приклеивающий код платформы к коду эмулятора, будет выглядеть примерно так:
MachineKeyDown(char key)
{
switch(key)
{
case LEFT:
port[1] |= 0x20; //Set bit 5 of port 1
break;
case RIGHT:
port[1] |= 0x40; //Set bit 6 of port 1
break;
/*....*/
}
}
PlatformKeyUp(char key)
{
switch(key)
{
case LEFT:
port[1] &= 0xDF //Clear bit 5 of port 1
break;
case RIGHT:
port[1] &= 0xBF //Clear bit 6 of port 1
break;
/*....*/
}
}
При желании вы можете как угодно комбинировать код автомата и платформы — это выбор реализации. Я не буду этого делать, потому что собираюсь портировать автомат на несколько различных платформ.
Прерывания
Изучив справочник, я понял, что 8080 обрабатывает прерывания следующим образом:
- Источник прерывания (внешний относительно ЦП) задаёт пин прерывания ЦП.
- Когда ЦП подтверждает приём прерывания, источник прерывания может передать в шину любой опкод и ЦП увидит его. (Чаще всего они используют команду RST.)
- ЦП выполняет эту команду. Если это RST, то это аналог команды CALL для фиксированного адреса в нижней части памяти. Она записывает в стек текущий PC.
- Код в адресе нижней памяти обрабатывает то, что прерывание хочет сообщить программе. После завершения обработки RST завершается вызовом RET.
Видеооборудование игры генерирует два прерывания, которые мы должны эмулировать программно: конца кадра и середины кадра. Оба выполняются при 60 Гц (60 раз в секунду). 1/60 секунды — это 16,6667 миллисекунды.
Для упрощения работы с прерываниями я добавлю функцию в эмулятор 8080:
void GenerateInterrupt(State8080* state, int interrupt_num)
{
//perform "PUSH PC"
Push(state, (state->pc & 0xFF00) >> 8, (state->pc & 0xff));
//Set the PC to the low memory vector.
//This is identical to an "RST interrupt_num" instruction.
state->pc = 8 * interrupt_num;
}
Код платформы должен реализовать таймер, который мы сможем вызывать (пока я назову его просто time ()). Код автомата будет использовать его, чтобы передавать эмулятору 8080 прерывание. В коде автомата при истечении времени таймера я буду вызывать GenerateInterrupt:
while (!done)
{
Emulate8080Op(state);
if ( time() - lastInterrupt > 1.0/60.0) //1/60 second has elapsed
{
//only do an interrupt if they are enabled
if (state->int_enable)
{
GenerateInterrupt(state, 2); //interrupt 2
//Save the time we did this
lastInterrupt = time();
}
}
}
Есть некоторые подробности того, как 8080 на самом деле обрабатывает прерывания, которые мы не будем эмулировать. Я считаю, что такой обработки для наших целей будет достаточно.