3 Maneiras de Escrever "Hello World" em ASM

=========================================

Postado em January 29, 2025 por Paulo

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 último

  • Enquanto 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.asm

O 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,"",@progbits

Nota-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 rsi

  • Mover o tamanho do meu texto para rdx

  • Fazer 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!", 10

Nem 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!", 10

Mé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 -lc

O 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", 10

Mé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