20 de janeiro de 2013

Um pouco sobre Assembly

Quando você começa a programar, a fazer algoritmos simples, você tende a querer otimizar eles ao máximo, por gosto pessoal, até por que isso não é tão difícil. Os professores incentivam isso, gostam de ver seus alunos programando melhor, no entanto, as vezes, chega-se a um limite, onde será necessário procurar outras abordagens para deixar o programa mais leve.

Outra maneira seria utilizar uma linguagem de nível mais baixo, que, ao menos na teoria, deve ser mais rápida. No caso da linguagem Assembly, que pode diminuir os acessos a memória e tornar as coisas mais diretas. No entanto, sua complexidade é extremamente elevada, o que desestimula seu uso, deixando-a apenas para pequenos pedaços ou programas para microcontroladores.

Filme Exterminador do Futuro, a linguagem do lado direito é Assembly de Apple II (fonte) com o processador MOS 6502
Agora o Assembly!

Esse artigo tem um nível um pouco elevado, você deve saber programação de alto nível e ter uma noção de como um processador funciona. O Assembly está intimamente relacionado com a arquitetura do processador, pois acessa diretamente seus elementos.

Um dos elementos principais do processador são os registradores, podemos considerá-los, de certa maneira, como as variáveis das linguagens de alto nível. O processador os acessa para pegar os dados para uma operação, como vemos abaixo, além da variação da sintaxe dependendo da arquitetura:

Em x86 (arquitetura utilizada em computadores), a instrução de soma, é escrita como:

ADD registrador fonte, registrador destino

que seria equivalente a destino = fonte + destino, uma máquina de 2 endereços, já em Assembly de ARM seria:

ADD primeiro registrador, segundo registrador, registrador destino

que seria equivalente a destino = primeiro + segundo, uma máquina de 3 endereços.

Existem dois tipos de arquiteturas (na verdade mais, só que são casos restritos) a CISC como os processadores x86 e 68k (no qual aprendi Assembly) e a RISC, presente nos ARM e PowerPC. Na teoria a diferença é que a CISC conta com mais instruções e mais formas de acesso a memória, mas na pratica a diferença não é tão visível.

Memória

Memória RAM (fonte)
Como você já viu, em Assembly, você referencia registradores, dependendo da arquitetura pode referenciar endereços de memória, conteúdo de um endereço de memória presente no registrador. Exemplificando o último, suponha que no registrador d1 exista o valor 0x00000016 e que na posição de memória 0x00000016 exista o valor 1, se você usar esse método, o valor lido será 1, se você usar o valor do registrador será 0x16 (isso é hexadecimal). Existem diversas maneiras de acessar memória, isso poderia ser o conteúdo de uma matéria inteira de faculdade.

Numa mesma arquitetura pode existir instruções que só suportam alguns tipos de acesso. Por exemplo, na arquitetura Coldfire da Freescale existem algumas instruções que suportam o operador imediato ("move #1, 0x00000010", copia 1 para o endereço de memória 0x00000010), em outras instruções é necessário copiar o valor imediato para um registrador, para posteriormente usá-lo na instrução.

Em arquitetura RISC, a tendencia é limitar os modos de acesso a memória, em ARM, por exemplo, só é possível referenciar registradores em operações de soma, subtração. Dependendo da arquitetura também muda o número de registradores disponíveis, por exemplo em ARM são 16, em x86 são 8, em 68k são 8 de dados e 8 de memória.

Com as diferenças nos tipo de endereçamento, nas instruções suportados e no número de registradores, fazer um programa desenvolvido em Assembly de um processador funcionar em outro é extremamente complicado, teria que ser praticamente reescrito. Por esse motivo foi desenvolvida a linguagem C, que pode ser portada sem grandes problemas de uma arquitetura a outra.

Tipos de variáveis

Quando programamos em linguagens de alto nível, existe a preocupação de declarar as variável (ok, em algumas não é necessário), é necessário tomar cuidado para não colocar valores maiores do que a variável suporta, para evitar um overflow. Se você acessa uma variável de maneira errada (dependendo da linguagem) ela irá te avisar. Em assembly isso é um pouco mais complexo. Não existe tipos de variável, tudo são bits, se você quiser trabalhar com caracteres, você deve trabalhar com os bits em código ASCII.

Então, como vou definir o tamanho das variáveis? Tudo vai depender da instrução que você utilizar, se eu quero somar dois números de 8 bit, eu usaria ADD.B (b de byte), seria ADD.W (word) para 16 bit e ADD.L para 32 bit (esses valores podem mudar dependendo da arquitetura). Temos que tomar muito cuidado com isso, pois caso tentemos colocar um word no lugar de um byte, ele irá apagar o byte que veio em sequencia do anterior.

Como realmente funciona o IF?

if? (fonte)
Sempre tive curiosidade de saber como o processador pode decidir se "a" é maior ou menor que 0. Primeiramente é necessário entender que o processador tem uma unidade responsável por fazer cálculos matemáticos, chamada de ULA (unidade lógica aritmética). Quando você faz uma conta nela, o processador manda os dados da onde você quer, geralmente dos registradores, e depois envia para algum registrador, só que durante a conta, algumas saídas da ULA são mudadas.

Elas são armazenadas em um registrador (conhecido como registrador de status da ULA) e indicam, entre outras coisas, se a conta que foi feita resultou em um numero negativo ou se resultou em 0. Cada arquitetura tem um registrador de status da ULA diferente (mais um motivo para elas não serem compatíveis).

Assim você pode usar uma instrução como a sub (de subtração, ou uma apenas para a ULA fazer a operação, mas não salvar o resultado, apenas o status dela) e depois verificar o status. As instruções para fazer isso são conhecidas como branch, se o que elas verificam é verdade, ela faz o programa saltar para a posição indicada (isso já entraria em Assembly um pouco mais avançado).

Um simples exemplo para ver a complexidade do Assembly.

O código em C:

int a=0,b=1,c;
if (a>b){
          c=a+b;
          a=b;
          b=c;
} else {
          c=b-a;
          a=a+b;
          b=c;
}

Já em Assembly seria algo como, fazendo os registrados d1, d2 e d3 como as variáveis a, b e c, respectivamente:

          MOVE #0, d1
          MOVE #1, d2
          CMP d1,d2
          BGT P1 //Caso d1>d2, pula para P1
          BLE P2 //Caso d2>=d1, pula para P2
P1:     MOVE d2, d4
          ADD d1, d2
          MOVE d2, d3
          MOVE d4, d1
          MOVE d2, d3
          BRA FIM //Vai para o fim do programa
P2:     MOVE d1, d4
          SUB d2, d1
          MOVE d4, d1
          ADD d2, d1
          MOVE d3, d2
FIM:  HALT

É importante ressaltar que possivelmente um compilador C gerasse código bem diferente, usasse o if de outra maneira, colocasse diversos acessos a memória, fizesse alguma confusão com os registradores. Esse é um dos motivos que programar em Assembly diretamente é mais rápido, no entanto, mesmo em um programa bem simples já é bem complicado entender, imagine em projetos complexos.

Essas instruções são convertidas quase que diretamente para binário (existem alguns passos para adequar o endereçamento que não convém ao caso). Por exemplo, vamos pegar a arquitetura 68k, uma instrução "move d0, d6" seria convertida para o seguinte:


Agora você tem alguma noção de como o Assembly funciona, mas não é o suficiente para programar nada, nem o foco desse artigo era esse. Programei em diversas linguagem, que na realidade eram praticamente a mesma coisa, só mudando um pouco a sintaxe, agora com Assembly, você precisa mudar sua maneira de pensar. Se quiser se aprofundar um pouco no assunto, recomendo esse curso. Se procurar um pouco, pode achar ótimos cursos das mais diversas arquitetura em portais de universidades.

Também é importante destacar, que Assembly não é muito útil para fazer programas para desktop, já que o tempo ganho seria ínfimo talvez pequenas partes, que são muito repetitivas. A maior utilidade seriam microcontroladores que contam com pouquíssima memória, nesse caso você teria que aprender sobre as interfaces de comunicação do microcontrolador, os modos de interrupção, os timers, entre outras coisas.

Agora um bônus, uma curiosidade, até porque é algo bem complicado. Engenharia reversa de programas pode ser feita transformando o código de máquina em Assembly, que depois de algum trabalho pode ser transformado em linguagem C, esses interessantes 3 artigos chamados de "Reverse Engineering is no Rocket Science" tratam disso (Parte 1, 2 e 3).

Nenhum comentário: