Pull to refresh

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

Reading time19 min
Views4.8K
Original author: kpmiller
image

Части первая и вторая.

Эмулятор процессора 8080


Оболочка эмулятора


Теперь у вас должны быть все необходимые знания, чтобы приступить к созданию эмулятора процессора 8080.

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

Для начала я создам структуру памяти, которая будет содержать поля для всего, что показалось мне необходимым при написании дизассемблера. Также там будет место для буфера памяти, который будет представлять собой ОЗУ.

   typedef struct ConditionCodes {    
    uint8_t    z:1;    
    uint8_t    s:1;    
    uint8_t    p:1;    
    uint8_t    cy:1;    
    uint8_t    ac:1;    
    uint8_t    pad:3;    
   } ConditionCodes;

   typedef struct State8080 {    
    uint8_t    a;    
    uint8_t    b;    
    uint8_t    c;    
    uint8_t    d;    
    uint8_t    e;    
    uint8_t    h;    
    uint8_t    l;    
    uint16_t    sp;    
    uint16_t    pc;    
    uint8_t     *memory;    
    struct      ConditionCodes      cc;    
    uint8_t     int_enable;    
   } State8080;

Теперь создадим процедуру с вызовом ошибки, которая будет завершать программу с ошибкой. Это будет выглядеть приблизительно так:

   void UnimplementedInstruction(State8080* state)    
   {    
    //у pc будет более сложная процедура, так что отменяем это
    printf ("Error: Unimplemented instruction\n");    
    exit(1);    
   }

   int Emulate8080Op(State8080* state)    
   {    
    unsigned char *opcode = &state->memory[state->pc];    

    switch(*opcode)    
    {    
        case 0x00: UnimplementedInstruction(state); break;    
        case 0x01: UnimplementedInstruction(state); break;    
        case 0x02: UnimplementedInstruction(state); break;    
        case 0x03: UnimplementedInstruction(state); break;    
        case 0x04: UnimplementedInstruction(state); break;    
        /*....*/    
        case 0xfe: UnimplementedInstruction(state); break;    
        case 0xff: UnimplementedInstruction(state); break;    
    }    
    state->pc+=1;  //для опкода
   }

Давайте реализуем несколько опкодов.

  void Emulate8080Op(State8080* state)    
   {    
    unsigned char *opcode = &state->memory[state->pc];

    switch(*opcode)    
    {    
        case 0x00: break;                   //NOP - это просто!    
        case 0x01:                          //LXI   B,слово
            state->c = opcode[1];    
            state->b = opcode[2];    
            state->pc += 2;                  //Перемещаемся ещё на 2 байта
            break;    
        /*....*/    
        case 0x41: state->b = state->c; break;    //MOV B,C    
        case 0x42: state->b = state->d; break;    //MOV B,D    
        case 0x43: state->b = state->e; break;    //MOV B,E    
    }    
    state->pc+=1;    
   }

Вот так. Для каждого опкода мы изменяем состояние и память, как это бы делала команда, выполняемая на настоящем 8080.

У 8080 есть примерно 7 типов, в зависимости от того, как их классифицировать:

  • Передачи данных
  • Арифметический
  • Логический
  • Ветвления
  • Стека
  • Ввода-вывода
  • Особый

Давайте рассмотрим каждый из них по отдельности.

Арифметическая группа


Арифметические команды — это многие из 256 опкодов процессора 8080, включающие в себя различные разновидности суммирования и вычитания. Большинство арифметических инструкций работает с регистром A и сохраняет результат в A. (Регистр A ещё называют накопителем (accumulator)).

Интересно заметить, что эти команды влияют на коды состояний (condition code). Коды состояний (также называемые флагами) задаются в зависимости от результата выполненной команды. Не все команды влияют на флаги, и не все влияющие на флаги команды влияют сразу на все флаги.

Флаги 8080


В процессоре 8080 флаги называются Z, S, P, CY и AC.

  • Z (zero, ноль) принимает значение 1, когда результат равен нулю
  • S (sign, знак) принимает значение 1, когда задан бит 7 (самый значимый бит, most significant bit, MSB) математической команды
  • P (parity, чётность) задаётся, когда результат чётный, и обнуляется, когда он нечётный
  • CY (carry, перенос) принимает значение 1, когда в результате команды выполняется перенос или заимствование в бит высокого порядка
  • AC (auxillary carry, вспомогательный перенос) используется в основном для математики BCD (binary coded decimal, двоично-десятичных чисел). Подробнее см. в справочнике, в Space Invaders этот флаг не используется.

Коды состояний используются в командах условного ветвления, например, JZ выполняет ветвление, только если задан флаг Z.

Большинство инструкций имеет три формы: для регистров, для непосредственных значений и для памяти. Давайте реализуем несколько инструкций, чтобы разобраться в их формах и посмотреть, на что похожа работа с кодами состояний. (Учтите, что я не реализую флаг вспомогательного переноса, потому что он не используется. Если бы я его реализовал, то не смог бы протестировать.)

Форма для регистров


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

    case 0x80:      //ADD B    
        {    
            // выполняем математические вычисления с повышенной точностью,
            // чтобы можно было отследить перенос
            uint16_t answer = (uint16_t) state->a + (uint16_t) state->b;

            // Флаг нуля: если результат равен нулю,    
            // то задаём флаг нуля,
            // в противном случае обнуляем его
            if ((answer & 0xff) == 0)    
                state->cc.z = 1;    
            else    
                state->cc.z = 0;    

            // Флаг знака: если бит 7 задан,    
            // то задаём флаг знака,
            // в противном случае обнуляем его
            if (answer & 0x80)    
                state->cc.s = 1;    
            else    
                state->cc.s = 0;    

            // Флаг переноса
            if (answer > 0xff)    
                state->cc.cy = 1;    
            else    
                state->cc.cy = 0;    

            // Чётность обрабатывается подпрограммой
            state->cc.p = Parity( answer & 0xff);    

            state->a = answer & 0xff;    
        }    

    //Код для ADD можно сжать до такого
    case 0x81:      //ADD C    
        {    
            uint16_t answer = (uint16_t) state->a + (uint16_t) state->c;    
            state->cc.z = ((answer & 0xff) == 0);    
            state->cc.s = ((answer & 0x80) != 0);    
            state->cc.cy = (answer > 0xff);    
            state->cc.p = Parity(answer&0xff);    
            state->a = answer & 0xff;    
        }

Я эмулирую 8-битные математические команды с помощью 16-битного числа. Это позволяет проще отслеживать случаи, когда вычисления генерируют перенос.

Форма для непосредственных значений


Форма для непосредственных значений почти такая же, за исключением того, что источником прибавляемого является байт после команды. Поскольку «опкод» — это указатель на текущую команду в памяти, opcode[1] будет непосредственно следующим байтом.

    case 0xC6:      //ADI байт
        {    
            uint16_t answer = (uint16_t) state->a + (uint16_t) opcode[1];    
            state->cc.z = ((answer & 0xff) == 0);    
            state->cc.s = ((answer & 0x80) != 0);    
            state->cc.cy = (answer > 0xff);    
            state->cc.p = Parity(answer&0xff);    
            state->a = answer & 0xff;    
        }

Форма для памяти


В форме для памяти прибавляемым будет байт, на который указывает адрес, хранящийся в паре регистров HL.

    case 0x86:      //ADD M    
        {    
            uint16_t offset = (state->h<<8) | (state->l);    
            uint16_t answer = (uint16_t) state->a + state->memory[offset];    
            state->cc.z = ((answer & 0xff) == 0);    
            state->cc.s = ((answer & 0x80) != 0);    
            state->cc.cy = (answer > 0xff);    
            state->cc.p = Parity(answer&0xff);    
            state->a = answer & 0xff;    
        }

Примечания


Остальные арифметические команды реализуются похожим образом. Дополнения:

  • В разных версиях с переносом (ADC, ACI, SBB, SUI) мы в соответствии со справочником используем в вычислениях бит переноса.
  • INX и DCX влияют на пары регистров, эти команды не влияют на флаги.
  • DAD — это ещё одна команда пары регистров, она влияет только на флаг переноса
  • INR и DCR не влияют на флаг переноса

Группа ветвления


После того, как вы разберётесь с кодами состояний, группа ветвления станет для вас достаточно понятной. Существует два типа ветвления — переходы (JMP) и вызовы (CALL). JMP просто задаёт PC значение целевого адреса перехода. CALL используется для подпрограмм, он записывает адрес возврата в стек, а затем присваивает PC целевой адрес. RET выполняет возврат из CALL, получая адрес из стека и записывая его в PC.

И JMP, и CALL выполняют переход только к абсолютным адресам, которые закодированы в байтах после опкода.

JMP


Команда JMP выполняет безусловное ветвление к целевому адресу. Также существуют команды условных переходов для всех кодов состояний (за исключением AC):

  • JNZ и JZ для нуля
  • JNC и JC для переноса
  • JPO и JPE для чётности
  • JP (плюс) и JM (минус) для знака

Вот реализация некоторых из них:

        case 0xc2:                      //JNZ адрес
            if (0 == state->cc.z)    
                state->pc = (opcode[2] << 8) | opcode[1];    
            else    
                // Ветвление не выполнено
                state->pc += 2;    
            break;

        case 0xc3:                      //JMP адрес
            state->pc = (opcode[2] << 8) | opcode[1];    
            break;

CALL и RET


CALL записывает в стек адрес инструкции после вызова, а затем переходит к целевому адресу. RET получает адрес из стека и сохраняет его в PC. Существуют условные версии CALL и RET для всех состояний.

  • CZ, CNZ, RZ, RNZ для нуля
  • CNC, CC, RNC, RC для переноса
  • CPO, CPE, RPO, RPE для чётности
  • CP, CM, RP, RM для знака

        case 0xcd:                      //CALL адрес
            {    
            uint16_t    ret = state->pc+2;    
            state->memory[state->sp-1] = (ret >> 8) & 0xff;    
            state->memory[state->sp-2] = (ret & 0xff);    
            state->sp = state->sp - 2;    
            state->pc = (opcode[2] << 8) | opcode[1];    
            }    
                break;

        case 0xc9:                      //RET    
            state->pc = state->memory[state->sp] | (state->memory[state->sp+1] << 8);    
            state->sp += 2;    
            break;

Примечания


  • Команда PCHL выполняет безусловный переход к адресу в паре регистров HL.
  • Ранее рассмотренную RST я не включил в эту группу. Она записывает адрес возврата в стек, а затем переходит к заранее заданному адресу в нижней части памяти.

Логическая группа


Эта группа выполняет логические операции (см. первый пост туториала). По своей природе они похожи на арифметическую группу в том, что большинство операций работает с регистром A (накопителем), и большинство операций влияет на флаги. Все операции выполняются над 8-битными величинами, в этой группе нет команд, влияющих на пары регистров.

Булевы операции


AND, OR, NOT (CMP) и «исключающее или» (XOR) называются булевыми операциями. OR и AND я объяснял ранее. Команда NOT (для процессора 8080 она называется CMA, или complement accumulator) просто меняет значения битов — все единицы становятся нулями, а нули — единицами.

Я воспринимаю XOR как «распознаватель различий». Её таблица истинности выглядит так:

x y Результат
0 0 0
0 1 1
1 0 1
1 1 0

У AND, OR и XOR есть форма для регистров, памяти и непосредственных значений. (У CMP есть только работающая с регистром команда). Вот реализация пары опкодов:

    case 0x2F:                    //CMA (not)    
        state->a = ~state->a    
        //В справочнике написано, что CMA не влияет на флаги
        break;

    case 0xe6:                    //ANI    байт
        {    
        uint8_t x = state->a & opcode[1];    
        state->cc.z = (x == 0);    
        state->cc.s = (0x80 == (x & 0x80));    
        state->cc.p = parity(x, 8);    
        state->cc.cy = 0;           //В справочнике сказано, что ANI обнуляет CY    
        state->a = x;    
        state->pc++;                //для байта данных
        }    
        break;

Команды циклического сдвига


Эти команды изменяют порядок битов в регистрах. Сдвиг вправо перемещает их вправо на один бит, а сдвиг влево — влево на один бит:

Сдвиг вправо (0b00010000) = 0b00001000

Сдвиг влево (0b00000001) = 0b00000010

Кажется, что они бесполезны, но на самом деле это не так. Их можно использовать для умножения и деления на степени двойки. Возьмём для примера сдвиг влево. 0b00000001 — это десятичная 1, и сдвиг её влево делает её 0b00000010, то есть десятичной 2. Если выполнить ещё один сдвиг влево, то мы получим 0b00000100, то есть 4. Ещё один сдвиг влево, и мы умножили на 8. Это сработает с любыми числами: 5 (0b00000101) при сдвиге влево даёт 10 (0b00001010). Ещё один сдвиг влево даёт 20 (0b00010100). Сдвиг вправо делает то же самое, но для деления.

У 8080 нет команды умножения, но её можно реализовать с помощью этих команд. Если поймёте, как это сделать, то получите бонусные очки. Однажды такой вопрос мне задали на собеседовании. (Я справился, хотя это и заняло у меня несколько минут.)

Эти команды выполняют циклический сдвиг накопителя и влияют только на флаг переноса. Вот эта пара команд:

    case 0x0f:                    //RRC    
        {    
            uint8_t x = state->a;    
            state->a = ((x & 1) << 7) | (x >> 1);    
            state->cc.cy = (1 == (x&1));    
        }    
        break;

    case 0x1f:                    //RAR    
        {    
            uint8_t x = state->a;    
            state->a = (state->cc.cy << 7) | (x >> 1);    
            state->cc.cy = (1 == (x&1));    
        }    
        break;

Сравнение


Задача CMP и CPI заключается только в задании флагов (для ветвления). Они реализуют это с помощью вычитания, задающего флаги, но не сохраняющего результат.

  • Равно: если два числа равны, то устанавливается флаг Z, поскольку их вычитание друг из друга даёт ноль.
  • Больше: если A больше, чем сравниваемое значение, то флаг CY сбрасывается (поскольку вычитание можно выполнить без заимствования).
  • Меньше: если A меньше сравниваемого значения, то флаг CY устанавливается (потому что A должен выполнить заимствование, чтобы завершить вычитание).

Есть версии этих команд для регистров, памяти и непосредственных значений. Реализация заключается в простом вычитании без сохранения результата:

    case 0xfe:                      //CPI  байт
        {    
        uint8_t x = state->a - opcode[1];    
        state->cc.z = (x == 0);    
        state->cc.s = (0x80 == (x & 0x80));    
        //Из справочника неочевидно, что делать с p - пришлось выбирать
        state->cc.p = parity(x, 8);    
        state->cc.cy = (state->a < opcode[1]);    
        state->pc++;    
        }    
        break;

CMC и STC


На них завершается логическая группа. Они используются для задания и сброса флага переноса.

Группа ввода-вывода и особых команд


Эти команды нельзя отнести ни к какой другой категории. Я упомяну их для полноты, но, как мне кажется, нам придётся снова к ним вернуться, когда мы начнём эмулировать «железо» Space Invaders.

  • EI и DI включают и отключают способность процессора обрабатывать прерывания. Я добавил в структуру состояния процессора флаг interrupt_enabled, и устанавливаю/сбрасываю его с помощью этих команд.
  • Похоже, что RIM и SIM в основном используются для последовательного ввода-вывода. Если вам интересно, то можете почитать справочник, но в Space Invaders эти команды не используются. Я не буду их эмулировать.
  • HLT — это останов. Не думаю, что нам нужно его эмулировать, однако вы можете вызывать свой код quit (или exit(0)), когда видите эту команду.
  • IN и OUT — это команды, которые оборудование процессора 8080 использует для общения с внешним оборудованием. Пока мы реализуем их, но они ничего не будут делать, кроме как пропускать свой байт данных. (Позже мы к ним вернёмся).
  • NOP — это «no operation». Одно из применений NOP — это тайминг панели управления (для выполнения требуется четыре цикла ЦП).

Ещё одно применение NOP — модификация кода. Допустим, нам нужно изменить код ROM игры. Мы не можем просто удалять ненужные опкоды, поскольку не хотим изменять все команды CALL и JMP (они будут неверными, если хотя бы одна часть кода сдвинется). С помощью NOP мы можем избавиться от кода. Добавлять код гораздо сложнее! Его можно добавить, найдя где-нибудь в ROM пространство и изменив команду на JMP.

Группа стеков


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

PUSH и POP


PUSH и POP работают только с парами регистров. PUSH записывает пару регистров в стек, а POP берёт 2 байта из вершины стека и записывает их в пару регистров.

Существует по четыре опкода для PUSH и POP, по одному для каждой из пар: BC, DE, HL и PSW. PSW — это особая пара регистров флагов накопителя и кодов состояний. Вот моя реализация PUSH и POP для BC и PSW. В ней нет комментариев — не думаю, что здесь есть что-то особо хитрое.

        case 0xc1:                      //POP B    
            {    
                state->c = state->memory[state->sp];    
                state->b = state->memory[state->sp+1];    
                state->sp += 2;    
            }    
            break;    

        case 0xc5:                      //PUSH B    
            {    
            state->memory[state->sp-1] = state->b;    
            state->memory[state->sp-2] = state->c;    
            state->sp = state->sp - 2;    
            }    
            break;    

        case 0xf1:                      //POP PSW    
            {    
                state->a = state->memory[state->sp+1];    
                uint8_t psw = state->memory[state->sp];    
                state->cc.z  = (0x01 == (psw & 0x01));    
                state->cc.s  = (0x02 == (psw & 0x02));    
                state->cc.p  = (0x04 == (psw & 0x04));    
                state->cc.cy = (0x05 == (psw & 0x08));    
                state->cc.ac = (0x10 == (psw & 0x10));    
                state->sp += 2;    
            }    
            break;    

        case 0xf5:                      //PUSH PSW    
            {    
            state->memory[state->sp-1] = state->a;    
            uint8_t psw = (state->cc.z |    
                            state->cc.s << 1 |    
                            state->cc.p << 2 |    
                            state->cc.cy << 3 |    
                            state->cc.ac << 4 );    
            state->memory[state->sp-2] = psw;    
            state->sp = state->sp - 2;    
            }    
            break;

SPHL и XTHL


В группе стеков есть ещё две команды — SPHL и XTHL.

  • SPHL перемещает HL в SP (заставляя SP получить новый адрес).
  • XTHL обменивает то, что находится на вершине стека с тем, что находится в паре регистров HL. Зачем бы это нужно было делать? Я не знаю.

Ещё чуть-чуть о двоичных числах


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

Знаковые и беззнаковые


Когда мы начинали говорить о hex-числах, то считали их беззнаковыми — то есть каждая двоичная цифра шестнадцатеричного числа имела положительное значение, и каждая считалась степенью двойки (единицы, двойки, четвёрки и т.д.).

Мы касались вопроса хранения компьютером отрицательных чисел. Если вы знаете, что рассматриваемые данные имеют знак, то есть могут быть отрицательными, то можете распознать отрицательное число по самому старшему биту числа (most significant bit, MSB). Если размер данных равен одному байту, то каждое число с заданным значением бита MSB является отрицательным, а каждое с нулевым MSB — положительным.

Значение отрицательного числа хранится виде дополнительного кода. Если у нас есть знаковое число, а MSB равен одному, и мы хотим узнать, что это за число, то можно преобразовать его следующим образом: выполнить двоичное «НЕ» для hex-чисел, а затем прибавить единицу.

Например, у hex-числа 0x80 бит MSB задан, то есть оно является отрицательным. Двоичное «НЕ» числа 0x80 — это 0x7f, или десятичное 127. 127+1 = 128. То есть 0x80 в десятичном виде — это -128. Второй пример: 0xC5. Not(0xC5) = 0x3A = десятичное 58 +1 = десятичное 59. То есть 0xC5 — это десятичное -59.

Удивительно в числах с дополнительным кодом то, что мы можем выполнять вычисления с ними как с беззнаковыми числами, и они всё равно будут работать. Компьютеру не нужно делать ничего специального со знаками. Я покажу пару примеров, доказывающих это.

   Пример 1

     decimal   hex           binary    
      -3       0xFD      1111 1101    
   +  10       0x0A         +0000 1010    
   -----                   -----------    
       7       0x07       1 0000 0111    
                       ^ Это заносится в бит переноса

   Пример 2    

     decimal   hex           binary    
     -59       0xC5      1100 0101    
   +  33       0x21         +0010 0001    
   -----                   -----------    
     -26       0xE6         1110 0110


В примере 1 мы видим, что при сложении 10 и -3 получается 7. Был выполнен перенос результата сложения, поэтому может быть установлен флаг C. В примере 2 результат сложения был отрицательным, поэтому декодируем это: Not(0xE6) = 0x19 = 25 + 1 = 26. 0xE6 = -26 Взрыв мозга!

Если хотите, прочитайте подробнее о дополнительном коде в Википедии.

Типы данных


В языке C существует соотношение между типами данных и количеством используемых под этот тип байтов. На самом деле нас интересуют только целые числа. Стандартными/олдскульными типами данных C являются char, int и long, а также их друзья unsigned char, unsigned int и unsigned long. Проблема в том, что на разных платформах и в разных компиляторах эти типы могут иметь различные размеры.

Поэтому лучше всего будет подобрать для нашей платформы такой тип данных, который объявляет размер данных явным образом. Если на вашей платформе есть stdint.h, то можете использовать int8_t, uint8_t и т.д.

Размер целого числа определяет максимальное число, которое можно в него сохранить. В случае беззнаковых целых в 8 битах можно хранить числа от 0 до 255. Если перевести в hex, то это от 0x00 до 0xFF. Поскольку у 0xFF «все биты заданы», и оно соответствует десятичному 255, то совершенно логично, что интервал однобайтного беззнакового целого — 0-255. Интервалы сообщают нам, что все размеры целых чисел будут работать абсолютно одинаково — числа соответствуют тому числу, которое получается при задании всех битов.

Тип Интервал Hex
8-битное беззнаковое 0-255 0x0-0xFF
8-битное знаковое -128-127 0x80-0x7F
16-битное беззнаковое 0-65535 0x0-0xFFFF
16-битное знаковое -32768-32767 0x8000-0x7FFF
32-битное беззнаковое 0-4294967295 0x0-0xFFFFFFFF
32-битное знаковое -2147483648-2147483647 0x80000000-0x7FFFFFFF

Ещё интереснее то, что -1 в каждом знаковом типе данных — это число, у которого заданы все биты (0xFF для знакового байта, 0xFFFF для знакового 16-битного числа и 0xFFFFFFFF для знакового 32-битного числа). Если данные считаются беззнаковыми, то при всех заданных битах получается максимально возможное для этого типа данных число.

Для эмуляции регистров процессора мы выбираем тип данных, соответствующий размеру этого регистра. Вероятно, по умолчанию стоит выбрать беззнаковые типы и преобразовывать их, когда нужно считать их знаковыми. Например, используем для представления 8-битного регистра тип данных uint8_t.

Подсказка: для преобразования типов данных используйте отладчик


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

#используем модификатор /c, чтобы заставить gdb интерпретировать входящие данные как знаковые
(gdb) print /c 0xFD
$1 = -3 '?'

#используем модификатор /x, чтобы заставить gdb выводить результат в hex
#нажимаем для вывода "p" вместо "print"
(gdb) p /c 0xA
$2 = 10 '\n'

#Это числа из примера 2 раздела "Дополнительный код"
(gdb) p /c 0xC5
$3 = -59 '?'
(gdb) p /c 0xC5+0x21
$4 = -26 '?'

#Если использовать print без модификатора, то gdb ответит десятичным значением
(gdb) p 0x21
$9 = 33

#Это отрицательные числа из показанного выше, но я не говорю gdb,
#что они знаковые, он обрабатывает их как беззнаковые
(gdb) p 0xc5
$5 = 197 #беззнаковое
(gdb) p /c 0xc5
$3 = -59 '?' #знаковое
(gdb) p 0xfd
$6 = 253

#он также сообщает значение в виде дополнительного кода (по умолчанию это 32-битное целое)
(gdb) p /x -3
$7 = 0xfffffffd

# Данные размером 1 байт обрабатываются как знаковые
(gdb) print (char) 0xff
$1 = -1 '?'
# Данные размером 1 байт обрабатываются как беззнаковые
(gdb) print (unsigned char) 0xff
$2 = 255 '?'


Когда я работаю с hex-числами, я всегда делаю это в gdb — а это бывает почти каждый день. Так гораздо проще, чем открывать программистский калькулятор с GUI. На машинах с Linux (и Mac OS X), чтобы начать сессию gdb, достаточно просто открыть терминал и ввести «gdb». Если вы пользуетесь XCode на OS X, то после запуска программы можете использовать консоль внутри XCode (ту, в которую выводятся выходные данные printf). В Windows отладчик gdb доступен из Cygwin.

Завершение эмулятора процессора


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

Если вы решили делать полную эмуляцию, то вам понадобятся ещё несколько инструментов. Я расскажу о них в следующем разделе.

Другой путь заключается в эмуляции только инструкций, используемых игрой. Мы продолжим заполнять ту огромную конструкцию switch, которую создали в разделе «Оболочка эмулятора». Мы будем повторять следующий процесс, пока у нас не останется ни одной нереализованной команды:

  1. Запускаем эмулятор с ROM Space Invaders
  2. Вызов UnimplementedInstruction() выполняет выход, если команда не готова
  3. Эмулируем эту инструкцию
  4. Goto 1

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

   int Emulate8080Op(State8080* state)    
   {    
       unsigned char *opcode = &state->memory[state->pc];    
       Disassemble8080Op(state->memory, state->pc);    
       switch (*opcode)    
       {    
           case 0x00:   //NOP    
           /* ... */    
       }    
       /* выводим состояние процессора */    
       printf("\tC=%d,P=%d,S=%d,Z=%d\n", state->cc.cy, state->cc.p,    
           state->cc.s, state->cc.z);    
       printf("\tA $%02x B $%02x C $%02x D $%02x E $%02x H $%02x L $%02x SP %04x\n",    
           state->a, state->b, state->c, state->d,    
           state->e, state->h, state->l, state->sp);    
   }

Также я добавил в конец код для вывода всех регистров и флагов состояний.

Хорошие новости: для того, чтобы углубиться в программу на 50 тысяч команд, нам достаточно только подмножества опкодов 8080. Я даже дам список опкодов, которые нужно реализовать:

Опкод Команда
0x00 NOP
0x01 LXI B,D16
0x05 DCR B
0x06 MVI B,D8
0x09 DAD B
0x0d DCR C
0x0e MVI C,D8
0x0f RRC
0x11 LXI D,D16
0x13 INX D
0x19 DAD D
0x1a LDAX D
0x21 LXI H,D16
0x23 INX H
0x26 MVI H,D8
0x29 DAD H
0x31 LXI SP,D16
0x32 STA adr
0x36 MVI M,D8
0x3a LDA adr
0x3e MVI A,D8
0x56 MOV D,M
0x5e MOV E,M
0x66 MOV H,M
0x6f MOV L,A
0x77 MOV M,A
0x7a MOV A,D
0x7b MOV A,E
0x7c MOV A,H
0x7e MOV A,M
0xa7 ANA A
0xaf XRA A
0xc1 POP B
0xc2 JNZ adr
0xc3 JMP adr
0xc5 PUSH B
0xc6 ADI D8
0xc9 RET
0xcd CALL adr
0xd1 POP D
0xd3 OUT D8
0xd5 PUSH D
0xe1 POP H
0xe5 PUSH H
0xe6 ANI D8
0xeb XCHG
0xf1 POP PSW
0xf5 PUSH PSW
0xfb EI
0xfe CPI D8

Это всего лишь 50 инструкций, и 10 из них — это перемещения, которые реализуются тривиально.

Отладка


Но у меня есть и плохие новости. Ваш эмулятор почти совершенно точно не будет работать правильно, а баги в таком коде находить очень сложно. Если вы знаете, какая команда ведёт себя плохо (например переход или вызов, переходящие к бессмысленному коду), то можете попробовать исправить ошибку, изучив свой код.

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

  1. Создаём состояние для своего эмулятора
  2. Создаём состояние для моего
  3. Для следующей команды
  4. Вызываем ваш эмулятор с вашим состоянием
  5. Вызываем мой с моим состоянием
  6. Сравниваем два наших состояния
  7. Ищем ошибки в любых отличиях
  8. goto 3

Ещё один способ заключается в ручном использовании этого сайта. Это эмулятор процессора 8080 на Javascript, в который даже встроен ROM Space Invaders. Вот каким будет процесс:

  1. Перезапускаем эмуляцию Space Invaders, нажав кнопку «Space Invaders»
  2. Нажимаем кнопку «Run 1», чтобы выполнить команду.
  3. Выполняем следующую команду в своём эмуляторе
  4. Сравниваем состояние процессора с вашим
  5. Если состояния совпадают, goto 2
  6. Если состояния не совпадают, то ваша эмуляция инструкции ошибочна. Исправьте её, а затем начните заново с шага 1.

Я использовал этот способ в начале для отладки своего эмулятора 8080. Не буду лгать — процесс может быть долгим. Многие из моих проблем в результате оказались опечатками и ошибками копипастинга, которые после обнаружения очень легко было исправить.

Если пошагово выполнять свой код, то большая часть первых 30 тысяч команд выполняется в цикле около $1a5f. Если посмотреть в эмуляторе на javascript, то можно увидеть, что этот код копирует данные на экран. Уверен, что этот код вызывается часто.

После первой отрисовки экрана, спустя 50 тысяч команд, программа застревает в этом бесконечном цикле:

   0ada LDA    $20c0    
   0add ANA    A    
   0ade JNZ    $0ada

Он ждёт, пока значение в памяти по адресу $20c0 изменится на ноль. Поскольку код в этом цикле совершенно точно не изменяет $20c0, то это должен быть сигнал откуда-то ещё. Настало время поговорить об эмуляции «железа» аркадного автомата.

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

Для справки посмотрите мои исходники.

Полная эмуляция 8080


Урок, стоивший мне многого: не реализуйте команды, которые не можете протестировать. Это хорошее практическое правило для любого разрабатываемого ПО. Если вы не проверите команду, то она точно окажется сломанной. И чем дальше вы будете уходить от её реализации, тем сложнее будет находить проблемы.

Есть ещё один вариант решения, если вы хотите сделать полный эмулятор 8080 и убедиться в его работоспособности. Я обнаружил код для 8080 под названием cpudiag.asm, предназначенный для тестирования каждой команды процессора 8080.

Я знакомлю вас с этим процессом после первого по нескольким причинам:

  1. Я хотел, чтобы описание этого процесса можно было повторить для другого процессора. Не думаю, что аналог cpudiag.asm существует для всех процессоров.
  2. Как вы видите, процесс довольно кропотливый. Думаю, новичок в отладке ассемблерного кода испытает большие трудности, если не перечислить ему эти шаги.

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

Сборка теста


Я попробовал пару вещей, но в результате остановился на использовании этой хорошей страницы. Я вставил текст cpudiag.asm в левую панель, и сборка выполнилась без проблем. Мне понадобилась минута, чтобы понять, как скачать результа, но нажав на кнопку «Make Beautiful Code» внизу слева, я скачал файл под названием test.bin, являющийся скомпилированным кодом 8080. Я смог убедиться в этом, воспользовавшись своим дизассемблером.

Скачать cpudiag.asm с зеркала на моём сайте.

Скачать cpudiag.bin (скомпилированный код 8080) с моего сайта.

Загрузка теста в мой эмулятор


Вместо загрузки файлов invaders.* я загружаю этот двоичный файл.

Тут возникают небольшие трудности. Во-первых, в исходном ассемблерном коде есть строка ORG 00100H, то есть подразумевается, что весь файл скомпилирован с предположением, что первая строка кода находится в 0x100 hex. Я никогда раньше не писал код на ассемблере 8080, поэтому не знал, что делает эта строка. Мне потребовалась всего минута, чтобы разобраться, что все адреса перехода ветвления были неверными и нужно было, чтобы память начиналась с 0x100.

Во-вторых, поскольку мой эмулятор начинает с нуля, то я в первую очередь должен выполнить переход к реальному коду. Вставив в память по нулевому адресу hex-значение JMP $0100, я с этим справился. (Или можно было просто инициализировать PC со значением 0x100.)

В-третьих, я нашёл в собранном коде баг. Думаю, причина заключается в неправильной обработке последней строки кода STACK EQU TEMPP+256, но я не уверен. Как бы то ни было, стек в процессе компиляции располагался по адресу $6ad, а первые несколько PUSH начинали перезаписывать код. Я предположил, что переменная тоже должна быть смещена на 0x100, как и остальной код, поэтому исправил это, вставив «0x7» в строку кода, инициализирующую указатель стека.

Наконец, поскольку я не реализовал в своём эмуляторе DAA или вспомогательный перенос, я изменяю код, чтобы пропустить эту проверку (просто перескакиваем её с помощью JMP).

    ReadFileIntoMemoryAt(state, "/Users/kpmiller/Desktop/invaders/cpudiag.bin", 0x100);

    //Исправляем первую команду, чтобы было JMP 0x100    
    state->memory[0]=0xc3;    
    state->memory[1]=0;    
    state->memory[2]=0x01;    

    //Fix the stack pointer from 0x6ad to 0x7ad
    // this 0x06 byte 112 in the code, which is    
    // byte 112 + 0x100 = 368 in memory    
    state->memory[368] = 0x7;    

    //Пропускаем проверку DAA
    state->memory[0x59c] = 0xc3; //JMP    
    state->memory[0x59d] = 0xc2;    
    state->memory[0x59e] = 0x05;

Тест пытается выполнять вывод


Очевидно, этот тест рассчитывает на помощь от ОС CP/M. Я выяснил, что у CP/M есть некий код по адресу $0005, выводящий сообщения в консоль, и изменил мою эмуляцию CALL, чтобы обрабатывать это поведение. Не уверен, всё ли получилось правильно, но это сработало для двух сообщений, которые программа пытается вывести. Моя эмуляция CALL для запуска этого теста выглядит так:

        case 0xcd:                      //CALL адрес
   #ifdef FOR_CPUDIAG    
            if (5 ==  ((opcode[2] << 8) | opcode[1]))    
            {    
                if (state->c == 9)    
                {    
                    uint16_t offset = (state->d<<8) | (state->e);    
                    char *str = &state->memory[offset+3];  //пропускаем байты-префиксы
                    while (*str != '$')    
                        printf("%c", *str++);    
                    printf("\n");    
                }    
                else if (state->c == 2)    
                {    
                    //видел это в исследованном коде, никогда не замечал, чтобы оно вызывалось
                    printf ("print char routine called\n");    
                }    
            }    
            else if (0 ==  ((opcode[2] << 8) | opcode[1]))    
            {    
                exit(0);    
            }    
            else    
   #endif    
            {    
            uint16_t    ret = state->pc+2;    
            state->memory[state->sp-1] = (ret >> 8) & 0xff;    
            state->memory[state->sp-2] = (ret & 0xff);    
            state->sp = state->sp - 2;    
            state->pc = (opcode[2] << 8) | opcode[1];    
            }    
                break;

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

Я пошёл дальше и реализовал все опкоды (за исключением DAA и его друзей). Устранение проблем в моих вызовах и реализация новых заняли у меня 3-4 часа. Это было определённо быстрее, чем ручной процесс, который я описал выше — прежде чем нашёл этот тест, я потратил на ручной процесс больше 4 часов. Если вы сможете разобраться в этом объяснении, то рекомендую вместо сравнения вручную использовать такой способ. Однако знание ручного процесса — тоже отличный навык, и если вы захотите эмулировать другой процессор, то стоит вернуться к нему.

Если вы не можете выполнять этот процесс или он кажется слишком сложным, то определённо стоит выбрать описанный выше подход с двумя разными эмуляторами с запущенными внутри вашей программы. Когда в программе появится несколько миллионов команд и добавятся прерывания, то вручную сравнивать два эмулятора будет невозможно.
Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
Total votes 16: ↑14 and ↓2+12
Comments7

Articles