DEV Community

Cover image for Construindo um web server em Assembly x86, parte III, código de máquina
Leandro Proença
Leandro Proença

Posted on • Edited on

Construindo um web server em Assembly x86, parte III, código de máquina

Agora que já temos uma base de entendimento sobre hierarquia de memória, arquitetura de CPU e registradores, vamos aplicar estes conceitos em exemplos práticos: construindo programas de computador.

Mas o que é um programa de computador?

Teremos a resposta para esta pergunta ao longo deste artigo. Vamos abordar muitos conceitos, desde código de máquina (o mais importante na minha opinião), a sistemas de numeração binário, decimal e hexadecimal.

Iremos também compreender opcodes, chamadas de sistema, modo kernel, libc, ASCII, standard streams; e alterar arquivos binários em hexadecimal.

Ao final deste artigo vamos estar em um patamar de entendimento mais holístico de como um programa é interpretado na CPU.

Ainda não entraremos em Assembly. Foi escolhido desta forma pois o intuito com esta saga é detalhar ao máximo como as peças de encaixam, e acredito que trazer Assembly sem explicar outros conceitos primordiais pode confundir bastante.

Também não é esperado que você escreva os códigos de máquina deste artigo, pois aqui neste link providencio o binário já pronto para que você possa acompanhar com as ferramentas que irei utilizar. Basta apenas baixar o arquivo binário no link fornecido e atribuir permissão de execução com chmod +x, se necessário.

Lembrando que é importante que esteja em um ambiente Linux, caso contrário não irá funcionar. Se estiver em outro ambiente e não puder virtualizar, poderá acompanhar esta saga apenas lendo, pois a ideia é também trazer muitos conceitos fundamentais de baixo-nível

Ainda não será o código do web server, o programa proposto neste post é bastante simples, mas estamos quase lá. Vamos focar em conceitos fundamentais para que futuros artigos, que cobrem o desenvolvimento do web server, possam ser melhor compreendidos.

Sem mais delongas, prepare-se para a partir de agora entrar numa espiral de código de máquina e manipulação de memória.


Agenda


O que é um programa de computador

Como já vimos na parte II, a função primordial de uma CPU é ler uma instrução da memória, decodificar, executar e armazenar o resultado de volta na memória.

CPU decodifica instrução

Então a grosso modo, um programa de computador é um conjunto de instruções pra a CPU processar. Em um cenário típico, teríamos diversos programas diferentes lendo e escrevendo da mesma memória do computador:

programas e memória

Mas um potencial problema, é que neste cenário poderíamos ter dois diferentes programas acessando ou modificando o mesmo endereço de memória:

conflito em memória

Pra resolver isto, precisamos agrupar as instruções de um programa de modo a "isolar" de outros programas que também estão rodando no computador.

É aí que entra um dos papéis do sistema operacional com o conceito de processos.

Lembrando que nesta saga, vamos focar apenas em sistemas UNIX-like, mais precisamente distruibuições GNU/Linux

🔵 Sistemas Operacionais e Processos

Cada programa executado no SO é encapsulado em uma estrutura chamada processo, que vai ter uma área virtual na memória principal.

Na prática, cada programa vai ter seu próprio "0x10000", isolado dos demais.

sistemas operacionais e processos

🔵 Um programa deve sempre terminar

Como o SO aloca recursos de memória (dentre outros) para o processo, nosso programa precisa indicar quando termina.

Desta forma aquele espaço reservado de memória fica livre para ser utilizado por outro processo. Isto evita problemas como vazamento de memória entre outros.

program exit

Caso isto não seja feito, o SO vai lançar uma exceção e o programa não pode ser admitido como processo.


Nosso primeiro programa

Vamos trabalhar inicialmente com um exemplo bastante simples. Um programa que não faz nada.

Nossa Leandro, como assim um programa que não faz nada?

Sim, parece estranho para linguagens de mais alto nível. Mas pensando em CPU isto já é alguma coisa, pelo que precisamos de ao menos algumas instruções para um programa que "não faz nada": o programa precisa terminar, lembra?.

🔵 A linguagem das CPU's, o sistema binário

Pensando na CPU como um dispositivo eletrônico, esta só entende pulso elétrico. Mas conseguimos abstrair tais pulsos como 0 ou 1.

Consequentemente, quando falamos em código de máquina para uma CPU estamos falando em instruções que utilizam sistema binário, composto de 0 ou 1.

Nosso programa que não faz nada além de "terminar" pode ser representado então pelo seguinte conjunto de instruções:

Não se preocupe em escrever o programa. Por enquanto são só exemplos de código de máquina para que possamos entender bem os conceitos



10111111 00000001 00000000 00000000 00000000   
10111000 00111100 00000000 00000000 00000000  
00001111 00000101                            


Enter fullscreen mode Exit fullscreen mode

No sistema binário, cada símbolo é chamado de bit. Este simples programa tem 12 conjuntos de 8 bits cada. Conte você mesmo para confirmar!

O sistema binário tem esse nome porque só fornece dois tipos de símbolos para representar números. Vamos contar:



0
1


Enter fullscreen mode Exit fullscreen mode

Acabou. Com 1 dígito podemos ter 2 combinações apenas. Mas e se quisermos representar mais números? Aí nos resta ficar combinando com mais dígitos.

Para 2 dígitos, conseguimos aumentar para 4 combinações:



00
01
10
11


Enter fullscreen mode Exit fullscreen mode

Se quisermos continuar, temos que entrar com 3 dígitos, sempre começando com o mais à esquerda possível, o que nos dá 8 combinações:



000 
001 
010 
100
100 
101 
110 
111


Enter fullscreen mode Exit fullscreen mode

Com isto, temos um padrão:

  • 1 dígito: 2 combinações
  • 2 dígitos: 4 combinações
  • 3 dígitos: 8 combinações

Repare no padrão de exponenciação. Estamos pegando o número 2 como base e aplicando o número do dígito como expoente:

numero de símbolos ^ número de dígitos

  • 2^1 = 2
  • 2^2 = 4
  • 2^3 = 8

Extrapolando para 4 ou mais dígitos, podemos chegar na seguinte conclusão:

  • 2^4 = 16 combinações
  • 2^5 = 32 combinações
  • 2^6 = 64 combinações
  • 2^7 = 128 combinações
  • 2^8 = 256 combinações

E assim por diante...

Você tá brincando com minha cara, né Leandro? Vim aqui pra ficar escovando bit?

Não exatamente. No programa, cada conjunto ali de 8 bits (chamado de byte) tem um significado para a CPU, o que faz nosso programa ter 12 bytes.

E como não somos uma CPU, estamos nada preocupados em representar instruções em bits, vamos então converter para o sistema decimal para conseguirmos representar nosso mesmo programa de forma mais simples e intuitiva.

🔵 O famoso sistema decimal

Já estamos habituados com o sistema decimal. Muitos números no nosso dia-dia são representados através do sistema decimal.

Falamos de números como "dez", "cento e quinze", "quarenta e dois" sem qualquer problema, pois foi o que aprendemos desde a primeira infância. Nosso cérebro já fixou o aprendizado tão intrinsicamente, que sequer pensamos que se trata de um sistema de numeração como qualquer outro.

Vamos por um momento esquecer que sabemos sistema decimal e aplicar as mesmas regras que aplicamos para o sistema binário.

Repetindo mais uma vez, no sistema binário temos à disposição apenas dois símbolos: 0 e 1.

E no decimal?

Temos dez símbolos à disposição, que são:



0 1 2 3 4 5 6 7 8 9


Enter fullscreen mode Exit fullscreen mode

Tal como no sistema binário, com 1 dígito apenas temos essas 10 possibilidades acima.

Já chegou no nove? Acabaram as combinações? Não tem problema, vamos subir pra dois dígitos sempre começando pelo dígito mais à equerda possível:



00 01 02 03 04 05 06 07 08 09
10 11 12 13 14 15 16 17 18 19
20 21 22 23 24 25 26 27 28 29
...
...........................99


Enter fullscreen mode Exit fullscreen mode

Olha só, com apenas dois dígitos no sistema decimal, podemos combinar 100 números diferentes!

O padrão é o mesmo no sistema binário, podemos logo aplicar exponenciação da base, sendo:

numero de símbolos ^ número de dígitos

Portanto:

  • 10^2 = 100 combinações
  • 10^3 = 1000 combinações
  • 10^4 = 10000 combinações
  • etc etc etc

Convertendo binário em decimal

Uma vez que entendendo sistemas de numeração binário e decimal, podemos "compactar" nosso programa inicial de binário para decimal de modo a termos uma leitura mais intuitiva, não?

Aplicando a regra de exponenciação, não fica difícil fazer a conversão:

  • 0 é sempre 0, pois este símbolo está presente em ambos sistemas de numeração
  • Mesmo vale para 1, pois está presente em ambos

Se quisermos então converter 10 (que é o próximo número depois de 1 em binário) de binário pra decimal, vamos aplicar a seguinte regra:

dígito x 2^posição do dígito

...somando o resultado de cada operação em dígito, onde a posição mais à direita possível começa com zero, pois de acordo com a senhora matemática, qualquer número elevado a zero é UM*.

Com isto:

  • 10 = (1 x 2^1) + (0 x 2^0) = 2 + 0 = 2

Vamos extrapolar um pouquinho?

  • 11 = (1 x 2^1) + (1 x 2^0) = 2 + 1 = 3
  • 100 = (1 x 2^2) + (0 x 2^1) + (0 x 2^0) = 2 + 0 + 0 = 4

Yay! Nice, uh?

Agora vamos converter byte a byte do nosso programa:



10111111 00000001 00000000 00000000 00000000   
10111000 00111100 00000000 00000000 00000000  
00001111 00000101                            


Enter fullscreen mode Exit fullscreen mode
  • 10111111 = (1 x 2^7) + (0 x 2^6) ... = 191
  • 00000001 = (0 x 2^7) + (0 x 2^6) ... + (1 + 2^0) = 1
  • etc etc etc até chegar ao 12º byte

Temos então um programa convertido para decimal da seguinte forma:



191 1 0 0 0
184 60 0 0 0
15 5


Enter fullscreen mode Exit fullscreen mode

Entretanto, não é comum representar sistema decimal para instruções de CPU, pois cada byte (8 bits) não seria dividido em partes iguais no sistema decimal, o que poderia causar um desalinhamento, criando buracos desnecessários na memória.

Uma forma de ver este problema é que o sistema decimal composto de 10 símbolos não é divisível por 8 (resto 2), que é a quantidade de bits em um byte

E se tivéssemos um sistema de numeração com uma quantidade de dígitos que fosse divisível por 8?

Sabendo que 16 é divisível por 8 (resto 0) e não causaria desalinhamento entre bits pra representar um programa, será que existe um sistema de numeração de 16 símbolos para podermos compactar ainda mais a representação textual do nosso programa?

Sim, estamos falando do sistema hexadecimal.

🔵 Hexadecimal, o queridinho dos computadores

Certamente você já ouviu falar, viu ou até praticou hexadecimal. Similar ao que fizemos com sistema decimal, vamos esquecer tudo o que sabemos sobre hexadecimal e aplicar algumas regras.

Quantos símbolos temos à disposição? 16.

Daí o nome: hexa, representando 6, e decimal representando 10. DEZ + SEIS!!!!!111

Os primeiros 10 símbolos são exatamente como no decimal:



0 1 2 3 4 5 6 7 8 9


Enter fullscreen mode Exit fullscreen mode

E quanto aos 6 símbolos restantes? Poderíamos representar emojis, gifs animados ou derivados de batata, mas seria muito mais simples representarmos as primeiras 6 letras do alfabeto, não?



0 1 2 3 4 5 6 7 8 9 A B C D E F


Enter fullscreen mode Exit fullscreen mode

As regras pra combinar são as mesmas, sempre do mais à esquerda possível:



0 1 2 3 4 5 6 7 8 9 A B C D E F
10 11 12 13 14 15 16 17 18 19 ?


Enter fullscreen mode Exit fullscreen mode

Qual seria o próximo? 20?

Não, jovem, vamos combinar com as letras restantes



...
19
1A
1B
1C
1D
1E
1F


Enter fullscreen mode Exit fullscreen mode

Agora sim, as combinações com o dígito 2:



20 21 22 23 24 25 26 27 28 29 2A 2B 2C 2D 2E 2F 
30 31 32 33 34 ............................. 3F 
...........................99................9F
100


Enter fullscreen mode Exit fullscreen mode

Com apenas 2 dígitos no sistema hexadecimal, podemos ter 256 combinações de números diferentes!

  • em binário, 2 dígitos = 4 combinações
  • em decimal, 2 dígitos = 100 combinações
  • em hexa, 2 dígitos = 256 combinações

Por exemplo, para termos 256 combinações com 2 símbolos FF, precisaríamos de 8 símbolos 1111 1111 no sistema binário, obviamente ocupando mais espaço pra representar e visualizar programas.

O céu é o limite 🚀

Aplicando regra de exponenciação para convertermos pra decimal, vamos ter o seguinte:

  • do 0 ao 9 é tudo igual
  • A = 10, B = 11, C = 12, D = 13, E = 14, F = 15

Onde:

dígito do sistema * 16^posição do dígito

Com isto, convertendo 1A e FF para decimal, ficaria:

  • 1A = (1 * 16^1) + (10 * 16^0) = 16 + 10 = 26
  • FF = (15 * 16^1) + (15 * 16^0) = 240 + 15 = 255

Ok, mas precisamos converter de binário para hexa

Com razão, como podemos converter o byte 11111111 para hexadecimal? Dá pra fazer por dedução da conversão decimal, por exemplo:

  • sabendo que o byte 11111111 representa 255 em decimal, e sabendo que FF em decimal é 255, portanto concluímos que 11111111 = FF
  • se dividirmos o byte em 2 partes, podemos calcular que 1111 = F, portanto chegamos no mesmo resultado

Geralmente empregamos a técnica de dividir o byte em 2 partes de 4 bits cada. Assim fica mais fácil visualizar.

O simples programa original escrito em binário, fica então convertido em hexadecimal da seguinte forma:



BF 01 00 00 00 
B8 3C 00 00 00 
0F 05          


Enter fullscreen mode Exit fullscreen mode

Opa! Já conseguimos ter uma leitura mais intuitiva, certo? Mas que raios significa BF 01, B8 3C ou 0F 05 na CPU?

🔵 Opcodes

Cada CPU possui uma arquitetura específica. Falando de x64 (ou x86_64), esta traz um conjunto de opcodes no manual que representam as instruções disponíveis, registradores entre outras operações de CPU.

De acordo com o manual:

  • o opcode BF move um valor imediato para o registrador RDI
  • 01 00 00 00: valor imediato hexa que representa 1 em decimal, mas na ordem inversa no formato little-endian (vamos falar de endianness mais a seguir)
  • o opcode B8 move um valor imediato para o registrador RAX
  • 3C 00 00 00: valor imediato hexa que representa 60 em decimal, mas na ordem inversa no formato little-endian
  • o opcode 0F 05 entra no modo "kernel" do SO e aguarda a resposta de uma chamada de sistema (syscall)

Mas por quê os bytes são representados na ordem inversa nesta arquitetura de CPU?

🔵 Endianness

O conceito de endianness está relacionado com a forma que CPU's lêem e processam bytes na memória.

Vamos trazer o exemplo de um byte em binário, 10000001, que sabemos que é 129 em decimal. Prestando atenção nos expoentes:

2^7 + 2^6 + 2^5 + 2^4 + 2^3 + 2^2 + 2^1 + 2^0

  • o bit mais à esquerda, 1 x 2^7 = 128
  • somado com o bit mais à direita, 1 x 2^0 = 1
  • o restante dos bits está tudo a zero, não precisam entrar pra soma

Podemos inferir que os bits mais à direita não têm tanto peso no valor final, por isso são chamados bits menos significativos.

O bit da direita incorporou apenas o valor 1 pro resultado final

Da mesma forma, os bits mais à esquerda têm mais peso no valor final, por isso são chamados de bits mais significativos.

O bit da esquerda incorporou 128 pro resultado final, trazendo mais significância

Esta propriedade de definir significância de bits é chamada de endianness. Diferentes arquiteturas de CPU podem decidir ler do mais significativo ao menos significativo (padrão intuitivo de leitura, big-endian) ou do menos significativo ao mais significativo (little-endian).

A decisão passa por fatores históricos ou por facilitar manipulação de ponteiros. Diferentes sistemas podem decidir por inverter a leitura/escrita ou não dos bytes.

Na CPU x86_64, o formato é little-endian, portanto em hexa o valor de 4 bytes 00 0D 00 3C passa a ser 3C 00 0D 00 no formato little-endian.

Concluindo, vamos adicionar comentários ao nosso pseudo-programa:



BF 01 00 00 00  ; MOVE 1 PARA RDI
B8 3C 00 00 00  ; MOVE 60 PARA RAX
0F 05           ; CHAMADA DE SISTEMA (SYSCALL)


Enter fullscreen mode Exit fullscreen mode

Como a CPU sabe qual syscall deve executar? Por padrão, o número da syscall fica no registrador RAX. E como saber qual o número da syscall?

Neste link, temos uma lista completa de todas as syscalls da arquitetura x64, e ali podemos conferir que a syscall exit do kernel representa o número 60 decimal, ou 3C hexa. É exatamente o que a instrução B8 3C está fazendo!

Mais a seguir neste artigo vamos aprofundar no mundo das syscalls e chamadas do kernel.


Nosso segundo programa

Até agora vimos apenas o código de máquina de um programa que não faz nada (apenas termina), mas foi bastante útil para entendermos sistema binário, hexadecimal e outros conceitos.

Continuaremos ainda explorando código de máquina. Não precisa escrever nada, mas pode acompanhar com o código que disponibilizei no início do artigo e rodar os comandos em Linux

Vamos agora elaborar um hipotético programa que imprime "Hello, World" na saída (STDOUT). Para isto, devemos:

  • alocar memória para a string "Hello, World"

Sim, dados ficam na memória junto com o programa, lembra?

  • escrever a string na saída STDOUT, que é a saída padrão do programa (em outras palavras, a "tela")

Se quiser mais detalhes do que é STDOUT, standard streams e redirecionamento de streams, sugiro ler outros artigos que escrevi sobre UNIX pipes

  • terminar o programa

Alocar, imprimir, terminar. Escrever no STDOUT é uma chamada de sistema, e terminar o programa é outra chamada de sistema. Portanto, temos:

  • uma alocação de dados na memória do programa
  • 2 chamadas de sistema

🔵 Alocando dados na memória do programa

Em linguagem de máquina, fazemos alocação byte a byte, e sabendo que queremos alocar "Hello, World" literalmente, como representar cada letra, o caracter vírgula e o caracter espaço na memória?

Precisamos de uma tradução dos caracteres para representação decimal ou hexadecimal. É isso mesmo que você está lendo, vamos entrar em ASCII.

🔵 ASCII

ASCII (American Standard Code for Information Interchange), é um padrão para codificação de caracteres em comunicação eletrônica, criado nos anos 60.

O padrão ASCII estabeleceu inicialmente 7 bits para cada caracter e foi concebido para suportar caracteres presentes somente na língua inglesa. Por conta desta limitação, tem suporte para um máximo de 128 caracteres (2^7) que abordam os dígitos decimais, caracteres especais e letras do alfabeto, maiúsculas e minúsculas.

tabela ASCII

Mais tarde veio uma extensão da tabela com um bit a mais portanto suportando mais caracteres como acentuações, porém ainda com limitações para suportar outros idiomas e caracteres especiais.

Tempos depois surgiu o padrão Unicode, que adiciona a capacidade de codificação de tamanho variável, permitindo assim uma multitude de caracteres e alfabetos de diversos idiomas.

Unicode também contempla a mesma tabela ASCII nos primeiros 128 caracteres por questões de retrocompatibilidade em sistemas. Portanto, apesar de sistemas modernos utilizarem esquemas de codificação Unicode tais como UTF-8, neste artigo focaremos na terminologia ASCII por ser suficiente nos nossos exemplos

Podemos verificar na tabela ASCII que os códigos hexa de cada caractere da nossa string são:



H: 0x48     
e: 0x65     
l: 0x6C     
l: 0x6C     
o: 0x6F
,: 0x2C

W: 0x57
o: 0x6F
r: 0x72
l: 0x6C
d: 0x64



Enter fullscreen mode Exit fullscreen mode

0x é uma notação, um prefixo para determinar que o número depois de x é um hexadecimal

Com isto podemos então escrever os primeiros bytes hexa do nosso programa:



48 65 6C 6C 6F 2C 20 57 6F 72 6C 64 0A


Enter fullscreen mode Exit fullscreen mode

Nice, agora vamos "montar" as instruções para a syscall que escreve no STDOUT. Como o kernel faz isso?

🔵 Syscalls

Syscalls são chamadas de sistema onde o programa sai do modo "user" e entra no modo "kernel". Basicamente, o programa fica à espera que o kernel do sistema operacional execute a função que foi solicitada.

Apesar de neste link estar tudo compilado, temos que entender um fator muito importante sobre o kernel que estamos trabalhando, e se trata do kernel Linux.

O kernel Linux foi escrito em C na virada da década de 80 para 90, e por ser escrito em C, todas as chamadas de sistema são declaradas em C.

Por exemplo, no manual de system calls do kernel podemos pesquisar sobre qualquer chamada de sistema ou comando utilitário.

A chamada que queremos utilizar pra escrever no STDOUT se chama write, e é definida pela função:



ssize_t write(int fd, const void buf[.count], size_t count);


Enter fullscreen mode Exit fullscreen mode

...que está presente na biblioteca padrão C (libc), que o kernel incorpora.

Para distribuições GNU, que é o meu caso utilizando Ubuntu, há um repositório mirror do glibc que é a biblioteca padrão em C para sistemas GNU/Linux.

Repara que a função write espera 3 argumentos:

  • fd, ou file descriptor, que no nosso caso é o STDOUT, representado pelo valor 1

💡 STDIN representa 0 e STDERR representa 2. Tá lá no nosso outro artigo sobre Bash e UNIX pipes, corre dar uma olhada

  • buf, ou buffer, que é o ponteiro para o início do buffer de dados. No caso de escrever "Hello, World" com quebra de linha, o buf apontaria para o início da string "Hello, World\n" na memória.
  • count, que é o tamanho do buffer de dados a ser escrito. Para a string "Hello, World\n", o count seria 13, pois isso inclui os 12 caracteres da string mais 1 byte para a quebra de linha.

🔵 Montando a syscall write

Para montar uma chamada de sistema, é necessário seguir uma interface que determina como um programa deve comunicar com o sistema operacional.

Quem determina isto é a ABI (Application Binary Interface), que define como estruturas de dados ou funções computacionais conversam entre si.

Como precisamos chamar uma função do kernel Linux, vamos utilizar as convenções determinadas por este sistema operacional e a arquitetura da CPU em questão.

Com isto, podemos montar as instruções utilizando registradores para a syscall write. Novamente seguindo esta tabela (que ajuda muito), vamos fazer instrução por instrução:

👉 ARG0
O primeiro argumento da função vai no registrador RDI, e aqui vamos colocar o valor 1 que representa o file descriptor STDOUT. Em hexa, o manual x86 diz que o opcode hexa é BF, seguido do hexa 00 00 00 01 em formato little-endian:



BF 01 00 00 00


Enter fullscreen mode Exit fullscreen mode

👉 ARG1
O segundo argumento é o ponteiro para o buffer onde começa a string na memória, movido para o registrador RSI.

Como a string fica no começo do programa, geralmente este endereço fica em 0x401000. Portanto, o opcode para o move no registrador é 48 BE e o valor do endereço de memoria no formato little-endian em hexa, 00 10 40



48 BE 00 10 40 


Enter fullscreen mode Exit fullscreen mode

👉 ARG2
Já o terceiro argumento da função vai para o registrador RDX, que representa o tamanho do buffer em bytes a ser escrito no file descriptor definido no registrador RDI (ARG0).

O opcode é o BA e o valor é 13 em hexa, que é 00 00 00 0D só que no formato little-endian:



BA 0D 00 00 00


Enter fullscreen mode Exit fullscreen mode

Agora, vamos colocar no registrador RAX o número da syscall write, que de acordo com esta tabela (sempre esta tabela, habitue-se a ela), é o número 1, mas em hexa e little-endian:



B8 01 00 00 00


Enter fullscreen mode Exit fullscreen mode

Falei bastante desta tabela, de syscalls e little-endian aqui, nos próximos artigos vou falar cada vez menos. A ideia é focar em outras coisas e estes detalhes estarem já bem fundamentados

Por último, vamos montar a instrução da syscall em si, que é o opcode:



0F 05


Enter fullscreen mode Exit fullscreen mode

Trecho final da syscall write:



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

BF 01 00 00 00                 ; MOV 1 para RDI
48 BE 00 10 40                 ; MOV 0x401000 para RSI
BA 0D 00 00 00                 ; MOV 13 para RDX
B8 01 00 00 00                 ; MOV 1 para RAX (write)
0F 05                          ; SYSCALL


Enter fullscreen mode Exit fullscreen mode

A syscall write já tá montada, agora falta terminar o programa.

🔵 Montando a syscall exit

A API da chamada de sistema exit pode ser consultada no manual, e tem a seguinte assinatura no glibc:



void _exit(int status);


Enter fullscreen mode Exit fullscreen mode

👉 ARG0
O primeiro argumento é o status de término, que de acordo com a especificação POSIX, pode ser qualquer inteiro de 0 a 255 mas sendo o 0 indicando que o progama terminou sem erros.



BF 00 00 00 00


Enter fullscreen mode Exit fullscreen mode

Agora, vamos ao trecho final da syscall exit:



BF 00 00 00 00          ; MOV 0 para RDI
B8 3C 00 00 00          ; MOV 60 para RAX (exit)
0F 05                   ; SYSCALL


Enter fullscreen mode Exit fullscreen mode

Programa completo:



48 65 6C 6C 6F 2C 20 57 6F 72 6C 64 0A
BF 01 00 00 00 
48 BE 00 10 40
BA 0D 00 00 00 
B8 01 00 00 00 
0F 05          
BF 00 00 00 00 
B8 3C 00 00 00 
0F 05          


Enter fullscreen mode Exit fullscreen mode

Tomando como exemplo o binário que disponibilizei, ele tem mais bytes por conta de headers necessários para o próprio sistema operacional admitir o programa.

Vamos executar o binário:



$ ./example

Hello, world


Enter fullscreen mode Exit fullscreen mode

Yay! Que dia, hein?


Manipulando o nosso programa

Com o arquivo binário em mãos, e sugiro baixar do repositório, vamos utilizar o utilitário xxd que faz um dump de hexa de qualquer binário, e com ele podemos reparar que o binário vai ter, a partir do byte 4096, a mesma quantidade de bytes que escrevemos aqui neste artigo:



xxd -s 4096 -g1 -c8 -l52 example

00001000: 48 65 6c 6c 6f 2c 20 57  Hello, W
00001008: 6f 72 6c 64 0a bf 01 00  orld....
00001010: 00 00 48 be 00 10 40 00  ..H...@.
00001018: 00 00 00 00 ba 0d 00 00  ........
00001020: 00 b8 01 00 00 00 0f 05  ........
00001028: bf 00 00 00 00 b8 3c 00  ......<.
00001030: 00 00 0f 05              ....


Enter fullscreen mode Exit fullscreen mode

Maravilhoso, não?

E por fim, antes de concluir este artigo que por si só dá quase uma saga, vamos alterar o binário direto em código de máquina utilizando o utilitário hexedit.

Esta dica peguei em vídeos do Blau Araújo. Ele é realmente fantástico e traz conteúdo de primeira. Pra mim é a melhor referência para conteúdo de baixo nível em pt-BR

Para mudar o binário, rodamos o comando:



hexedit --color example


Enter fullscreen mode Exit fullscreen mode

Que vai abrir um editor bastante específico. Com /, podemos buscar por um hexa, por exemplo "48", que vai levar para o início da string.

Vamos trocar o "W" maiúsculo por "w" minúsculo, diretamente em código de máquina, que significa trocar o byte 57 da tabela ASCII por 77:

Então:



48 65 6C 6C  6F 2C 20 57  6F 72 6C 64  0A 


Enter fullscreen mode Exit fullscreen mode

Passa a ser:



48 65 6C 6C  6F 2C 20 77  6F 72 6C 64  0A 


Enter fullscreen mode Exit fullscreen mode

Gravar o arquivo com ctrl+s e depois ctrl+c para sair. Executar novamente e:



$ ./example

Hello, world


Enter fullscreen mode Exit fullscreen mode

MEO DEOS, impressionante!!!!111


A vida de quem programa é assim?

Leitores mais atentos devem estar se perguntando:

É sempre assim a vida de quem programa? Ficar escovando bits e mudar diretamente código de máquina em hexadecimal?

Para a maioria esmagadora dos casos, não.

E se a gente criasse um programa tradutor que nos permitisse montar as instruções em uma linguagem mais human-friendly e traduzisse para o código de máquina tal o que vimos aqui neste artigo?

Estamos falando de assemblers, que são montadores que permitem escrever em uma linguagem de montagem específica de uma arquitetura (um assembly) e converter para os opcodes, as instruções de CPU.

Reforçando, nesta saga vamos focar no montador NASM para a linguagem Assembly x86 de 64-bits em sistemas GNU/Linux.


Conclusão

Jornada longa essa, hein? Este artigo cobriu diversos conceitos fundamentais, tais como sistema binário, hexadecimal, opcodes, syscalls, libc, ASCII...

Estes conceitos fundamentam o entendimento para escrever código Assembly, que será o tema principal dos próximos artigos.

No próximo artigo, vamos abordar conceitos básicos de Assembly, buscando converter o simples programa que fizemos neste artigo em um asm (atalho pra dizer "assembly") bem organizadinho.

Em seguida, iremos entrar nas syscalls de socket, bind e accept justamente para montarmos o código do nosso web server. Vamos também manipular buffer de arquivos, alocar dados dinamicamente na memória, trabalhar com threads, locks, criar fila na mão, enfim, ainda há muita coisa por vir. Isto aqui é apenas a ponta do iceberg.

Não será um tutorial, mas vamos falar de mnemonics, endereçamento de memória, segmentos de memória, layout de memória de um programa, debugging com gdb entre outras coisas.

Fiquem ligades!


Referências


Blau Araújo, material de curso de Assembly
https://codeberg.org/blau_araujo/assembly-nasm-x86_64/raw/branch/main/pdf/aula01.pdf
Felix Cloutier, "x86 instruction reference"
https://www.felixcloutier.com/x86/
ASCII Table
https://upload.wikimedia.org/wikipedia/commons/thumb/1/1b/ASCII-Table-wide.svg/2560px-ASCII-Table-wide.svg.png
POSIX, Wikipedia
https://en.wikipedia.org/wiki/POSIX
ASM x86 Manual
http://ref.x86asm.net/coder64.html
Syscalls table
https://x64.syscall.sh/
Linux Kernel, Wikipedia
https://en.wikipedia.org/wiki/Linux_kernel
ABI, Wikipedia
https://en.wikipedia.org/wiki/Application_binary_interface
x86 calling conventions
https://en.wikipedia.org/wiki/X86_calling_conventions

Top comments (8)

Collapse
 
criskell profile image
criskell

oiiiii
belo artigo
nn consigo baixar o arquivo binário citado no link github.com/leandronsp/monica/blob/...

Collapse
 
criskell profile image
criskell

achei aqui nesse commit vlw
github.com/leandronsp/monica/blob/...

Collapse
 
leandronsp profile image
Leandro Proença

muito obrigado pelo feedback, vou corrigir o link

Collapse
 
dodocamilo profile image
Jonathan

Muito bom mesmo! Bem escrito e com uma didática excelente. Parabéns!

Collapse
 
eduardojm profile image
Eduardo Oliveira

Perfeito! Cada texto melhor que os anteriores nessa sequência

Collapse
 
leandronsp profile image
Leandro Proença

que issooo valeu demais o feedback

Collapse
 
jessilyneh profile image
Jessilyneh

Quero ser voce quando eu crescer, manda bem demais!

Collapse
 
leandronsp profile image
Leandro Proença

Jess, fico lisonjeado pelas palavras vindo de vc, uma referencia sem igual, e poliglota kk

sou um mero aprendiz que gosta de consolidar conhecimento mediante texto, valeu demais pelo apoio tmj 🫶