Iniciação ao Assembly x86: Aspectos teóricos

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

Para quem não está familiarizado, 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 na arquitectura 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, o processador e os 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:

  • Código máquina
  • Linguagens de baixo nível
  • 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 em 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: GAS (GNU Assembler, http://www.gnu.org/software/binutils/), TASM (Borland Turbo Assembler, http://en.wikipedia.org/wiki/Turbo_Assembler), MASM (Microsoft Macro Assembler, http://masm32.com/), FASM (Flat Assembler, http://flatassembler.net/) e NASM (Netwide Assembler, http://nasm.sourceforge.net/). Este último é 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 assemblers (existem 2 tipos genéricos de sintaxe: AT&T e Intel), logo um código para um determinado assembler pode não funcionar 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 na arquitectura Von Neumann, seguindo o nome do seu criador. Esta arquitectura divide o computador em duas partes principais: o processador (CPUCentral Processing Unit) e a memória. Esta arquitectura é usada em todos os computadores modernos, incluindo 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:

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.
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 Architecture, ex. arquitectura MIPS) e CISC (Complex Instruction Set Architecture, ex. arquitectura 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 constituído por três 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.

Outro componente do processador são os registos. Os registos são como variáveis ultra-rápidas embutidas no processador. É nos registos que são armazenados todos os dados necessários para efectuar os cálculos ou outras operações realizadas pelo processador.

O problema da família x86 de processadores é que existem poucos registos disponíveis, o que leva a que o programador tenha de gerir bem os registos necessários para a sua aplicação.

Os registos são normalmente de 32 bits, mas também existem registos de 16 bits e de 8 bits. Por exemplo, tomando como base o registo AX de 16 bits, podemos considerar o registo EAX (Extended AX) de 32 bits, e os registos AH (AX Higher) e AL (AX Lower) que correspondem a dois registos de 8 bits.

Registos gerais

Tal como o nome indica estes são os registos usados a maior parte do tempo. A maioria das instruções tem como base estes registos.

EAX, AX, AH, AL (Accumulator register)
Normalmente usado para acesso I/O, aritmética, chamadas de sistema, etc.
EBX, BX, BH, BL (Base register)
É usado como um ponteiro base para o acesso à memória.
ECX, CX, CH, CL (Counter register)
É usado como contador de loops e para shifts.
EDX, DX, DH, DL (Data register)
Semelhante ao registo EAX.

Registos de segmento

CS (Code segment)
Armazena o segmento de código do programa.
DS (Data segment)
Armazena o segmento de dados do programa.
ES, FS, GS
Registos de segmentos adicionais para armazenamento de segmentos.
SS (Stack segment)
Armazena o segmento da stack do programa

Index e ponteiros

EDI (Destination index register)
Usado para cópia de strings e arrays de memória e para endereçamento de ponteiros em conjunto com o ESI.
ESI (Source index register)
Usado para cópia de strings e arrays de memória.
EBP (Stack Base pointer register)
Armazena o endereço da base da stack.
ESP (Stack pointer register)
Armazena o endereço do topo da stack.
EIP (Index Pointer)
Armazena o offset para a próxima instrução.
Indicador EFLAGS
Armazena o estado do processador

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 addressing 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 os dados devem ser obtidos. Por exemplo, imaginemos que o registo eax está populado com o valor 10. Se estivéssemos a usar este modo de endereçamento indirecto, e pedíssemos 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

O comando 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—estes dados não são modificados durante a execução; e bbs, onde declaramos as variáveis e reservamos memória para os dados e estruturas que sejam precisos durante a execução do programa.

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 instruçõ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 o kernel 

Primeiro declaramos a string Hello World usando db (declare byte). No fim da string usamos o caracter de representação hexadecimal 0x0a, mais conhecido por \n ou mudança de linha.

Na linha seguinte atribuímos a len o tamanho da string msg, usando para isso o equ. Para obter o valor do tamanho da string subtraímos a posição actual $ ao endereço inicial de msg.

No resto do programa usamos a chamada ao sistema write, para escrever a mensagem para a consola, e depois usamos o código do exemplo anterior para retornar do programa.

Esta foi a primeira parte deste artigo, saiu mais teórica do que o previsto. Assim ficam com as bases para aprender a maior parte dos conceitos mais complexos desta fabulosa linguagem. Estejam atentos às próximas edições para o próximo artigo que vai contar com muitos mais exemplos de código.

Publicado na edição 10 (PDF) da Revista PROGRAMAR.