Assembly

Last updated 3 months ago

Agora que você já sabe como um binário PE é construído, está na hora de entender como o código contido em suas seções de código de fato executa. Acontece que um processador é programado em sua fábrica para entender determinadas sequências de bytes como código e executar alguma operação. Para entender isso, vamos fazer uma analogia com um componente muito mais simples que um processador, um circuito integrado (popularmente chamado de chip).

Um circuito integrado (CI) bastante conhecido no mundo da eletrônica é o 7400 que tem o seguinte diagrama esquemático:

Se você já estudou portas lógicas, vai perceber que este CI tem 4 portas NAND (AND com saída negada). Cada porta possui duas entradas e uma saída, cada uma delas conectada a seu respectivo pino/perna.

Admitindo duas entradas (A e B) e uma saída S, a tabela verdade de cada uma das portas deste CI é a seguinte:

A

B

A & B

S

0

0

0

1

1

0

0

1

0

1

0

1

1

1

1

0

Podemos dizer então que este CI faz uma única operação sempre, com as entradas de dados que recebe.

Se seu projeto precisasse também de portas OR, XOR, AND, etc você precisaria comprar outros circuitos integrados, certo? Bem, uma solução inteligente seria utilizar um chip que fosse programável. Dessa forma, você o configuraria, via software, para atuar em certos pinos como porta NAND, outros como porta OR e por aí vai, de acordo com sua necessidade. Aumentando ainda mais a complexidade, temos os microcontroladores, que podem ser programados em linguagens de alto nível, contando com recursos como repetições, condicionais, e tudo que uma linguagem de programação completa oferece. No entanto, estes chips requerem uma reprogramação a cada mudança no projeto, assim como se faz com o Arduino hoje em dia.

Neste sentido um microprocessador, ou simplesmente processador é muito mais poderoso. Ao invés de o usuário gravar um programa nele, o próprio fabricante já o faz, de modo que este microprograma entenda diferentes instruções para realizar diferentes operações de muito alto nível (se comparadas às simples operações booleanas). Sua entrada de dados também é muito mais flexível: ao invés de entradas binárias, um processador pode receber números inteiros, como o antigo Intel 8088, que já possuía um barramento de 8 bits.

Isso significa que se um processador receber em seu barramento um conjunto de bytes específico, sabe que vai precisar executar uma operação específica. À estes bytes possíveis damos o nome de opcodes. Ao conjunto dos opcodes + operandos damos o nome de instrução.

Supondo que queiramos então fazer uma operação OR entre os valores 0x20 e 0x18 utilizando um processador x86. Na documentação deste processador, constam as seguintes informações:

  • Ao receber o opcode 0xb8, os próximos quatro bytes serão um número salvo em memória (similar àquela memória M+ de calculadora), acessível através do byte identificador 0xc8.

  • Ao receber o opcode 0x83, seguido de um byte 0xc8 (que identifica a memória), seguido de mais um byte, o número armazenado na memória identificada pelo segundo byte vai sofrer uma operação OR com este terceiro byte.

Precisaríamos então utilizar as duas instruções, da seguinte forma:

B8 20 00 00 00
83 C8 18

Na primeira, que tem um total de 5 bytes, o opcode 0xb8 é utilizado para colocar o número de 32-bits (4 bytes) na sequência em memória. Como nosso número desejado possui somente 1 byte, preenchemos os outros três com zero, respeitando o endianess.

A segunda instrução tem 3 bytes sendo que o primeiro é o opcode dela (OR), o segundo é o identificador da área de memória e terceiro é o nosso operando 0x18, que deve sofrer o OR com o valor na área de memória indicada por C8.

Temos que concordar que criar um programa assim não é nada fácil. Para resolver este problema foi criada uma linguagem de programação, completamente presa à arquitetura do processador (seus opcodes, suas instruções), chamada Assembly. Com ela, os programadores poderiam escrever o programa acima praticamente em inglês:

MOV EAX, 20
OR EAX, 18

De posse de um compilador de Assembly, chamado na época de assembler, o resultado da compilação do código-fonte acima é justamente um arquivo (objeto) que contém os opcodes e argumentos corretos para o processador alvo, onde o programa vai rodar.

Agora você sabe o motivo pelo qual um programa compilado não é compatível entre diferentes processadores, de diferentes arquiteturas. Como estes possuem instruções diferentes e opcodes diferentes, não há mesmo compatibilidade.

Perceba que Assembly é uma linguagem legível para humanos, diferente da linguagem de máquina que não passa de uma tripa de bytes. Os comandos da linguagem Assembly são chamados de mnemônicos. No exemplo de código acima, utilizamos dois: o MOV e o OR. Estudaremos mais mnemônicos de instruções em breve.