[macosx-x86] Fazendo Hello World
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 aostdout
, 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:
- Carrega um byte da posição de memória apontada por
RSI
no registradorRAX
(no pedaçoAL
, ou seja, o primeiro byte deRAX
) - 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 flagZERO
seja1
-
jnz ENDEREÇO
=> Salta para endereço, caso flagZERO
seja0
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 dejz
, salta se os valores comparados forem iguais -
jne ENDEREÇO
=> O mesmo dejnz
, 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:
- Não precisamos salvar nenhum deles na stack
- 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:
- 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
- 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"