Ir para o conteúdo

FIXME Rever todo o artigo de forma a que se encaixe neste wiki segundo as suas regras. Se possivel a revisão deve ser feita pelos autores.

Tutorial da linguagem Assembly

Autor: João Matos

Licença: GNU Free Documentation License

Agradecimentos: TheDark, pela revisão de parte do texto

Este tutorial é baseado em textos de artigos da Wikipedia (http://www.wikipedia.org/) e do livro Programming from the Ground Up (http://savannah.nongnu.org/projects/pgubook/), entre outros.

Revisão:

  • versão 0.1 - 27/03/2007
  • versão 0.2 - 24/06/2007
  • versão 0.3 - 03/07/2007

Bem, como não encontrei nada de jeito em português sobre programação Assembly em arquitecturas x86 em ambientes GNU/Linux, decidi escrever um pequeno tutorial para explicar as bases as quem se quer iniciar nesta fabulosa linguagem.

Introdução

Este tutorial pretende ensinar os básicos de programação em linguagem Assembly para processadores x86 em ambientes GNU/Linux.

Para quem não está familiarizado com GNU/Linux, é um sistema operativo modelado no UNIX. A parte GNU refere-se ao projecto GNU (GNU's Not Unix, http://www.gnu.org/), iniciado em 1983 por Richard Stallman, com o objectivo de criar um sistema operativo livre. Em 1991/1992, o projecto GNU já tinha desenvolvido a maior parte das aplicações essenciais para criar um sistema operativo livre, faltando o kernel (núcleo do sistema). Neste momento surge o Linux, um kernel baseado no Unix, desenvolvido por Linus Torvalds, um estudante finlandês. Com a integração dos dois projectos, surge o GNU/Linux, um sistema operativo livre e de código-fonte aberto.

O kernel é o componente principal de um sistema operativo, responsável por gerir os recursos do computador e a comunicação entre o hardware e o software. Também funciona como uma camada de abstracção para os componentes/periféricos do computador (por exemplo: a memória, processador, e dispositivos de I/O). Geralmente o sistema operativo disponibiliza estes recursos através de mecanismos de comunicação entre processos e chamadas de sistema (system calls).

No que toca às linguagens de programação, podemos considerar três categorias:

  1. Código máquina
  2. Linguagens de baixo nível
  3. Linguagens de alto nível

A linguagem Assembly é uma linguagem de baixo nível constituída por um conjunto de mnemónicas e abreviações. Em comparação com código máquina (uma série de números em formato binário), Assembly torna as instruções mais fáceis de lembrar facilitando a vida ao programador.

O uso da linguagem Assembly já data da década de 1950, sendo nessa altura uma linguagem bastante popular. Actualmente, com a evolução das linguagens de alto nível, é usada maioritariamente no desenvolvimento de drivers, sistemas integrados e na área de "reverse engineering" (como a maior parte dos programas só estão disponíveis num executável binário ou código máquina, é muito mais fácil traduzi-los para linguagem Assembly do que para linguagens de alto nível - este processo designa-se por disassembly).

O código-fonte de um programa em linguagem Assembly está directamente relacionado com a arquitectura específica do processador alvo - ao contrário das linguagens de alto nível, que são geralmente independentes da plataforma, bastando recompilar o código para o executar numa arquitectura diferente.

A linguagem Assembly é traduzida para código máquina através de um programa chamado assembler. Um assembler é diferente de um compilador na medida que traduz as mnemónicas uma-a-uma para instruções em código máquina, enquanto um compilador traduz as instruções por blocos de código.

Antes de executar o código máquina gerado pelo assembler, temos de fazer a "linkagem" do executável. Este processo é realizado pelo linker, que basicamente substitui símbolos presentes no código do programa pelos locais concretos onde esses residem. Imaginem que é chamada uma função no código: o linker substitui essa referência pelo local em algum ficheiro onde o código da função se encontra (exemplo: função getline -> "módulo iosys - 123 bytes a partir do início").

Apresentados alguns pormenores desta linguagem, passamos à instalação das ferramentas necessárias:

Assembler

Existem muitos assemblers disponíveis, destacam-se:

Este último é grátis, multi-plataforma e o código-fonte está disponível gratuitamente. O código-fonte apresentado neste tutorial foi desenvolvido para o NASM, logo recomendo que o usem. Atenção que a sintaxe pode ser diferente entre diferentes assemblers (existem 2 tipos genéricos de sintaxe: AT&T e Intel), logo um código para um determinado assembler pode não funcionar directamente noutro.

Linker

No que toca a linkers, não existem tantas opções como na categoria dos assemblers. O linker que vai ser usado é o ld, que vem com o pacote binutils do projecto GNU (http://www.gnu.org/software/binutils/) . Outra alternativa é o alink (http://alink.sourceforge.net/).

Editor

Podem usar qualquer editor de texto. As escolhas mais populares em ambientes GNU/Linux são o vi/vim, emacs, e pico/ed/nano. Caso não se sintam à vontade a editar o código na consola (shell), também podem usar um editor de texto com interface gráfica, como o gEdit, Geany, etc.

Caso o vosso sistema não tenha os pacotes instalados, procurem na documentação da vossa distribuição como o fazer.

Arquitectura do computador

Antes de começarmos a programar em Assembly, temos de aprender os conceitos básicos do funcionamento interno de um computador.

A arquitectura dos computadores modernos é baseada da arquitectura Von Neumann, seguindo o nome do seu criador. Este arquitectura divide o computador em duas partes principais: o processador(CPU - Central Processing Unit) e a memória. Este arquitectura é usada em todos os computadores modernos, incluíndo os computadores pessoais, super computadores, mainframes, consolas de jogos e até mesmo telemóveis.

Estrutura da memória do computador

A memória do computador é o espaço onde estão armazenados todos os dados do computador. Este espaço tem um tamanho fixo e os dados podem ser acedidos através de endereços. Por exemplo, imaginem que têm 128MB de RAM no computador. Isto corresponde a 131072 kilobytes, ou 134217728 bytes. Neste caso, estão disponíveis 134217728 posições de armazenamento diferentes do tamanho de um byte. Não esquecer que o computador começa a contar no 0, logo os endereços de memória disponíveis neste caso começam no 0 e acabam em 134217727.

Processador

O processador é o componente do computador que interpreta e executa as instruções dos programas e processa os dados. Este é constituído por vários sub-sistemas, dos quais se destacam a ALU (Arithmetic Logic Unit) - responsável por todas as operações aritméticas (ex. adição e subtracção) e lógicas (ex. AND, XOR, OR); FPU (Floating Point Unit) - equivalente ao ALU, mas para números decimais; os registos - zona de armazenamento ultra-rápida, utilizada pelo processador para acelerar a execução dos programas permitindo acesso aos valores utilizados mais frequentemente.

Existe um número limitado de operações, sendo o conjunto de todas essas operações e das suas variações designado por ISA (Instruction Set Architecture). Existem diferentes conjuntos de instruções, mas consideram-se duas categorias: RISC (Reduced Instruction Set Computer, ex. MIPS) e CISC (Complex Instruction Set Computer, ex. x86).

Este tutorial vai abordar o conjunto de instruções base x86 (este surgiu pela primeira vez em 1978 no processador Intel 8086). Ao longo dos anos têm sido feitas extensões a este conjunto de instruções, tais como o MMX, 3DNow!, SSE, SSE2 e SSE3.

Todos os processadores com base na arquitectura von Neumann funcionam com base num ciclo constitúido por 3 passos essenciais: fetch, decode, execute.

No primeiro passo o processador obtém a próxima instrução a executar a partir da posição contida no registo PC, que armazena a posição actual da memória do programa; no segundo passo, o processador divide a instrução (em código máquina) em secções: uma com o opcode da operação a executar (Operation Code) e as outras com dados complementares para realizar a operação; no terceiro passo a operação é executada.

MIPS32 Add Immediate Instruction

Modos de endereçamento de memória

  1. Endereçamento por valor imediato (immediate address mode)
  2. Endereçamento de registo (register address mode)
  3. Endereçamento directo (direct addressing mode)
  4. Endereçamento por index (indexed addressing mode)
  5. Endereçamento indirecto (indirect adressing mode)
  6. Endereçamento por ponteiro base (base pointer addressing mode)

No primeiro caso, atribuímos o valor directamente. Por exemplo, se quisermos inicializar um registo para 0, introduzimos directamente o valor 0, em vez de darmos um endereço para o processador ler o valor 0.

No modo de endereçamento de registo, a instrução contém o registo de onde deve obter o valor, em vez de uma localização na memória.

No modo de endereçamento directo, a instrução contém o endereço da memória que contém o valor. Por exemplo, podemos pedir ao processador para copiar um valor num determinado endereço da memória para o registo do processador.

No modo de endereçamento por index, a instrução contém um endereço de memória para aceder e um index, que funciona como um offset. Por exemplo, se utilizarmos o endereço 1390 e um index de 10, o valor lido vai ser o da localização 1400. Nos processadores de arquitectura x86, ainda podemos especificar um multiplicador para o index. Isto permite aceder blocos de um determinado tamanho.

No modo de endereçamento indirecto, a instrução contém um registo que por sua vez contém um ponteiro para um endereço da memória onde a data deve ser obtida. Por exemplo, imaginemos que o registo eax está populado com o valor 10. Se estivermos a usar este modo de endereçamento indirecto, e pedissemos o valor indirecto do registo eax, obteríamos o valor que estivesse na posição 10 da memória.

Finalmente, o modo de endereçamento por ponteiro base funciona de forma semelhante ao modo de endereçamento indirecto, mas é permitido especificar um index tal como no modo de endereçamento por index.

Chamadas ao sistema (system calls)

Quase todos os programas precisam de lidar com várias operações de entrada e saída de dados, controlo de pastas e ficheiros, obter detalhes do sistema, ou seja, interagir com o sistema operativo chamando as suas APIs (Application Programming Interface). Essas operações são efectuadas com recurso ao kernel, usando um mecanismo de chamadas ao sistema (system calls), através de um processo designado por interrupção.

Basicamente, quando o processador encontra uma instrução de interrupção, faz uma chamada ao kernel que executa a operação pedida. Acabada a operação, o kernel volta a ceder o controlo do processador ao programa, retornando ainda um código, possibilitando ao programa saber informação sobre o resultado da operação (exemplo: se um directório foi criado com sucesso, se os dados foram escritos correctamente num determinado ficheiro, etc.).

Este processo é efectuado com a instrução "int", estando o número do serviço no registo eax do processador. Dependendo de cada chamada, são necessários outros dados presentes noutros registos do processador, por exemplo, na chamada de saída (exit), que permite ao programa acabar a sua execução, o código de retorno para a consola é obtido no registo ebx.

Primeiro programa

Para começar vamos criar um programa que apenas retorna o código de saída para a consola de execução, de forma a demonstrar como se executam as chamadas ao sistema.

section .text     ; inicio da seccao de texto 
    global _start ; onde deve comecar a execucao

_start:           ; label start - a execucao comeca aqui
    mov eax, 1    ; move o valor 1 para o registo eax
    mov ebx, 0    ; move o valor 0 para o registo ebx
    int 0x80      ; chamada de sistema para o kernel

Assembling e linking

Os comandos para assemblar o ficheiro de código-fonte num ficheiro objecto é o seguinte: nasm -f elf <codigo.asm>

Se forem detectados alguns erros durante o processo, o NASM fará o output para consola dos erros e das linhas onde ocorreram.

O próximo passo é a linkagem, que pode ser feita com o seguinte comando: ld -s -o <codigo> <codigo.o>

Por fim executem o programa: ./<ficheiro>

O programa deve ter terminado sem qualquer erro, para verem o código de saída com que o programa retornou: echo $?.

Nota: Por norma, usa-se a extensão .asm para código-fonte em Assembly.


Agora que executámos o nosso primeiro programa em Assembly, vamos dissecá-lo e perceber como funciona.

Na linguagem Assembly, cada instrução está associada a uma linha distinta.

A primeira linha do nosso programa inicia a secção de texto (section .text).

Em Assembly podemos considerar três secções lógicas que dividem um programa: text, onde se encontram as instruções que vão ser executadas pelo processador; data, onde definimos constantes, como nomes de ficheiros e buffers — esta data não é modificada durante a execução. A outra secção é a bbs, sendo nesta secção que declaramos as variáveis e reservamos memória para os dados e estruturas que sejam precisas durante a execução da programação.

Seguidamente a instrução global _start diz ao assembler que o ponto de início de execução do programa é uma label chamada _start. Por convenção, usa-se _start em todos os programas desenvolvidos no ambiente GNU/Linux.

Na linha seguinte, é declarada uma label, de nome _start. Uma label é um conjunto de instruções. Quando se chama uma label para execução, as intruções desta são executadas sequencialmente pela ordem que aparecem no código.

Neste caso, a primeira instrução a ser realizada é a mov eax, 1. O que esta execução faz é mover o valor 1 para o registo eax do processador. Em todas as operações da sintaxe Intel, o primeiro operando corresponde ao local de destino, e o segundo ao valor inicial.

Nota: Na sintaxe AT&T, a ordem dos operandos é inversa.

Nas linhas seguintes movemos o valor 0 para o registo ebx do processador, e fazemos uma chamada ao sistema com a instrução int 0x80 (abreviatura de interrupt).

Como estamos a chamar o serviço exit do sistema (valor 1 no registo eax), o programa retorna à consola com o valor no registo ebx. Se experimentarem alterar este valor no código-fonte, e voltarem a correr o programa, podem ver que o valor que o programa retorna para a consola é diferente.

Nota: Não utilizar valores superiores a 255 (o valor máximo de um unsigned byte) ou podem ocorrer problemas de overflow. Ao ultrapassar o valor máximo que o byte permite, o comportamento do sistema pode ser inesperado. No meu caso, ao utilizar 266, o valor retornado foi de 10 (266-255).

De seguida o clássico Hello World:

section .data
    msg    db    "Hello World!",0x0a    ; string hello world
    len    equ    $-msg                 ; calcula o tamanho da string msg

section .text       ; inicio da seccao de texto
    global _start   ; onde deve comecar a execucao

_start:             ; label start - a execucao comeca aqui
    ; write
    mov ebx, 1      ; ficheiro de saida - stdin
    mov ecx, msg    ; apontador para o buffer
    mov edx, len    ; tamanho do buffer
    mov eax, 4      ; chamada write ao sistema
    int 0x80

    ; exit
    mov eax, 1      ; move o valor 1 para o registo eax
    mov ebx, 0      ; move o valor 0 para o registo ebx
    int 0x80        ; chamada de sistema para a kernel