Construindo um web server em Assembly x86, parte III, código de máquina
Leandro Proença
Posted on April 23, 2024
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
- Nosso primeiro programa
- Nosso segundo programa
- Manipulando o nosso programa
- A vida de quem programa é assim?
- Conclusão
- Referências
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.
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:
Mas um potencial problema, é que neste cenário poderíamos ter dois diferentes programas acessando ou modificando o mesmo endereço de 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.
🔵 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.
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
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
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
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
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
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
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
- 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
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
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
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 ?
Qual seria o próximo? 20?
Não, jovem, vamos combinar com as letras restantes
...
19
1A
1B
1C
1D
1E
1F
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
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 que11111111 = 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
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 representa1
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 representa60
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)
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.
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
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
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);
...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
👉 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
👉 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
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
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
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
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);
👉 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
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
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
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
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 ....
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
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
Passa a ser:
48 65 6C 6C 6F 2C 20 77 6F 72 6C 64 0A
Gravar o arquivo com ctrl+s e depois ctrl+c para sair. Executar novamente e:
$ ./example
Hello, world
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
Posted on April 23, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.