Criando uma máquina de arcade emulador. Parte 3

imagem

Partes um e dois .

Emulador de processador 8080


Shell do emulador


Agora você deve ter todo o conhecimento necessário para começar a criar um emulador de processador 8080.

Vou tentar deixar meu código o mais claro possível, cada código operacional é implementado separadamente. Quando você se sentir confortável com isso, poderá reescrevê-lo para otimizar o desempenho ou reutilizar o código.

Para começar, vou criar uma estrutura de memória que conterá campos para tudo o que me pareceu necessário ao escrever um desmontador. Também haverá um local para um buffer de memória, que será a RAM.

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; 

Agora crie um procedimento com uma chamada de erro que encerre o programa com um erro. Será algo parecido com isto:

  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; //  } 

Vamos implementar alguns códigos de operação.

  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; } 

Lá vai você. Para cada opcode, alteramos o estado e a memória, como faria um comando executado em um 8080 real.

O 8080 possui cerca de 7 tipos, dependendo de como você os classifica:

  • Transferência de dados
  • Aritmética
  • Logical
  • Ramos
  • Stack
  • Entrada-saída
  • Especial

Vamos olhar para cada um deles individualmente.

Grupo aritmético


As instruções aritméticas são muitos dos 256 opcodes do processador 8080, que incluem vários tipos de adição e subtração. A maioria das instruções aritméticas trabalha com o registro A e salva o resultado em A. (O registro A também é chamado de acumulador).

É interessante notar que esses comandos afetam os códigos de condição. Os códigos de estado (também chamados de sinalizadores) são configurados dependendo do resultado do comando executado. Nem todos os comandos afetam sinalizadores e nem todas as equipes que afetam sinalizadores afetam todos os sinalizadores de uma só vez.

Flags 8080


Em um processador 8080, os sinalizadores são chamados Z, S, P, CY e AC.

  • Z (zero, zero) assume o valor 1 quando o resultado é zero
  • S (sinal) assume o valor 1 quando o bit 7 (o bit mais significativo, o bit mais significativo, MSB) do comando matemático é fornecido
  • P (paridade, paridade) é definido quando o resultado é par e é redefinido quando é ímpar
  • CY (carry) assume o valor 1 quando, como resultado do comando, é realizada uma transferência ou empréstimo para um bit de ordem superior
  • AC (transporte auxiliar) é usado principalmente para matemática BCD (decimal com código binário). Para mais detalhes, consulte o manual, em Space Invaders, esse sinalizador não é usado.

Os códigos de estado são usados ​​em comandos de ramificação condicional, por exemplo, JZ executa ramificação apenas se o sinalizador Z estiver definido.

A maioria das instruções possui três formas: para registros, valores imediatos e memória. Vamos implementar algumas instruções para entender seus formulários e ver como é trabalhar com códigos de estado. (Observe que eu não implemento o sinalizador de transferência auxiliar porque ele não é usado. Se eu o implementei, não poderia testá-lo.)

Formulário de Registro


Aqui está um exemplo de implementação de duas instruções com um formulário de registro; no primeiro, implantei o código para facilitar o trabalho e, no segundo, é apresentado um formulário mais compacto que faz a mesma coisa.

  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; } 

Eu emulo comandos matemáticos de 8 bits com um número de 16 bits. Isso facilita o rastreamento de casos em que os cálculos geram uma carga.

Formulário para valores imediatos


O formulário para valores imediatos é quase o mesmo, exceto que o byte após o comando é a fonte do adicionado. Como “opcode” é um ponteiro para o comando atual na memória, opcode [1] será imediatamente o próximo byte.

  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; } 

Forma para memória


No formulário de memória, um byte será adicionado ao qual o endereço armazenado em um par de registros HL indica.

  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; } 

Anotações


As demais instruções aritméticas são implementadas de maneira semelhante. Adições:

  • Em diferentes versões com transporte (ADC, ACI, SBB, SUI), de acordo com o manual de referência, usamos bits de transporte nos cálculos.
  • INX e DCX afetam pares de registradores; esses comandos não afetam sinalizadores.
  • DAD é outro comando de um par de registros, afeta apenas a bandeira de transporte
  • INR e DCR não afetam o sinalizador de transporte

Grupo de filiais


Depois de lidar com os códigos de estado, o grupo de filiais ficará claro o suficiente para você. Existem dois tipos de ramificação - transições (JMP) e chamadas (CALL). O JMP apenas define o PC para o valor do destino do salto. CALL é usado para rotinas, grava o endereço de retorno na pilha e atribui ao PC o endereço de destino. RET retorna de CALL, recebendo o endereço da pilha e gravando-o no PC.

JMP e CALL apenas vão para endereços absolutos que são codificados em bytes após o código de operação.

Jmp


O comando JMP ramifica incondicionalmente no endereço de destino. Também existem comandos de ramificação condicional para todos os códigos de status (exceto para AC):

  • JNZ e JZ para zero
  • JNC e JC para migração
  • JPO e JPE para paridade
  • JP (mais) e JM (menos) para o sinal

Aqui está uma implementação de alguns deles:

  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; 

CHAMADA e RET


CALL envia o endereço da instrução para a pilha após a chamada e depois pula para o endereço de destino. O RET recebe o endereço da pilha e o salva no PC. Versões condicionais do CALL e RET existem para todos os estados.

  • CZ, CNZ, RZ, RNZ para zero
  • CNC, CC, RNC, RC para transferência
  • CPO, CPE, RPO, RPE para paridade
  • CP, CM, RP, RM para sinal

  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; 

Anotações


  • O comando PCHL salta incondicionalmente para um endereço em um par de registros HL.
  • Não incluí o RST discutido anteriormente neste grupo. Ele grava o endereço de retorno na pilha e salta para o endereço predefinido na parte inferior da memória.

Grupo lógico


Este grupo executa operações lógicas (consulte a primeira postagem do tutorial). Por sua natureza, eles são semelhantes a um grupo aritmético, pois a maioria das operações trabalha com o registro A (unidade) e a maioria das operações afeta sinalizadores. Todas as operações são executadas em valores de 8 bits; nesse grupo, não há comandos que afetem pares de registradores.

Operações booleanas


AND, OR, NOT (CMP) e "exclusivo ou" (XOR) são chamados de operações booleanas. OU e AND eu expliquei anteriormente. O comando NOT (para o processador 8080 é chamado de CMA, ou acumulador de complemento) simplesmente altera os valores dos bits - todas as unidades se tornam zeros e zeros se tornam um.

Eu percebo o XOR como um "reconhecedor de diferenças". Sua tabela de verdade é assim:

xyResultado
0 00 00 0
0 011
10 01
110 0

AND, OR e XOR têm um formulário para registros, memória e valores imediatos. (O CMP possui apenas um comando que diferencia maiúsculas de minúsculas). Aqui está uma implementação de um par de códigos de operação:

  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; 

Comandos de mudança cíclica


Esses comandos alteram a ordem dos bits nos registradores. Um deslocamento para a direita os move um pouco para a direita e um deslocamento para a esquerda - um pouco para a esquerda:

(0b00010000) = 0b00001000

(0b00000001) = 0b00000010

Eles parecem não ter valor, mas na realidade não é assim. Eles podem ser usados ​​para multiplicar e dividir por potências de dois. Tome o deslocamento à esquerda como exemplo. 0b00000001 é decimal 1, e 0b00000001 -lo para a esquerda torna 0b00000010 , ou seja, 2. Se executarmos outro deslocamento para a esquerda, obtemos 0b00000100 , ou seja, 4. Outro deslocamento para a esquerda e multiplicamos por 8. Isso funcionará com qualquer por números: 5 ( 0b00000101 ) quando deslocado para a esquerda, fornece 10 ( 0b00001010 ). Outro deslocamento à esquerda fornece 20 ( 0b00010100 ). Uma mudança para a direita faz o mesmo, mas para a divisão.

O 8080 não possui um comando de multiplicação, mas pode ser implementado usando esses comandos. Se você entender como fazer isso, receberá pontos de bônus. Uma vez que essa pergunta me foi feita em uma entrevista. (Fiz, embora demorei alguns minutos.)

Esses comandos giram o inversor ciclicamente e afetam apenas o sinalizador de transporte. Aqui estão alguns comandos:

  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; 

Comparação


A tarefa do CMP e CPI é apenas definir sinalizadores (para ramificação). Eles fazem isso subtraindo sinalizadores, mas não armazenando o resultado.

  • Igualmente: se dois números são iguais, o sinalizador Z é definido, pois a subtração um do outro dá zero.
  • Maior que: se A for maior que o valor que está sendo comparado, o sinalizador CY será apagado (já que a subtração pode ser feita sem empréstimo).
  • Menor: se A for menor que o valor comparado, o sinalizador CY é definido (porque A deve concluir o empréstimo para concluir a subtração).

Existem versões desses comandos para registros, memória e valores imediatos. A implementação é uma subtração simples sem salvar o resultado:

  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 e STC


Eles completam o grupo lógico. Eles são usados ​​para definir e limpar a bandeira de transporte.

Grupo de entrada-saída e comandos especiais


Esses comandos não podem ser atribuídos a nenhuma outra categoria. Vou mencioná-los por completo, mas parece-me que teremos que retornar a eles novamente quando começarmos a emular o hardware dos Space Invaders.

  • EI e DI ativam ou desativam a capacidade do processador de lidar com interrupções. Adicionei o sinalizador interrupt_enabled à estrutura de estado do processador e o configurei / redefinii usando esses comandos.
  • Parece que RIM e SIM são usados ​​principalmente para E / S serial. Se você estiver interessado, pode ler o manual, mas esses comandos não são utilizados no Space Invaders. Eu não vou imitá-los.
  • HLT é uma parada. Acho que não precisamos imitá-lo, mas você pode chamar seu código de saída (ou sair (0)) quando vir este comando.
  • IN e OUT são comandos que o equipamento do processador 8080 usa para se comunicar com equipamento externo. Enquanto os estivermos implementando, eles não farão nada além de pular seus bytes de dados. (Mais tarde retornaremos a eles).
  • NOP é "sem operação". Uma aplicação do NOP é controlar o tempo do painel (são necessários quatro ciclos de CPU para executar).

Outra aplicação do NOP é a modificação do código. Digamos que precisamos alterar o código ROM do jogo. Não podemos excluir apenas opcodes desnecessários, porque não queremos alterar todos os comandos CALL e JMP (eles estarão incorretos se pelo menos uma parte do código for movida). Com o NOP, podemos nos livrar do código. Adicionar código é muito mais difícil! Você pode adicioná-lo localizando espaço em algum lugar da ROM e alterando o comando para JMP.

Grupo de pilhas


Já completamos a mecânica da maioria das equipes no grupo de pilhas. Se você fez o trabalho comigo, esses comandos serão fáceis de implementar.

PUSH e POP


PUSH e POP funcionam apenas com pares de registradores. PUSH grava um par de registradores na pilha, e o POP pega 2 bytes do topo da pilha e os grava em um par de registradores.

Existem quatro opcodes para PUSH e POP, um para cada um dos pares: BC, DE, HL e PSW. PSW é um par especial de registros de sinalizadores de unidade e códigos de status. Aqui está a minha implementação de PUSH e POP para BC e PSW. Não há comentários - não acho que exista algo particularmente complicado aqui.

  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 e XTHL


Existem mais duas equipes no grupo de pilhas - SPHL e XTHL.

  • SPHL move o HL para o SP (forçando o SP a obter um novo endereço).
  • XTHL troca o que está no topo da pilha pelo que está em um par de registros HL. Por que você precisaria fazer isso? Eu não sei

Um pouco mais sobre números binários


Ao escrever um programa de computador, uma das decisões que você precisa tomar é escolher o tipo de dados usado para os números - se você deseja que eles sejam negativos e qual deve ser o tamanho máximo deles. Para o emulador de CPU, precisamos do tipo de dados para corresponder ao tipo de dados da CPU de destino.

Assinado e não assinado


Quando começamos a falar sobre números hexadecimais, os consideramos sem sinal - ou seja, cada dígito binário do número hexadecimal tinha um valor positivo e cada um era considerado uma potência de dois (unidades, dois, quatro, etc.).

Lidamos com a questão do armazenamento em computador de números negativos. Se você souber que os dados em questão têm um sinal, ou seja, eles podem ser negativos, você poderá reconhecer um número negativo pelo bit mais significativo do número (bit mais significativo, MSB). Se o tamanho dos dados for de um byte, cada número com um determinado valor de bit MSB será negativo e cada um com um MSB zero será positivo.

O valor de um número negativo é armazenado como um código adicional. Se temos um número assinado e o MSB é igual a um, e queremos descobrir qual é esse número, podemos convertê-lo da seguinte forma: execute “NOT” binário para números hexadecimais e adicione um.

Por exemplo, para um número hexadecimal 0x80, o bit MSB é definido, ou seja, é negativo. O "NÃO" binário do número 0x80 é 0x7f, ou decimal 127. 127 + 1 = 128. Ou seja, 0x80 em decimal é -128. Segundo exemplo: 0xC5. Não (0xC5) = 0x3A = decimal 58 +1 = decimal 59. Ou seja, 0xC5 é decimal -59.

O que é surpreendente em números com código adicional é que podemos realizar cálculos com eles, assim como com números não assinados, e eles ainda funcionarão . O computador não precisa fazer nada de especial com sinais. Vou mostrar alguns exemplos que provam isso.

  Exemplo 1

      binário hexadecimal decimal    
       -3 0xFD 1111 1101    
    + 10 0x0A +0000 1010    
    ----- -----------    
        7 0x07 1 0000 0111    
                        ^ Isso é gravado no bit de transporte

    Exemplo 2    

      binário hexadecimal decimal    
      -59 0xC5 1100 0101    
    + 33 0x21 +0010 0001    
    ----- -----------    
      -26 0xE6 1110 0110 


No Exemplo 1, vemos que a adição de 10 e -3 resulta em 7. O resultado da adição foi transferido para que o sinalizador C. No Exemplo 2, o resultado da adição foi negativo, portanto decodificamos isso: Not (0xE6) = 0x19 = 25 + 1 = 26. 0xE6 = -26 Explosão do cérebro!

Se você quiser, leia mais sobre o código adicional na Wikipedia .

Tipos de dados


Em C, há um relacionamento entre os tipos de dados e o número de bytes usados ​​para esse tipo. De fato, estamos interessados ​​apenas em números inteiros. Os tipos de dados C padrão / old school são char, int e long, assim como seus amigos char não assinado, int não assinado e não assinado por muito tempo. O problema é que, em plataformas diferentes e em compiladores diferentes, esses tipos podem ter tamanhos diferentes.

Portanto, é melhor selecionar um tipo de dados para nossa plataforma que declare explicitamente o tamanho dos dados. Se sua plataforma possui stdint.h, você pode usar int8_t, uint8_t, etc.

O tamanho de um número inteiro determina o número máximo que pode ser armazenado nele. No caso de números inteiros não assinados, você pode armazenar números de 0 a 255 em 8 bits. Se você converter em hexadecimal, será de 0x00 a 0xFF. Como 0xFF possui “todos os bits definidos” e corresponde ao decimal 255, é completamente lógico que o intervalo de um número inteiro não assinado de byte único seja de 0 a 255. Intervalos nos dizem que todos os tamanhos de números inteiros funcionarão exatamente da mesma maneira - os números correspondem ao número obtido quando todos os bits são definidos.

TipoIntervaloHex
8 bits não assinado0-2550x0-0xFF
Assinado de 8 bits-128-1270x80-0x7F
16 bits não assinado0-655350x0-0xFFFF
Assinado de 16 bits-32768-327670x8000-0x7FFF
32 bits não assinado0-42949672950x0-0xFFFFFFFFFF
Assinado de 32 bits-2147483648-21474836470x80000000-0x7FFFFFFF

Ainda mais interessante é que -1 em cada tipo de dado assinado é um número que possui todos os bits definidos (0xFF para byte assinado, 0xFFFF para número assinado de 16 bits e 0xFFFFFFFF para número assinado de 32 bits). Se os dados forem considerados sem sinal, então, para todos os bits fornecidos, o número máximo possível para esse tipo de dado é obtido.

Para emular os registros do processador, selecionamos o tipo de dados correspondente ao tamanho desse registro. Provavelmente vale a pena selecionar tipos não assinados por padrão e convertê-los quando você precisar considerá-los assinados. Por exemplo, usamos o tipo de dados uint8_t para representar um registro de 8 bits.

Dica: use um depurador para converter tipos de dados


Se o gdb estiver instalado na sua plataforma, é muito conveniente usá-lo para trabalhar com números binários. Abaixo, mostrarei um exemplo - na sessão mostrada abaixo, as linhas que começam com # são comentários que adicionei posteriormente.

# /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 '?'


Quando trabalho com números hexadecimais, sempre o faço em gdb - e isso acontece quase todos os dias. Muito mais fácil do que abrir a calculadora de um programador com uma GUI. Nas máquinas Linux (e Mac OS X), para iniciar uma sessão de gdb, basta abrir um terminal e inserir "gdb". Se você usar o Xcode no OS X, depois de iniciar o programa, poderá usar o console dentro do Xcode (aquele no qual a saída printf é emitida). No Windows, o depurador gdb está disponível no Cygwin.

Terminação do emulador de CPU


Depois de receber todas essas informações, você está pronto para uma longa jornada. Você deve decidir como implementar o emulador - crie uma emulação 8080 completa ou implemente apenas os comandos necessários para concluir o jogo.

Se você decidir fazer uma emulação completa, precisará de mais algumas ferramentas. Vou falar sobre eles na próxima seção.

Outra maneira é emular apenas as instruções usadas pelo jogo. Continuaremos preenchendo a enorme construção de switch que criamos na seção Shell do Emulador. Repetiremos o seguinte processo até termos um único comando não realizado:

  1. Inicie o emulador com ROM Space Invaders
  2. A chamada UnimplementedInstruction()sai se o comando não estiver pronto
  3. Emule esta instrução
  4. Goto 1

A primeira coisa que fiz ao começar a escrever meu emulador foi adicionar código do meu desmontador. Então, eu pude emitir um comando que deve ser executado da seguinte maneira:

  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); } 

Também adicionei código no final para exibir todos os registros e sinalizadores de estado.

Boas notícias: para nos aprofundarmos no programa para 50 mil equipes, precisamos apenas de um subconjunto dos códigos de operação 8080. Vou até fornecer uma lista de códigos de operação que precisam ser implementados:

OpcodeA equipe
0x00Nop
0x01LXI B, D16
0x05DCR B
0x06MVI B, D8
0x09Pai b
0x0dDCR C
0x0eMVI C, D8
0x0fRrc
0x11LXI D, D16
0x13Inx d
0x19Pai d
0x1aLDAX D
0x21LXI H, D16
0x23Inx h
0x26MVI H, D8
0x29Pai h
0x31LXI SP, D16
0x32STA adr
0x36MVI M, D8
0x3aLda adr
0x3eMVI A, D8
0x56MOV D, M
0x5eMOV E, M
0x66MOV H, M
0x6fMOV L, A
0x77MOV M, A
0x7aMOV A, D
0x7bMOV A, E
0x7cMOV A, H
0x7eMOV A, M
0xa7ANA A
0xafXRA A
0xc1Pop b
0xc2Jnz adr
0xc3Jmp adr
0xc5PUSH B
0xc6ADI D8
0xc9Ret
0xcdCall adr
0xd1Pop d
0xd3OUT D8
0xd5PUSH D
0xe1Pop h
0xe5PUSH H
0xe6ANI D8
0xebXchg
0xf1POP PSW
0xf5PUSH PSW
0xfbEi
0xfeCPI D8

Estas são apenas 50 instruções e 10 delas são movimentos que são implementados trivialmente.

Depuração


Mas tenho más notícias. Seu emulador quase certamente não funcionará corretamente, e é muito difícil encontrar erros nesse código. Se você souber qual comando está se comportando mal (por exemplo, uma transição ou uma chamada que vá para código sem sentido), tente corrigir o erro examinando seu código.

Além de examinar o código, há outra maneira de corrigir o problema - comparando seu emulador com um que funcione exatamente. Assumimos que outro emulador sempre funcione corretamente, e todas as diferenças são erros no seu emulador. Por exemplo, você pode usar meu emulador. Você pode executá-los manualmente em paralelo. Você pode economizar tempo se integrar meu código ao seu projeto para obter o seguinte processo:

  1. Crie um estado para o seu emulador
  2. Crie um estado para o meu
  3. Para a próxima equipe
  4. Chamando seu emulador com seu estado
  5. Chamando a minha com minha fortuna
  6. Compare nossos dois estados
  7. Procurando erros em quaisquer diferenças
  8. ir para 3

Outra maneira é usar manualmente este site . Este é um emulador de processador Javascript 8080 que inclui até Invasores de Espaço ROM. Aqui está o processo:

  1. Reinicie a emulação do Space Invaders clicando no botão Space Invaders
  2. Pressione o botão "Executar 1" para executar o comando.
  3. Executamos o seguinte comando em nosso emulador
  4. Compare o status do processador com o seu
  5. Se as condições coincidirem, vá para 2
  6. Se as condições não corresponderem, sua emulação de instrução está incorreta. Corrija-o e inicie novamente a partir da etapa 1.

Eu usei esse método no começo para depurar meu emulador 8080. Não vou mentir - o processo pode ser longo. Como resultado, muitos dos meus problemas acabaram sendo erros de digitação e de copiar e colar, que após a detecção foram muito fáceis de corrigir.

Se você executar passo a passo seu código, a maioria das 30 mil instruções será executada em um ciclo de cerca de US $ 1a5f. Se você observar o javascript no emulador , poderá ver que esse código copia dados para a tela. Estou certo de que esse código é chamado com frequência.

Após a primeira renderização da tela, após 50 mil comandos, o programa fica preso nesse loop infinito:

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

Espera até que o valor na memória em $ 20c0 mude para zero. Como o código nesse loop não altera exatamente $ 20c0, ele deve ser um sinal de outro lugar. É hora de falar sobre como emular o "ferro" de uma máquina de arcade.

Antes de avançarmos para a próxima seção, verifique se o emulador de CPU cai nesse loop infinito.

Para referência, veja minhas fontes .

Emulação 8080 completa


Uma lição que me custou muito: não implemente equipes que você não pode testar. Essa é uma boa regra geral para qualquer software em desenvolvimento. Se você não checar a equipe, ela será quebrada. E quanto mais você se afasta de sua implementação, mais difícil será encontrar problemas.

Existe outra solução se você deseja criar um emulador 8080 completo e garantir que ele funcione. Descobri um código para o 8080 chamado cpudiag.asm, projetado para testar cada comando do processador 8080.

Apresento esse processo depois do primeiro por vários motivos:

  1. Eu queria que a descrição desse processo fosse repetida para outro processador. Eu não acho que o análogo do cpudiag.asm exista para todos os processadores.
  2. Como você pode ver, o processo é bastante meticuloso. Eu acho que um iniciante na depuração de código assembler terá grandes dificuldades se essas etapas não estiverem listadas.

Foi assim que usei esse teste com meu emulador. Você pode usá-lo ou criar uma maneira melhor de integrá-lo.

Montagem de teste


Eu tentei algumas coisas, mas como resultado, decidi usar esta página legal . Colei o texto cpudiag.asm no painel esquerdo e a compilação foi concluída sem problemas. Levei um minuto para descobrir como baixar o resultado, mas clicando no botão "Criar código bonito" no canto inferior esquerdo, baixei um arquivo chamado test.bin, que é o código 8080 compilado. Pude verificar isso usando meu desmontador.

Baixe cpudiag.asm do espelho no meu site.

Baixe cpudiag.bin (código compilado 8080) do meu site.

Carregando um teste no meu emulador


Em vez de carregar invasores. * Arquivos, eu carrego esse binário.

Pequenas dificuldades surgem aqui. Primeiramente, existe uma linha no código do assembler de origem ORG 00100H, ou seja, significa que o arquivo inteiro é compilado com a suposição de que a primeira linha de código está no hexadecimal 0x100. Eu nunca tinha escrito código no assembler 8080 antes, então não sabia o que essa linha faz. Levei apenas um minuto para descobrir que todos os endereços das filiais estavam incorretos e era necessário que a memória iniciasse em 0x100.

Segundo, desde que meu emulador começa do zero, primeiro devo fazer a transição para o código real. Depois de inserir o valor hexadecimal na memória no endereço zero JMP $0100, lidei com isso. (Ou você pode apenas inicializar o PC com um valor de 0x100.)

Em terceiro lugar, encontrei um bug no código compilado. Acho que o motivo é o processamento incorreto da última linha de código STACK EQU TEMPP+256, mas não tenho certeza. Seja como for, a pilha durante a compilação foi localizada em US $ 6ad, e os primeiros PUSH começaram a reescrever o código. Sugeri que a variável também fosse deslocada em 0x100, como o restante do código, então a corrigi inserindo "0x7" na linha de código que inicializa o ponteiro da pilha.

Por fim, como não implementei o DAA ou a migração auxiliar no meu emulador, modifiquei o código para ignorar essa verificação (apenas a ignoramos usando o 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; 

O teste está tentando concluir


Obviamente, este teste depende da ajuda do CP / M OS. Descobri que o CP / M tem um código de US $ 0005 que imprime mensagens no console e alterei minha emulação de CHAMADA para lidar com esse comportamento. Não sei se tudo deu certo, mas funcionou para as duas mensagens que o programa está tentando imprimir. Minha emulação CALL para executar este teste é assim:

  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; 

Com este teste, encontrei vários problemas no meu emulador. Não tenho certeza de quais deles estariam envolvidos no jogo, mas, se estivessem, seria muito difícil encontrá-los.

Fui em frente e implementei todos os opcodes (com exceção do DAA e seus amigos). Demorei de 3 a 4 horas para resolver problemas nos meus desafios e implementar novos. Definitivamente, era mais rápido que o processo manual que descrevi acima - antes de encontrar esse teste, passei mais de 4 horas no processo manual. Se você conseguir descobrir essa explicação, recomendo usar esse método em vez de comparar manualmente. No entanto, conhecer o processo manual também é uma grande habilidade e, se você quiser emular outro processador, retorne a ele.

Se você não pode executar esse processo ou parece muito complicado, definitivamente vale a pena escolher a abordagem descrita acima com dois emuladores diferentes executando dentro do seu programa. Quando vários milhões de comandos aparecerem no programa e as interrupções forem adicionadas, será impossível comparar manualmente dois emuladores.

Source: https://habr.com/ru/post/pt418699/


All Articles