#include <stdio.h>
int assembly(void);
int main(void)
{
printf("Resultado: %d\n", assembly());
return 0;
}
Aprendendo a usar o x87 para fazer cálculos.
Podemos usar a FPU para fazer cálculos com valores de ponto flutuante. A arquitetura x86 segue a padronização IEEE-754 para a representação de valores de ponto flutuante.
Apenas algumas instruções da FPU serão ensinadas aqui, não sendo uma lista completa.
Um adendo que normalmente compiladores de C não trabalham com valores de ponto flutuante desta maneira em x86-64 porque a arquitetura x86 hoje em dia tem maneiras mais eficientes de fazer esses cálculos. Isso será demonstrado no próximo tópico.
As instruções da FPU trabalham com os registradores de st0 até st7, são 8 registradores de 80 bits de tamanho cada. Juntos eles formam uma stack (pilha) onde você pode empilhar valores para trabalhar com eles ou desempilhar para armazenar o resultado das operações em algum lugar.
O empilhamento de valores funciona colocando o novo valor em st0 e todos os outros valores anteriores são "empurrados" para os registradores posteriores. Um exemplo bem leviano dessa operação:
Detalhe que só é possível usar esses registradores em instruções da FPU, algo como esse código está errado:
As instruções da FPU todas começam com um prefixo F, e as que operam com valores inteiros (convertendo DE ou PARA inteiro) também tem uma letra I após a letra F. Por fim, instruções que fazem o pop de um valor da pilha, isto é, remove o valor de lá, terminam com um sufixo P. Entendendo isso fica muito mais fácil identificar o que cada mnemônico significa e assim você não perde tempo tentando decorar uma sopa de letrinhas, se essas letras existem é porque tem um significado.
Caso tenha vindo de uma arquitetura RISC, geralmente o termo load é usado para a operação em que você carrega um valor da memória para um registrador. Já store é usado para se referir a operação contrária, do registrador para a memória.
Nesse caso as operações podem ser feita entre registradores da FPU também, conforme será explicado.
Fazer load de um valor é basicamente carregar um valor da memória para a pilha em st0, é como um push quando estamos falando da pilha convencional. A diferença aqui é a maneira como o valor é colocado na pilha, como já foi explicado anteriormente.
Já o store é pegar o valor da pilha, mais especificamente em st0, e armazenar em algum lugar da memória. Algumas instruções store permitem armazenar o valor em outro registrador da FPU.
Aqui eu vou ensinar a usar a FPU mas sem diretamente trabalhar com a linguagem C e os tipos float ou double, pois como já foi mencionado, não é assim que o compilador trabalha com cálculos de ponto flutuante.
Vou usar a notação memXXfp
e memXXint
para especificar valores na memória que sejam float ou inteiro, respectivamente. Onde XX seria o tamanho do valor em bits. Já a notação st(i)
será usada para se referir a qualquer registrador de st0 até st7. O st(0)
seria o registrador st0 especificamente.
Normalmente vamos usar essa instrução antes de começar a usar a FPU, pois ela reseta a FPU para o estado inicial. Dessa forma quaisquer operações anteriores com a FPU são descartadas e podemos começar tudo do zero. Assim não é necessário, por exemplo, a gente limpar a pilha da FPU toda vez que terminar as operações com ela. Basta rodar essa instrução antes de usá-la.
A instrução fld
carrega um valor float de 32, 64 ou 80 bits para st0. Repare como é possível dar load em um dos registradores da pilha, o que torna possível retrabalhar com valores anteriormente carregados. Se você rodar fld st0
estará basicamente duplicando o último valor carregado.
Já fild
carrega um valor inteiro sinalizado de 16, 32 ou 64 bits o convertendo para float de 64 bits.
Existem várias instruções para dar push de valores constantes na pilha da FPU, e elas são:
Pega o valor float de st0 e copia para o operando destino. A versão com o sufixo P também faz o pop do valor da stack, sendo possível dar store em um float de 80 bits somente com essa instrução.
Pega o valor em st0, converte para inteiro sinalizado e armazena no operando destino. Só é possível dar store em um inteiro de 64 bits na versão da instrução que faz o pop.
Só com essas instruções já podemos converter um float para inteiro e vice-versa. Conforme exemplo:
Se você rodar esse teste irá notar que o valor foi convertido para 24 já que houve um arredondamento.
As versões de fadd
com operando na memória faz a soma do operando com st0 e armazena o resultado da soma no próprio st0. Já fiadd
com operando em memória faz a mesma coisa, porém convertendo o valor inteiro para float 64 bits antes.
As instruções com registradores fazem a soma e armazenam o resultado no operando mais a esquerda, o operando destino. Enquanto a faddp
sem operandos soma st0 com st1, armazena o resultado em st1 e depois faz o pop.
Exemplo de soma simples:
Mesma coisa que as instruções acima, só que fazendo uma operação de subtração.
Mesma coisa que FADD etc. porém faz uma operação de divisão.
Cansei de repetir, já sabe né? Operação de multiplicação.
Faz a mesma coisa que a família FSUB só que com os operandos ao contrário. Conforme ilustração:
Ou seja faz a subtração na ordem inversa dos operandos, porém onde o resultado é armazenado continua sendo o mesmo.
Mesma lógica que as instruções acima, porém faz a divisão na ordem inversa dos operandos.
Seguindo a mesma lógica da instrução xchg
, troca o valor de st0 com st(i). A versão da instrução sem operando especificado faz a troca entre st0 e st1.
Calcula a raíz quadrada de st0 e armazena o resultado no próprio st0.
Calcula o valor absoluto de st0 e armazena em st0. Basicamente zera o bit de sinalização do valor.
Inverte o sinal de st0, se era negativo passa a ser positivo e vice-versa.
Calcula o cosseno de st0 que deve ser um valor radiano, e armazena o resultado nele próprio.
Calcula o seno de st0, que deve estar em radianos.
Calcula o seno e o cosseno de st0. O cosseno é armazenado em st0 enquanto o seno estará em st1.
Calcula a tangente de st0 e armazena o resultado no próprio registrador, logo após faz o push do valor 1.0 na pilha. O valor em st0 para ser calculado deve estar em radianos.
Calcula o arco-tangente de st1 dividido por st0, armazena o resultado em st1 e depois faz o pop. O resultado tem o mesmo sinal que o operando que estava em st1.
Faz o cálculo de 2 elevado a st0 menos 1, e armazena o resultado em st0.
Faz esse cálculo aí com logaritmo de base 2:
Após o cálculo é feito um pop.
Mesma coisa que a instrução anterior porém somando 1 a st0.
Arredonda st0 para a parte inteira mais próxima e armazena o resultado em st0.
As duas instruções armazenam a sobra da divisão entre st0 e st1 no registrador st0. Com a diferença que fprem1
segue a padronização IEEE-754.
Faz a comparação entre st0 e st(i) setando as status flags de acordo. A diferença de fucomi
e fucomip
é que essas duas verificam se os valores nos registradores não são NaN, sendo o caso a instrução irá disparar uma exception #IA.
Faz uma operação move condicional levando em consideração as status flags.
Adiantando que um valor float na convenção de chamada do C é retornado no registrador XMM0. Podemos ver o resultado de nossos testes da seguinte forma usando a instrução MOVSS:
A instrução MOVSS e os registradores XMM serão explicados no próximo tópico.
Instrução
Valor
FLD1
+1.0
FLDZ
+0.0
FLDL2T
log2(10)
FLDL2E
log2(e)
FLDPI
Valor de PI. (3.1415 blabla...)
FLDLG2
log10(2)
FLDLN2
logE(2)