Na primeira parte fizemos nosso primeiro programa em assembly sair sem nenhum erro. Mas um programa que só sai, é um programa inútil. Então vamos dar um propósito maior a este programa: Mandar um Olá, mundo!. Para isso precisamos entender como um programa interage com um terminal do sistema.

Em sistemas unix, todas as comunicações são feitas através de “arquivos”. Estes arquivos podem ser arquivos no disco, na memoria ram, ou até mesmo arquivos virtuais que representam uma conexão TCP ou similar. Por padrão, todo aplicativo tem 3 arquivos abertos quando é iniciado: stdout, stderr, stdin.

  • stdin => Arquivo onde toda interação que usuário fizer (ex. digitar no teclado) será gravado
  • stdout => Arquivo onde o programa irá escrever a saída que deseja mostrar no terminal
  • stderr => Similar ao stdout, porém é usado para descrever erros ou problemas

Cada arquivo carregado é representado por um file descriptor (fd). O fd é um número inteiro arbitrario escolhido pelo kernel do sistema operacional, para o seu aplicativo. Os arquivos stdin, stdout e stderr são mapeados por padrão os FDs 0, 1 e 2 respectivamente. Logo se desejarmos interagir com algum destes arquivos, basta usar os fd’s definidos.

Na linguagem C, podemos escrever diretamente no terminal usando a função write:

#define STDOUT 1
#define STDERR 2
write(STDOUT, "Ola Mundo", 9);        // write(fd, buffer, tamanho)
write(STDERR, "Houve um erro!", 14);

Chamando write no assembly

Mas como chamamos isso através do assembly? Bom, para nossa sorte, a função write na real é uma chamada de sistema também! A syscall write tem o número 4 associada a ela (então nosso rax será 4), e recebe os argumentos fd, buffer, tamanho nos registradores rdi, rsi, rdx respectivamente. Mas antes que possamos chamar a syscall, temos que colocar em algum lugar o conteúdo que desejamos escrever. Para isso criaremos uma string no nosso programa:

section .text
global _main

_main:
  mov rax, 0x2000001  ; 1 == Syscall Exit
  mov rdi, 0          ; 0 == OK
  syscall             ; Chamar o sistema operacional
                      ; Nunca vai voltar pra cá

section .data

helloworld:
  db `Ola, mundo!\n`, 0 ; 12 caracteres

Com isso, qualquer lugar que mencionarmos a palavra helloworld, irá apontar para a posição da memória onde está escrito Ola, mundo!. Agora podemos escrever nossa syscall, assumindo que nossa mensagem tem 12 caracteres. O \n no final representa uma quebra de linha (é apenas um caracter)

section .text
global _main

_main:
  mov rax, 0x2000004  ; 4 == Syscall write
  mov rdi, 1          ; 1 == stdout
  mov rsi, helloworld ; Nossa string
  mov rdx, 12         ; 12 caracteres
  syscall             ; Chamar o sistema operacional

  mov rax, 0x2000001  ; 1 == Syscall Exit
  mov rdi, 0          ; 0 == OK
  syscall             ; Chamar o sistema operacional
                      ; Nunca vai voltar pra cá

section .data

helloworld:
  db `Ola, mundo!\n`, 0 ; 12 caracteres

Feito isso, podemos recompilar e testar:

make clean
make
./helloworld

E agora nosso programa deverá escrever Ola, mundo! na tela!

Criando nossa primeira “função”

Na etapa passada conseguimos escrever algo na tela, porém precisamos passar qual é o número de caracteres, e vários argumentos. Isso pode ficar meio impraticável se precisarmos fazer isso toda vez que formos imprimir algo na tela, não é mesmo? Vamos então criar uma função para que possamos facilitar a vida na hora de escrever na tela.

A primeira delas, vai contar quantas letras temos que imprimir. Repare na mensagem que escrevemos na etapa anterior:

  db `Ola, mundo!\n`, 0 ; 12 caracteres

Além da mensage, existe um número 0 no final. Esse número é um terminador nulo, um padrão adotado pela linguagem C para representar o fim de uma sequencia de caracteres. Vamos usar essa ideia para conseguir contar quantos caracteres existem na mensagem que devemos imprimir, e aí usar esse valor para a chamada write. Mas antes, vamos colocar todo trecho da chamada write, em outra função, e chama-la a partir do _main.

section .text
global _main

imprime:
  mov rax, 0x2000004  ; 4 == Syscall write
  mov rdi, 1          ; 1 == stdout
  mov rsi, helloworld ; Nossa string
  mov rdx, 12         ; 12 caracteres
  syscall             ; Chamar o sistema operacional
  ret

_main:
  call imprime
  mov rax, 0x2000001  ; 1 == Syscall Exit
  mov rdi, 0          ; 0 == OK
  syscall             ; Chamar o sistema operacional
                      ; Nunca vai voltar pra cá

section .data

helloworld:
  db `Ola, mundo!\n`, 0 ; 12 caracteres

Criamos agora outro símbolo, chamado imprime. Esse simbolo termina com a instrução ret, representando que este trecho de código será chamado por uma instrução call. No trecho _main substitumos todo o código da chamada write por uma instrução call imprime, a qual irá chamar o símbolo imprime esperando que seja um código executável.

Neste momento, criamos nossa primeira função, a imprime que não faz nada além do que já fizemos, mas de maneira desacoplada do fluxo do _main. Tanto é que podemos facilmente agora imprimir varias vezes a mensagem Ola, mundo apenas repetindo a instrução call:

section .text
global _main

imprime:
  mov rax, 0x2000004  ; 4 == Syscall write
  mov rdi, 1          ; 1 == stdout
  mov rsi, helloworld ; Nossa string
  mov rdx, 12         ; 12 caracteres
  syscall             ; Chamar o sistema operacional
  ret

_main:
  ; Chamar 4 vezes, pra ter certeza que o mundo vai ouvir
  call imprime
  call imprime
  call imprime
  call imprime
  mov rax, 0x2000001  ; 1 == Syscall Exit
  mov rdi, 0          ; 0 == OK
  syscall             ; Chamar o sistema operacional
                      ; Nunca vai voltar pra cá

section .data

helloworld:
  db `Ola, mundo!\n`, 0 ; 12 caracteres

Agora podemos fazer um pedaço de código para contar quantos caracteres tem na string helloworld para que não precisemos manualmente calcular isso e colocar fixo no código. Para isso iremos fazer um loop, incrementando um registrador enquanto o valor na posicao da memória não for 0. Como o registrador rdx é usado para o envio do número de caracteres, podemos usá-lo diretamente como o contador. No conjunto de instruções temos uma instrução interessante a boa para nosso uso: lodsb. A instrução lodsb faz basicamente os seguintes passos:

  1. Carrega um byte da posição de memória apontada por RSI no registrador RAX (no pedaço AL, ou seja, o primeiro byte de RAX)
  2. Incrementa 1 em RSI

Com isso podemos colocar a posição da memória onde está helloworld em RSI, chamar lodsb, e verificar se o registrador AL (primeiro byte de RAX) é zero. Se não for zero, incrementamos o registrador RDX e voltamos pra linha do lodsb. Caso seja zero, continuamos nosso código (quebrando o loop) e o número de caracteres estará em RDX. Em pseudo-código, será mais ou menos isso:

RDX = 0
lodsb
do {
  RDX++
  lodsb
} while (AL != 0)

Mas como fazemos um loop em assembly?

Fazendo um loop em assembly

O assembly x86 provê algumas mecânicas de salto no código:

  • jmp ENDEREÇO => Salta para o endereço
  • jz ENDEREÇO => Salta para endereço, caso flag ZERO seja 1
  • jnz ENDEREÇO => Salta para endereço, caso flag ZERO seja 0

Algumas outras condições são possíveis, porém estão fora do escopo deste tutorial. A flag ZERO é um bit dentro de um registrador especial de FLAGS. Este registrador armazena o estado de algumas operações executadas no processador. Por exemplo as instruções cmp e test resultam um valor que é armazenado em flags e podem ser posteriormente usados em saltos condicionais.

Usaremos a instrução cmp neste tutorial para construir nosso loop. Ela é usada da seguinte maneira:

  cmp registrador, valor

O resultado da instrução cmp é armazenado no registrador de status, e pode ser usados pelas instruções de salto. Além das instruções de salto anteriormente citadas, existem alguns outros saltos condicionais:

  • je ENDEREÇO => Mesmo de jz, salta se os valores comparados forem iguais
  • jne ENDEREÇO => O mesmo de jnz, salta se os valores comparados forem diferentes
  • jg ENDEREÇO => Salta se o valor for maior que o comparado. (registrador > valor)
  • jge ENDEREÇO => Salta se o valor for maior ou igual que o comparado. (registrador >= valor)
  • jl ENDEREÇO => Salta se o valor for menor que o comparado. (registrador < valor)
  • jle ENDEREÇO => Salta se o valor for menor ou igual que o comparado. (registrador <= valor)

Desta maneira podemos criar nosso loop. Porém, como saberemos o endereço para qual saltar? Simples! Labels

Labels

Labels são rótulos usados no assembly para referenciar posições no código. Nós já usamos eles sem mesmo saber! Todo símbolo declarado é um label, logo quando declaramos nossa mensagem, nós automaticamente criamos um label. O mesmo conceito pode ser aplicado para pedaços de código, e nós fizemos isso quando criamos a função imprime. Os labels ficam sem identação e são terminados por :. Podemos usar o label para indicar para onde devemos ir. Por exemplo:

  mov ecx, 16 ; ECX = 16
meuloop:
  dec ecx     ; ECX = ECX - 1
  cmp ecx, 0  ; Compara ECX com 0
  jnz meuloop ; Enquanto ECX não for 0, salta pra `meuloop`
  mov eax, 1  ; O processador só vai chegar aqui caso ECX == 0 

Criando nosso contador de strings

Agora que sabemos como criar um loop, podemos continuar com nosso código, para contar quantos caracteres tem na mensagem antes de enviá-la para a chamada de sistema.

imprime:
  mov rdx, 0          ; Inicializa RDX com 0
  mov rsi, helloworld ; Nossa mensagem

proxchar:
  lodsb               ; Carrega primeiro caracter
  cmp al, 0           ; Compara AL com 0
  je chamawrite       ; Caso AL == 0, temos o final da nossa mensagem. Salta para chamawrite
  inc rdx             ; Caso não, incremente RDX e volte para proxchar
  jmp proxchar

chamawrite:
  mov rsi, helloworld ; RSI foi alterado pelas chamadas lodsb, então vamos restaurar ovalor dela aqui
  mov rax, 0x2000004  ; 4 == Syscall write
  mov rdi, 1          ; 1 == stdout
  syscall             ; Chamar o sistema operacional
  ret

Feito isso podemos compilar e testar:

make clean
make
./helloworld

E o resultado será exatamente o mesmo, porém agora não estamos declarando explicitamente o número de caracteres na mensagem! Faça o teste alterando o conteudo da mensagem para Ola mundo, agora essa mensagem eh longa.

Convenções de chamada, argumentos de função e registradores “sujos”

Estamos a todo momento alterando registradores. Em alguns momentos pode ser nescessário salvar os valores dos registradores em algum lugar para que possamos garantir que não estamos interferindo em um código externo. Imagine a seguinte situação: quando você chama uma syscall o kernel também vai utilizar os mesmos registradores que o seu programa utiliza, logo ele precisa garantir que quando voltar pro seu código, apenas os registradores que identificam um resultado são alterados, e todos os outros permanecem no mesmo valor. Para isso é usado um espaço especial da memória chamada stack. A stack é um pedaço de memória que funciona como uma pilha. A pilha tem duas operações push e pop.

Imagine uma pilha de livros: quando você coloca o livro, você coloca em cima do ultimo livro colocado. Quando você pega um livro, vc pega o ultimo que foi colocado. A operação push é a de colocar um livro, a pop é a de tirar o livro. O nome pilha vem da estrutura de dados pilha onde segue-se um padrão de LIFO (last in, first out)(ultimo a entrar, é o primeiro a sair). No x86, as instruções para operar na stack são nomeadas exatamente push e pop:

  mov eax, 16 ; EAX = 16 
  push eax    ; Coloca valor de EAX na stack
  mov eax, 10 ; EAX = 10
  ; Faz algo, com EAX = 10
  pop eax
  ; EAX agora tem 16 novamente

Desta maneira, podemos usar a operação push logo que entrarmos em uma função, para salvar qualquer registrador que iremos usar mas que não representam nenhum resultado, e logo antes de chamar a instrução ret, podemos restaurar eles usando pop. A posição da memória onde será salvo o valor é indicado pelo registrador ESP.

Pela convenção da Intel, os registradores EAX, ECX, EDX são considerados temporários, e estes não precisam ser salvos. Isso tem duas consequências no nosso código:

  1. Não precisamos salvar nenhum deles na stack
  2. Não podemos assumir que eles não serão alterados caso chamemos alguma função externa

Todos os outros devem ser salvos caso precisemos usa-los. Um registrador é considerado sujo, quando ele não é um registrador temporário, e estamos alterando o valor dele.

Argumentos de uma função

Embora usualmente os argumentos de uma função são enviados através de registradores (no caso x86-64 são os registradores RDI, RSI, RDX, RCX, R8, R9, [XYZ]MM0–7 respectivamente), nem toda linguagem faz isso, e nem toda arquitetura faz isso. Temos dois exemplos diferentes aqui:

  1. Golang: O golang envia todos argumentos de uma chamada de função através de um frame, que pode ser considerado como uma mini-stack
  2. Nas arquiteturas x86 32 bit, os argumentos são passados pela stack pela convenção cdecl da microsoft (a qual praticamente todos compiladores seguem)

** Há uma proposta para alterar a convenção de chamada do golang para registradores, isso irá aumentar a performance pois gravar na stack / frame requer um acesso de memória, enquanto em um registrador não é nescessário.

Melhorando a função imprime

Agora com conhecimento sobre stack e passagem de argumentos, por que não fazemos a função imprime receber a mensagem ao invés de usar fixamente o endereço helloworld? Desta maneira, qualquer momento que queiramos escrever na tela, podemos simplesmente chamar a função imprime, não é mesmo?

imprime:
  push rdi            ; Salva RDI na stack, vamos alterar ele
  push rsi            ;

  mov rdx, 0          ; Inicializa RDX com 0
  mov rsi, rdi        ; O primeiro argumento pela convenção é o RDI, 
                      ; porém precisamos do valor em RSI para chamada lodsb

proxchar:
  lodsb               ; Carrega primeiro caracter
  cmp al, 0           ; Compara AL com 0
  je chamawrite       ; Caso AL == 0, temos o final da nossa mensagem. Salta para chamawrite
  inc rdx             ; Caso não, incremente RDX e volte para proxchar
  jmp proxchar

chamawrite:
  mov rsi, rdi        ; RSI foi alterado pelas chamadas lodsb, então vamos restaurar ovalor dela aqui
  mov rax, 0x2000004  ; 4 == Syscall write
  mov rdi, 1          ; 1 == stdout
  syscall             ; Chamar o sistema operacional

  pop rsi             ; Restaura RSI
  pop rdi             ; Restaura RDI
  ret

Com essa alteração, agora iremos chamar a função imprime de maneira diferente, passando a mensagem helloworld no argumento RDI.


_main:
  ; Chamar 4 vezes, pra ter certeza que o mundo vai ouvir
  mov rdi, helloworld
  call imprime
  mov rax, 0x2000001  ; 1 == Syscall Exit
  mov rdi, 0          ; 0 == OK
  syscall             ; Chamar o sistema operacional
                      ; Nunca vai voltar pra cá

E só para testar a funcionalidade, vamos declarar uma outra mensagem?


section .data

helloworld:
  db `Ola, mundo!\n`, 0
qqeutofazendo:
  db `QQ eu to fazendo aqui?\n`, 0

Nosso código ficou assim:

section .text
global _main

imprime:
  push rdi            ; Salva RDI na stack, vamos alterar ele
  push rsi            ;

  mov rdx, 0          ; Inicializa RDX com 0
  mov rsi, rdi        ; O primeiro argumento pela convenção é o RDI, 
                      ; porém precisamos do valor em RSI para chamada lodsb

proxchar:
  lodsb               ; Carrega primeiro caracter
  cmp al, 0           ; Compara AL com 0
  je chamawrite       ; Caso AL == 0, temos o final da nossa mensagem. Salta para chamawrite
  inc rdx             ; Caso não, incremente RDX e volte para proxchar
  jmp proxchar

chamawrite:
  mov rsi, rdi        ; RSI foi alterado pelas chamadas lodsb, então vamos restaurar ovalor dela aqui
  mov rax, 0x2000004  ; 4 == Syscall write
  mov rdi, 1          ; 1 == stdout
  syscall             ; Chamar o sistema operacional

  pop rsi             ; Restaura RSI
  pop rdi             ; Restaura RDI
  ret


_main:
  mov rdi, helloworld
  call imprime
  mov rdi, qqeutofazendo
  call imprime
  mov rax, 0x2000001  ; 1 == Syscall Exit
  mov rdi, 0          ; 0 == OK
  syscall             ; Chamar o sistema operacional
                      ; Nunca vai voltar pra cá

section .data

helloworld:
  db `Ola, mundo!\n`, 0
qqeutofazendo:
  db `QQ eu to fazendo aqui?\n`, 0

E agora rodando novamente:

make clean
make
./helloworld

Temos nossas duas mensagens escritas na tela:

Ola, mundo!
QQ eu to fazendo aqui?

Vamos salvar?

git add helloworld.asm
git commit -am "Função imprime tunada. Strings go brrrr"