25 KiB
9 – Conversão de representações de ponto flutuante
- Objetivos
- Tipos de ponto flutuante em C
- Representações em expressões constantes
- Codificação binária do tipo double (64 bits)
- Codificação binária do tipo float (32 bits)
- Unidades de ponto flutuante em x86_64
- Conversão de strings para float
- Referências
Objetivos
- Conhecer as formas de codificação binária de números de ponto flutuante.
- Identificar as representações de números de ponto flutuante com strings.
- Implementar funções e sub-rotinas para converter os representações de ponto flutuante nos valores representados.
Tipos de ponto flutuante em C
A linguagem C define três tipos principais de ponto flutuante (x86_64):
Tipo | Precisão | Tamanho |
---|---|---|
float |
Simples | 4 bytes |
double |
Dupla | 8 bytes |
long double |
Estendida | 16 bytes |
A precisão refere-se à quantidade de dígitos que podem ser representados de forma exata em um número, sendo um parâmetro essencial para determinar a precisão de resultados de operações aritméticas, ate onde podemos representar números muito pequenos sem que eles virem zero e distinguir valores em operações de comparação.
No entanto, observe o que acontece com a execução do exemplo abaixo…
Arquivo: precision.c
#include <stdio.h>
#include <float.h> // Para as precisões
int main(void) {
float f = 10.0f / 3.0f;
double d = 10.0 / 3.0;
long double ld = 10.0L / 3.0L;
printf("float (%d): %.20f\n", FLT_DIG, f);
printf("double (%d): %.20lf\n", DBL_DIG, d);
printf("long double (%d): %.20Lf\n", LDBL_DIG, ld);
return 0;
}
Aqui, nós queremos imprimir os resultados das divisões utilizando os três
tipos de ponto flutuante e, segundo as definições da glibc
, quantos dos seus
dígitos decimais teriam precisão garantida. Para isso, estamos utilizando as
constantes simbólicas do cabeçalho float.h
:
FLT_DIG
: Quantidade de dígitos decimais garantidos no tipofloat
.DBL_DIG
: Quantidade de dígitos decimais garantidos no tipodouble
.LDBL_DIG
: Quantidade de dígitos decimais garantidos no tipolong double
.
Compilando e executando:
:~$ gcc -Wall precision.c :~$ ./a.out float (6): 3.33333325386047363281 double (15): 3.33333333333333348136 long double (18): 3.33333333333333333326
De fato, os resultados são precisos até os dígitos decimais esperados:
6 | float (6): 3.33333325386047363281 15 | double (15): 3.33333333333333348136 18 | long double (18): 3.33333333333333333326
Representações em expressões constantes
Ainda no código do exemplo, observe como os valores de ponto flutuante foram escritos:
float f = 10.0f / 3.0f;
double d = 10.0 / 3.0;
long double ld = 10.0L / 3.0L;
Tivemos que escrever assim porque, por padrão, expressões constantes com ponto
decimal são avaliadas como double
, a menos que sejam seguidas de f
(float
) ou
L
(long double
), como podemos ver nesta tabela, que inclui as notações com
expoente:
Literal | Tipo |
---|---|
3.14 |
double |
3.14f ou 3.14F |
float |
3.14l ou 3.14L |
long double |
3e[+/-]2 |
double |
3e[+/-]2f (ou F ) |
float |
3e[+/-]2l (ou L ) |
long double |
Também existem as representações com expoentes binários, introduzidos no C99 para representar valores em hexadecimal com expoente em base 2 (ex.:
0x1.8p1
), mas eles não serão estudados neste curso.
Para os nossos propósitos, porém, a representação literal da linguagem C que
nos interessa é a do tipo double
(3.14
ou 0.42
) com ou sem sinal. Sobre isso,
valores negativos são representados da mesma forma que os positivos, mas com
o bit de sinal ativado.
Sendo assim, os dígitos dos valores convertidos pelas nossas implementações
deverão corresponder a todos os caracteres da string numérica e serão
retornados como double
, o que implica:
- Valor retornado em 8 bytes (64 bits) com ou sem sinal;
- Limite máximo em DBL_MAX
- Limite mínimo em DBL_TRUE_MIN (menor subnormal).
Codificação binária do tipo double (64 bits)
Um número de ponto flutuante com dupla precisão (double) ocupa 64 bits divididos em três campos:
63 62 51 0 ┌───┬──────────┬────────────────────────────┐ │ S │ EXPOENTE │ MANTISSA │ └───┴──────────┴────────────────────────────┘
De modo a representar números da faixa normal (parte inteira >=1
) como:
(-1)^sinal × (1 + (mantissa/2^52)) × 2^(expoente - 1023)
Nota: mantissa é como chamamos a parte fracionária do número.
Bits de expoente
O expoente diz quantas vezes o valor da mantissa será multiplicado ou dividido por 2. O campo possui 11 bits (bits 62 a 52) e pode receber valores entre 0 e 2047 que assumirão três significados diferentes:
Expoente | Significado |
---|---|
0 |
Número muito pequenos (subnormais) ou 0 |
2047 |
Infinito ou não é um número (NaN ) |
1 a 2046 |
Números na faixa normal |
Se o expoente for 0
, ele será interpretado com o valor fixo -1022
e a
interpretação será feita sem o bit implícito, resultando na expressão
de valores iniciados com 0.
alguma coisa. Neste caso, nós teremos valores
muito pequenos, abaixo dos valores normais e, por isso mesmo, chamados de
subnormais.
Exemplos:
0x0000000000000000 => +0.0 0x0000000000000001 => Menor subnormal positivo
Já a interpretação do expoente 2047
depende do que está na mantissa: se for
0
, o número é interpretado como infinito; caso contrário, não será um
número.
Exemplos:
0x7ff0000000000000 => +Infinito (0x7ff = 2047) 0x7ff0000000000001 => NaN
Quando o expoente representa números na faixa normal, a interpretação dos 64 bits deve ser feita considerando como se existisse um bit 1 antes da mantissa, garantindo que a parte inteira do número normalizado nunca seja zero.
Exemplo:
123.456 => 0x405edd2f1a9fbe77 Primeiros 12 bytes: 0x405 = 0100 0000 0101 bit de sinal (+1) ↓ 0 10000000101 ↑ 11 bits do expoente (0x405 => 1029) Expoente real: 1029 - 1023 = 6 => 2^6 = 64 Mantissa: 0xedd2f1a9fbe77 Mantissa real: 1 + 0xedd2f1a9fbe77 / 2^52 = 1.929 Valor final: +1 × 1.929 × 64 = +123.456
Codificação binária do tipo float (32 bits)
O tipo float armazena números reais com precisão simples em 32 bits, também divididos em três campos:
31 30 22 0 ┌───┬──────────┬────────────────────────────┐ │ S │ EXPOENTE │ MANTISSA │ └───┴──────────┴────────────────────────────┘
Deste modo, valores normalizados (parte inteira >=1
) podem ser calculados
pela fórmula:
(-1)^sinal × (1 + (mantissa/2^23)) × 2^(expoente - 127)
Bits de expoente
O expoente é expresso com 8 bits, o que pode resultar em valores de 0 a 255 com os seguintes significados:
Expoente | Significado |
---|---|
0 |
Número subnormal (0.xxx ) ou zero. |
1 a 254 |
Números na faixa normal (1.xxx ). |
255 |
Infinito ou NaN. |
Assim como acontece na representação binária do tipo double, expoentes entre
1
e 254
implicam a soma de 1 à mantissa real (mantissa/2^23
) na interpretação
do valor representado. Com expoente 0
, a mantissa real ainda é utilizada para
obter a parte fracionária de um número subnormal (0.xxx
). Contudo, se o
expoente for 255
, utiliza-se a mantissa armazenada para decidir se o valor é
infinito (mantissa 0
) ou não é um número (mantissa diferente de 0
).
Unidades de ponto flutuante em x86_64
A arquitetura x86_64 representa e manipula números reais por meio de três mecanismos históricos:
- FPU (Float-Point Unit): coprocessador matemático;
- Instruções SSE/SSE2: suporte a operações com ponto flutuante;
- Registradores XMM: utilizados com instruções SSE.
A FPU (x87) surgiu em 1980 com o coprocessador 8087, mais tarde integrado ao
processador 486DX. Ainda é suportado na arquitetura x86_64 por compatibilidade,
mas raramente é utilizado em códigos modernos – a não ser para lidar com
long double
(números de ponto flutuante com 128 bits).
As instruções SSE/SSE2 foram introduzidas nos processadores Pentium III e IV, dos anos 2000, como mecanismos preferenciais para operações de ponto flutuante. A partir da arquitetura x86_64, as instruções SSE2 tronam-se obrigatórias e substituem praticamente toda a FPU em compiladores modernos.
Mais recentemente, os processadores passaram a oferecer extensões como AVX (Advanced Vector Extensions), que ampliam o número e a largura de registradores XMM (passando a YMM e ZMM, com 256 e 512 bits) e otimizam operações vetoriais e de ponto flutuante em larga escala. Essas instruções são usadas por compiladores modernos em programas de alto desempenho.
Nota: os registradores de propósito geral nunca foram projetados para trabalhar com ponto flutuante. Mas, ao utilizá-los nas nossas demonstrações de conversões, nossos objetivos são apresentar o núcleo lógico dessas operações e demonstrar os problemas e as limitações que os recursos modernos superam.
Conversão de strings para float
Ao converter uma string como "12.75"
para um número real no Assembly, as
principais abordagens para representar o resultado são:
- Formato escalado (também chamado de fixed-point);
- Formato empacotado como número IEEE 754 de 32 bits (float).
Ambas as abordagens são úteis em contextos distintos e possuem vantagens e limitações que devem ser compreendidas e consideradas.
Alternativa 1: Representação escalada (fixed-point)
Em Assembly (especialmente sem FPU), não há suporte direto para números com
parte fracionária. Mas é possível simular valores reais utilizando inteiros,
contanto que todos os números sejam representados na mesma escala. A ideia é
preservar as casas decimais multiplicando o valor original por um fator
fixo (como 2^23
) e armazenar o resultado como um inteiro.
Por exemplo, podemos representar o número 12.34
como:
12.75 × 2^23 = 1065353216
O exemplo de fator
2^23
(ou8.388.608
) está relacionado com o fato deste ser o número de bits disponíveis para representar a mantissa em um número float de 32 bits no padrão IEEE 754, como vimos.
O valor em escala pode ser armazenado em um registrador de propósito geral e
operado com instruções inteiras comuns, como add
, sub
, imul
, idiv
, etc. Após
a operação, o resultado pode ser interpretado diretamente em escala ou
desescalado para a forma de ponto flutuante.
Por exemplo, numa conversão de Celsius para Fahrenheit…
; ----------------------------------------------------------
; RAX = Temperatura em Celsius × 2^23
; Conversão para Fahrenheit: F = C × 9/5 + 32
; ----------------------------------------------------------
imul rax, rax, 9 ; rax = rax × 9
cqo ; estende o sinal de rax em rdx (para imul)
mov rcx, 5 ; divisor
idiv rcx ; rax = rax / 5
add rax, 32 << 23 ; rax = rax + (32 × 2^23)
; Resultado final: Fahrenheit × 2^23
Para exibir o resultado como uma string, bastaria dividi-lo por 2^23
(8.388.608
). Em Assembly, podemos utilizar mov
e idiv
para obter parte
inteira e parte decimal separadas antes de fazer a conversão para texto…
; ----------------------------------------------------------
; RAX = Fahrenheit × 2^23
; ----------------------------------------------------------
mov rbx, rax ; copia valor escalado
mov rcx, 8388608 ; fator da escala (2^23)
xor rdx, rdx ; zera rdx para o resto
idiv rcx ; rax = parte inteira, rdx = parte fracionária (resto)
; converter a partte inteira para string...
; ----------------------------------------------------------
; A parte fracionária ainda está em escala...
; ----------------------------------------------------------
mov rbx, 1000 ; multiplicador da parte fracionária
imul rdx, rbx ; x1000 => um inteiro desescalado com 3 algarismos
idiv rcx ; rax = parte fracionária
; converter a parte fracionária para string e concatenar com '.'...
A grande vantagem dessa abordagem é sua simplicidade e compatibilidade com qualquer conjunto de instruções, sem depender da FPU ou de registradores XMM. Contudo, ela exige que todas as operações preservem a escala, o que requer muita atenção com as operações aritméticas. Ao final, o resultado pode ser convertido para exibição ou empacotado como float para ser usado com instruções de ponto flutuante ou passado para funções externas da Glibc.
Alternativa 2: Representação IEEE 754
A representação empacotada como float segue o padrão IEEE 754. Um número como
12.75
, por exemplo, seria convertido para a forma binária através dos
seguintes passos:
- Determinar o sinal (0 para positivo);
- Converter o número para binário normalizado;
- Calcular o expoente com o bias (deslocamento);
- Codificar a mantissa (sem o bit implícito).
Passo 1: determinar o sinal
O número é positivo, então o sinal é 0
.
Passo 2: converter o número para binário normalizado
- Parte inteira (12):
11000
em base 2. - Parte fracionária (0.75):
11
, obtido por multiplicações sucessivas por 2. - Resultado da conversão:
1100.11
Normalizar um binário de ponto flutuante é representá-lo como:
1.xxxxxx × 2^n
No caso de 1100.11
, será preciso deslocar o ponto 3 posições para a esquerda.
Portanto, para manter seu valor original, nós temos que multiplicá-lo por
2^3
:
1100.11 = 1.10011 × 2^3
Passo 3: calcular o expoente com bias
Como o deslocamento da normalização foi de 2^3
, o expoente com bias será:
3 + 127 (bias IEEE 754) = 130 130 = 10000010 na base 2
Passo 4: codificar a mantissa
A mantissa é a parte fracionária do valor normalizado em binário seguida de tantos zeros quanto forem necessários para totalizar 23 bits, portanto…
Mantissa de 1.10011 => 10011 Mantissa armazenada => 10011000000000000000000
Empacotamento dos campos
Sinal : 0 (positivo) Expoente : 10000010 (130 = 127 + 3) Mantissa : 10011000000000000000000 Resultado: 01000001010011000000000000000000 => 0x41480000 em hexa
A forma empacotada é utilizada em operações de ponto flutuante com SSE/FPU
e para passar argumentos float
para funções em C. Ela permite o uso de
instruções especializadas da arquitetura, mas requer decodificação
e empacotamento.
Quando optar por uma ou outra alternativa
Via de regra, nós só empacotamos para o formato IEEE 754 quando realmente precisamos interagir com outros componentes que exijam o tipo float. Nas situações mais comuns, a representação em escala é mais que suficiente.
Objetivo | Formato recomendado |
---|---|
Aritmética simples com inteiros | Escalado (fixed) |
Comparações e fórmulas básicas | Escalado (fixed) |
Compatibilidade com SSE/FPU | IEEE 754 |
Chamada de função com "%f" | IEEE 754 |
Dificuldades comuns
1. Arredondamento de frações
Ao representar "0.333" como inteiro escalado, há perda de precisão. Aumentar o fator de escala (por exemplo, 2^30 em vez de 2^23) melhora a precisão, mas pode causar overflow em multiplicações.
2. Erros ao interpretar IEEE como inteiro
Se usarmos um float empacotado (como 0x41200000
, que representa 10.0
) em uma
operação inteira, o valor será incorreto. Isso ocorre porque o padrão IEEE é
uma codificação binária, e não o valor numérico diretamente.
3. Falta de suporte a ponto flutuante
Alguns ambientes minimalistas, como bootloaders ou sistemas bare-metal, podem não ter suporte a SSE/FPU. Nestes casos, a representação escalada é a única alternativa viável.
Diferenças na conversão para double (64 bits)
O tipo double (também do padrão IEEE 754) ocupa 64 bits e permite maior precisão e maior faixa de representação. Sua estrutura é similar à de um float, mas com:
- 1 bit para o sinal
- 11 bits para o expoente (
bias=1023
) - 52 bits para a mantissa (com bit 1 implícito)
Sendo assim, as operações de empacotamento e desempacotamento seguem o mesmo processo do float, mas precisam trabalhar com registradores de 64 bits completos.
A representação escalada também pode ser estendida para o caso de valores que
eventualmente serão convertidos para double, com os mesmos benefícios de
usar apenas instruções inteiras durante o processamento. Contudo, quando
demonstramos o uso do tipo float, nós adotamos o fator de escala 2^23
,
por ser compatível com o número de bits disponíveis para a mantissa. No caso
de double, porém, a mantissa tem 52 bits, o que permitiria escalar até 2^52
(aproximadamente 4.5 × 10^15) sem perda de precisão por arredondamento.
No entanto, escalar por 2^52
pode causar overflow em algumas multiplicações
se o valor base for muito grande. Assim, uma alternativa prática seria
escalar por 2^30
, 2^32
ou outro fator suficientemente alto, mas ainda seguro
para uso com registradores de 64 bits.
Sub-rotinas para demonstração
Aqui estão duas sub-rotinas:
str_to_float_scaled
: converte strings no formato<int>.<frac>
para representações de números de ponto flutuante como inteiros de 32 bits escalados.str_to_float_ieee754
: converte strings no formato<int>.<frac>
para representações de números de ponto empacotados no formato IEEE 754 de 32 bits.
Embora seja possível que elas funcionem corretamente, nenhuma delas chegou a ser testada. Portanto, o exercício proposto para esta aula será:
- Analisar o código de cada uma delas;
- Compreender e explicar as decisões tomadas;
- Estudar as instruções ainda desconhecidas;
- Elaborar formas de testá-las;
- Corrigir eventuais erros;
- Buscar formas de otimizá-las;
- Implementar versões para 64 bits (double).
str_to_float_scaled
; ----------------------------------------------------------
; Converte uma string no formato <int>.<uint> para float
; Entrada: RDI -> ponteiro para string válida
; Saída: EAX = número codificado como float (IEEE 754, 32 bits)
; Altera: RBX, RCX, RDX, RSI, R8, R9, R10
; ----------------------------------------------------------
str_to_float_scaled:
; ----------------------------------------------------------
push rbx ; salva rbx na pilha
push rsi ; salva rsi na pilha
mov rsi, rdi ; salva ponteiro da string em rsi
xor rbx, rbx ; RBX = parte inteira
xor rcx, rcx ; RCX = parte fracionária
xor rdx, rdx ; RDX = divisor decimal (potência de 10)
xor r8d, r8d ; sinal (0 = positivo, 1 = negativo)
; --------------------------------------------------
; Verifica e armazena o sinal
; --------------------------------------------------
mov al, byte [rsi]
cmp al, '-'
jne .check_plus
inc rsi
mov r8b, 1
jmp .read_integer
.check_plus:
cmp al, '+'
jne .read_integer
inc rsi
; --------------------------------------------------
; Lê parte inteira: <int>
; --------------------------------------------------
.read_integer:
xor eax, eax ; eax = 0
.read_int_loop:
mov al, byte [rsi]
cmp al, 0 ; fim da string
je .build_float
cmp al, '.' ; fim da parte inteira
je .read_fraction
sub al, '0' ; converte dígito em número
jb .build_float ; se al < 0x30, fim do número
cmp al, 9
ja .build_float ; se al > 9, fim do número
imul rbx, rbx, 10 ; rbx = rbx * 10
add bl, al ; bl = bl + al
inc rsi
jmp .read_int_loop
; --------------------------------------------------
; Lê parte decimal: .<uint>
; --------------------------------------------------
.read_fraction:
inc rsi ; pula o ponto
xor ecx, ecx ; zera a parte decimal
mov edx, 1 ; divisor = 1
.read_frac_loop:
mov al, byte [rsi]
cmp al, 0 ; número é xxx.0
je .build_float
sub al, '0' ; converte dígito em número
jb .build_float ; se al < 0x30, fim do número
cmp al, 9
ja .build_float ; se al > 9, fim do número
imul rcx, rcx, 10 ; rcx = rcx * 10
add cl, al ; cl = cl + al
imul rdx, rdx, 10 ; divisor *= 10
inc rsi
jmp .read_frac_loop
; --------------------------------------------------
; Combina: float = inteiro + (fracao / divisor)
; rbx = parte inteira (int)
; rcx = parte fracionária (uint)
; rdx = divisor decimal
; converte parte inteira para float (em eax)
; --------------------------------------------------
.build_float:
mov eax, ebx
test r8b, r8b
jz .skip_negate_int ; número é positivo
neg eax ; aplica sinal de negativo
; --------------------------------------------------
; converte parte decimal: (rcx / rdx) → aproximação inteira × escala
; Aqui usamos multiplicação por 2^n e bit shift para simular uma fração
; Exemplo: 123 / 1000 ≈ (123 << 23) / 1000
; --------------------------------------------------
.skip_negate_int:
shl rcx, 23
xor r9d, r9d
div rdx ; rcx / rdx (com resto em rdx), quociente em rax
mov r9d, eax ; parte decimal em r9d
; --------------------------------------------------
; soma a parte inteira com a parte decimal (ambos em "escala flutuante")
; --------------------------------------------------
shl eax, 23 ; inteiro convertido para a mesma escala
add eax, r9d ; eax = pf aproximado * 2^23
pop rsi
pop rbx
ret
str_to_float_ieee754
; ----------------------------------------------------------
; Converte uma string no formato <int>.<uint> para float IEEE 754 (32 bits)
; Entrada: RDI -> ponteiro para string válida
; Saída: EAX = número codificado como float (IEEE 754, 32 bits)
; Altera: RBX, RCX, RDX, RSI, R8, R9, R10
; ----------------------------------------------------------
str_to_float_ieee754:
; ----------------------------------------------------------
push rbx
push rsi
mov rsi, rdi ; ponteiro da string
xor rbx, rbx ; parte inteira
xor rcx, rcx ; parte fracionária
xor rdx, rdx ; divisor decimal (potência de 10)
xor r8d, r8d ; sinal (0 = positivo, 1 = negativo)
; --------------------------------------------------
; Sinal
; --------------------------------------------------
mov al, byte [rsi]
cmp al, '-'
jne .check_plus
inc rsi
mov r8b, 1
jmp .read_int
.check_plus:
cmp al, '+'
jne .read_int
inc rsi
; --------------------------------------------------
; Parte inteira
; --------------------------------------------------
.read_int:
xor eax, eax
.read_int_loop:
mov al, byte [rsi]
cmp al, 0
je .build_scaled
cmp al, '.'
je .read_frac
sub al, '0'
jb .build_scaled
cmp al, 9
ja .build_scaled
imul rbx, rbx, 10
add bl, al
inc rsi
jmp .read_int_loop
; --------------------------------------------------
; Parte fracionária
; --------------------------------------------------
.read_frac:
inc rsi
xor ecx, ecx
mov edx, 1
.read_frac_loop:
mov al, byte [rsi]
cmp al, 0
je .build_scaled
sub al, '0'
jb .build_scaled
cmp al, 9
ja .build_scaled
imul rcx, rcx, 10
add cl, al
imul rdx, rdx, 10
inc rsi
jmp .read_frac_loop
; --------------------------------------------------
; Combina: inteiro + (frac/divisor)
; Resultado com fator 2^23
; --------------------------------------------------
.build_scaled:
mov eax, ebx
test r8b, r8b
jz .skip_neg
neg eax
.skip_neg:
shl rcx, 23
xor r9d, r9d
div rdx ; rcx / rdx -> rax
mov r9d, eax ; parte fracionária escalada
shl eax, 23 ; inteiro * 2^23
add eax, r9d ; valor total em escala 2^23
; --------------------------------------------------
; IEEE 754: empacotamento
; --------------------------------------------------
xor ecx, ecx
test eax, eax
jns .abs_ready
neg eax
mov ecx, 1 ; sinal = 1
.abs_ready:
bsr r8d, eax ; MSB
mov edx, r8d
sub edx, 23
add edx, 127 ; bias
mov ebx, eax
mov ecx, r8d
sub ecx, 23
shl ebx, cl
and ebx, 0x7FFFFF ; mantissa
shl r8d, 31 ; sinal
shl edx, 23 ; expoente
or eax, edx
or eax, ebx
or eax, r8d
pop rsi
pop rbx
ret