Construindo um web server em Assembly x86, parte IV, um assembly modesto

leandronsp

Leandro Proença

Posted on May 13, 2024

Construindo um web server em Assembly x86, parte IV, um assembly modesto

Uma vez que temos uma compreensão sobre sistema binário, hexadecimal, ASCII e código de máquina, chegou o grande momento de entrarmos no assunto principal desta saga: assembly.

Vamos iniciar transportando o "Hello, World" feito em código de máquina para assembly x86 e, posteriormente, abordar um exemplo de programa que recebe argumento da linha de comando.

Ao longo deste artigo vamos aprender a base de conceitos como rótulos, segmentos de memória, muito gdb, layout de memória, muita stack, procedures (subrotinas, ou funções), loops, condicionais, flags, tipos de registradores e etc

Aperte os cintos, pois este será um artigo bem extenso. Sugiro ao leitor, - que tem interesse em aprender na prática com esta saga -, que tenha o ambiente preparado e que execute cada exemplo seguindo os passos aqui descritos.

Sem mais delongas, vamos ao que importa.


Agenda


Antes de iniciar, quero novamente deixar uma menção especial ao excelente curso gratuito de Assembly x86 do Blau Araújo. É importante reforçar o quanto este material dele é necessário e foi crucial para que eu pudesse fundamentar diversos conceitos explorados ao longo desta saga

Humanizar é preciso

Como vimos no artigo anterior, CPU só entende código de máquina:

48 65 6C 6C 6F 2C 20 57 6F 72 6C 64 0A  ; Hello, World

BF 01 00 00 00     ; RDI ⬅️ 1
48 BE 00 10 40     ; RSI ⬅️ 0x401000
BA 0D 00 00 00     ; RDX ⬅️ 13
B8 01 00 00 00     ; RAX ⬅️ 1
0F 05              ; SYSCALL
BF 00 00 00 00     ; RDI ⬅️ 0
B8 3C 00 00 00     ; RAX ⬅️ 60
0F 05              ; SYSCALL
Enter fullscreen mode Exit fullscreen mode

Entretanto, para uma pessoa desenvolvedora manter um programa em código de máquina, é preciso ter muita paciência e atenção ao detalhe, pelo que também manter programas assim é muito propenso a bugs.

Precisamos de alguma forma, representar cada instrução em código de máquina em uma linguagem mais "human-friendly".

Mnemonics

É aí que entram os mnemonics, que são uma forma textual de representar informações visando facilitar a memorização para o cérebro humano.

Ao invés de trabalharmos com BF 01 00 00 00, podemos trocar por MOV RDI, 1, que significa:

estou movendo o valor imediato 1 para o registrador RDI

E assim vamos montando instrução por instrução, tal e qual faríamos com código de máquina, mas utilizando uma linguagem de fácil memorização.

Mas a CPU não entende essa "linguagem". Temos de construir um programa que faz a tradução de mnemonics para código de máquina, ou seja, de MOV RSI, 1 para BF 01 00 00 00.

assembly

Estamos falando de montadores, ou simplesmente assemblers.


Assemblers

Ao longo do tempo, foram desenvolvidos diversos assemblers para diferentes arquiteturas.

Para arquitetura x86, há diversos assemblers já construídos, GNU Assembler (as), NASM, FASM, pra mencionar alguns.

Assemblers para esta arquitetura em específico podem seguir 2 tipos de sintaxe que são predominantes:

  • AT&T, desenvolvida pela AT&T corporation
  • Intel, desenvolvida pela Intel

Nesta saga, vamos focar no Assembler NASM para arquitetura X86 64-bits (x64), com sintaxe Intel e rodando em sistema GNU/Linux, como já mencionamos algumas vezes em artigos anteriores.

  • Arquitetura x86_64 (x64)
  • Sistema Operacional GNU/Linux (Ubuntu)
  • Assembler NASM 2.16.01
  • GNU ld 2.38 (ligador, ou linker)
  • Debugger GNU gdb 12.1
  • strace 5.16 (tracing de syscalls)

Uma vez que definimos as ferramentas utilizadas, vamos seguir traduzindo o "Hello, World" para asm x86 enquanto entendemos o uso de cada uma delas.

A partir de agora, quando me referir a Assembly ou simplesmente "asm", leia-se Assembly x86_64


Nosso primeiro programa

Em Assembly, todo programa deve ter um ponto de entrada, também chamado de entry point:

global _start

_start:
    ; código do programa vai aqui
Enter fullscreen mode Exit fullscreen mode

E a primeira coisa que nosso programa vai fazer é sair:

kkkkkkkkkk

A chamada de sistema exit

Brincadeiras à parte, a chamada de sistema que precisamos executar é a exit, definida da seguinte forma no glibc:

void _exit(int status);
Enter fullscreen mode Exit fullscreen mode

Com isto, temos de seguir a lógica para montar as instruções tal como fizemos com os opcodes, que seguindo a mesma tabela de syscalls, é:

  • nome da syscall vai em RAX
  • primeiro argumento (o status de erro) vai em RDI
global _start

_start:
    mov rdi, 0   ; error status
    mov rax, 60  ; nome da syscall: SYS_exit
    syscall       
Enter fullscreen mode Exit fullscreen mode

Este programa simplesmente faz aquilo que mencionamos no artigo anterior: que todo programa deve terminar.

  • mov rdi, 0 move o valor imediato 1 para o registrador RDI; vai representar o error code da syscall exit: 0 para término sem erros
  • mov rax, 60 move o valor imediato 60 para o registrador RAX; vai representar o nome da syscall em si, exit
  • syscall faz a chamada de sistema da syscall exit, definida em RAX

Para que o programa seja compilado, precisamos primeiro fazer a "montagem" das instruções com NASM:

$ nasm -f elf64 hello.asm -o hello.o
Enter fullscreen mode Exit fullscreen mode
  • -f elf64: arquitetura de destino, x64
  • hello.asm input, ou seja, o arquivo que contém o código fonte
  • -o hello.o: define saída para o arquivo hello.o

Mas o quê é este arquivo hello.o?

Arquivos objeto

Arquivo objeto (Object File) é um arquivo que contém código de máquina gerado por um assembler ou compilador.

Porém este arquivo ainda não é um executável final, porque podemos querer combinar com outros arquivos objeto e bibliotecas nativas do SO.

A partir deste arquivo, que geralmente tem a extensão .o, podemos utilizar outro programa para "ligar" com outros arquivos, se necessário, no intuito de gerar um arquivo com código de máquina final e executável.

Este programa se chama linker, pelo que utilizaremos a versão padrão do ld que vem com o GNU no nosso sistema operacional GNU/Linux.

Linker

Linker é o programa responsável por, a partir de um ou mais arquivos objeto, gerar um arquivo final executável com o código de máquina.

Como já geramos anteriormente o arquivo objeto hello.o utilizando o assembler NASM, podemos concluir o processo de compilação do nosso programa fonte asm x86 com ld

$ ld hello.o -o hello
Enter fullscreen mode Exit fullscreen mode

E agora, vamos rodar o binário final executável hello:

$ ./hello
echo $?
0
Enter fullscreen mode Exit fullscreen mode

Hurray! Nosso primeiro programa em Assembly concluído com sucesso!

Contudo, vamos lembrar de um ponto importante que vimos na parte II da saga: que o programa e seus dados ficam na memória. Queremos entender o que está acontecendo na memória com este simples programa.


Depurando o programa

Uma das etapas mais importantes, senão a mais importante, em desenvolvimento de software, é a depuração (ou debugging, em inglês).

Depurar é o ato de conseguir interceptar a execução do programa, analisar o estado, alterar o estado, adicionar pontos de parada (breakpoints) entre outras técnicas.

O processo de depuração também consiste em analisar a saída do programa como um todo, seu tamanho e trace de chamadas no sistema operacional.

O utilitário size

Vamos iniciar o processo de depuração do nosso programa analisando o tamanho, com o utilitário GNU size:

$ size hello

   text    data     bss     dec     hex filename
     12       0       0      12       c hello
Enter fullscreen mode Exit fullscreen mode

Mas o quê significa "text, data, bss, etc"?

Cada programa no sistema operacional é dividido em seções, que representam alguma característica para o sistema operacional.

layout de memória

text
Esta seção contém todo o código fonte do programa, e assim o SO sabe que precisa buscar esta seção na memória principal

data
Seção de dados inicializados na memória

bss
Seção de dados não-inicializados na memória

O comando size traz justamente o tamanho (em bytes) de cada seção.

dec e hex não são seções, são apenas a representação do valor total (em bytes) tanto em decimal quanto hexadecimal

  • text: esta seção contém todo o código fonte do nosso programa, também chamado de "texto"
  • data: seção de dados inicializados, logo a seguir neste artigos entramos em detalhe
  • bss: seção de dados não-inicializados, logo a seguir também falaremos deste
  • dec: o tamanho total em decimal
  • hex: o tamanho total em hexadecimal

Nosso programa por enquanto só tem a seção text, que é exatamente todo o código a partir do rótulo _start.

Nossa, Leandro, nosso programa tem apenas 12 bytes?

Aparentemente sim. Vamos confirmar:

$ ls -lh hello

...... 4.6K ... hello
Enter fullscreen mode Exit fullscreen mode

Como assim o arquivo tem 4,6Kb? O programa não ocupa 12 bytes apenas?

Bom, isto ocorre por causa dos headers que são adicionados pelo linker, que contém informação relevante para que o sistema operacional possa admitir a execução do arquivo.

Vamos novamente utilizar o comando size mas desta vez:

$ size --format sysv --radix 16 hello

hello  :
section   size       addr
.text    0xc   0x401000
Total    0xc
Enter fullscreen mode Exit fullscreen mode

A opção --format indica o formato sysv que traz também os símbolos. E a opção --radix 16 permite visualizar o tamanho de cada seção em hexadecimal.

Na seção size, 0xc representa o número 12 em decimal. Nada de novo aqui. Mas se repararmos na coluna addr, temos um valor hexadecimal para a seção text (0x401000).

Já vimos isto no artigo anterior, que 0x401000 se referia ao endereço em hexadecimal de memória virtual que indica o início do programa, lembra?

Hora de confirmar isto com uma análise mais profunda na depuração, chegou o momento de utilizarmos GNU gdb.

Debugging com GDB

GDB é um depurador (debugger em inglês) que permite ver o que está acontecendo dentro de um programa em execução.

Com um depurador, podemos analisar as informações estáticas contidas no binário do programa, estabelecer breakpoints (pontos de parada) em qualquer parte do código, executar e analisar mudanças de estado do programa durante sua execução.

Para habilitar o programa com gdb, precisamos montar o programa com a opção -g, que exporta símbolos necessários para depuração:

$ nasm -g -f elf64 hello.asm -o hello.o
$ ld hello.o -o hello
Enter fullscreen mode Exit fullscreen mode

Podemos verificar os símbolos exportados no binário com o comando size novamente:

$ size --format sysv --radix 16 hello

hello  :
section           size       addr
.text              0xc   0x401000
.debug_aranges    0x30        0x0
.debug_info       0x75        0x0
.debug_abbrev     0x1d        0x0
.debug_line       0x3d        0x0
Total            0x10b

##############

$ ls -lh hello
...... 5.1K ... hello
Enter fullscreen mode Exit fullscreen mode

Como demonstrado acima, o binário agora contém seções adicionais de "debug" que serão utilizadas pelo gdb, e consequentemente o tamanho do programa teve um acréscimo de 500MB 512 bytes!

Sem mais delongas, vamos entrar no gdb:

$ gdb --quiet

(gdb)
Enter fullscreen mode Exit fullscreen mode

E agora, dentro do shell gdb, podemos utilizar diversos comandos de depuração. O comando help traz a lista de classes de comandos disponíveis:

help

...
aliases -- User-defined aliases of other commands.
breakpoints -- Making program stop at certain points.
data -- Examining data.
files -- Specifying and examining files.
internals -- Maintenance commands.
obscure -- Obscure features.
running -- Running the program.
stack -- Examining the stack.
status -- Status inquiries.
support -- Support facilities.
text-user-interface -- TUI is the GDB text based interface.
tracepoints -- Tracing of program execution without stopping the program.
user-defined -- User-defined commands.
...
Enter fullscreen mode Exit fullscreen mode

Para o escopo deste artigo vamos utilizar apenas alguns comandos para depuração, mas a lista de comandos disponíveis é gigante. Deixo o desafio ao leitor para se aventurar com o help do gdb e brincar de depurar qualquer binário executável

Como queremos depurar o binário hello, podemos carregar os símbolos utilizando o comando file:

(gdb) file hello
Reading symbols from hello...
(gdb)
Enter fullscreen mode Exit fullscreen mode

O comando info files traz alguns insights:

(gdb) info files
Symbols from "/code/asm-x64/hello".
Local exec file:
        `/code/asm-x64/hello', file type elf64-x86-64.
        Entry point: 0x401000
        0x0000000000401000 - 0x000000000040100c is .text
(gdb)
Enter fullscreen mode Exit fullscreen mode

Que interessante! O entry point do programa começa justamente em 0x401000, que é o que está definido na seção .text.

Para visualizar o código fonte do programa, utilizamos o comando list:

(gdb) list
1       global _start
2
3       _start:
4               mov rdi, 0   ; error code
5               mov rax, 60  ; SYS_exit
6               syscall
(gdb)
Enter fullscreen mode Exit fullscreen mode

Lembrando que o programa ainda não está em execução, estamos apenas analisando o binário executável com o gdb

Com o comando x, de examine, podemos examinar o rótulo _start que é o ponto de entrada do programa:

(gdb) x _start
0x401000 <_start>:      0x000000bf
(gdb)
Enter fullscreen mode Exit fullscreen mode

Se quisermos executar o programa, podemos fazê-lo com o comando run:

(gdb) run
Starting program: /code/asm-x64/hello
[Inferior 1 (process 7991) exited normally]
(gdb)
Enter fullscreen mode Exit fullscreen mode

Entretanto, podemos definir breakpoints antes de executar, assim temos controle do estado do programa em execução:

# Aqui, definimos um ponto de parada no rótulo _start_
(gdb) break _start
Breakpoint 1 at 0x401000: file hello.asm, line 4.

# Info sobre breakpoints
(gdb) info breakpoints
Num     Type           Disp Enb Address            What
1       breakpoint     keep y   0x0000000000401000 hello.asm:4
(gdb)
Enter fullscreen mode Exit fullscreen mode

Agora sim, vamos executar:

(gdb) run
Starting program: /code/asm-x64/hello

Breakpoint 1, _start () at hello.asm:4
4               mov rdi, 0   ; error code
(gdb)
Enter fullscreen mode Exit fullscreen mode

O programa está parado na linha 4 como solicitado. Esta linha no código não foi avaliada, pelo que podemos analisar e alterar o estado do programa:

# Neste momento o valor no registrador RDI está 0 (default)
(gdb) info register rdi
rdi            0x0                 0

# Mudamos o valor do registrador para 42
(gdb) set $rdi = 42

# Agora verificamos que foi modificado diretamente do GDB
(gdb) info register rdi
rdi            0x2a                42
(gdb)
Enter fullscreen mode Exit fullscreen mode

Para avaliar a linha atual, utilizamos o comando next:

(gdb) next
5               mov rax, 60  ; SYS_exit

# E podemos agora verificar que o valor de RDI foi modificado para 0, 
# conforme descrito no programa
(gdb) info register rdi
rdi            0x0                 0
(gdb)
Enter fullscreen mode Exit fullscreen mode

Poderíamos continuar indo linha a linha com next, ou então continuar a execução do programa com continue que pára no próximo ponto de parada ou executa todas as instruções que faltam até terminar o programa.

# Inicia execução e pára no primeiro breakpoint definido
(gdb) run
Starting program: /Users/leandronsp/Documents/code/asm-x64/hello

Breakpoint 1, _start () at hello.asm:4
4               mov rdi, 0   ; error code

# Continua execução. Neste caso termina o programa pois
# não há mais breakpoints a partir deste ponto
(gdb) continue
Continuing.
[Inferior 1 (process 8000) exited normally]
(gdb)
Enter fullscreen mode Exit fullscreen mode

Pronto, terminamos a demonstração do primeiro programa com gdb. Para sair, utilizamos o comando exit.

Rastreando execução com strace

O utilitário strace permite rastrear todas as chamadas de sistema e sinais que um programa faz. É bastante útil quando queremos saber o que pode ter acontecido com determinada syscall, quais parâmetros foram enviados e o que a syscall retornou.

$ strace ./hello

execve("./hello", ["./hello"], 0x7ffc504b5710 /* 24 vars */) = 0
exit(0)                                 = ?
+++ exited with 0 +++
Enter fullscreen mode Exit fullscreen mode

Vamos entender a saída do strace por partes.

execve("./hello", ["./hello"], 0x7ffc504b5710 /_ 24 vars _/) = 0:

  • execve é uma chamada do Linux que executa um determinado programa
  • ./hello é o caminho para o programa que será executado
  • ["./hello"] é a lista de argumentos passados para o programa. Como só há o nome do programa (que entra na lista ARGV), indica que este programa não recebe argumentos extras na linha de comando
  • 0x7ffc504b5710 é o endereço de memória onde as variáveis de ambiente do processo em execução estão armazenadas
  • /* 24 vars */ indica que há 24 variáveis de ambiente definidas no shell atual
  • =0 é o resultado da chamada execve, o que significa que foi bem-sucedido e executado com sucesso

exit(0) = ?:

  • exit é a chamada de sistema (syscall) feita no sistema operacional, e geralmente é definida no libc, sendo no caso de sistema GNU, glibc. Foi o valor 60 passado para o registrador RAX, lembra?
  • (0) é o parâmetro passado para a função, que neste caso foi o que determinamos no registrador RDI, indicando que nosso programa em execução vai terminar sem erros
  • = ? indica que o resultado da chamada de sistema não é conhecido, ou seja não houve um retorno explícito de valor da chamada de sistema

+++ exited with 0 +++:

  • +++ sinaliza o início de uma mensagem de saída do strace
  • exited with 0 indica que o programa terminou sem erros
  • +++ sinaliza o fim da mensagem de saída

Uma vez que entendemos como depurar nosso programa, podemos evolui-lo para imprimir a mensagem "Hello, World" na saída do terminal.


Evoluindo nosso primeiro programa

Vamos agora evoluir o programa anterior para que possamos imprimir a mensagem "Hello, World" na saída padrão STDOUT.

Para isto, conforme vimos na parte III da saga, "Código de Máquina", vamos por partes.

Alocando bytes para "Hello, World"

Precisamos primeiro definir os bytes de cada caracter da string em hexadecimal de acordo com a tabela ASCII, que resulta em 48 65 6C 6C 6F 2C 20 57 6F 72 6C 64 0A.

0x48 para "H", 0x65 para "e", 0x6C para "l" e assim por diante...

Portanto, se quisermos evoluir o primeiro programa que contém apenas a syscall exit, podemos começar por definir a string utilizando a diretiva db que significa define byte, utilizando o endereço do primeiro byte em um rótulo que iremos chamar de msg:

global _start

msg: db 0x48, 0x65, 0x6C, 0x6C, 0x6F, \
        0x2C, 0x20, 0x57, 0x6F, 0x72, \
        0x6C, 0x64, 0xA

_start:
    mov rdi, 0   ; error code
    mov rax, 60  ; SYS_exit
    syscall       
Enter fullscreen mode Exit fullscreen mode

Antes de sair adicionando mais código, vamos utilizar o gdb para analisar o que esta mudança provoca na memória:

# Examinar o que há no rótulo msg
(gdb) x msg
0x401000 <msg>: 0x6c6c6548

# Examinar o que há no rótulo _start
(gdb) x _start
0x40100d <_start>:      0x000000bf
(gdb)
Enter fullscreen mode Exit fullscreen mode

Ora ora, o que temos aqui?

  • msg aponta para o endereço 0x401000 que era o endereço usado pelo _start no nosso programa anterior
  • e agora _start aponta para outro endereço, 0x40100d que está 13 bytes ("d" em hexa) acima de msg, exatamente os 13 bytes da string "Hello, World" adicionado com quebra de linha!!!!!1

Superb! Mas o que significa o valor 0x6c6c6548?

Se analisarmos com calma, dá pra perceber que se trata dos caracteres da string em ASCII segundo o que foi definido no programa. Mas eles estão invertidos, lembra de endianness que foi explicado no artigo anterior?

Então, esta arquitetura segue o padrão little-endian, onde os bytes são armazenados na ordem inversa, do menos relevante (expoentes menores da base 2) para o mais relevante (expoentes maiores).

Voltando ao gdb, podemos confirmar que todos os bytes da string estão alocados trabalhando com ponteiros de 4 em 4 bytes:

(gdb) x msg
0x401000 <msg>: 0x6c6c6548 ; Hell

(gdb) x msg+4
0x401004:       0x57202c6f ; o, W

(gdb) x msg+8
0x401008:       0x646c726f ; orld
Enter fullscreen mode Exit fullscreen mode

Ou então, o comando x permite passar uma quantidade junto com o formato de apresentação, por exemplo queremos que traga os primeiros 13 hexabytes a partir do ponteiro msg:

(gdb) x/13xb msg
0x401000 <msg>: 0x48    0x65    0x6c    0x6c    0x6f    0x2c    0x20    0x57
0x401008:       0x6f    0x72    0x6c    0x64    0x0a
Enter fullscreen mode Exit fullscreen mode

Exatamente os hexadecimais da string "Hello, World" com quebra de linha!

Mas em Assembly, não precisamos definir os bytes de uma string em hexadecimal. Podemos utilizar os quotes literais, assim o programa fica menos verboso e o assembler faz o processo de traduzir o caracter para o hexadecimal da tabela ASCII:

msg: db "Hello, World", 0xA
Enter fullscreen mode Exit fullscreen mode

Não conseguimos representar a quebra de linha dentro de quotes literais, então vamos manter esta com 0xA

Adicionando a chamada de sistema write

Como já sabemos, o programa precisa utilizar a syscall write para escrever na saída, que está definida da seguinte forma no glibc:

ssize_t write(int fd, const void buf[.count], size_t count);
Enter fullscreen mode Exit fullscreen mode
  • nome da syscall vai em RAX
  • primeiro argumento (file descriptor, no caso o STDOUT) vai em RDI
  • segundo argumento (ponteiro para o início do buffer) vai em RSI
  • terceiro argumento (quantidade de bytes a serem escritos) vai em RDX
global _start

msg: db "Hello, World", 0xA
_start:
    ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
    ; Chamada de sistema
    ; glibc -> ssize_t write(int fd, 
                             const void buf[.count], 
                             size_t count)
    ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
    mov rdi, 1   ; STDOUT
    mov rsi, msg ; ponteiro para o início da string
    mov rdx, 13  ; quantidade de bytes a serem escritos
    mov rax, 1   ; nome da syscall: SYS_write
    syscall      ; chamada de sistema

    ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
    ; Chamada de sistema
    ; glibc -> void _exit(int status)
    ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
    mov rdi, 0   ; erro de saída
    mov rax, 60  ; nome da syscall: SYS_exit
    syscall           
Enter fullscreen mode Exit fullscreen mode

Ao compilar o programa com nasm + ld, seguindo a mesma lógica do primeiro programa, temos de fato a saída tão desejada:

$ ./hello
Hello, World
Enter fullscreen mode Exit fullscreen mode

Yay! que dia maravilhoso!

Vamos ver como fica o trace disso tudo agora?

$ strace ./hello

execve("./hello", ["./hello"], 0x7fff139437f0 /* 24 vars */) = 0
write(1, "Hello, World\n", 13Hello, World
)          = 13
exit(0)                                 = ?
+++ exited with 0 +++
Enter fullscreen mode Exit fullscreen mode

Wow, podemos ver que, agora, o programa executa primeiro a syscall write, que retorna o valor 13, que é quantidade de bytes escritos com sucesso; e a seguir executa a syscall exit, também com sucesso, indicando que nosso programa imprime a string na saída e termina sem erros.

Como ficou o tamanho do programa agora?

$ size hello

   text    data     bss     dec     hex filename
     52       0       0      52      34 hello
Enter fullscreen mode Exit fullscreen mode

Hmm, parece que a seção text aumentou de tamanho, que é a adição da string "Hello, World" e das instruções para a syscall write. Mas por enquanto é a única seção existente:

$ size --format sysv --radix 16 hello

hello  :
section           size       addr
.text             0x34   0x401000
(omitindo seções de debug)
Total            0x139

Enter fullscreen mode Exit fullscreen mode

Podemos ver que a string definida no rótulo msg, que começa no endereço 0x401000 está contida na seção .text.

Isto é um problema?

Mais ou menos:

  1. O rótulo msg , que é um "dado", contendo a string, está definido num endereço de memória anterior, ou seja, em endereço de memória mais baixo em direção a 0
  2. O rótulo _start, que é o início do programa, está definido num endereço posterior, ou seja, em endereço de memória mais alto com relação à string

No sistema operacional, todo programa é encapsulado em um processo tal como vimos no artigo anterior. E sendo um processo, é submetido a um "layout" que deve seguir algumas regras.

Layout de memória

Fazendo paralelo com a saída do comando size, a memória do programa segue um layout, que basicamente contém as seguintes seções, ou segmentos de memória:

  • text
  • data
  • bss

Já falamos disto anteriormente neste artigo, mas basicamente na seção text fica todo o código, instruções do programa.

Na seção data, ficam dados inicializados (aqui deveria estar a nossa string). E na seção bss vão os dados não-inicializados, mas já com uma área pré-alocada na memória.

Em termos de espaço virtual de memória do programa, a seção text deve ficar nos endereços de memória mais baixos, próximos ao entry point 0x401000.

Com isto, o programa deve crescer a partir da seção text em direção a data e bss, dos menores endereços de memória para os maiores (da esquerda pra direita):

text -> data -> bss
Enter fullscreen mode Exit fullscreen mode

Ou então, analisando numa imagem em vertical, de baixo pra cima:

layout de memoria 2

Existem mais seções no layout mas vamos adicioná-las à medida que avançamos no artigo. Por agora, como nosso programa está tratando dados (msg) como text, devemos colocar na seção correta, que é data:

global _start

; segmento de dados (endereços mais altos)
section .data
msg: db "Hello, World", 0xA

; segmento de texto (endereços mais baixos)
section .text
_start:
    mov rdi, 1   ; STDOUT
    mov rsi, msg ; ponteiro para o início da string
    mov rdx, 13  ; quantidade de bytes a serem escritos
    mov rax, 1   ; nome da syscall: SYS_write
    syscall      ; chamada de sistema

    mov rdi, 0   ; erro de saída
    mov rax, 60  ; nome da syscall: SYS_exit
    syscall  
Enter fullscreen mode Exit fullscreen mode

Com gdb, podemos conferir que agora estamos obedecendo o layout de memória estabelecido para o programa:

(gdb) x _start
0x401000 <_start>:      0x000000bf

(gdb) x &msg
0x402000 <msg>: 0x6c6c6548
(gdb)
Enter fullscreen mode Exit fullscreen mode

Note que para acessar msg no segmento de dados, precisamos examinar através da referência, com o operador &

Definindo constantes

Em Assembly podemos definir constantes que podem ser reutilizadas em diversas partes do programa, evitando assim alguma redundância com repetição de código e valores.

A diretiva %define permite definir valores constantes tanto para string quanto números:

global _start

%define SYS_write 1
%define SYS_exit 60
%define EXIT_STATUS 1
%define STDOUT 1
%define NEWLINE 0xA

section .data
msg: db "Hello, World", NEWLINE

section .text
_start:
    mov rdi, STDOUT
    mov rsi, msg 
    mov rdx, 13
    mov rax, SYS_write
    syscall      

    mov rdi, EXIT_STATUS
    mov rax, SYS_exit
    syscall  
Enter fullscreen mode Exit fullscreen mode

Podemos também definir uma constante baseada em uma expressão aritmética. Por exemplo, ao invés de deixarmos o tamanho em bytes com valor fixo 13, podemos fazer que isto seja calculado com base em aritmética de ponteiros na memória com a diretiva equ:

...
section .data
msg: db "Hello, World", NEWLINE
msgLen: equ $ - msg
...
Enter fullscreen mode Exit fullscreen mode

O operador $ tem o ponteiro de memória para o último byte no programa, no caso o NEWLINE definido na linha anterior. Ao subtrair do ponteiro msg com a expressão $ - msg, temos o tamanho em bytes calculado e desta forma não precisa ser um valor fixo em RDX:

global _start

%define SYS_write 1
%define SYS_exit 60
%define EXIT_STATUS 1
%define STDOUT 1
%define NEWLINE 0xA

section .data
msg: db "Hello, World", NEWLINE
msgLen: equ $ - msg

section .text
_start:
    mov rdi, STDOUT
    mov rsi, msg 
    mov rdx, msgLen
    mov rax, SYS_write
    syscall      

    mov rdi, EXIT_STATUS
    mov rax, SYS_exit
    syscall  
Enter fullscreen mode Exit fullscreen mode

Wonderful! Nosso programa agora tá muito mais elegante!

Ufa, parece que terminamos o nosso primeiro programa e este por si só já foi uma jornada longa. Mas tenha um pouco mais de paciência, vem comigo, pois chegou o momento de escrevermos um programa um pouco mais sofisticado.

Hora de explorar mais funcionalidades no Assembly e entrar no mundo da stack.


Um programa mais sofisticado

Vamos começar por um programa simples e evoluindo conforme depuramos e entendemos a memória. Ao fim, o programa deve ser capaz de receber um nome através dos argumentos da linha de comando e imprimir "Hi, <nome>".

Desejado:

$ ./greeting Leandro
Hi, Leandro
Enter fullscreen mode Exit fullscreen mode

Definindo labels

Já sabemos que o programa precisa imprimir "Hi, " alguma coisa. Então as instruções pra syscall write são necessárias, e já fazendo uso de constantes:

global _start

%define SYS_write 1
%define SYS_exit 60
%define STDOUT 1

section .data
greet: db "Hi", 0xA

section .text
_start:
    mov rdi, STDOUT
    mov rsi, greet
    mov rdx, 3
    mov rax, SYS_write
    syscall

    mov rdi, 0
    mov rax, SYS_exit
    syscall
Enter fullscreen mode Exit fullscreen mode

Este programa imprime "Hi" apenas. Mas podemos melhorar a organização separando em blocos com algum valor semântico:

  • separar o bloco de exit
  • separar o bloco de write

Assembly emprega o conceito de labels, que são rótulos, mas que podem ser definidas em qualquer parte do código. Utilizando o caracter ponto (.), o programa fica bem mais expressivo:

global _start

%define SYS_write 1
%define SYS_exit 60
%define STDOUT 1

section .data
greet: db "Hi", 0xA

section .text
_start:
.print:
    mov rdi, STDOUT
    mov rsi, greet
    mov rdx, 3
    mov rax, SYS_write
    syscall
.exit:
    mov rdi, 0
    mov rax, SYS_exit
    syscall
Enter fullscreen mode Exit fullscreen mode

Assim como qualquer rótulo, o programa vai executando top-down. O que fizemos aqui foi apenas colocar rótulos em determinadas partes do programa, mas sem alterar seu fluxo de execução.

Desvio de fluxo com jump

Se quisermos alterar o fluxo de execução, podemos utilizar a instrução JMP que altera o fluxo do programa para outro ponto, continuando a partir desde novo ponto.

global _start

%define SYS_write 1
%define SYS_exit 60
%define STDOUT 1

section .data
greet: db "Hi", 0xA

section .text
_start:
    ; Faz o jump para a label .print, sem passar por .exit
    jmp .print
.exit:
    mov rdi, 0
    mov rax, SYS_exit
    syscall
.print:
    mov rdi, STDOUT
    mov rsi, greet
    mov rdx, 3
    mov rax, SYS_write
    syscall

    ; Faz o jump para a label .exit, caso contrário o programa não terminaria
    ; da forma adequada (todo programa deve terminar)
    jmp .exit
Enter fullscreen mode Exit fullscreen mode

Este foi um exemplo bastante simples com jump e desvio de fluxo. Mas é possível também desviar o fluxo, executar a lógica do novo fluxo, e retornar ao ponto anterior.

Entretanto, para que isto funcione, vamos imaginar uma possível solução:

  • definir algum registrador "especial" que guarda sempre o ponteiro da próxima instrução
  • antes de desviar o fluxo, guardar o endereço de memória da próxima instrução do programa em alguma estrutura de dados para que possa ser resgatado quando a lógica do desvio terminar

Sim, estamos falando do desvio com call, ret, registradores e pilha.

Desvio de fluxo com call

Tendo o exemplo anterior, ao invés de fazer jmp, vamos utilizar a instrução call que faz o desvio para outra rotina:

call .print  ; <------ chamada da rotina
Enter fullscreen mode Exit fullscreen mode

Além disso, a última linha da rotina .print deve "retornar" o fluxo desviado para o ponto anterior.

.print:
    mov rdi, STDOUT
    mov rsi, greet
    mov rdx, 3
    mov rax, SYS_write
    syscall
    ret  ; <------ retorno da rotina
Enter fullscreen mode Exit fullscreen mode

Antes de analisarmos com gdb passo a passo, precisamos entender um aspecto importante dos programas no sistema operacional.

Quando um programa é executado, ele é definido em uma estrutura chamada processo (já falamos disto no artigo anterior). Todo processo carrega o layout de memória definido no binário do programa, conforme vimos anteriormente:

text -> data -> bss
Enter fullscreen mode Exit fullscreen mode

Nos endereços mais altos da memória virtual do processo (programa em execução), o sistema operacional também define uma outra estrutura de dados, chamada stack, que tem um formato de pilha (LIFO, Last In, First Out).

text -> data -> bss ---------> <-------- stack
Enter fullscreen mode Exit fullscreen mode

layout de memória com stack

A stack fica nos endereços mais altos e carrega informações como argumentos do programa, lista de variáveis de ambiente definidas no shell, argumentos para funções entre qualquer informação pertinente para o programa. Stack sempre cresce para baixo em direção aos endereços menores.

rsp
Em um programa Assembly x86, é preciso armazenar o ponteiro atual do topo da stack, e esta informação fica no registrador RSP, ou stack pointer.

rip
Já o ponteiro da instrução atual fica no registrador RIP, ou instruction pointer.

Com estes dois registradores conseguimos demonstrar o uso de call e ret para desvio de fluxo. Voltando ao programa:

global _start

%define SYS_write 1
%define SYS_exit 60
%define STDOUT 1

section .data
greet: db "Hi", 0xA

section .text
_start:
    call .print     ; <--------- desvio do fluxo

    ; aqui neste ponto, já continua a execução normal em direção ao exit
    ; para terminar o programa
.exit:
    mov rdi, 0
    mov rax, SYS_exit
    syscall
.print:
    mov rdi, STDOUT
    mov rsi, greet
    mov rdx, 3
    mov rax, SYS_write
    syscall
    ret            ; <---------- retorno ao ponto anterior
Enter fullscreen mode Exit fullscreen mode

E agora demonstrando com gdb:

$ gdb greeting

# Breakpoint em _start, início do programa
(gdb) break _start_
(gdb) run

# RIP apontando para 0x401000 (_start), o entry point do programa
(gdb) info register $rip
rip            0x401000            0x401000 <_start>

# RSP apontando para um endereço de memória
# Se formos examinar com x $rsp, temos
# 0x7fffffffe450: 0x00000001, que é a quantidade de argumentos
# passados, no caso 1 representa apenas o nome do programa, ou seja
# não há argumentos na linha de comando
(gdb) i r $rsp
rsp            0x7fffffffe450      0x7fffffffe450
Enter fullscreen mode Exit fullscreen mode

Até aqui okay, agora vamos andar um step para que o desvio de call seja avaliado e analisar o RIP:

(gdb) step
18              mov rdi, STDOUT

# O RIP andou conforme esperado
(gdb) i r $rip
rip            0x401011            0x401011 <_start.print>

# RIP apontando para 0x000001bf, que é BF 01 00 00 
# Lembra? é o opcode pra MOV RDI, 1
# Exatamente onde paramos
(gdb) x $rip
0x401011 <_start.print>:        0x000001bf
Enter fullscreen mode Exit fullscreen mode

E a stack (RSP) como ficou?

# A pilha andou alguns bytes (no caso foi feito um "push", o que a fez crescer para endereços de memórias menores)
# Lembra? Pilha "cresce pra baixo" na memória
(gdb) i r rsp
rsp            0x7fffffffe448      0x7fffffffe448

# Opaaa, o que temos aqui? 0x00401005
# É alguma pista...
(gdb) x $rsp
0x7fffffffe448: 0x00401005

# Examinando o ponteiro do início do programa...
(gdb) x _start
0x401000 <_start>:      0x00000ce8

# Se andarmos alguns bytes, 
# temos exatamente o endereço da label .exit, 0x401005
(gdb) x _start + 5
0x401005 <_start.exit>: 0x000000bf
Enter fullscreen mode Exit fullscreen mode

Se você prestou atenção nos comentários do snippet acima...

É muito importante prestar atenção em todos os comentários, se não estiver fazendo isso, volte o artigo do início e tente acompanhar no terminal, é extremamente importante para entender os conceitos

...se prestarmos a devida atenção, este é o endereço que tá no topo da pilha agora, que foi adicionado pela instrução call.

Ok Leandro, mas como fazemos então para voltar ao ponto anterior?

Calma, jovem. Estamos parados no início da rotina .print. Vamos continuar a depuração com gdb até parar em ret:

(gdb) next
19              mov rsi, greet
(gdb) next
20              mov rdx, 3
(gdb) next
21              mov rax, SYS_write
(gdb) next
22              syscall
(gdb) next
Hi
_start.print () at greeting.asm:23
23              ret
(gdb)
Enter fullscreen mode Exit fullscreen mode

Nice, antes de avaliar a instrução ret, podemos ver que RIP andou mas RSP continua na mesma, com o endereço da próxima instrução antes do desvio:

# RIP aponta para a instrução da linha "ret"
(gdb) x $rip
0x40102c <_start.print+27>:     0x000000c3

# RSP aponta para o endereço de memória que está a instrução .exit, que
# vem a seguir ao desvio feito com "call" lá em cima
(gdb) x $rsp
0x7fffffffe448: 0x00401005
Enter fullscreen mode Exit fullscreen mode

Vamos andar com ret e....

# RIP agora aponta para 0x401005, que é a instrução .exit
(gdb) x $rip
0x401005 <_start.exit>: 0x000000bf

# Foi feito "pop" em RSP e agora este aponta para o topo da pilha
# com o valor exato quando estava no início do programa
(gdb) x $rsp
0x7fffffffe450: 0x00000001
(gdb)
Enter fullscreen mode Exit fullscreen mode

OMG!! Acabamos de demonstrar manipulação de registradores e pilhas.

Brincando com pilhas

Pilhas é divertido.

Mas prefiro filas, gosto de tratar as coisas de modo ordenado. Quem chega primeiro precisa ser atendido primeiro kkkkkkkk

Mas com pilhas não é assim. Quem entra por último sai primeiro.

Com base nisto, como podemos manipular a stack do programa? Vamos alterar um pouco o código adicionando o ponteiro de greet na stack:

global _start

%define SYS_write 1
%define SYS_exit 60
%define STDOUT 1

section .data
greet: db "Hi", 0xA

section .text
_start:
    push greet    ; <----- adiciona o ponteiro de greet na stack
    call .print
.exit:
    mov rdi, 0
    mov rax, SYS_exit
    syscall
.print:
    mov rdi, STDOUT
    mov rsi, greet
    mov rdx, 3
    mov rax, SYS_write
    syscall
    ret
Enter fullscreen mode Exit fullscreen mode

No gdb, vamos colocar um breakpoint na linha da chamada call:

# Breakpoint na linha 13
(gdb) b 13
Breakpoint 1 at 0x401005: file greeting.asm, line 13.

# Run
(gdb) r

# Examinando o topo da pilha
(gdb) x $rsp
0x7fffffffe448: 0x00402000

# Examinando o endereço de memória que tá no topo da pilha
(gdb) x 0x00402000
0x402000 <greet>:       0x2c0a6948
Enter fullscreen mode Exit fullscreen mode

Cool, temos 0x48 0x69 0x0A (little-endian), exatamente a string "Hi" seguida de uma quebra de linha. Com esta rica informação, ao invés da rotina .print passar pro registrador RSI o ponteiro de greet, porque não passar o ponteiro do topo da pilha?

Algo nessa linha:

; ao invés disso (atual)
mov rsi, greet

; que tal mover o ponteiro que tá em rsp (topo da pilha) para rsi
mov rsi, rsp
Enter fullscreen mode Exit fullscreen mode

Por enquanto, seguramos esta ideia no bolso. Ainda no gdb, vamos continuar analisando a pilha depois de entrar na rotina:

(gdb) step
_start.print () at greeting.asm:19

# Agora o topo da pilha foi modificado, "call" colocou o endereço de 
# memória da próxima instrução quando voltar do "ret"
(gdb) x $rsp
0x7fffffffe440: 0x0040100a

# O endereço de memória aponta justamente pra próxima instrução quando voltar do "ret", no caso a instrução que tá na label .exit do programa
(gdb) x 0x0040100a
0x40100a <_start.exit>: 0x000000bf
Enter fullscreen mode Exit fullscreen mode

Mas agora o topo da pilha estraga nossa ideia de fazer mov rsi, rsp, mas podemos fazer aritmética com ponteiros e mover o conteúdo resultante, e é muito fácil:

# Topo da pilha apontando pra instrução guardada pelo "call"
(gdb) x $rsp
0x7fffffffe440: 0x0040100a

# Topo da pilha + 8 bytes apontando pro endereço onde tá a string "Hi"
(gdb) x $rsp+8
0x7fffffffe448: 0x00402000
Enter fullscreen mode Exit fullscreen mode

Nesta arquitetura, a pilha, assim como os registradores, armazenam por padrão até 8 bytes por cada informação

Então teoricamente, tudo o que precisamos é mov rsi, [rsp + 8]

Note que é preciso usar [rsp + 8], com square brackets é uma forma de fazermos aritmética de ponteiros e acessar o valor resultante da operação na memória, no caso o endereço apontando para a string "Hi"

Para finalizar este primeiro exemplo, é muito importante fazermos "pop" da pilha. Todo push deve ter um pop, caso contrário podemos gastar a pilha desnecessariamente e talvez chegar a um stack overflow se exagerarmos bastante.

global _start

%define SYS_write 1
%define SYS_exit 60
%define STDOUT 1

section .data
greet: db "Hi", 0xA

section .text
_start:
    push greet             ; <----- push na pilha
    call .print
    pop rbp                ; <----- pop da pilha, jogando o valor em rbp
                           ; note que rbp é outro registrador de propósito geral,
                           ; mas que é utilizado para manter a base da pilha
.exit:
    mov rdi, 0
    mov rax, SYS_exit
    syscall
.print:
    mov rdi, STDOUT
    mov rsi, [rsp + 8]     ; <----- 8 bytes depois do topo da pilha está o
                           ; endereço de memória da string
    mov rdx, 3
    mov rax, SYS_write
    syscall
    ret
Enter fullscreen mode Exit fullscreen mode

Podemos reparar 2 coisas:

  • A rotina .print está ficando bastante genérica, ou seja ela não sabe o que está na pilha, simplesmente move para o registrador RSI e faz a syscall write
  • A rotina .print ainda usa o tamanho em bytes como valor fixo, no caso 3 bytes. Deveria ser dinâmico também se quisermos fazer com que esta rotina seja bem genérica

Colocamos o tamanho também na pilha? Nah, seria mais interessante ainda se calculássemos dinamicamente o que vem da pilha. Para fazer este cálculo, teríamos que "iterar", em forma de loop, por cada byte que queremos imprimir, incrementar em um registrador e utilizar isto na syscall.

Vamos entrar no mundo dos loops e condicionais.

Calculando tamanho dinamicamente com loop

Combinando labels e jumps, podemos criar um loop em assembly, como neste pequeno exemplo a seguir:

; Um loop infinito sem condição de parada
; Não façam isso

global _start

_start:
.loop
    jmp .loop
Enter fullscreen mode Exit fullscreen mode

Entretanto para adicionarmos uma condição de parada do loop, é necessário utilizar uma instrução de comparação e outra que muda algum estado.

No nosso exemplo, vamos introduzir um loop que calcula o tamanho da string antes de fazer a syscall. Entendendo a necessidade:

; Pseudo-code

.print:
    mov rdi, STDOUT
    mov rsi, [rsp + 8]   ; string em RSI     

    mov rdx, ?           ; <--- aqui devemos introduzir um loop que vai
                         ; modificando o valor de RDX, lendo byte a byte
                         ; o conteúdo da string
    mov rax, SYS_write
    syscall
    ret
Enter fullscreen mode Exit fullscreen mode

Para resolver isto, podemos criar uma label chamada .calculate_size que contém um jmp para ela mesma:

.print:
    mov rsi, [rsp + 8]       ; string em RSI 
    mov rdx, 0               ; RDX começa em 0
.calculate_size:             ; label
    jmp .calculate_size      ; jmp "recursivo"
.done:
    mov rdi, STDOUT
    mov rax, SYS_write
    syscall
    ret
Enter fullscreen mode Exit fullscreen mode

Ao rodarmos o programa, obviamente caímos em loop infinito. Precisamos definir uma condição de parada, que consiste em:

  • mudar o estado de alguma variável condicional
  • desviar o fluxo para outra label quando a condição for verdadeira

Em Assembly, podemos fazer a mudança de estado utilizando a instrução inc:

.print:
    mov rsi, [rsp + 8]     
    mov rdx, 0             ; RDX (contador) começa em 0
.calculate_size:
    inc rdx                ; incrementa o valor que está em RDX (linha 23)
    jmp .calculate_size
.done:
    mov rdi, STDOUT
    mov rax, SYS_write
    syscall
    ret
Enter fullscreen mode Exit fullscreen mode

Com gdb, verificamos que o valor de RDX está sempre sendo incrementado:

# Adicionar breakpoint na linha 23 (<<inc rdx>>)
(gdb) break 23
Breakpoint 1 at 0x401021: file live.asm, line 23.

# Executar o programa, que vai no primeiro breakpoint
(gdb) run
Breakpoint 1, _start.calculate_size () at live.asm:23
23              inc rdx

# Continuar execução até o próximo breakpoint ou fim do programa.
# Mas como estamos em loop, o programa vai parar de novo nesta linha
(gdb) continue

# Atalho para "info register rdx"
(gdb) i r rdx
rdx            0x1                 1

# Próxima iteração...
(gdb) continue
(gdb) i r rdx
rdx            0x2                 2

# E assim infinitamente pois não temos ainda a segunda premissa da condição de parada, que é a condicional
(gdb) continue
(gdb) i r rdx
rdx            0x19                25
Enter fullscreen mode Exit fullscreen mode

Como podemos elaborar esta condicional, uma vez que o valor em RDX pode ser infinito, logo ter todas as possibilidades?

Uma ideia é irmos consumindo byte a byte da string até chegar a zero. Para isto, podemos definir o fim da string com 0x0 e fazer aritmética binária na própria string, consumindo os bytes até chegar a 0x0!

Eis o exemplo com um pseudo-código:

; "Hi", 0 
; que em hexabyte fica 0x49, 0x69, 0x00

INCREMENT
0x69 0x00   ; consumiu o byte mais à esquerda 0x49

INCREMENT   ; consumiu o byte mais à esquerda 0x69
0x00 0x...
Enter fullscreen mode Exit fullscreen mode

increment em endereço de memória

Nossa Leandro, que fantástico! Podemos então fazer inc em um registrador que contém a string, nesse caso o próprio RSI?

Isso mesmo!

.....
section .data
greet: db "Hi", 0xA, 0      ; <--- adicionamos o "zero" para identificar o 
                            ; fim da string

.....

.print:
    mov rsi, [rsp + 8]     
    mov rdx, 0
.calculate_size:
    inc rdx                 ; incrementa o valor inteiro em RDX (contador)
    inc rsi                 ; <--- além de incrementar o RDX, incrementamos
                            ; também o RSI, que contém o endereço de
                            ; memória para a string. Aritmética em hexabytes
                            ; vai fazer o efeito de "consumir os bytes até zero
    jmp .calculate_size
.done:
    mov rdi, STDOUT
    mov rax, SYS_write
    syscall
    ret

Enter fullscreen mode Exit fullscreen mode

Agora, com gdb, vamos verificar o que está acontecendo com nosso programa:

# Breakpoint na linha <jmp .calculate_size>
(gdb) break 25
Breakpoint 1 at 0x401027: file live.asm, line 25.

(gdb) run

# Cool, o contador RDX foi incrementado
(gdb) i r rdx
rdx            0x1                 1

# Em RSI, temos outro endereço de memória.
# Anteriormente era o início da string, 0x402000, mas agora está
# apontando para 0x402001
(gdb) i r rsi
rsi            0x402001            4202497

# Wow! Temos os bytes da string "i", seguido de "\n", e depois o 0x00
# Parece que o inc RSI funcionou como esperávamos?
(gdb)x /4xb 0x402001
0x402001:       0x69    0x0a    0x00    0x2c

# A vida continua...
(gdb) continue

# Caminho mais curto
# E parece que RSI andou mais ainda, agora apontando para o byte "\n"
(gdb) x $rsi
0x402002:       0x0a

# A vida continua...
(gdb) continue

# O nosso grande momento! Agora RSI aponta para 0x00
(gdb) x $rsi
0x402003:       0x00
Enter fullscreen mode Exit fullscreen mode

Tudo o que precisamos fazer, neste momento, é comparar o valor que está em RSI com zero. Se chegou a zero, significa que podemos parar o loop. Vamos verificar o que está no contador RDX (esperamos que seja 3):

(gdb) i r rdx
rdx            0x3                 3
Enter fullscreen mode Exit fullscreen mode

Yay! Que grande momento!

Mas como verificar em Assembly se chegou ou não no valor? Existe "IF" e "ELSE" em Assembly?

Hell no!

Não. Não tem "IF" e "ELSE" em Assembly.

Uma possível solução seria:

  • utilizar uma instrução que compare o valor de um registrador ou em algum endereço de memória com qualquer outro valor
  • esta instrução iria guardar o resultado da comparação em outro registrador "especial"
  • utilizar outra instrução para fazer desvio do fluxo de acordo com o valor que estive neste registrador especial

Sim, é aí que entramos no tal do registrador RFLAGS.

RFLAGS

O registrador de flags é um registrador de status que mantém sempre o estado atual da CPU, neste caso estamos referindo a uma CPU x86_64, pelo que chamamos este registrador de RFLAGS.

Este registrador guarda opcodes condicionais, que são resultado de diversas operações lógicas e aritméticas que afetam o estado da CPU.

Voltando ao nosso exemplo, podemos comparar o registrador RSI com o valor 0, e então verificar o que está acontecendo com o registrador eflags:

.print:
    mov rsi, [rsp + 8]     
    mov rdx, 0
.calculate_size:
    inc rdx
    inc rsi
    cmp byte [rsi], 0x00   ; <-- aqui comparamos (em byte) o valor que está
                           ; em RSI com o byte 0x00
    jmp .calculate_size
.done:
    mov rdi, STDOUT
    mov rax, SYS_write
    syscall
    ret
Enter fullscreen mode Exit fullscreen mode

E com isto podemos conferir com gdb:

# Breakpoint na linha <cmp byte [rsi], 0x00>
(gdb) break 25

(gdb) run

# O que temos no primeiro byte de RSI? "i", pois o "H" já foi
# consumido no <inc rsi>
(gdb) x /1xb $rsi
0x402002:       0x69

# E no eflags?
# Nossa, temos o IF que estávamos precisando!!!!!11
(gdb) i r eflags
eflags         0x202               [ IF ]
Enter fullscreen mode Exit fullscreen mode

Calma jovem, IF não é o que você está pensando!

IF é uma flag chamada interrupt flag, que está sempre presente no programa em execução. Ela determina se o programa pode ou não sofrer interrupções de hardware. No nosso caso, está sempre habilitada por padrão, e é por este motivo que podemos fazer chamadas de sistema (syscalls).

Continuando no gdb...

(gdb) continue

# O que temos em RSI? "\n"
(gdb) x /1xb $rsi
0x402002:       0x0a

# Executar a instrução <cmp byte [rsi], 0x00>
(gdb) next

# Ok, segunda iteração continua na mesma, sem flags adicionais
(gdb) i r eflags
eflags         0x202               [ IF ]

######## Próxima iteração ##########

(gdb) continue

# O que temos em RSI? 0x00, cool.
(gdb) x /1xb $rsi
0x402003:       0x00

# Executar a instrução <cmp byte [rsi], 0x00>
(gdb) next

# Outras flags foram adicionadas ao estado: PF e ZF
(gdb) i r eflags
eflags         0x246               [ PF ZF IF ]
Enter fullscreen mode Exit fullscreen mode

PF é a parity flag, que é adicionada quando uma operação aritmética em qualquer registrador resulta em paridade ímpar.

Não é do escopo deste artigo entrar em detalhes sobre PF, sugiro a leitura sobre o assunto

Já a ZF é chamada zero flag, adicionada quando uma operação aritmética resulta em zero, que é exatamente o que estamos buscando aqui.

Agora o que precisamos é desviar o fluxo (lembra do jmp) quando a flag zero está presente. Para isto, temos a disposição diversas instruções de jump baseadas em flags:

  • jz (jump if zero)
  • jnz(jump if not zero)
  • je (jump if equal)
  • jne (jump if not equal)

Isto pra mencionar apenas algumas, existem muitas outras que podem ser consultadas aqui

Com isto, a instrução que precisamos é a jz, que verifica se a flag ZF está presente:

.print:
    mov rsi, [rsp + 8]     
    mov rdx, 0
.calculate_size:
    inc rdx
    inc rsi
    cmp byte [rsi], 0x00     ; <--- compara RSI com 0x00. Adiciona a flag ZF                                  ; quando chegar a zero
    jz .done                 ; <--- desvia fluxo para a label ".done" caso a
                             ; flag ZF esteja presente
    jmp .calculate_size
.done:
    mov rdi, STDOUT
    mov rax, SYS_write
    syscall
    ret
Enter fullscreen mode Exit fullscreen mode

Com gdb, colocamos o breakpoint na linha mov rdi, STDOUT que é depois do loop. Caso o programa fique parado nesta linha, significa que o loop foi concluído com sucesso e os bytes da string devidamente calculados:

# Breakpoint na linha <mov rdi, STDOUT>
(gdb) break 29

(gdb) run

# Olha o que temos aqui
(gdb) i r rdx rsi
rdx            0x3                 3
rsi            0x402003            4202499

# E se formos examinar a string (com x/s) em RSI, temos isto:
(gdb) x/s $rsi
0x402003:       ""
Enter fullscreen mode Exit fullscreen mode
  • Em RDX, temos o contador, que está em 3, que é a quantidade de bytes que será passada como terceiro argumento da syscall write. Okay, aqui ficou tudo certo.
  • Em RSI, temos 0x402003, e o valor está vazio, ou 0x00. Isto é um problema

O problema reside no fato de que RSI precisa ter o ponteiro para a string em si, e as operações de inc rsi modificaram o registrador, pelo que não queremos que isto aconteça.

Podemos então inicialmente mover o valor que está em RSI para outro registrador temporário, que pode ser um daqueles registradores de rascunho, chamados de draft registers:

.print:
    mov rsi, [rsp + 8]     
    mov r9, rsi          ; aqui preservamos RSI, movendo o valor para R9
    mov rdx, 0
.calculate_size:
    inc rdx
    inc r9               ; incrementar o valor em R9, preservando assim RSI
    cmp byte [r9], 0x00  ; comparar 0x00 com R9, e não mais RSI
    jz .done
    jmp .calculate_size
.done:
    mov rdi, STDOUT       
    mov rax, SYS_write
    syscall              ; no momento da syscall, RSI está intacto, contendo
                         ; o ponteiro para o endereço de memória onde está
                         ; localizada a nossa queridíssima string "Hi"
    ret
Enter fullscreen mode Exit fullscreen mode

Após estas alterações, vamos executar o programa completo:

./greeting
Hi
Enter fullscreen mode Exit fullscreen mode

Que dia maravilhoso! Nosso programa imprime a string "Hi" calculando dinamicamente o tamanho dos bytes da string!

Entretanto, queremos implementar a proposta inicial, não é, Leandro? O programa não tem que ler o nome da linha de comando e imprimir "Hi, Leandro"?

Botando mais pilha no negócio

Nosso objetivo é chamar ./greeting com argumento e assim o programa deve imprimir Hi, com o argumento enviado:

# Objetivo, isto ainda não funciona
./greeting Leandro
Hi, Leandro
Enter fullscreen mode Exit fullscreen mode

Se pensarmos um pouco, podemos inferir que qualquer argumento pode ser armazenado na stack do processo, que é quando o programa está em execução.

Com gdb, podemos confirmar isto:

# Breakpoint na primeira linha do programa, depois do _start
(gdb) break 12

# Executa o programa com o argumento "Leandro"
(gdb) run Leandro

# Onde estará Leandro? Na pilha? (rsp)
#      -> x de examine
#      -> /8xb os primeiros 8 hexa bytes
(gdb) x /8xb $rsp
0x7fffffffe450: 0x02    0x00    0x00    0x00    0x00    0x00    0x00    0x00
Enter fullscreen mode Exit fullscreen mode

Mas o quê significa esse número 2? Vamos examinar a stack e a ordem das informações contidas nela.

Voltando ao gdb, e se lermos os próximos 8 bytes na stack?

(gdb) x /8xb $rsp + 8
0x7fffffffe458: 0xb1    0xe6    0xff    0xff    0xff    0x7f    0x00    0x00
Enter fullscreen mode Exit fullscreen mode

Lembrando que os bytes são escritos na stack em formato little-endian, ou seja estão invertidos

Com isto, temos um hexadecimal 0x7fffffffe6b1. Parece um endereço de memória, não?

# Examinando o endereço de memória no formato de string (/s)
(gdb) x /s 0x7fffffffe6b1
0x7fffffffe6b1: "/Users/..../code/asm-x64/live"
Enter fullscreen mode Exit fullscreen mode

Wow, temos o primeiro argumento, também chamado de ARG0 que é o nome do programa com o caminho absoluto no sistema operacional.

Andando mais 8 bytes...

# Endereço de memória...
(gdb) x /8xb $rsp + 16
0x7fffffffe460: 0xdf    0xe6    0xff    0xff    0xff    0x7f    0x00    0x00

# Examinando o valor que está no endereço
(gdb) x /s 0x7fffffffe6df
0x7fffffffe6df: "Leandro"
Enter fullscreen mode Exit fullscreen mode

Yay! Temos o nosso argumento, armazenado na stack. É o primeiro argumento, também chamado de ARG1.

Se continuarmos andando na stack de 8 em 8 bytes, vamos passar por todos os argumentos (no nosso caso não há mais), e a seguir vamos chegar no vetor ambiente, que contém todas as variáveis de ambiente contidas no shell que está executando o nosso programa

Com isto, sabemos que o primeiro argumento está localizado em rsp + 16:

  • rsp: quantidade de argumentos
  • rsp + 8: ARG0, nome do programa
  • rsp + 16: ARG1, primeiro argumento (se existir)
  • rsp + 24: ARG2, segundo argumento (se existir)
  • e assim sucessivamente...até chegar no vetor de variáveis de ambiente (vetor ambiente)

layout de memória com stack

Sabendo que nossa sub-rotina .print já recebe uma string da stack e calcula dinamicamente o tamanho da string que foi passada, podemos passar pro topo da stack o nosso argumento (que por acaso também está na stack), e em seguida chamada a rotina .print novamente:

section .data
greet: db "Hi, ", 0

_start:
    push greet      
    call .print     
    pop rbp     

    ; aqui fazemos push pro topo da stack o valor que está em RSP + 16 (ARG1)
    ; utilizamos o tipo "qword" que significa "quadword"
    push qword [rsp + 16]
    call .print
    pop rbp
...
Enter fullscreen mode Exit fullscreen mode

O quê significa quadword? Em assembly podemos definir tipos de bytes, que basicamente são grupos de bytes, podendo ou não caber em um registrador ou stack dependendo da arquitetura da CPU.

  • byte: especifica 1 byte (8-bit)
  • word: 2 bytes (16-bit)
  • dword: 4 bytes (32-bit)
  • qword: 8 bytes (64-bit)
  • tbyte: 10 bytes

Na arquitetura x86_64, precisamos especificar que o tipo de byte adicionado na stack, quando não vier de um registrador mas sim de um lugar arbitrário na memória ou stack, tem um determinado tamanho em bytes.

Neste caso, estamos utilizando qword que é justamente 8 bytes (ou 64-bit) que representa a arquitetura em questão.

Precisamos adicionar mais um caracter, que é o newline, ou \n, no fim da mensagem. Para isto, podemos definir um dado inicializado e chamar a rotina .print , que já está bem "crescidinha", não?

Programa completo, com comentários:

global _start

%define SYS_write 1
%define SYS_exit 60
%define STDOUT 1

section .data
greet: db "Hi, ", 0
newline: db 0xA, 0

section .text
_start:
    push greet             ; adiciona "Hi, " na stack para print
    call .print     
    pop rbp         

    push qword [rsp + 16]  ; adiciona ARG1 na stack para print
    call .print
    pop rbp

    push newline           ; adiciona newline na stack para print
    call .print
    pop rbp
.exit:                     ; label de término do programa
    mov rdi, 0
    mov rax, SYS_exit
    syscall
.print:                    ; rotina de print no STDOUT
    mov rsi, [rsp + 8]     
    mov r9, rsi
    mov rdx, 0
.calculate_size:           ; loop para calcular tamanho da string
    inc rdx
    inc r9
    cmp byte [r9], 0x00
    jz .done
    jmp .calculate_size
.done:                     ; label para finalizar a rotina print e retornar
    mov rdi, STDOUT
    mov rax, SYS_write
    syscall
    ret
Enter fullscreen mode Exit fullscreen mode

Rodamos o programa e:

./greeting Leandro
Hi, Leandro
Enter fullscreen mode Exit fullscreen mode

OMG! Eu não estou acreditando no que estou vendo!!!!!11

Depurando o programa final com strace

Com strace, podemos fazer o trace de syscalls do programa final. Olha que maravilha isto:

$ strace ./greeting Leandro

execve("./greeting", ["./greeting", "Leandro"], 0x7ffc30f75368 /* 24 vars */) = 0
write(1, "Hi, ", 4Hi, )                     = 4
write(1, "Leandro", 7Leandro)                  = 7
write(1, "\n", 1
)                       = 1
exit(0)                                 = ?
+++ exited with 0 +++
Enter fullscreen mode Exit fullscreen mode

Foram feitas 4 chamadas de sistema, sendo:

  • 1 write, "Hi, "
  • 1 write "Leandro"
  • 1 write "\n"
  • 1 exit

Falando um pouco de registradores

Até o momento, vimos durante este artigo a utilização de alguns registradores que foram muito úteis para o desenvolvimento do programa, dentre eles RSI, RAX, RDX, RSP, RIP, RFLAGS e assim por diante.

Mas qual o propósito de cada registrador? Posso usar qualquer registrador para qualquer operação, de forma aleatória?

De forma prática, sim. Mas nem sempre convém.

Nada impede que o teu programa coloque qualquer valor em um registrador arbitrário. Por exemplo, com gdb vamos alterar alguns registradores e ver como o programa se comporta:

# Breakpoint & run
(gdb) break 13
(gdb) run Leandro

# Vamos alterar alguns registradores arbitrários
(gdb) set $rax = 42
(gdb) set $rdx = 33

# Confirmando que foram modificados
(gdb) i r rax rdx
rax            0x2a                42
rdx            0x21                33

# Continuando...
(gdb) continue
Continuing.
Hi, Leandro
[Inferior 1 (process 19231) exited normally]
Enter fullscreen mode Exit fullscreen mode

Okay, podemos ver que ter mudado estes registradores para qualquer valor não impactou o programa. No meio do programa, provavelmente eles são sobrescritos novamente e utilizados de acordo com determinada lógica.

Mas e se alterarmos, por exemplo, um registrador como o rip, que é o ponteiro da próxima instrução?

# Breakpoint & run
(gdb) break 13
(gdb) run Leandro

# Antes de alterar o RIP, podemos ver qual o valor ele carrega,
# que é o ponteiro da próxima instrução
(gdb) i r rip
rip            0x401000            0x401000 <_start>

# Vamos alterar o registrador RIP
(gdb) set $rip = 42

# Confirmando que foi alterando
(gdb) i r rip
rip            0x2a                0x2a

# Continuando...
(gdb) continue
Continuing.
Program received signal SIGSEGV, Segmentation fault.
0x000000000000002a in ?? ()
Enter fullscreen mode Exit fullscreen mode

Ouch! Agora o programa não pôde ser finalizado com sucesso. Confirmamos então que nem sempre convém mudar os registradores sem haver algum critério.

Propósito dos registradores

Os registradores, e falando especificamente da arquitetura x86, seguem um propósito original para o qual foram designados. Mas também podem ser utilizados em convenções de chamadas de sistema tal como vimos na montagem das syscalls write e exit, e neste caso a utilização correta importa bastante.

E além disso, alguns registradores contém dados importantes para a execução do programa, tais como o rip e eflags.

  • Propósito original
  • Convenções de chamadas
  • Funcionamento crítico do programa

Apesar destas características importantes de uso dos registradores, podem haver situações em que utilizar um registrador de propósito geral é o que faz mais sentido para o programa. Vamos a seguir destacar alguns registradores e seus propósitos originais.

Registradores de propósito geral

Podemos categorizar os registradores de uso geral em 2 partes: manipulação de dados diretos ou endereços de memória.

Dados
Registradores podem manipular dados, que chamamos de valor imediato, e nesta categoria podemos utilizar RAX, RBX, RCX, RDX e os registradores de rascunho que vão de R8 a R15.

  • RAX: operações aritméticas e armazenamento de resultados; também usado para o nome de chamadas de sistema em convenções de chamada (syscalls)
  • RBX: ponteiro de base, utilizado para o endereço de algumas informações na memória
  • RCX: geralmente usado como contador, para armazenar a quantidade de vezes que uma instrução deve ser executada
  • RDX: usado para algumas operações de multiplicação e divisão, muito utilizado para armazenar o resto de operações
  • R8 a R15: registradores de rascunho utilizados para propósito geral

Endereços de memória
Registradores também permitem manipular endereços de memória. Nesta categoria temos RSI, RDI, RBP e RSP.

  • RSI: utilizado como um ponteiro de origem em operações de transferências de dados, frequentemente usado em loops para iterar sobre arrays ou buffers de dados
  • RDI: utilizado como ponteiro de destino em operações de transferências de dados, frequentemente usado junto com RSI
  • RBP: frequentemente usado como ponteiro base em operações de memória, para referenciar variáveis locais e parâmetros de função na stack
  • RSP: ponteiro para o topo da pilha (stack) do programa em execução

Registradores especiais

Vamos destacar apenas 2 dos registradores considerados "especiais":

  • RFLAGS: utilizado para armazenar o estado da CPU, frequentemente modificado por instruções aritméticas e controle de paridade binária
  • RIP: ponteiro de instrução, que sempre contém o endereço da próxima instrução a ser executada. Por exemplo, a instrução ret busca o endereço do topo da pilha e modifica o rip para que o programa continue a partir daquele ponto

tipos de registradores

Precisamos sempre utilizar todos os 64 bits?

Sabemos que registradores nesta arquitetura ocupam 64 bits de memória. Mas e quando o dado que estamos manipulando não precisa dos 64 bits? Conseguimos otimizar o uso de memória?

A ideia seria algo do tipo "por favor me dê uma fatia dos 64 bits, não preciso de tudo"

Historicamente, como vimos na parte II desta saga, as CPU's x86 não começaram com 64 bits. Evoluíram de 8 bits, para 16, então 32 até chegar em 64 bits.

Para manter compatibilidade, os registradores "legados" podem ser utilizados na arquitetura x64, e assim quando não houver necessidade de utilizar todos os bits do registrador, podemos utilizar uma fatia menor.

Por exemplo, o registrador RAX de 64-bits tem o seu equivalente de 32-bits que é o EAX, que ocupa os 32 bits mais baixos.

O registrador EAX, por sua vez, tem o equivalente AX de 8-bits. Dentro deste AX, podemos utilizar ainda a parte maior que se chama AH ou a parte menor que se chama AL.

O "H" em AH vem de "high", e consequentemente "L" de AL significa "low". Óbvio, não? :P

Sendo assim, há situações em que ao invés de:

mov rax, 7  ; 1 byte mas ocupa 8 bytes (64 bits)
Enter fullscreen mode Exit fullscreen mode

E sabendo que 42 não ocupa 64 bits, podemos mudar para:

mov eax, 7  ; 1 byte mas ocupa 4 bytes (16 bits)
Enter fullscreen mode Exit fullscreen mode

Ou então:

mov ax, 7   ; 1 byte ocupando exatamente 1 byte (8 bits)
Enter fullscreen mode Exit fullscreen mode

Assim o programa final passa a ocupar menos memória em sua totalidade.

Seguindo esta lógica, podemos aplicar para todos os registradores, trazendo alguns como exemplo:

  • RAX: EAX -> AX -> AH -> AL
  • RBX: EBX -> BX -> BH -> BL
  • RDX: RDX -> DX -> DH -> DL
  • R8: R8W -> R8B

fatias de registradores

E assim por diante.


Uma side note sobre stack frames

Depois de publicar o artigo, o Rodrigo Gonçalves de Branco decidiu dar um feedback ultra detalhado executando todos os exemplos aqui demonstrados, e um dos insights foi sobre a utilização de stack frames.

Foi um trabalho fenomenal, meus agradecimentos ao Rodrigo

Voltando ao exemplo dos argumentos na pilha, dentro da rotina _start, temos a pilha do programa com o seguinte layout:

rsp antes

Quando fazemos a chamada:

...
    push greet    ; adiciona "Hi, " na stack para print
    call .print     
...
Enter fullscreen mode Exit fullscreen mode

Estamos basicamente manipulando a pilha original do programa. O push vai colocar no topo da pilha (RSP) o endereço de greet, como demonstrado a seguir no GDB:

# Breakpoint no <push greet>
(gdb) break 13   

(gdb) run
(gdb) next

(gdb) x $rsp
0x7fffffffe448: 0x00402000
Enter fullscreen mode Exit fullscreen mode

Agora a pilha ficou assim:

layout com pilha e data

Se fizermos step no GDB, podemos ver que o RSP foi modificado novamente, desta vez adicionando o endereço da próxima instrução por conta da chamada call:

(gdb) step

(gdb) x $rsp
0x7fffffffe440: 0x0040100a
Enter fullscreen mode Exit fullscreen mode

layout com data e text

Isso é o que acontece com a pilha em uma simples chamada de rotina com argumentos!

Bom, sabendo disso, vemos que o argumento que precisamos está em rsp + 8, exatamente como no nosso programa original. So far, so good.

O problema é que podemos reparar que o RSP é modificado durante as chamadas de funções no programa. Não temos controle sobre isso.

E podem acontecer comportamentos inesperados (bugs?) quando isso ocorre, pelo simples fato de estarmos apontando dados na pilha e eles já estarem em posições que não esperávamos.

Para mitigar este potencial problema, podemos preservar a base da pilha em algum registrador sempre no início de cada função, desta forma cada rotina/função pode ter sua própria "versão" da pilha sem correr riscos de apontar para o dado errado.

Esta técnica é chamada de stack frame.

E é pra isso que usamos o registrador RBP! No prólogo de cada rotina, adicionamos o rbp na pilha e em seguida colocamos o ponteiro de rsp dentro do registrador rbp, igualando assim ambos registradores:

_start:
    push rbp
    mov rbp, rsp
....
Enter fullscreen mode Exit fullscreen mode

rsp e rbp

Repare que esta técnica consiste em igualar RSP com RBP, assim pode-se de forma segura manipular o ponteiro em RBP, pois mesmo RSP sendo modificado pelo programa, RBP continua intacto.

Continuando com o programa:

push rbp
mov rbp, rsp
....
push greet
call .print
....
Enter fullscreen mode Exit fullscreen mode

Constatamos no GDB que a stack foi alterada, portanto RSP foi modificado para apontar para o endereço da próxima instrução, ao passo que RBP continua apontando pro valor anterior:

# RBP 
(gdb) x $rbp
0x7fffffffe448: 0x00000000

# RSP aponta para o endereço da próxima instrução 
# antes da chamada da rotina
(gdb) x $rsp
0x7fffffffe438: 0x0040100e

# RSP + 8 aponta para o primeiro argumento da rotina
(gdb) x $rsp + 8
0x7fffffffe440: 0x00402000

# RSP + 16 aponta para o mesmo valor de RBP (base da pilha),
# ou seja, `RBP = RSP + 16` neste caso porque houve um PUSH
# explícito do argumento e também outro push feito pelo CALL
(gdb) x $rsp + 16
0x7fffffffe448: 0x00000000
Enter fullscreen mode Exit fullscreen mode

rbp  = rsp + 16

E modificando a rotina .print para também ter seu próprio stack frame, como fica a pilha depois de executar:

.print:
    push rbp
    mov rbp, rsp
....
Enter fullscreen mode Exit fullscreen mode

Analisando com GDB:

(gdb) x $rbp
0x7fffffffe430: 0xffffe448

(gdb) x $rsp
0x7fffffffe430: 0xffffe448
Enter fullscreen mode Exit fullscreen mode

RSP e RBP ficaram igualados novamente, dando uma característica de stack frame, preservando a pilha como podemos ver na imagem a seguir:

stack frame

Portanto, o argumento da rotina, ao invés de ser rsp + 8, passa a ser rbp + 16 por conta da stack frame, ficando da seguinte forma:

.print:                  
    push rbp
    mov rbp, rsp

    mov rsi, [rbp + 16]     
    mov r9, rsi
    mov rdx, 0
Enter fullscreen mode Exit fullscreen mode

Uma coisa importante: ao final de cada rotina, antes do retorno, devemos fazer pop do topo da pilha para voltar ao estado original antes do push rbp feito no início da rotina:

push rbp
mov rbp, rsp
....
pop rbp
ret
Enter fullscreen mode Exit fullscreen mode

Desta forma, ao fazer o pop rbp, o que está em RSP é justamente o endereço de retorno antes da chamada da função:

pop do rbp antes do retorno

Ao continuar com o programa, a instrução ret (já falamos sobre ela anteriormente) faz pop do topo da pilha (RSP) e continua a execução do programa na próxima instrução:

_start:
    push rbp
    mov rbp, rsp     ; <--- iguala RSP e RBP

    push greet       ; <--- adiciona <greet> na pilha
    call .print      ; <--- adiciona ponteiro da próxima 
                         ; instrução na pilha
    pop rax          ; <--- faz pop de <greet> da pilha      

............
.print:                 
    push rbp         
    mov rbp, rsp     ; <--- iguala RSP e RBP

    mov rsi, [rbp + 16]     
        .............
    syscall
    pop rbp          ; <--- remove frame RBP da pilha
    ret              ; <--- faz pop do pointeiro da 
                         ; próxima instrução e atualiza RIP
Enter fullscreen mode Exit fullscreen mode

layout depois do ret

Quando o fluxo volta para quem chamou a rotina, a próxima instrução deve ser sempre o pop dos argumentos que entraram na pilha.

Neste caso no exemplo anterior estamos fazendo pop do argumento e descartando o valor em RAX com pop rax, deixando assim a pilha em seu estado anterior à chamada da rotina:

final

Ao fim do programa (rotina _start), devemos também fazer pop rbp, assim a pilha volta ao estado original de quando foi iniciado o programa.

Código completo:

global _start

%define SYS_write 1
%define SYS_exit 60
%define STDOUT 1

section .data
greet: db "Hi, ", 0
newline: db 0xA, 0

section .text
_start:
    push rbp               ; <-- cria um stack frame
    mov rbp, rsp           ; para preservar a pilha

    push greet             ; adiciona "Hi, " na pilha
    call .print            ; chama sub-rotina
    pop rax                ; remove "Hi, " da pilha

    push qword [rbp + 24]  ; adiciona argumento na pilha
    call .print            ; chama sub-rotina
    pop rax                ; remove argumento da pilhha

    push newline           ; adiciona newline na pilha
    call .print            ; chama-subrotina
    pop rax                ; remove newline da pilha

    pop rbp                ; remove RBP da pilha, 
                               ; retornando ao estado original
.exit:               
    mov rdi, 0
    mov rax, SYS_exit
    syscall                ; termina o programa
.print:                   
    push rbp               ; <-- cria um stack frame
    mov rbp, rsp           ; para preservar a pilha

    mov rsi, [rbp + 16]     
    mov r9, rsi
    mov rdx, 0
.calculate_size:               ; loop para calcular tamanho
    inc rdx
    inc r9
    cmp byte [r9], 0x00
    jz .done
    jmp .calculate_size
.done:                     
    mov rdi, STDOUT
    mov rax, SYS_write
    syscall

    pop rbp                ; <--- remove RBP da pilha, 
                               ; retornando ao estado anterior

    ret                    ; <--- retorna fluxo para o
                               ; estado anterior
Enter fullscreen mode Exit fullscreen mode

voltando ao estado original da pilha

É isto. Esta seção foi apenas uma demonstração de como utilizar boas práticas de manipulação da pilha quando utilizamos argumentos em funções, através da técnica de criar um frame como base da pilha com o registrador RBP.


Conclusão

É isto, pessoal. Esta parte da saga foi bastante densa. Passamos pela criação de um programa simples em Assembly, ao passo em que íamos depurando o programa com ferramentas como strace, size e muito gdb.

Também aprendemos sobre labels, tipos de registradores, desvio de fluxo com jmp, call, ret, muita stack, depurando tudo e mais um pouco, loops, FLAGS e aritmética de ponteiro.

Apesar de ter sido muito denso, os tópicos aqui abordados servirão de base para entendermos o próximo artigo que já começa pesado com syscalls de rede, para iniciarmos o nosso tão esperado web server.

Nos vemos no próximo artigo!


Referências


Mnemonics
https://en.wikipedia.org/wiki/Mnemonic
Comparison of Assemblers
https://en.wikipedia.org/wiki/Comparison_of_assemblers
Linker (computing)
https://en.wikipedia.org/wiki/Linker_(computing)
Assembly x86 tutorial
https://www.tutorialspoint.com/assembly_programming/index.htm
Data segment
https://en.wikipedia.org/wiki/Data_segment
FLAGS register
https://en.wikipedia.org/wiki/FLAGS_register
Debugging with GDB
https://ncona.com/2019/12/debugging-assembly-with-gdb/
GDB command reference
https://visualgdb.com/gdbreference/commands/
GDB cheatsheet
https://cs.brown.edu/courses/cs033/docs/guides/gdb.pdf
[Vídeo] Introdução ao GNU Debugger - Blau Araújo
https://www.youtube.com/watch?v=t9OKpBKbJ4Q

💖 💪 🙅 🚩
leandronsp
Leandro Proença

Posted on May 13, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related