Главная » Хабрахабр » [Перевод] Создание эмулятора аркадного автомата. Часть 2

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

image

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

Знакомство

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

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

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

Определение команды JMP гласит, что она получает двухбайтный адрес, то есть следующие два байта — это адрес перехода JMP. Четвёртая команда — это $C3, то есть судя по таблице, это 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) 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 %s\n", 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.

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

Звук: 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. Думаю, что каждый, кто хочет программировать на низком уровне, должен знать, как создавать простые программы из командной строки.

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

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

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

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

Если вы работаете в одной из систем на основе 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 '\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 '\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");

Запустим дизассемблер для 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 написано ниже).

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

Её код заходит на $0018 (это место для ISR 3). Кроме того, похоже, что ISR 2 длиннее, чем выделенная под неё память. Думаю, что 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.

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


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

Конференция «Контентинг» — теперь с поддержкой hyper-threading

Друзья, на связи контент-студия Хабра. 29 ноября мы проводим собственную конференцию про контент и авторов. Будем учить, вдохновлять и рассказывать, как лучше, — в два синхронных потока докладов и дискуссий. Спикеры — эксперты в области контент-маркетинга, которые годами нарабатывали и тестировали ...

J2CL — Лучше поздно, чем никогда

Ещё никому не удалось опоздать на свои похороны.Валентин Домиль Идея трансляции Java в JavaScript далеко не нова, и все уже давно набили шишек с Google Web Toolkit, однако этот продукт сообщество ждало как ни один другой — о нем говорили ...