Configurando o ambiente

  • Sistema Operacional: Linux
  • Compilador Assembly: NASM 2.11.05
  • Compilador C: GCC 4.9.2
  • GNU Make 4.0
  • GDB como depurador

Escrevendo “Hello, world”

Seguindo a ideologia Unix, “tudo é um arquivo”. Por meio de arquivos é possível abstrair operações como:

  • acesso a dados em disco rígido/SSD
  • troca de dados entre programas
  • interação com dispositivos externos

Esse programa simplesmente irá mostrar uma mensagem “Hello, world!” na tela. No entanto um programa como esse deve mostrar caracteres na tela, o que não pode ser feito se o programa não estiver executando direto no hardware, para isso é necessário o sistema operacional para gerenciar as atividades. O sistema operacional oferece uma série de rotinas para tratar a comunicação com dispositivos externos, outros programas, sistemas de arquivo, etc… Um programa não pode ignorar o sistema operacional e nem interagir diretamente com os recursos. O programa fica limitado às chamadas de sistema (system calls) que são rotinas oferecidas pelo sistema operacional para o usuário

O Unix identifica um arquivo pelo seu descritor assim que ele é aberto por um programa. Um arquivo é aberto por meio da chamada de sistema open. Outros 3 arquivos são abertos assim que um programa inicia, são eles: stdin, stdout e stderr. Seus descritores são 0, 1 e 2 respectivamente. stdin é usado para tratar a entrada, stdout para tratar a saída e stderr é usado para a saída de informações sobre o processo de execução do programa

Para executar o programa a mensagem deve ser escrita em stdout e para isso é necessário utilizar a chamada de sistema write. Ela escreve uma dada quantidade de bytes da memória, começando em um dado endereço, em um arquivo com um dado descritor (nesse caso 1)

código:

global _start

section .data
message: db 'hello, world!', 10

section .text
_start:
	mov rax, 1         ; O número da chamada do sistema que deve ser armazenado em rax
	mov rdi, 1         ; Onde escrever (descritor) - arg1
	mov rsi, message   ; Onde começa a string - arg2
	mov rdx, 14        ; Quantos bytes devem ser escritos - arg3
	syscall            ; Faz a chamada de sistema

Estrutura do programa

Há apenas uma memória tanto para o código quanto para os dados. Entretanto o programador pode querer separa-los. Um programa Assembly é dividido em seções. Cada seção tem uma finalidade: por exemplo, .text armazena instruções, .data é usada para variáveis globais.

Para não precisar usar os valores dos endereços, pode-se usar rótulos (labels). Esses são apenas nomes legíveis e endereços. Podem anteceder qualquer comando e, geralmente, estão separados por dois-pontos. Nesse programa tem um label o _start

Um programa Assembly pode ser dividido em vários arquivos. Um deles deve conter o label _start que marca a primeira instrução do programa e ele deve ser declarado como global

Os comentários começam com “;” e se estendem até o final da linha

A linguagem Assembly é feita de comandos, mas nem todas as construções da linguagem são comandos, algumas controlam o processo de tradução e normalmente são chamadas de diretivas. Nesse exemplo há três diretivas: global, section e db

A linguagem Assembly, em geral, não faz distinção entre letras maiúsculas e minúsculas, mas isso não vale para os nomes de labels. mov, mOV, Mov são o mesmo comando, porém global _start e global _START não são iguais. Os nomes de seção também distinguem letras maiúsculas de minúsculas

A diretiva db é usada para criar bytes de dados. Em geral os dados são definidos usando uma dessas diretivas:

  • db - bytes
  • dw - (words), correspondem a 2 bytes
  • dd - (double words), correspondem a 4 bytes
  • dq - (quad words), correspondem a 8 bytes
message: db 'hello, world!', 10

Letras, digitos e outros caracteres são codificados em ASCII. Na linha acima, começamos com o endereço do label “message” e então é armazenado cada código da tabela ASCII correspondente as letras da frase, e no final é adicionado um byte igual a 10, que representa o caractere especial de nova linha

Instruções básicas

A instrução mov é usada para escrever um valor no registrador ou memória. O valor pode ser obtido de outro registrador ou memória, ou pode ser um valor imediato. Entretanto:

  • mov não pode copiar dados da memória para a memória
  • os operandos de origem e destino devem ter o mesmo tamanho

A instrução syscall é usada para fazer chamadas de sistema (system calls) em sistemas *nix. Cada chamada de sistema tem um número único. Para executá-la:

  • O registrador rax deve armazenar o número da chamada de sistema
  • Os seguintes registradores devem armazenar seus argumentos: rdi, rsi, rdx, r10, r8 e r9 Uma chamada de sistema não pode aceitar mais do que seis argumentos
  • Executar a instrução syscall

No programa “hello world” foi utilizado a syscall write simples. Ela aceita:

  • Um descritor de arquivo
  • O endereço do buffer
  • A quantidade de bytes para escrever

Para compilar o programa foi utilizado a seguinte sequencia de comandos:

> nasm -felf64 hello.asm -o hello.o
> ld -o hello hello.o
> chmod u+x hello

Executando o programa:

A mensagem foi exibida, porém o programa acabou causando um erro. O problema é que não foi escrita nenhuma instrução após a syscall e o programa continuou executando lendo o “lixo” existente na memória

Para resolver esse problema é necessário utilizar a chamada de sistema exit que encerra o programa de forma correta

global _start

section .data
message: db 'hello, world!', 10

section .text
_start:
	mov rax, 1         ; O número da chamada do sistema que deve ser armazenado em rax
	mov rdi, 1         ; Onde escrever (descritor) - arg1
	mov rsi, message   ; Onde começa a string - arg2
	mov rdx, 14        ; Quantos bytes devem ser escritos - arg3
	syscall            ; Faz a chamada de sistema

	mov rax, 60        ; Número da syscall exit
	xor rdi, rdi       ; Valor que será retornado pela syscall
	syscall

Ao executar a instrução xor rdi, rdi o valor de rdi é zerado, e esse é o valor do argumento utilizado pela syscall exit, que será retornado pelo programa

Exibindo conteúdo de registrador

Esse programa vai exibir o conteúdo de rax em formato hexadecimal.

código:

section .data
codes: 
  db '0123456789ABCDEF'

section .text
global _start
_start:
  ; movendo 112233... para rax em formato hexadecimal
  mov rax, 0x1122334455667788
  ; argumentos da syscall write
  mov rdi, 1
  mov rdx, 1
  ; usado para controle do loop
  mov rcx, 64

.loop:
  ; como o rax eh usado pela syscall eh preciso salvar o valor
  push rax
  ; subtrai 4 do controle
  sub rcx, 4
  ; gera o indice para obter o valor de codes
  sar rax, cl
  and rax, 0xf

  lea rsi, [codes + rax]
  ; move para rax o valor da syscall
  mov rax, 1

  ; a syscall altera o valor de rcx e r10, entao precisa salvar o valor
  push rcx
  syscall
  pop rcx

  pop rax
  ; verifica se rcx eh igual a 0
  ; test eh uma instrucao mais rapida que cmp
  test rcx, rcx

  jnz .loop

  ; syscall exit
  mov rax, 60
  xor rdi, rdi
  syscall

Ao fazer o shifting do valor de rax e seu and lógico com a máscara 0xf, o número todo é transformado em apenas um de seus dígitos hexadecimais. Esse valor é usado como índice e depois é somado ao endereço do rótulo codes para obter o caractere

Por exemplo, rax = 0x4A, nesse caso será usado o índice 0x4 = 4 e 0xA = 10. O primeiro vai resultar no caractere ‘4’ e o segundo no caractere ‘a’.

Rótulos locais

No código acima foi utilizado o rótulo .loop, diferente dos outros ele começa com um ‘.’, o que significa que ele é um rótulo local. Pode-se reutilizar nomes de rótulos sem causar conflito, desde que sejam locais

O último rótulo global utilizado serve de base para todos os rótulos locais subsequentes, até ocorrer o próximo rótulo global. Então, nesse caso, o nome completo do rótulo ‘.loop’ é _start.loop. Esse nome pode ser usado para endereçar o rótulo em qualquer lugar do código, mesmo depois da ocorrência de outros rótulos globais

Endereçamento relativo

lea rsi, [codes + rax]

Os colchetes indicam um endereçamento relativo

  • mov rsi, rax - copia rax para rsi
  • mov rsi, [rax] - copia o conteúdo da memória (8 bytes sequenciais) começando no endereço armazenado em rax

As instruções mov e lea tem uma diferença sútil. lea permite calcular o endereço de uma célula de memória e armazena-lo em algum lugar

Diferença entre mov e lea:

; rsi <- endereço do rótulo 'codes', um numero
mov rsi, codes

; rsi <- conteúdo da memória começando no rótulo 'codes'
; 8 bytes porque o tamanho de rsi é 8 bytes
mov rsi, [codes]

; rsi <- endereço de 'codes'
; o mesmo que mov rsi, codes
lea rsi, [codes]

; rsi <- conteúdo da memória começando em (codes + rax)
mov rsi, [codes + rax]

; rsi <- codes + rax
; O mesmo que fazer:
; mov rsi, codes
; add rsi, rax
lea rsi, [codes + rax]

Ordem de execução

Todos os comandos são executados de modo consecutivo, exceto quando há uma instrução jump. Jumps condicionais dependem do conteúdo do registrador rflags

Em geral utilizamos a instrução test ou cmp para configurar as flags necessárias, em conjunto com a instrução jump

cmp subtrai o segundo operando do primeiro; ela não armazena o resultado em lugar nenhum, mas ativa as flags com base nele. test faz o mesmo, porém utiliza o AND lógico no lugar da subtração

Usar a instrução ‘test rcx, rcx’ é uma maneira rápida de comparar se o valor do registrador é igual a zero