=========================================
Eu durante essas as férias do final de ano explorei alguns novos assuntos, inclusive algumas coisas relacionadas a linguagem de programação amd64 linux e quero compartilhar alguns de dos meus estudos aqui.
Assembly é uma linguagem de programação de nível baixo, significa que ela traduz 1:1 para o comportamento das CPUs (machine code), existem vários assemblers – que são programas que traduzem texto de assembly para machine code. Cada assembler tem suas variações, hoje eu vou trabalhar com o Flat Assembler – que é um assembler extremamente poderoso com suporte a macros e outras coisas interessantes, existe uma comunidade de pessoas que vem criando um sistema operacional, o MenuetOS que é 100% escrito usando fasm. Além disso existem várias sintaxes para representar código em assembly, os mais famosos são sintaxe do GNU Assembly, Sintaxe da A&T e a Sintaxe da Intel. Vamos usar nesse artigo a sintaxe da Intel – a que eu acho mais legível dentre elas. a diferença mais impactante entre a sintaxe A&T e Intel é a ordem dos argumentos da instrução, sendo que:
A instrução na sintaxe intel tem estrutura básica:
<opcode> <saída> <entrada>, ou seja o endereço de memória/registrador que vai armazenar o valor de saída vem primeiro e a entrada vem por últimoEnquanto na sintaxe A&T tem estrutura básica:
<opcode> <entrada> <saída>
Compiladores GNU também podem gerar código assembly para nós, se especificarmos a flag -S. Por padrão esse assembly gerado vem na sintaxe A&T e para gerar o código na sintaxe intel, usa-se a flag -masm.
Quando compilarmos um simples olá mundo em C para assembly, esse será o resultado.
gcc hello.c -S -masm=intel -o hello.asmO código gerado pode ser parecido com isso:
.file "hello.c"
.intel_syntax noprefix
.text
.section .rodata
.LC0:
.string "Ola Mundo"
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
push rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
mov rbp, rsp
.cfi_def_cfa_register 6
lea rax, .LC0[rip]
mov rdi, rax
call puts@PLT
mov eax, 0
pop rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (GNU) 14.2.1 20240910"
.section .note.GNU-stack,"",@progbitsNota-se que o assembly gerado é muito grande e complexo, não? Cheio de coisas que não fazem muito sentido, podemos fazer isso mais eficientemente.
Método 1
Nesse primeiro método, vamos criar um olá mundo que depende de nenhum runtime ou standard library.
Primeiro antes de tudo, vamos recapitular 3 conceitos importantes, que todo programador de assembly precisa saber: instruções, memória e syscalls.
Instruções
Instruções são as operações executados pelo processador, todo processador que se prese implementa 3 tipos de instruções, a de manipulação de memória (mover uma variável de lugar), controle (instruções de branching, condicionais e looping, na verdade são as instruções que mudam o fluxo de controle do programa) e aritmética (adição, subtração, aritmética booleana e entre outros).
Memória
Quando nos referimos sobre memória, estamos falando sobre as coisas que armazenamos os objetos dos programas executados, existem 3 níveis de prioridade de memória num computador convencional ou uma máquina Von Neumannm que são os registradores, memória RAM e memória secundária.
Registradores: É Memória embutida na CPU, geralmente o mesmo contém contendo um número pequeno deles, em CPUs amd64 existem 48 registradores (registradores de uso específico como o Program Counter – registrador que armazena a instrução atual sendo executad inclusos), todos esses registradores com 64 bits de largura. Os objetos são trazidos da memória RAM e armazenados em registradores para uso imediato, por exemplo como argumento de uma syscall ou função.
Memória RAM: Tudo que vem a ser executado, isso incluindo variáveis, instruções são carregados da memória secundária para a RAM.
Memória secundária: (HDD, SSD) arquivos estáticos são armazenados aqui, usado quando é necessário um armazenamento de longo termo. A Interface com a memória secundária é abstraída pelo sistema operacional feita pelas chamadas de sistema ou instruções de I/O.
Syscalls
Em sistemas operacionais existem 2 conceitos importantes, user-mode/userland e kernel-mode/kernelland.
Tudo que tem acesso direto e privilegiado aos diversos componentes do hardware rodam no que é chamado kernel-mode, isso inclui os drivers e o próprio kernel do sistema operacional.
Já os diversos outros programas são executados em user-mode, que por segurança não tem acesso as essas instruções privilegiadas e perigosas, então esses programas user-mode precisam solicitar para o kernel fazer essa interação com o hardware (como interação com o disco rígido, a placa de rede, interação com os diversos periféricos de entrada e saída, criação de novos processos) e a maneira que esses programas solicitam serviços é por meio das syscalls ou chamadas de sistema, em que o contexto de um programa que roda na userland é brevemente passada para o kernel, para que essas operações sejam feitas.
As syscalls funcionam como uma extensão do conjunto de instruções das CPUs, a cada segundo milhares delas são usadas pelos diversos programas que estão em execução, para coisas como escrever ou ler algum arquivo, interagir com o teclado e mouse ou criar algum socket.
Cada syscall tem um ID, e a maneira em que elas (ou qualquer rotina em assembly) são chamadas é pela alocação de valores específicos para registradores específicos e chamando a instrução syscall.
No linux eu uso essa tabela como referência para as diversas syscalls
https://blog.rchapman.org/posts/Linux_System_Call_Table_for_x86_64/.
Por exemplo para escrever alguma coisa no terminal deve-se executar esse algoritmo.
Mover valor 1 para o registrador
rax(ID da ssyscall)Mover valor 1 para o registrador
rdi(ID para stdout)Mover o endereço do começo do meu texto para
rsiMover o tamanho do meu texto para
rdxFazer a Chamada
Peculiaridades do FASM
Cada assembler tem suas próprias peculiaridades na forma de escrever o assembly, no FASM existem o que é chamado de diretivas, que são algumas funcionalidades que definem comportamentos extras no momento que o assembler gera o arquivo binário. Por exemplo a diretiva format define o formato do arquivo gerado, segment define as permissões de cada segmento de código (pense como se fosse o chmod do linux) e entry define a partir de qual label deve-se executar o programa.
O código em si
Sem mais demoras aqui está Olá Mundo em FASM:
format ELF64 executable
segment readable executable
entry main
main:
mov rax, 1 ;; mover ID da syscall write (basicamente printf do C) para rax
mov rdi, 1 ;; mover ID da flag stdout para rdi
mov rsi, msg ;; mover endereço do começo do meu texto para rsi
mov rdx, msg_len ;; mover o tamanho da minha mensagem para rdx
syscall ;; efetuar a syscall
mov rax, 60 ;; mover ID da syscall para terminar o programa seguramente (masicamente o return do C)
mov rdi, 0 ;; mover código de retorno, para rdi
syscall ;; efetuar a chamada de sistema
segment readable writable
msg db "Ola Mundo!", 10 ;; defini uma string de bytes no final do arquivo contendo "Ola Mundo!\n" (10 é o código para \n)
msg_len = $-msg ;; defino o tamanho da mensagem sendo $ (o endereço da linha atual) - (menos) msg (o endereço do começo da mensagem)Esse arquivo de código fonte tem apenas 253 Bytes de tamanho e o mais interessante é que o executável gerado pelo fasm é ainda menor que o código fonte original, tendo apenas 233 Bytes de tamanho.
Método 2
Já ouviu falar de macros do C/C++? Eles também estão presentes no FASM, com isso é possível refatorar esse primeiro código.
Caso não sabia o que são macros, eles são unidades de códigos reusávies que você define e que são literalmente copiados e colados nos lugares que são chamados.
Preste atenção de como agora ficou a rotina main:
entry main
main:
print hello, hello.size
print goodbye, goodbye.size
return 0
segment readable writable
hello string "Hello, World!", 10
goodbye string "Goodbye, World!", 10Nem parece assembly!
Agora o código completo comentado:
format ELF64 executable
segment readable executable
macro print msg, size { ;; transformei o boilerplate de imprimir mensagens numa macro
mov rax, 1 ;; mover ID da syscall write (basicamente printf do C) para rax
mov rdi, 1 ;; mover ID da flag stdout para rdi
mov rsi, msg ;; mover endereço do começo do meu texto para rsi
mov rdx, msg_len ;; mover o tamanho da minha mensagem para rdx
syscall ;; efetuar a syscall
}
macro return retcode {
mov rax, 60 ;; mover ID da syscall para terminar o programa seguramente (masicamente o return do C)
mov rdi, 0 ;; mover código de retorno, para rdi
syscall ;; efetuar a chamada de sistema
}
entry main
main:
print hello, hello.size
print goodbye, goodbye.size
return 0
segment readable writable
struc string [data] { ;; estou declarando uma macro para meu tipo de dado string
common
. db data ;; "." simboliza o nome da variável atual por exemplo ali embaixo a variável "hello"
.size = $-. ;; ".size" simboiza alguma coisa tipo "hello.size"
}
hello string "Hello, World!", 10
goodbye string "Goodbye, World!", 10Método 3
Também podemos “linkar” nosso código assembly com bibliotecas externas (como libc, raylib, sdl), usando o ld. Linkando com a libc nos permite usar funções da biblioteca padrão da linguagem C em nosso assembly!
Existe uma convenção sobre quais registradores usar quando passar argumentos para funções, do primeiro ao sétimo argumento da função são passados respectivamente para os regirstradores: rdi, rsi, rdx, rdx, rcx, r8, r9; o resto dos parâmetros são dados no stack.
Tudo que basta é declarar os símbolos como extern e usar esse script para compilar o programa (assumindo que o nome do seu programa chama-se printf.asm)
#!/bin/bash
fasm printf.asm
ld printf.o -o printf -dynamic-linker /lib/ld-linux-x86-64.so.2 -lcO código fonte
format ELF64
section '.text' executable
public _start
extrn printf
extrn _exit
_start:
mov rdi, msg
mov rsi, 0
call printf
mov rdi, 0
call _exit
section '.data' writable
msg db "Hello printf", 10Método bônus (Quine em FASM!!!)
Quine é o nome de uma técnica usada para incluir o código fonte da própria aplicação nela mesma, presumo que seja importante para aplicações relacionadas à debugging.
No quine você pode incluir arquivos no próprio executável em tempo de compilação, recurso que linguagens de alto nível não tem.
O código é baseado no método 1 e a única alteração foi na penúltima linha, utilizei a diretiva file para poder concatenar o conteúdo do meu código fonte no binário (assumindo que o código fonte chama-se quine.asm).
format ELF64 executable
entry start
start:
mov rax, 1
mov rdi, 1
mov rsi, src
mov rdx, srcsize
syscall
mov rax, 60
mov rdi, 0
syscall
segment readable writable
src file "quine.asm"
srcsize = $-src