Criando uma máquina de arcade emulador. Parte 1

imagem

Escrever um emulador de máquina de arcade é um ótimo projeto educacional e, neste tutorial, analisaremos detalhadamente todo o processo de desenvolvimento. Deseja realmente colocar as mãos no processador? Em seguida, criar um emulador é a melhor maneira de aprendê-lo.

Você precisará de conhecimento de C, bem como de montador. Se você não conhece a linguagem assembly, escrever um emulador é a melhor maneira de aprendê-lo. Você também precisará dominar a matemática hexadecimal (também conhecida como base 16 ou apenas "hex"). Vou falar sobre esse tópico.

Decidi escolher um emulador para a máquina Space Invaders, que usa o processador 8080. Este jogo e esse processador são muito populares, porque na Internet você pode encontrar muitas informações sobre eles. Você precisará dele para concluir o projeto.

Todo o código fonte do tutorial é carregado no github . Se você não domina o trabalho com o git, na página do github há um botão "Download ZIP" que permite baixar o arquivo com todo o código.

Introdução aos números binários e hexadecimais


Na matemática "comum", o sistema de números decimais é usado. Cada dígito do número pode ter um valor de zero a nove e, quando excedermos 9, adicionamos um ao número no próximo dígito e recomeçamos do zero. Tudo isso é bastante simples e direto, e você provavelmente nunca pensou nisso.

Você pode saber ou ouvir falar que os computadores funcionam com dados binários. Os geeks dos computadores chamam matemática decimal de base 10 e chamada binária de base 2. Em notação binária, cada dígito de um número pode ter apenas dois valores, zero ou um. No código binário, a contagem é a seguinte: 0, 1, 10, 11, 100, 101, 110, 111, 1000. Esses não são números decimais, portanto, você não pode chamá-los de "zero, um, dez, onze, cem, cento e um". Eles são pronunciados como "zero, um, um-zero, um-um, um-zero-zero", etc. Eu raramente leio números binários em voz alta, mas se necessário, você precisa indicar claramente o sistema de números usado. Dez, onze e cem não têm significado em notação binária.

Na notação decimal, um número tem os seguintes dígitos: unidades, dezenas, centenas, milhares, dezenas de milhares, etc. No sistema binário, os seguintes dígitos: unidades, duques, quatros, oito, etc. Na ciência da computação, o valor de cada bit binário é chamado de bit. 8 bits compõem um byte.

Em termos binários, uma série de números rapidamente se torna muito longa. Para representar o número decimal 20.000 em termos binários, são necessários 16 dígitos: 0b100111000100000. Para corrigir esse problema, é conveniente usar um sistema de números hexadecimais, também conhecido como base 16 (ou hex). Na base 16, cada dígito contém 16 valores. Para valores de zero a nove, os mesmos caracteres são usados ​​na base 10, mas para os 6 valores restantes, as substituições são usadas na forma das 6 primeiras letras do alfabeto, de A a F.

A conta no sistema hexadecimal é realizada da seguinte forma: 0 1 2 3 4 5 6 7 8 9 ABCDEF 10 11 12, etc. No hexadecimal, dezenas, centenas e assim por diante não têm o mesmo significado que no decimal; portanto, as pessoas pronunciam números separadamente. Por exemplo, $ A57 é pronunciado em voz alta como "A-cinco-sete". Para maior clareza, você também pode adicionar hexadecimal, por exemplo, "A-cinco-sete-hex". No sistema numérico hexadecimal, o equivalente ao número decimal 20.000 é $ 4E20 - uma forma muito mais compacta em comparação com 16 bits do sistema binário.

Eu acho que o sistema hexadecimal foi escolhido devido a uma conversão muito natural de binário para hexadecimal e vice-versa. Cada dígito hexadecimal corresponde a 4 bits (4 bits) de um número binário semelhante. 2 dígitos hexadecimais compõem um byte (8 bits). Um único dígito hexadecimal pode ser chamado de nibble, e algumas pessoas até escrevem através de y como "nybble".

Cada dígito hexadecimal é de 4 dígitos binários
HexUm57
Binário101001010111

Ao escrever o código C, acredita-se que o número seja decimal (base 10), a menos que esteja marcado de outra forma. Para dizer ao compilador C que o número é binário, adicionamos o número zero e a letra b em letras minúsculas, assim: 0b1101101 . O número hexadecimal pode ser escrito no código C adicionando no início de zero 0xA57 em letras minúsculas: 0xA57 . Algumas linguagens assembly usam o cifrão $: $A57 para indicar um número hexadecimal.

Se você pensar bem, a conexão entre números binários, hexadecimais e decimais é bastante óbvia, mas para o primeiro engenheiro, que pensara nisso antes da invenção do computador, isso deveria ter se tornado um momento de insight.

Entendeu tudo isso? Ótimo.

Uma breve introdução ao processador


Se você já sabe disso, pode pular com segurança a seção.

Uma unidade central de processamento (CPU) é uma máquina projetada para executar programas. Os blocos fundamentais da CPU são registros e instruções. Como desenvolvedor de software, você pode tratar esses registros como variáveis. Em nosso processador 8080, entre outros registros, existem registros de 8 bits chamados A, B, C, D e E. Esses registros podem ser interpretados como o seguinte código C:

 unsigned char A, B, C, D, E; 

Todos os processadores também possuem um contador de programa (contador de programa, PC). Você pode tomá-lo como um ponteiro.

 unsigned char* pc; 

Para uma CPU, um programa é uma sequência de números hexadecimais. Cada instrução de linguagem assembly em 8080 corresponde a 1-3 bytes no programa. Para descobrir qual comando corresponde a qual número, o manual do processador (ou qualquer outra informação sobre o processador 8080 da Internet) é útil.

Os nomes dos comandos (instruções) geralmente são mnemônicos das operações executadas por esses comandos. O mnemônico para carregamento no 8080 é MOV (movimento) e o ADD é usado para executar a adição.

Exemplos


O valor atual da memória indicado pelo contador de instruções é 0x79. Isso está de acordo com a instrução MOV A,C processador 8080. Esse código de montagem no código C se parece com A=C; .

Se, em vez disso, o valor no PC for 0x80, o processador executará ADD B Em C, isso corresponde à string A = A + B; .

Uma lista completa das instruções do processador 8080 pode ser encontrada aqui . Para implementar nosso emulador, usaremos essas informações.

Horários


Na CPU, a execução de cada instrução requer uma certa quantidade de tempo (tempo), medido em ciclos. Nos processadores modernos, essas informações podem ser difíceis de obter, porque os tempos dependem de muitos aspectos diferentes. Porém, em processadores mais antigos como o 8080, os tempos são constantes e essas informações são geralmente fornecidas pelo fabricante do processador. Por exemplo, uma instrução de transferência do registro para o registro MOV leva 1 ciclo.

As informações de tempo são úteis para escrever código eficiente no processador. Um programador pode tentar evitar instruções que levam muitos ciclos para serem concluídas.

Mais importante para nós é que usaremos informações de tempo para emular o processador. Para que o jogo funcione da mesma maneira que no original, as instruções devem ser executadas na velocidade correta. Alguns emuladores se esforçam muito para isso, mas quando chegarmos a isso, teremos que decidir qual precisão queremos obter.

Operações lógicas


Antes de fechar o tópico de números binários e hexadecimais, devemos falar sobre operações lógicas. Você provavelmente já está acostumado a usar lógica no seu código, por exemplo, em construções como if ((conditionA) and (conditionB)) . Nos programas que funcionam diretamente com o hardware, você geralmente precisa manipular bits individuais de números.

E operação


Aqui estão todos os resultados possíveis da operação AND (AND) (tabela verdade) entre dois números de um único bit.

xyResultado
0 00 00 0
0 010 0
10 00 0
111

O resultado de AND é igual à unidade somente quando ambos os valores são iguais à unidade. Quando combinamos dois números com a operação AND, AND para cada bit de um número é AND com o bit correspondente do outro número. O resultado é armazenado neste bit do número de destino. Provavelmente é melhor apenas ver um exemplo:

bináriohex
fonte x0 0110 010 011US $ 6 bilhões
fonte y110 010 00 010 0$ D2
x AND0 010 00 00 00 010 0$ 42

Em C, a operação AND lógica é um "e" comercial simples.

Operação OR (OR)


A operação OR funciona de maneira semelhante. A única diferença é que o resultado será igual a um se pelo menos um dos valores de x ou y for igual a um.

xyResultado
0 00 00 0
0 011
10 01
111

bináriohex
fonte x0 0110 010 011US $ 6 bilhões
fonte y110 010 00 010 0$ D2
x OU y111110 011$ Fb

Em C, uma operação lógica OU é indicada por uma barra vertical "|".

Por que isso é importante?


Em muitos processadores mais antigos, e especialmente em máquinas de fliperama, o jogo geralmente requer trabalho com apenas um bit do número. Muitas vezes, existe um código semelhante:

  /*  1:     */ char *buttons_ptr = (char *)0x2043; char buttons = *buttons_ptr; if (buttons & 0x4) HandleLeftButton(); /*  2:  LED-    */ char * LED_pointer = (char *) 0x2089; char led = *LED_pointer; led = led | 0x40; //,  LED   6 *LED_pointer = led; /*  3:   LED- */ char * LED_pointer = (char *) 0x2089; char led = *LED_pointer; led = led & 0xBF; //  6 *LED_pointer = led; 

No exemplo 1, o endereço $ 2043 alocado na memória é o endereço dos botões no painel de controle. Este código lê e responde ao botão pressionado. (Obviamente, no Space Invaders esse código estará em linguagem assembly!)

No exemplo 2, o jogo deseja acender um indicador LED, localizado no bit 6 do endereço de US $ 2089 alocado na memória. O código deve ler o valor existente, alterar apenas um bit e escrevê-lo novamente.

No exemplo 3, você precisa desativar o indicador do exemplo 2, portanto, o código deve redefinir o bit 6 do endereço $ 2089. Isso pode ser feito executando a operação AND para o byte de controle do indicador com um valor em que apenas o bit 6. é zero, portanto, afetamos apenas 6, mantendo os bits restantes inalterados.

Isso geralmente é chamado de "máscara". Em C, uma máscara geralmente é escrita usando o operador NOT, indicado por um til ("~"). Portanto, em vez de escrever 0xBF , eu apenas escrevo ~0x40 e obtenho o mesmo número, mas sem colocar muito esforço.

Introdução à linguagem assembly


Se você ler este tutorial, provavelmente está familiarizado com a programação de computadores, por exemplo, em Java ou Python. Esses idiomas permitem que você trabalhe bastante em apenas algumas linhas de código. O código é considerado habilmente escrito, se fizer o máximo de trabalho possível no menor número possível de linhas, possivelmente até usando a funcionalidade das bibliotecas internas. Esses idiomas são chamados de "idiomas de alto nível".

Em linguagem assembly, por outro lado, não há recursos internos para salvar vidas, e muitas linhas simples de código podem ser necessárias para concluir tarefas simples. A linguagem assembly é considerada uma linguagem de baixo nível. Nele, você precisa se acostumar a pensar no estilo de "que sequência específica de etapas deve ser executada para concluir esta tarefa?"

A coisa mais importante que você precisa saber sobre o idioma do assembler é que cada linha é traduzida em um comando do processador.

Considere essa construção da linguagem C:

 int a = b + 100; 

Na linguagem assembly, esta tarefa deverá ser executada na seguinte sequência:

  1. Carrega o endereço da variável B no registrador 1
  2. Carregue o conteúdo deste endereço de memória no registro 2
  3. Adicione valor direto 0x64 ao registro 2
  4. Carregue o endereço da variável A no registro 1
  5. Escreva o conteúdo do registro 2 no endereço armazenado no registro 1

No código, será algo parecido com isto:

  lea a1, #$1000 ;   a lea a2, #$1008 ;   b move.l d0,(a2) add.l d0, #$64 mov (a1),d0 

Vale ressaltar o seguinte:

  • Em uma linguagem de alto nível, o compilador decide onde colocar as variáveis ​​na memória. Ao escrever código no assembler, você é responsável por cada endereço de memória que usará.
  • Na maioria das linguagens assembly, colchetes significam "memória neste endereço".
  • Na maioria das linguagens assembler, # denota um número algébrico, também chamado de valor imediato. Por exemplo, na linha 1 do exemplo acima, o código realmente grava o valor # 0x1000 no registro a1. Se o código parecer move.l a1, ($1000) , a1 receberá o conteúdo da memória no endereço 0x1000.
  • Cada processador possui sua própria linguagem assembly, e a transferência de código de um processador para outro pode ser difícil.
  • Esta não é uma linguagem de montagem de processador real, eu vim com ela como exemplo.

No entanto, há algo em comum entre programadores inteligentes de alto nível e assistentes de montagem. Os programadores do Assembler consideram uma honra concluir a tarefa da maneira mais eficiente possível e minimizar o número de instruções usadas. O código para máquinas de arcade geralmente é altamente otimizado e todos os sucos são extraídos de cada byte e ciclo extras.

Pilhas


Vamos falar um pouco mais sobre a linguagem assembly. Em qualquer programa de computador bastante complexo nas sub-rotinas assembler são usadas. A maioria das CPUs possui uma estrutura chamada pilha.

Imagine uma pilha na forma de uma pilha. Se precisarmos salvar um número, colocamos no topo da pilha. Quando precisamos trazê-lo de volta, tiramos do topo da pilha. Os programadores de assembler chamam de "empurrar" o número da pilha e o de pop-up é chamado de "pop".

Digamos que meu programa precise chamar uma sub-rotina. Eu posso escrever código semelhante:

  0x1000 move.l (sp), d0 ;  d0   0x1004 add.l sp, #4 ;     0x1008 move.l (sp), d1 ;  d1   0x1010 add.l sp, #4 ;  .. 0x1014 move.l (sp), a0 0x1018 add.l sp, #4 0x101C move.l (sp), a1 0x1020 add.l sp, #4 0x1024 move.l (sp), #0x1030 ;   0x1028 add.l sp, #4 0x102C jmp #0x2040 ;   - 0x2040 0x1030 move.l a1, (sp) ;    0x1034 sub.l sp, #4 ;    0x1038 move.l a0, (sp) ;    0x103c sub.l sp, #4  .. 

O código mostrado acima coloca os valores d0, d1, a0 e a1 na pilha. A maioria dos processadores usa um ponteiro de pilha. Este pode ser um registro regular, por convenção, usado como ponteiro de pilha ou um registro especial com funções para determinadas instruções.

Nos processadores da série 68K, o ponteiro da pilha é determinado apenas por convenção; caso contrário, é um registro regular. No nosso processador 8080, o registro SP é um registro especial. Possui comandos PUSH e POP que gravam e saem da pilha em apenas um comando.

Em nosso projeto emulador, não escreveremos código do zero. Mas se você precisar analisar programas em linguagem assembly, é bom aprender a reconhecer essas construções.

Linguagens de alto nível


Ao gravar um programa em um idioma de alto nível, todas as operações de salvamento e restauração de registros são executadas a cada chamada de função. Não pensamos neles, porque o compilador lida com eles. As chamadas de função em um idioma de alto nível podem consumir muita memória e tempo do processador.

Você já experimentou um travamento de programa ao chamar uma sub-rotina em um loop infinito? Isso pode acontecer porque cada chamada de função colocou valores de registro na pilha e, em algum momento, a memória ficou sem pilha. (Se a pilha ficar muito grande, isso será chamado de estouro de pilha ou estouro de pilha).

Você pode ter ouvido falar de funções em linha. Eles evitam salvar e restaurar registros incluindo o código de rotina na função de chamada. O código fica maior, mas, graças a isso, vários comandos e operações de leitura / gravação na memória são salvos.

Convenções de chamada


Ao escrever um programa assembler que chama apenas seu código, você pode decidir por si mesmo como as rotinas se comunicarão. Por exemplo, como faço para retornar à função de chamada após a conclusão da rotina? Uma maneira é escrever o endereço de retorno em um registro específico. O outro é colocar o endereço de retorno no topo da pilha. Muitas vezes, a decisão depende do que o processador suporta. O 8080 possui um comando CALL que envia o endereço de retorno de uma função para a pilha. Talvez você use este comando 8080 para implementar chamadas de sub-rotina.

Mais uma decisão precisa ser tomada. A preservação do registro é de responsabilidade da função ou sub-rotina de chamada? No exemplo acima, os registradores são armazenados pela função de chamada. Mas e se tivermos 32 registros? Salvar e restaurar 32 registros quando uma rotina usa apenas uma pequena fração deles será uma perda de tempo.

O trade-off pode ser uma abordagem mista. Suponha que escolhemos uma política na qual uma rotina possa usar os registradores r10-r32 sem salvar seu conteúdo, mas não possa destruir r1-r9. Em uma situação semelhante, a função de chamada sabe o seguinte:

  • Ao retornar de uma função, o conteúdo de r1-r9 permanecerá inalterado
  • Não posso depender do conteúdo de r10-r32
  • Se eu precisar de um valor em r10-r32 depois de chamar uma sub-rotina, antes de chamá-lo, preciso salvá-lo em algum lugar

Da mesma forma, cada rotina sabe o seguinte:

  • Eu posso destruir r10-r32
  • Se eu quiser usar r1-r9, preciso salvar o conteúdo e restaurá-lo antes de retornar à função que me chamou

Abi


Na maioria das plataformas modernas, essas políticas são criadas por engenheiros e publicadas em documentos chamados ABI (Application Binary Interface). Graças a este documento, os criadores do compilador sabem como compilar código que pode chamar código compilado por outros compiladores. Se você deseja escrever um código assembler que possa funcionar em um ambiente assim, precisará conhecer a ABI e escrever um código de acordo com ele.

Conhecer a ABI também ajuda na depuração do código quando você não tem acesso à fonte. A ABI define a localização dos parâmetros para funções, portanto, ao considerar qualquer subprograma, você pode examinar esses endereços para entender o que é passado para as funções.

Voltar ao emulador


A maioria dos códigos de montagem escritos à mão, especialmente para processadores mais antigos e jogos arcade, não segue a ABI. Os programas são montados e podem não ter muitas rotinas. Cada rotina salva e restaura registros apenas em caso de emergência.

Se você quiser entender o que o programa faz, seria bom começar marcando os endereços direcionados aos comandos CALL.

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


All Articles