[ Autor: Nicholas Ferreira ]
[0x6] Printando números inteiros em assembly x64
20/08/2021Introdução
Neste texto eu mostrarei uma implementação que eu fiz para printar números inteiros em assembly x64. A ideia é simplesmente escrever no stdout um valor inteiro qualquer que esteja em algum lugar no programa (em algum registrador, na memória, na stack, em algum buffer, etc).Encontrei alguns modelos na internet, como esse e esse, e depois de entender a lógica eu fiz esse código, que provavelmente não é o mais otimizado, mas funciona relativamente bem. Gostaria de deixar claro que este é apenas um registro textual de alguns comentários sobre um código que eu fiz durante meus estudos em assembly. Não use o código para fins acadêmicos a menos que você tenha certeza do que está fazendo.
Dito isso, podemos começar. O problema que temos inicialmente é que para printarmos algo, precisamos usar a syscall write(), e ela recebe três argumentos: o file descriptor onde o conteúdo será escrito, o endereço do buffer que contém o conteúdo que será escrito e quantos bytes serão escritos. No nosso caso, o file descriptor é 1, que corresponde ao stdout, a quantidade de bytes é o número de algarismos do número e o endereço do buffer deveria conter o número em si.
O problema é que se printarmos literalmente o número "7359", que está em um registrador, por exemplo, o programa vai printar o correspondende em ascii ao hexadecimal \x73\x59, que seria "sY". Como o que queremos é printar literalmente os dígitos "7", "3", "5" e "9", nessa ordem, precisamos converter cada um deles ao seu código correspondente em ascii. Podemos fazer isso somando 48 (ou 0x30) a cada um deles, já que, na tabela ascii, os números começam em 48 e vão até 57 (0x30 a 0x39) . No final das contas, precisaríamos printar "\x37\x33\x35\x39".
Dividindo as coisas
Precisamos, então, fazer um código que receba um valor decimal e converta cada algarismo dele para o código correspondente em ascii.Para fazer isso, vamos usar uma propriedade interessante da divisão. Vamos considerar o decimal 7359. Se nós o dividirmos por 10, o quociente será 735 e ficaremos com 9 de resto. Agora vamos dividir este quociente também por 10. O resultado é 73, e o resto agora é 5. Novamente, se dividirmos 73 por 10, ficaremos com 7 e resto 3. Por fim, dividindo 7 por 10, ficamos com quociente 0 e resto 7.
Representando de maneira mais simples, temos o seguinte procedimento:
7359/10 = 735, resto = 9
735/10 = 73, resto = 5
73/10 = 7, resto = 3
7/10 = 0, resto = 7
Note a ordem em que os restos ocorrem de cima para baixo: 9, 5, 3, 7. Isso é precisamente a inversão (não aritmética) dos algarismos do nosso número. O fato de haver essa inversão não é por si só interessante, mas o fato de conseguirmos, no resto, o retorno individual de cada algarismo é ótimo. Como, nesse caso, cada operação de módulo retorna um número inteiro menor que 10, e esses números são exatamente os que compõem os algarismos do nosso número inicial, podemos usar isso para converter individualmente cada algarismo para seu correspondente em ascii, bastando somar 48 (ou 0x30).
O algoritmo para transformar o algarismo x em ascii fica mais ou menos assim:
1. Divida x por 10
2. Se x/10 != 0, vá para 4.
3. Se x%10 == 0, chame a função para invertê-lo
4. Seja y = (x % 10) + 48
5. Mova y para um buffer
6. Seja x = quociente
7. Vá para 1
Ou seja, a cada iteração, fazemos a divisão por 10 e verificamos se é o caso que tanto o quociente quanto o resto são zeros. Se ambos forem, então chegamos ao fim das divisões e podemos inverter o número. Se o quociente ou o resto não forem diferentes de zero, então transformamos o algarismo em ascii (somando o resto com 48) e repetimos o processo.
Essa função será chamada e o argumento será passado pela stack. Então, em nosso entrypoint, teremos uma instrução push colocando o nosso número na pilha e em seguida será chamada a função que seguirá o procedimento acima.
global _start
section .text
_start:
push 7359 ;número que será printado
call _movToBuf
Movendo para a stack
A função _movToBuf fará o procedimento acima e moverá os bytes correspondentes ao nosso número em ascii para um buffer, que no caso será um local da stack que criaremos. Inicialmente, precisamos criar um contador para sabemos em qual posição do número estamos. Esse valor será usado quando formos mover o resultado da sua conversão para a stack._movToBuf:
push rbp ;inicia o novo stack frame
mov rbp, rsp
xor rcx, rcx ;zera o contador
mov rax, [rsp+16] ;guarda em rax o argumento da função
mov rbp, 10 ;o número será dividido por 10
mov rsp, 16 ;cria um buffer de 16 bytes na stack
Dividindo, pra valer
Agora, precisamos fazer o código que fará o loop das divisões._divLoop:
xor rdx, rdx ;zera o rdx
div rbx ;divide rax por 10
cmp rax, 0 ;compara o quociente com 0
jnz _continue ;se não for igual, continua
cmp rdx, 0 ;compara o resto com 0
jnx _continue ;se não for igual, continua
jmp _invert ;se quociente e resto forem 0, inverte
A primeira coisa que fazemos é zerar o registrador rdx, porque quando trabalhamos com divisões de números maiores que 16 bits (65536), a operação de divisão funciona de um jeito um pouco estranho. Por exemplo, no caso de 32 bits, quando fazemos div ebx, a operação div vai considerar a concatenação de eax com edx (representado por edx:eax) como sendo um único registrador, de 64 bits. É o conteúdo desse registrador que será dividido por ebx.
Como o retorno da operação div coloca em rdx o resto, se nós não zerarmos o rdx sempre no começo do loop, teríamos um problema. Na primeira iteração, rdx estaria zerado e o loop seria feito sem problemas. Já na segunda, rdx conteria o resto da operação anterior. Então, a operação div rbx dividiria rdx:rax por rbx, e como rdx já tem um valor, o resultado da divisão seria diferente e possivelmente nem caberia em um registrador de 64 bits. Por esse motivo zeramos o rdx antes de fazer essa divisão
Em seguida, fazemos a divisão de rax por rbx, que contém sempre 10. A operação de divisão recebe apenas um 'argumento', que é o divisor. O dividendo é sempre rdx:rax. Depois de realizada a operação, rax recebe o quociente e rdx recebe o resto.
Nós então comparamos o quociente e o resto com 0. Se algum dos dois for diferente de zero, iremos para o procedimento "_continue", que ainda não definimos, mas que fará a conversão dos restos em ascii. Se tanto o quociente quanto o resto forem zero, isso quer dizer que chegamos ao fim do número e podemos ir para a função "_invert", que conterá outro loop para reinvertermos o número.
Transformando em ascii
Vamos agora para o "_continue"._continue:
add dl, 48 ;converte o resto em ascii
mov [rsp+rcx], dl ;move o resto p/ o próximo byte na stack
inc rcx ;incrementa o contador
jmp _divLoop ;repete o loop
Nessa parte, adicionaremos ao resto o número 48, correspondente ao 0 na tabela ascii, para obtermos o correpondente em ascii do resto. Como o resto é sempre um único algarismo, podemos lidar apenas com os bits menos significativos de rdx, e por isso usei o dl.
Em seguida, armazenaremos esse valor na pilha, através de mov [rsp+rcx], dl. O registrador rcx inicialmente está zerado, e ele é o nosso contador. A cada iteração desse loop de inversão ele será incrementado, e com ele saberemos o quão longe deveremos estar do topo da pilha para guardarmos o próximo dígito.
No caso de rcx=0, a instrução anterior vai simplesmente mover dl, que contém o valor em ascii do algarismo atual, para o topo da pilha. No caso de rcx=1, ou seja, na segunda iteração, a instrução vai mover dl para o próximo byte depois do começo da pilha. Ou seja, teremos um byte depois do outro.
Se não usássemos o +rcx, a instrução moveria os valores sempre para a mesma localização na pilha, sobrescrevendo o valor anterior. Por fim, incrementamos o contador (rcx) e voltamos para o começo do loop.
Esse loop terminará somente quando o divisor e o quociente forem ambos iguais a zero, indicando que a divisão chegou ao fim e que todos os algarismos do número já estão na stack. E, de fato, ao fim desse loop podemos verificar que os algarismos do nosso número (7359) chegam invertidos na stack:
Invertendo as coisas
Agora escreveremos o procedimento '_invert'. Ele será responsável por fazer a desinversão do número._invert:
push rbp ;novo stack frame
mov rbp, rsp
sub rsp, 16 ;cria um buffer na stack
mov rdx, rcx ;salva o tamanho do número
De início, criamos um novo stack frame para resetarmos o valor de rbp e podermos trabalhar melhor com ele posteriormente. Também foi criado um espaço de 16 bytes (o mesmo tamanho que o anterior) na pilha, que é onde o número desinvertido será armazenado.
Além disso, guardamos o counter, que estava em rcx, em rdx. Como o contador conta o número de iterações do loop e como cada iteração corresponde a um algarismo do número, o valor final do counter é exatamente o tamanho do número. Ou seja, no fim do _divLoop do nosso exemplo, com o número '7359', o valor de rcx é 4.
Faremos agora o procedimento que fará o novo loop para desinverter o número.
_invertLoop:
mov rax, [rbp+7+rcx] ;move para rax o byte atual da stack
mov rbx, rsp ;guarda rsp em rbx para podermos manipulá-lo
sub rbx, rcx ;rbx = rsp-rcx
mov [rbx], al ;move o último dígito p/ [rsp-rcx]
dec rcx ;decrementa o counter
cmp rcx, 0 ;compara o counter com zero
jnz _invertLoop ;se não for, refaz o loop
Antes de continuarmos, já vou fazer aqui os comentários.
A primeira linha desse procedimento move o conteúdo de um certo endereço de memória, que é um pedaço da stack, para o registrador rax. Como criamos um novo stack frame antes, o rbp atual contém o antigo rsp. Acontece que antes de fazermos mov rbp, rsp nós fizemos push rbp, o que diminuiu o valor de rsp em 8 (com base na imagem anterior, o novo rsp seria o endereço do topo, aquele terminando em e728, menos 8). Então, o novo rbp terminará em e720, exatamente 8 bytes acima do nosso número na stack (contando com o começo dela).
Então, [rbp+7] aponta para o endereço na stack onde nosso número se encontra. Porém, como já sabemos, nosso número está invertido.
Para desinvertermos o número, pegaremos o último dígito e gravaremos no buffer, depois faremos isso com o penúltimo, com o antepenúltimo, etc., até o primeiro. E para pegarmos o último dígito, basta somarmos a rbp+8 o valor do nosso counter, que contém justamente o tamanho do nosso número.
Ora, se [rbp+7] aponta para o primeiro dígito e nós precisamos do último, basta somar a isso o tamanho do número, que é 4, e chegaremos ao último dígito.
Após isso, salvamos o valor atual de rsp em rbx porque precisamos alterá-lo (sem alterar o real stack pointer). Nós precisamos mover o dígito que está atualmente em rax para o topo da stack, para reconstruir o número inversamente. Porém, novamente, precisamos a cada ĩteração mudar o local onde o dígito atual será escrito, pois, do contrário, ele sobrescrevará sempre a primeira posição.
Para mudarmos o local onde ele será escrito na stack, usaremos novamente o counter. Na primeira iteração, o counter é igual ao tamanho do número. No nosso caso, rcx=4. Então, podemos mover o último dígito para [rsp-4]. Na próxima, teremos rcx=3, já que ele é decrementado a cada iteração. Então, o próximo dígito será movido para [rsp-3], e assim por diante até que rcx seja igual a zero, quando o loop finaliza.
As instruções de mover rsp para rbx, subtrair rcx de rdx e mover al para [rbx] são justamente o "mov [rsp-4], dígito" que falei no parágrafo anterior.
Como estamos movendo para valores menores que a stack, nosso número ocorrerá acima do rsp, então precisamos diminuir o rsp de tal modo que nosso número fique nele. Continuando:
_invertLoop:
mov rax, [rbp+7+rcx] ;move para rax o byte atual da stack
mov rbx, rsp ;guarda rsp em rbx para podermos manipulá-lo
sub rbx, rcx ;rbx = rsp-rcx
mov [rbx], al ;move o último dígito p/ [rsp-rcx]
dec rcx ;decrementa o counter
cmp rcx, 0 ;compara o counter com zero
jnz _invertLoop ;se não for, refaz o loop
mov rax, rdx ;se for, move o counter original pra rax
mov rbx, 2 ;comentário detalhado em seguida
mul rbx ;multiplica o counter por 2 (rbx=2)
mov rbx, rax ;move counter*2 para rbx (será usado no write)
sub rsp, rax ;diminui a stack em counter*2 bytes
Como vemos, pegamos o tamanho do número e multiplicamos por 2. Isso foi feito porque cada dígito do nosso número (7359) corresponde a dois números da tabela ascii. Então, o número 7359, na stack, é na verdade 37333539. Então, qualquer que seja o número, temos que multiplicar seu tamanho por 2, e o resultado é o quanto precisamos diminuir nosso rsp para que nosso número caia exatamente em cima dele. No caso, nosso rsp é o terminado em e710, e o número foi escrito em e708, 8 bytes a menos (lembre-se que os endereços estão em hexadecimal).
Printando as coisas
Pronto, já temos nosso número em um formato printável no rsp. Agora basta chamar a função write. Essa syscall precisa de três parâmetros: o fd para onde o conteúdo será escrito (no caso, o fd será 1, que corresponde ao stdout), o buffer, que é o endereço para o conteúdo que será escrito, e quantos bytes serão escritos, que no caso é o tamanho do número (e por isso salvamos o tamanho do número em rbx lá em cima). O código dessa última parte fica assim: mov rax, 1 ;syscall write()
mov rdi, 1 ;fd (stdout)
mov rsi, rsp ;buf (número na stack)
mov rdx, rbx ;count (tamanho do número)
syscall
mov rax, 60 ;syscall exit()
mov rdi, 0
syscall
E isso nos retorna no terminal o valor que colocamos inicialmente na pilha no começo do código, com o "push 7359":
nich0las@0x7359:~$ ./printnum
7359
7359
Código final
Este é o código final, juntamente com as instruções de montagem e linkagem:
global _start
section .text
_start:
push 7359 ;número que será printado
call _movToBuf
_movToBuf:
push rbp ;inicia o novo stack frame
mov rbp, rsp
xor rcx, rcx ;zera o contador
mov rax, [rsp+16] ;guarda em rax o argumento da função
mov rbp, 10 ;o número será dividido por 10
mov rsp, 16 ;cria um buffer de 16 bytes na stack
_divLoop:
xor rdx, rdx ;zera o rdx
div rbx ;divide rax por 10
cmp rax, 0 ;compara o quociente com 0
jnz _continue ;se não for igual, continua
cmp rdx, 0 ;compara o resto com 0
jnx _continue ;se não for igual, continua
jmp _invert ;se quociente e resto forem 0, inverte
_continue:
add dl, 48 ;converte o resto em ascii
mov [rsp+rcx], dl ;move o resto p/ o próximo byte na stack
inc rcx ;incrementa o contador
jmp _divLoop ;repete o loop
_invert:
push rbp ;novo stack frame
mov rbp, rsp
sub rsp, 16 ;cria um buffer na stack
mov rdx, rcx ;salva o tamanho do número
_invertLoop:
mov rax, [rbp+7+rcx] ;move para rax o byte atual da stack
mov rbx, rsp ;guarda rsp em rbx para podermos manipulá-lo
sub rbx, rcx ;rbx = rsp-rcx
mov [rbx], al ;move o último dígito p/ [rsp-rcx]
dec rcx ;decrementa o counter
cmp rcx, 0 ;compara o counter com zero
jnz _invertLoop ;se não for, refaz o loop
mov rax, rdx ;se for, move o counter original pra rax
mov rbx, 2 ;comentário detalhado em seguida
mul rbx ;multiplica o counter por 2 (rbx=2)
mov rbx, rax ;move counter*2 para rbx (será usado no write)
sub rsp, rax ;diminui a stack em counter*2 bytes
mov rax, 1 ;syscall write()
mov rdi, 1 ;fd (stdout)
mov rsi, rsp ;buf (número na stack)
mov rdx, rbx ;count (tamanho do número)
syscall
mov rax, 60 ;syscall exit()
mov rdi, 0
syscall
nich0las@0x7359:~$ nasm -felf64 printnum.nasm -o printnum.o
nich0las@0x7359:~$ ld printnum.o -o printnum
nich0las@0x7359:~$ ld printnum.o -o printnum