Usando instruções da FPU

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.

Registradores

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:

st0 = 10
st1 = 20
st2 = 30

* é feito um push do valor 40

st0 = 40
st1 = 10
st2 = 20
st3 = 30

* é feito um pop, o valor 40 é pego.

st0 = 10
st1 = 20
st2 = 30

Detalhe que só é possível usar esses registradores em instruções da FPU, algo como esse código está errado:

mov eax, st1

Formato das instruções

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 memXXfpe 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.

FINIT | Initialization

finit

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.

FLD, FILD | (Integer) Load

fld mem32fp
fld mem64fp
fld mem80fp
fld st(i)

fild mem16int
fild mem32int
fild mem64int

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.

fild carrega um valor inteiro sinalizado de 16, 32 ou 64 bits o convertendo para float de 64 bits.

Load Constant

Existem várias instruções para dar push de valores constantes na pilha da FPU, e elas são:

FST, FSTP | Store (and Pop)

fst mem32fp
fst mem64fp
fst st(i)

fstp mem32fp
fstp mem64fp
fstp mem80fp
fstp st(i)

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.

FIST, FISTP | Integer Store (and Pop)

fist mem16int
fist mem32int

fistp mem16int
fistp mem32int
fistp mem64int

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:

bits 64

section .data
  num: dq 23.87

section .bss
  result: resd 1

section .text
global assembly
assembly:
  finit
  fld   qword [num]
  fistp dword [result]

  mov eax, [result]
  ret

Se você rodar esse teste irá notar que o valor foi convertido para 24 já que houve um arredondamento.

FADD, FADDP, FIADD | (Integer) Add (and Pop)

fadd mem32fp
fadd mem64fp
fadd st(0), st(i)
fadd st(i), st(0)

faddp st(i), st(0)
faddp

fiadd mem16int
fiadd mem32int

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:

bits 64

section .data
  num1: dq 24.3
  num2: dq 0.7

section .bss
  result: resd 1

section .text
global assembly
assembly:
  finit
  fld  qword [num1]
  fadd qword [num2]

  fist dword [result]
  mov eax, [result]
  ret

FSUB, FSUBP, FISUB | (Integer) Subtract (and Pop)

fsub mem32fp
fsub mem64fp
fsub st(0), st(i)
fsub st(i), st(0)

fsubp st(i), st(0)
fsubp

fisub mem16int
fisub mem32int

Mesma coisa que as instruções acima, só que fazendo uma operação de subtração.

FDIV, FDIVP, FIDIV | (integer) Division (and Pop)

fdiv mem32fp
fdiv mem64fp
fdiv st(0), st(i)
fdiv st(i), st(0)

fdivp st(i), st(0)
fdivp

fidiv mem16int
fidiv mem32int

Mesma coisa que FADD etc. porém faz uma operação de divisão.

FMUL, FMULP, FIMUL | (Integer) Multiply (and Pop)

fmul mem32fp
fmul mem64fp
fmul st(0), st(i)
fmul st(i), st(0)

fmulp st(i), st(0)
fmulp

fimul mem16int
fimul mem32int

Cansei de repetir, já sabe né? Operação de multiplicação.

FSUBR, FSUBRP, FISUBR | (Integer) Subtract Reverse (and Pop)

Faz a mesma coisa que a família FSUB só que com os operandos ao contrário. Conforme ilustração:

a = a - b // fsub etc.
a = b - a // fsubr etc.

Ou seja faz a subtração na ordem inversa dos operandos, porém onde o resultado é armazenado continua sendo o mesmo.

FDIVR, FDIVRP, FIDIVRP | (Integer) Division Reverse (and Pop)

Mesma lógica que as instruções acima, porém faz a divisão na ordem inversa dos operandos.

FXCH | Exchange

fxch st(i)
fxch

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.

FSQRT | Square root

fsqrt

Calcula a raíz quadrada de st0 e armazena o resultado no próprio st0.

FABS | Absolute

fabs

Calcula o valor absoluto de st0 e armazena em st0. Basicamente zera o bit de sinalização do valor.

FCHS | Change Sign

fchs

Inverte o sinal de st0, se era negativo passa a ser positivo e vice-versa.

FCOS | Cosine

fcos

Calcula o cosseno de st0 que deve ser um valor radiano, e armazena o resultado nele próprio.

FSIN | Sine

fsin

Calcula o seno de st0, que deve estar em radianos.

FSINCOS | Sine and Cosine

fsincos

Calcula o seno e o cosseno de st0. O cosseno é armazenado em st0 enquanto o seno estará em st1.

FPTAN | Partial Tangent

fptan

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.

FPATAN | Partial Arctangent

fpatan

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.

F2XM1 | 2^x - 1

f2xm1

Faz o cálculo de 2 elevado a st0 menos 1, e armazena o resultado em st0.

FYL2X | y * log2(x)

fyl2x

Faz esse cálculo aí com logaritmo de base 2:

Após o cálculo é feito um pop.

FYL2XP1 | y * log2(x + 1)

fyl2xp1

Mesma coisa que a instrução anterior porém somando 1 a st0.

FRNDINT | Round to Integer

frndint

Arredonda st0 para a parte inteira mais próxima e armazena o resultado em st0.

FPREM, FPREM1 | Partial Reminder

fprem
fprem1

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.

FCOMI, FCOMIP, FUCOMI, FUCOMIP | Compare

fcomi  st(0), st(i)
fcomip st(0), st(i)

fucomi  st(0), st(i)
fucomip st(0), st(i)

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.

FCMOVcc | Conditional Move

fcmovb  st(0), st(i)
fcmove  st(0), st(i)
fcmovbe st(0), st(i)
fcmovu  st(0), st(i)

fcmovnb  st(0), st(i)
fcmovne  st(0), st(i)
fcmovnbe st(0), st(i)
fcmovnu  st(0), st(i)

Faz uma operação move condicional levando em consideração as status flags.

Vendo os resultados

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:

bits 64

section .data
  num1: dq 3.0
  num2: dq 3.0

section .bss
  result: resd 1

section .text
global assembly
assembly:
  finit
  fld  qword [num1]
  fmul qword [num2]

  fst dword [result]
  movss xmm0, [result]
  ret

A instrução MOVSS e os registradores XMM serão explicados no próximo tópico.

Last updated