[linux-x86] Criando o projeto e um programa que sai
Aqui começaremos nosso primeiro passo ao mundo da baixaria! Faremos o que todo bom programador já fez na vida: um Hello World. Para isso iremos criar algumas coisas para nos ajudar no processo.
A primeira coisa que devemos fazer, é instalar as ferramentas nescessárias para que possamos transformar o código assembly em um executável válido no Linux. Qualquer distribuição linux pode ser usada (inclusive pelo LiveCD / LiveUSB). Para critério de padronização, usarei como referência os comandos de instalação para o Ubuntu 20.04
. Os nomes dos pacotes podem mudar um pouco entre distribuições, caso não encontre na sua distribuição, procure o nome do pacote no google junto com o nome da sua distribuição.
Instalando requisitos
Instalaremos dois pacotes:
-
build-essential
=> Um meta-pacote, que contém basicamente tudo que você pode precisar para desenvolver algo no linux. -
nasm
=> NASM é um dos assemblers (compiladores de assembly) existentes. Usaremos ele para criar nossos programas. -
git
=> Uma ferramenta de versionamento, para gente poder controlar melhor os códigos que criamos :D
sudo apt install build-essential nasm git
Preparando nosso projeto
Começaremos criando uma pasta chamada hello-asm
e iniciando um repositório git nela
mkdir -p hello-asm
cd hello-asm
git init
Com isso teremos nosso repositório git com nosso hello world.
Opcionalmente, você pode vinculá-lo a sua conta no Github ou Gitlab para que você possa revisitar o projeto caso perca algum dia (ou compartilhar com alguém).
Para nossa conveniencia, criaremos um arquivo Makefile
para que não precisemos digitar muitos comandos para compilar todos arquivos .asm
na pasta para o executável. Segue o modelo do arquivo:
ASM_FILES=$(shell find . -name "*.asm")
OBJECT_FILES=$(ASM_FILES:%.asm=%.o)
NASM_OPTS=-f elf64 -F dwarf -g
LD_OPTS=-m elf_x86_64
all: helloworld
# Importante! Use TABs, o Makefile apenas aceita TABs
%.o: %.asm
@echo "Montando arquivo $< -> $@"
@nasm $(NASM_OPTS) -o $@ $<
clean:
@echo "Limpando projeto"
@rm -f helloworld *.o
link: $(OBJECT_FILES)
@echo "Ligando objetos $(OBJECT_FILES)"
@ld $(LD_OPTS) -o helloworld $(OBJECT_FILES)
helloworld: link
@echo "YEY!"
.PHONY: clean
Aqui neste makefile temos duas etapas distintas: compilação
e ligação
.
Etapa de Compilação
A etapa de compilação
(denotado pelo trecho %.o: %.asm
) lê todos os arquivos .asm
, converte suas diretivas para código nátivo (binário) e cria um objeto contendo todas as funções, dados e trechos descritos no arquivo .asm
. A etapa de compilação preserva todos os simbolos
(nomes de função, pedaços de memória, varíaveis, etc…) que o usuário inseriu, e marca os trechos nativos com o nome. Desta maneira a próxima etapa (ligação
) pode interligar todas as menções de símbolos entre os arquivos! Parece meio confuso, mas veremos isso em prática :D
Etapa de Ligação
A etapa de ligação
é responsável por pegar todos os objetos gerados pela etapa de compilação
e ligar todas as refêrencias entre elas usando os simbolos
. Os símbolos costumam ser apenas nomes que o usuário deu em seu código. Além disso a ligação também cria o cabeçalho do arquivo de programa para que o sistema operacional possa entender o que deve ser executado. A saída da etapa de ligação é o seu programa pronto. Feito isso, iremos adicionar um commit
no repositório git criado, para salvar o nosso Makefile.
git add Makefile
git commit -am "Makefile adicionado"
Com isso, para criar nosso programa podemos apenas digitar
make clean
emake
na pasta, e nosso programa será gerado com o nomehelloworld
usando todos arquivos.asm
na pasta!
Começando nosso programa
Agora nós vamos começar o nosso primeiro arquivo .asm
. Embora as instruções sejam padrões dentro da mesma arquitetura, algumas arquiteturas tem modos de representação diferentes. Atualmente existem duas sintaxes comuns para x86: a AT&T
e Intel
.
Intel
add eax, ebx ; EAX = EAX + EBX
mov %eax, $0x100 ; Valor imediato EAX = 0x100
AT&T
addq %ebx, %eax // EAX = EAX + EBX
mov $0x100, %eax // Valor imediato EAX = 0x100
Na sintaxe AT&T os operadores são invertidos em relação ao intel, e o tamanho dos operadores é explicitamente adicionado nas instruções. Para simplicidade desse tutorial, iremos usar a sintaxe da Intel (que é o nasm usa). Porém é importante saber que a sintaxe da AT&T também é bem comum.
Mais detalhes da diferença podem ser vistos em Intel vs. AT&T syntax
helloworld.asm
Vamos começar criando nosso arquivo helloworld.asm
:
section .text
global _start
_start:
; Código Aqui
ret
E agora temos algumas coisas novas pra explicar:
Temos duas diretivas section
e global
e um símbolo _start
. A diretiva section
indica em qual seção do executável / memória ficará o trecho de código escrito abaixo. No linux a seção .text
é onde fica todo código executável. Por padrão (e segurança) a seção .text
apenas tem permissão de leitura e execução, não sendo possível gravar dados nessa seção. Temos mais algumas seções interessantes que iremos usar no decorrer do tutorial:
-
.data
=> Dados R/W - Nesta seção serão colocados dados onde podemos ler e gravar mas não executar. -
.rodata
=> Dados R - Nesta seção serão colocados dados somente leitura (por exemplo mensagens do nosso programa)
As seções também podem ter sub-seções, denotadas por um .
após a “base”. Por exemplo: .text.minhasecao
, .data.minhasvariaveis
. A sub-seção herdará as permissões da seção pai.
Já a diretiva global
indica que estamos declarando um símbolo como global. Isso significa que ele poderá ser acessível por qualquer outro arquivo caso a diretiva extern
seja usada. A etapa de ligação
fara o papel de ligar todos os lugares que mencionarem o símbolo. E falando em símbolos, temos o nosso primeiro símbolo, o _start
.
Este é um símbolo especial, pois é o símbolo que o sistema operacional irá procurar no nosso programa para saber o que executar. Logo tudo que for escrito após ele, será o código executado pelo sistema operacional. Por isso, o declaramos como símbolo global, pois ele ficará até no executável final.
Vamos tentar compilar e executar:
make clean
make
./helloworld
Porém agora temos um problema:
[1] 832397 segmentation fault ./helloworld
Ao contrário de um programa C que tem uma função main
e uma biblioteca de runtime (CRT) pra gerenciar o ciclo de vida da aplicação, aqui temos basicamente nada. Isso significa que caso não falemos explicitamente pro sistema operacional que queremos fechar, o programa irá crashar. Ok, isso é um jeito de fechar o programa, mas vamos fazer direito, ok?
Chamadas de Sistema
Nosso programa está sendo executado pelo processador. Porém qualquer acesso a algum espaço fora desse programa, deve ser feito através do sistema operacional. Inicialmente seu programa só tem acesso ao espaço de memória que foi dado a ele e caso você não peça nada pro sistema operacional, nem uma mensagem pode ser exibida na tela. Esse é um modelo de segurança amplamente adotado entre computadores modernos, onde o seu aplicativo roda uma camada de usuário, onde qualquer coisa fora do espaço de memória do proprio aplicativo, é uma camada privilegiada e passa por uma validação feita por outro programa (geralmente o kernel do sistema operacional). Para que o aplicativo possa acessar recursos externos a ele, o kernel providencia algumas funções úteis que são chamadas de chamadas de sistema
. Quando um aplicativo executa uma chamada de sistema, o processador para de processar as instruções do seu programa, e delega o controle para o kernel do sistema operacional decidir o que fazer. O kernel irá validar o acesso ao recurso, e entregar de volta informações relativas a ele (ou um erro) baseado na convenção de chamada.
Mas vamos a um exemplo mais prático, prometo que ficará mais claro. No caso da nossa arquitetura (x86-64) a chamada de sistema é feita por uma instrução chamada syscall
. Dependendo dos valores dos registradores no momento da chamada, uma ação especifica do kernel será executada. Queremos fazer nosso aplicativo fechar corretamente, para isso invocaremos a chamada de sistema exit
.
Na convenção da nossa arquitetura (x86-64 / amd64) o kernel linux espera que o número da chamada seja colocada no registrador rax
, o resultado da chamada (caso exista) ficará em rax
quando a execução do aplicativo voltar. Para sair do programa, o ID da chamada de sistema exit é 60
.
Você pode ver todas as chamadas de sistema do Linux em Linux x64
Além disso, a chamada de sistema exit
espera um valor no registrador rdi
que indica o status de saída. Esse valor é o mesmo que você retorna na sua função main
em um código C. Vamos então fazer nosso programa sair adequadamente?
section .text
global _start
_start:
mov rax, 60 ; 60 == Syscall Exit
mov rdi, 0 ; 0 == OK
syscall ; Chamar o sistema operacional
; Nunca vai voltar pra cá
Então colocamos 3 instruções:
-
mov rax, 60
=> coloca o valor60
no registradorrax
para indicar que chamaremosexit
-
mov rdi, 0
=> coloca o valor0
no registradorrdi
para indicar que nosso programa terminou OK -
syscall
=> executamos a chamada de sistema
Testando novamente:
make clean
make
./helloworld
Agora não tivemos nenhum erro! Apenas saiu sem reclamar de nada. Chamamos com sucesso nossa primeira chamada de sistema :party:
Agora vamos comitar pra não perder esse código né?
git add helloworld.asm
git commit -am "Meu programa agora sabe fechar"
Conclusão da primeira parte
Com isso concluimos a primeira do tutorial. Temos um repositório com mecanismo de compilação e ligação que consegue compilar um programa em assembly que apenas sai.
Na próxima parte iremos fazer nosso primeiro Hello World, escrevendo uma mensagem na tela!