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:

  1. build-essential => Um meta-pacote, que contém basicamente tudo que você pode precisar para desenvolver algo no linux.
  2. nasm => NASM é um dos assemblers (compiladores de assembly) existentes. Usaremos ele para criar nossos programas.
  3. 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 e make na pasta, e nosso programa será gerado com o nome helloworld 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 valor 60 no registrador rax para indicar que chamaremos exit
  • mov rdi, 0 => coloca o valor 0 no registrador rdi 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!

Fazendo Hello World