Создание эмулятора аркадного автомата. Часть 2

в 19:02, , рубрики: space invaders, аркадные автоматы, ассемблер, ненормальное программирование, разработка игр, эмулятор
image

Первая часть здесь.

Дизассемблер процессора 8080

Знакомство

Нам понадобится информация об опкодах и соответствующих им командах. Когда вы будете искать информацию в Интернете, то заметите, что есть много перемешанных сведений о 8080 и Z80. Процессор Z80 был последователем 8080 — он выполняет все инструкции 8080 с теми же hex-кодами, но также имеет и дополнительные инструкции. Думаю, пока вам стоит избегать информации о Z80, чтобы не запутаться. Я создал таблицу опкодов для нашей работы, она находится здесь.

У каждого процессора есть написанное изготовителем справочное руководство. Обычно оно называется как-то наподобие «Programmer's Environment Manual». Руководство для 8080 называется «Intel 8080 Microcomputer Systems User's Manual». Его всегда называли «справочником» («data book»), поэтому я тоже буду так его называть. Мне удалось скачать справочник по 8080 с http://www.datasheetarchive.com/. Эта PDF представляет собой некачественный скан, так что если найдёте версию получше, то используйте её.

Давайте приступим и посмотрим на ROM-файл игры Space Invaders. (ROM-файл можно найти в Интернете.) Я работаю на Mac OS X, поэтому для просмотра его содержимого просто использую команду «hexdump». Для дальнейшей работы найдите шестнадцатеричный редактор под свою платформу. Вот первые 128 байт файла «invaders.h»:

   $ hexdump -v invaders.h

   0000000 00 00 00 c3 d4 18 00 00 f5 c5 d5 e5 c3 8c 00 00    
   0000010 f5 c5 d5 e5 3e 80 32 72 20 21 c0 20 35 cd cd 17    
   0000020 db 01 0f da 67 00 3a ea 20 a7 ca 42 00 3a eb 20    
   0000030 fe 99 ca 3e 00 c6 01 27 32 eb 20 cd 47 19 af 32    
   0000040 ea 20 3a e9 20 a7 ca 82 00 3a ef 20 a7 c2 6f 00    
   0000050 3a eb 20 a7 c2 5d 00 cd bf 0a c3 82 00 3a 93 20    
   0000060 a7 c2 82 00 c3 65 07 3e 01 32 ea 20 c3 3f 00 cd    
   0000070 40 17 3a 32 20 32 80 20 cd 00 01 cd 48 02 cd 13    
   ...

Это начало программы Space Invaders. Каждое шестнадцатеричное число — это команда или данные для программы. Мы можем воспользоваться справочником или другой справочной информацией, чтобы понять, что значат эти hex-коды. Давайте ещё немного исследуем код образа ROM.

Первый байт этой программы имеет значение $00. Посмотрев в таблицу, мы видим, что это NOP, как и две следующие команды. (Но не расстраивайтесь, вероятно, программа Space Invaders использовала эти команды как задержку, чтобы дать системе немного успокоиться после включения питания.)

Четвёртая команда — это $C3, то есть судя по таблице, это JMP. Определение команды JMP гласит, что она получает двухбайтный адрес, то есть следующие два байта — это адрес перехода JMP. Затем идут ещё два NOP… так, знаете что? Давайте я просто сам распишу несколько первых инструкций…

   0000 00       NOP    
   0001 00       NOP    
   0002 00       NOP    
   0003 c3 d4 18 JMP    $18d4    
   0006 00       NOP    
   0007 00       NOP    
   0008 f5       PUSH   PSW    
   0009 c5       PUSH   B    
   000a d5       PUSH   D    
   000b e5       PUSH   H    
   000c c3 8c 00 JMP    $008c    
   000f 00       NOP    
   0010 f5       PUSH   PSW    
   0011 c5       PUSH   B    
   0012 d5       PUSH   D    
   0013 e5       PUSH   H    
   0014 3e 80    MVI    A,#0x80    
   0016 32 72 20 STA    $2072

Похоже, должен быть какой-то способ автоматизации этого процесса…

Дизассемблер, часть 1

Дизассемблер — это программа, которая просто транслирует поток hex-чисел обратно в исходный код на языке ассемблера. Именно такую задачу мы выполняли от руки в предыдущем разделе — отличная возможность автоматизировать эту работу. Записывая этот фрагмент кода, мы знакомимся с процессором и получаем удобный фрагмент отладочного кода, который пригодится при написании эмулятора ЦП.

Вот алгоритм дизассемблирования кода 8080:

  1. Считываем код в буфер
  2. Получаем указатель на начало буфера
  3. Используем байт в указателе для определения опкода
  4. Выводим название опкода, при необходимости используя байты после опкода в качестве данных
  5. Перемещаем указатель на количество байтов, использованных этой командой (1, 2 или 3 байта)
  6. Если буфер не закончился, переходим к шагу 3

Чтобы заложить основу процедуры, я добавил ниже пару инструкций. Я выложу полную процедуру для скачивания, но рекомендую вам попробовать написать её самостоятельно. Это не займёт много времени, и параллельно вы выучите набор команд процессора 8080.

   /*    
    *codebuffer - это валидный указатель на ассемблерный код 8080
    pc - это текущее смещение в коде

    возвращает количество байтов операции    
   */    

   int Disassemble8080Op(unsigned char *codebuffer, int pc)    
   {    
    unsigned char *code = &codebuffer[pc];    
    int opbytes = 1;    
    printf ("%04x ", pc);    
    switch (*code)    
    {    
        case 0x00: printf("NOP"); break;    
        case 0x01: printf("LXI    B,#$%02x%02x", code[2], code[1]); opbytes=3; break;    
        case 0x02: printf("STAX   B"); break;    
        case 0x03: printf("INX    B"); break;    
        case 0x04: printf("INR    B"); break;    
        case 0x05: printf("DCR    B"); break;    
        case 0x06: printf("MVI    B,#$%02x", code[1]); opbytes=2; break;    
        case 0x07: printf("RLC"); break;    
        case 0x08: printf("NOP"); break;    
        /* ........ */    
        case 0x3e: printf("MVI    A,#0x%02x", code[1]); opbytes = 2; break;    
        /* ........ */    
        case 0xc3: printf("JMP    $%02x%02x",code[2],code[1]); opbytes = 3; break;    
        /* ........ */    
    }    

    printf("n");    

    return opbytes;    
   }    

В процессе написания этой процедуры и изучения каждого опкода я многое узнал о процессоре 8080.

  1. Я понял, что большинство команд занимает один байт, остальные — два или три. В представленном выше коде предполагается, что команда имеет размер один байт, но двух- и трёхбайтные инструкции изменяют значение переменной «opbytes», чтобы возвращался правильный размер команды.
  2. У 8080 есть регистры с названиями A, B, C, D, E, H и L. Также есть счётчик команд (program counter, PC) и отдельный указатель стека (stack pointer, SP).
  3. Некоторые инструкции работают с регистрами в парах: B и C — это пара, а также DE и HL.
  4. A — это особый регистр, с ним работает множество инструкций.
  5. HL — тоже особый регистр, он используется в качестве адреса при каждом считывании и записи данных в память.
  6. Мне стала любопытна команда «RST», поэтому я немного почитал справочник. Я заметил, что она выполняет код в фиксированных местах и в справочнике упоминается обработка прерываний. При дальнейшем чтении выяснилось, что весь этот код в начале ROM — это процедуры обработки прерываний (interrupt service routines, ISR). Прерывания могут генерироваться программно при помощи команды RST, или генерироваться сторонними источниками (не процессором 8080).

Чтобы превратить всё это в работающую программу, я просто состряпал процедуру, выполняющую следующие действия:

  1. Она открывает файл, заполненный скомпилированным кодом 8080
  2. Считывает его в буфер памяти
  3. Проходит сквозь буфер памяти, вызывая Disassemble8080Op
  4. Увеличивает PC на величину, возвращённую Disassemble8080Op
  5. Выполняет выход в конце буфера

Она может выглядеть примерно так:

   int main (int argc, char**argv)    
   {    
    FILE *f= fopen(argv[1], "rb");    
    if (f==NULL)    
    {    
        printf("error: Couldn't open %sn", argv[1]);    
        exit(1);    
    }

    //Получаем размер файла и считываем его в буфер памяти
    fseek(f, 0L, SEEK_END);    
    int fsize = ftell(f);    
    fseek(f, 0L, SEEK_SET);    

    unsigned char *buffer=malloc(fsize);    

    fread(buffer, fsize, 1, f);    
    fclose(f);    

    int pc = 0;    

    while (pc < fsize)    
    {    
        pc += Disassemble8080Op(buffer, pc);    
    }    
    return 0;    
   }

Во второй части мы изучим выходные данные, полученные при дизассемблировании ROM Space Invaders.

Распределение памяти

Прежде чем приступать к написанию эмулятора процессора, нам нужно изучить ещё один аспект. Все ЦП имеют возможность общения с определённым количеством адресов. У старых процессоров были 16-, 24- или 32-битные адреса. У 8080 есть 16 адресных контактов, поэтому адреса находятся в интервале 0-$FFFF.

Чтобы разобраться с распределением памяти игры, нам нужно провести небольшое расследование. Собрав по кускам информацию здесь и здесь, я узнал, что ROM располагается по адресу 0, и у игры есть 8 КБ ОЗУ, начинающиеся с адреса $2000.

Автор одной из страниц выяснил, что видеобуфер начинается в ОЗУ с адреса $2400, а также рассказал нам, как порты ввода-вывода 8080 используются для общения с элементами управления и звуковым оборудованием. Отлично!

Внутри ROM-файла invaders.zip, который можно найти в Интернете, есть четыре файла: invaders.e, .f, .g и .h. После гугления я наткнулся на информативную статью, в которой рассказывается, как поместить эти файлы в память:

Space Invaders, (C) Taito 1978, Midway 1979

ЦП: Intel 8080, 2 МГц (он похож на более новый Zilog Z80)

Прерывания: $cf (RST 8) в начале vblank, $d7 (RST $10) в конце vblank.

Видео: 256(x)*224(y), 60 Гц, вертикальный монитор. Цвета симулируются
пластмассовой прозрачной накладкой и фоновым изображением.
Видеожелезо очень простое: битовая карта 7168 байт, 1 бит на пиксель (32 байта на строку развёртки).

Звук: SN76477 и сэмплы.

Распределение памяти:
ROM
$0000-$07ff: invaders.h
$0800-$0fff: invaders.g
$1000-$17ff: invaders.f
$1800-$1fff: invaders.e

RAM
$2000-$23ff: рабочая ОЗУ
$2400-$3fff: видеопамять

$4000-: зеркало ОЗУ

Там есть ещё кое-какая полезная информация, но мы пока не готовы её использовать.

Кровавые подробности

Если вы хотите знать, какой размер адресного пространства есть у процессора, то можно понять это, посмотрев на его характеристики. Спецификация 8080 говорит нам, что у процессора 16 адресных контактов, то есть в нём используется 16-битная адресация. (Вместо спецификации достаточно почитать справочник, Википедию, загуглить и так далее...)

В Интернете есть довольно много информации о «железе» Space Invaders. Если вам не удалось найти эту информацию, то можете получить её парой способов:

  • Понаблюдайте за запущенным в эмуляторе кодом и разберитесь, что он делает. Делайте заметки и внимательно следите. Должно быть достаточно просто понять, например, где, по мнению игры, должна находиться ОЗУ. Также легко определить место, где она ищет видеопамять (мы потратим на изучение этого какое-то время).
  • Найдите принципиальную схему аркадного автомата и отследите сигналы от адресных контактов ЦП. Посмотрите, куда они направляются. Например, A15 (самый старший адрес) может идти только к ПЗУ. Из этого можно сделать вывод, что адреса ПЗУ начинаются с $8000.

Может быть очень интересно и познавательно выяснить это самостоятельно, наблюдая за выполнением кода. Кому-то пришлось разбираться со всем этим в первый раз.

Разработка в командной строке

Задача этого туториала — не научить вас писать код под определённую платформу, хотя нам и не удастся избежать специфического для платформы кода. Надеюсь, что перед началом проекта вы уже знали, как компилировать под свою целевую платформу.

Когда вы работаете с автономным кодом, который просто считывает файлы и выводит текст в консоль, не обязательно использовать какую-то переусложнённую систему разработки. На самом деле она только всё усложняет. Всё, что вам понадобится — это текстовый редактор и терминал.

Думаю, что каждый, кто хочет программировать на низком уровне, должен знать, как создавать простые программы из командной строки. Можете считать, что я вас дразню, но ваши элитные навыки хакера не стоят многого, если вы не можете функционировать за пределами Visual Studio.

На Mac вы можете использовать для компилирования TextEdit и Terminal. На Linux можно использовать gedit и Konsole. В Windows можно установить cygwin и инструменты, а затем использовать N++ или другой текстовый редактор. Если вы хотите быть по-настоящему крутым, то все эти платформы поддерживают vi и emacs для редактирования текста.

Компиляция программ из одного файла с помощью командной строки — это тривиальная задача. Допустим, вы сохранили свою программу в файле с названием 8080dis.c. Перейдите в папку с этим текстовым файлом и скомпилируйте его так: cc 8080dis.c. Если не указать название выходного файла, то он будет называться a.out, и его можно запустить, введя ./a.out.

Вот, собственно, и всё.

Использование отладчика

Если вы работаете в одной из систем на основе Unix, то вот краткое введение в отладку программ командной строки с помощью GDB. Вам нужно компилировать программу так: cc -g -O0 8080dis.c. Параметр -g генерирует отладочную информацию (то есть можно выполнять отладку на основе исходного текста), а параметр -O0 отключает оптимизации, чтобы при пошаговом выполнении программы отладчик смог точно отслеживать код в полном соответствии с исходным текстом.

Вот аннотированный лог начала отладочной сессии. Мои комментарии в строках, помеченных «решёткой» (#).

$ gdb a.out
GNU gdb 6.3.50-20050815 (Apple version gdb-1708) (Mon Aug 8 20:32:45 UTC 2011)
Copyright 2004 Free Software Foundation, Inc.
GDB is free software, covered by the GNU General Public License, and you are
welcome to change it and/or distribute copies of it under certain conditions.
Type "show copying" to see the conditions.
There is absolutely no warranty for GDB. Type "show warranty" for details.
This GDB was configured as "x86_64-apple-darwin"...Reading symbols for shared libraries .. done

#устанавливаем точку останова, чтобы программа останавливалась на моей процедуре
(gdb) b Disassemble8080Op
Breakpoint 1 at 0x1000012ef: file 8080dis.c, line 7.

#запускаем программу с "invaders.h" в качестве аргумента
(gdb) run invaders.h
Starting program: /Users/bob/Desktop/invaders/a.out invaders.h
Reading symbols for shared libraries +........................ done

Breakpoint 1, Disassemble8080Op (codebuffer=0x100801000 "", pc=0) at 8080dis.c:7
7 unsigned char *code = &codebuffer[pc];
#gdb интерпретирует n как "next". Можно также ввести "next"
(gdb) n
8 int opbytes = 1;
#p - это сокращение от "print", я хочу увидеть значение *code
(gdb) p *code
$1 = 0 ''
(gdb) n
9 printf("%04x ", pc);
# Если просто нажать "ввод", gdb снова повторит ту же команду, в нашем случае "next"
(gdb)
10 switch (*code)
(gdb) n
#опкод был равен нулю, поэтому будет выведено "NOP"
12 case 0x00: printf("NOP"); break;
(gdb) n
285 printf("n");
#c - это "continue", поэтому выполнение продолжился до следующей точки останова
(gdb) c
Continuing.
0000 NOP

# Снова остановились в начале Disassemble8080Op. Я напечатал *opcode,
# увидел, что это будет ещё один NOP, поэтому просто продолжил выполнение.
Breakpoint 1, Disassemble8080Op (codebuffer=0x100801000 "", pc=1) at 8080dis.c:7
7 unsigned char *code = &codebuffer[pc];
(gdb) c
Continuing.
0001 NOP

Breakpoint 1, Disassemble8080Op (codebuffer=0x100801000 "", pc=2) at 8080dis.c:7
7 unsigned char *code = &codebuffer[pc];
(gdb) n
8 int opbytes = 1;
(gdb) p *code
$2 = 0 ''
# Третий NOP, ничего интересного
(gdb) c
Continuing.
0002 NOP

Breakpoint 1, Disassemble8080Op (codebuffer=0x100801000 "", pc=3) at 8080dis.c:7
7 unsigned char *code = &codebuffer[pc];
(gdb) n
8 int opbytes = 1;

# Вот новый опкод!
(gdb) p *code
$3 = 195 '?'
# print по умолчанию выводит десятичные значения, но можно использовать /x для отображения шестнадцатеричных
(gdb) p /x *code
$4 = 0xc3
(gdb) n
9 printf("%04x ", pc);
(gdb)
10 switch (*code)
(gdb)
# C3 - это JMP. Отлично.
219 case 0xc3: printf("JMP $%02x%02x",code[2],code[1]); opbytes = 3; break;
(gdb)
285 printf("n");

Дизассемблер, часть 2

Запустим дизассемблер для ROM-файла invaders.h и посмотрим на выводимую информацию.

0000 NOP
0001 NOP
0002 NOP
0003 JMP $18d4
0006 NOP
0007 NOP
0008 PUSH PSW
0009 PUSH B
000a PUSH D
000b PUSH H
000c JMP $008c
000f NOP
0010 PUSH PSW
0011 PUSH B
0012 PUSH D
0013 PUSH H
0014 MVI A,#$80
0016 STA $2072
0019 LXI H,#$20c0
001c DCR M
001d CALL $17cd
0020 IN #$01
0022 RRC
0023 JC $0067
0026 LDA $20ea
0029 ANA A
002a JZ $0042
002d LDA $20eb
0030 CPI #$99
0032 JZ $003e
0035 ADI #$01
0037 DAA
0038 STA $20eb
003b CALL $1947
003e SRA A
003f STA $20ea

/*
0000000 00 00 00 c3 d4 18 00 00 f5 c5 d5 e5 c3 8c 00 00
0000010 f5 c5 d5 e5 3e 80 32 72 20 21 c0 20 35 cd cd 17
0000020 db 01 0f da 67 00 3a ea 20 a7 ca 42 00 3a eb 20
0000030 fe 99 ca 3e 00 c6 01 27 32 eb 20 cd 47 19 af 32
*/

Первые инструкции соответствуют тем, которые мы вручную записали ранее. После них есть несколько новых инструкций. Ниже я вставил для справки hex-данные. Заметьте, что если сравнить память с командами, то адреса как будто хранятся в памяти в обратном порядке. Так и есть. Это называется little endian — машины с little endian, наподобие 8080, хранят в памяти младшие байты чисел первыми. (Подробнее об endian написано ниже)

Выше я упоминал, что этот код является ISR-кодом игры Space Invaders. Код для прерываний 0, 1, 2,… 7 начинается с адреса $0, $8, $20,… $38. Похоже, что 8080 просто отдаёт по 8 байт под каждую ISR. Иногда программа Space Invaders обходит эту систему, просто переходя к другому адресу с бОльшим количеством пространства. (Так происходит в $000c).

Кроме того, похоже, что ISR 2 длиннее, чем выделенная под неё память. Её код заходит на $0018 (это место для ISR 3). Думаю, что Space Invaders не ожидают увидеть ничего, что использует прерывание 3.

ROM-файл Space Invaders из Интернета состоит из четырёх частей. Я объясню это ниже, но пока, чтобы перейти к следующему разделу, нам нужно соединить эти четыре файла в один. В Unix:

cat invaders.h > invaders
cat invaders.g >> invaders
cat invaders.f >> invaders
cat invaders.e >> invaders

Теперь запустим дизассемблер с получившимся файлом «invaders». Когда программа начинает с $0000, то первое, что она делает — выполняет переход на $18d4. Я буду считать это началом программы. Давайте вкратце рассмотрим этот код.

   18d4 LXI    SP,#$2400    
   18d7 MVI    B,#$00    
   18d9 CALL   $01e6

Так, он выполняет две операции и вызывает $01e6. Я собираюсь вставить часть кода с переходами в этот код:

   01e6 LXI    D,#$1b00    
   01e9 LXI    H,#$2000    
   01ec JMP    $1a32    
   .....    
   1a32 LDAX   D    
   1a33 MOV    M,A    
   1a34 INX    H    
   1a35 INX    D    
   1a36 DCR    B    
   1a37 JNZ    $1a32    
   1a3a RET

Как мы видели из распределения памяти Space Invaders, некоторые из этих адресов интересны. $2000 — это начало «рабочей ОЗУ» программы. $2400 — начало видеопамяти.

Давайте добавим комментарии к коду, чтобы объяснить, что он делает непосредственно при запуске:

   18d4 LXI    SP,#$2400  ; SP=$2400 - задаём стек для всей программы
   18d7 MVI    B,#$00     ; B=0    
   18d9 CALL   $01e6    
   .....    
   01e6 LXI    D,#$1b00   ; DE=$1B00    
   01e9 LXI    H,#$2000   ; HL=$2000    
   01ec JMP    $1a32    
   .....    
   1a32 LDAX   D          ; A = (DE), то есть всё, что было в памяти по адресу $1B00    
   1a33 MOV    M,A        ; Сохраняем A в (HL), то есть по адресу $2000    
   1a34 INX    H          ; HL = HL + 1 (теперь $2001)    
   1a35 INX    D          ; DE = DE + 1 (теперь $1B01)    
   1a36 DCR    B          ; B = B - 1 (теперь 0xff, потому выполнился циклический переход от 0)    
   1a37 JNZ    $1a32      ; цикл, он будет работать, пока не выполнится условие b=0    
   1a3a RET

Похоже, что этот код скопирует 256 байт из $1b00 в $2000. Зачем? Я не знаю. Вы можете изучить программу более подробно и поразмышлять над тем, что она делает.

Здесь существует проблема. Если у нас есть произвольный фрагмент памяти, содержащей код, то вероятно с ним чередуются данные.

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

Пока мы почти ничего не можем с этим поделать. Просто имейте в виду, что такая проблема существует. Если вы видите что-то подобное:

  • переход из точно хорошего кода команде, которой нет в листинге дизассемблера
  • поток бессмысленного кода (например, POP B POP B POP B POP C XTHL XTHL XTHL)

то здесь, вероятно, есть данные, которые испортили часть дизассемблированного кода. Если такое случается, то необходимо начать заново со смещения.

Оказывается, в Space Invaders периодически попадаются нули. Если наше дизассемблирование когда-нибудь остановится, то нули заставят её выполнить сброс.

Подробный анализ кода Space Invaders можно прочитать здесь.

Endian

В разных моделях процессора байты хранятся в памяти по-разному, и хранение зависит от размера данных. Машины big-endian хранят данные от старших к младшим. Little-endian хранят их от самых младших до самых старших. Если в память каждой машины записать 32-битное целое число 0xAABBCCDD, то оно будет выглядеть так:

В little-endian: $DD $CC $BB $AA

В big-endian: $AA $BB $CC $DD

Я начинал программировать на процессорах Motorola, в которых использовалось big-endian, поэтому это казалось мне более «естественным», но потом привык и к little-endian.

Мои дизассемблер и эмулятор полностью избегают проблемы с endian, потому что считывают за раз только по одному байту. Если вы хотите, например, использовать 16-битный считыватель для считывания адреса из ROM, то учтите, что этот код не портируем между архитектурами ЦП.

Автор: PatientZero

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js