pbn/curso/aula-09.org

25 KiB
Raw Blame History

9 Conversão de representações de ponto flutuante

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 tipo float.
  • DBL_DIG: Quantidade de dígitos decimais garantidos no tipo double.
  • LDBL_DIG: Quantidade de dígitos decimais garantidos no tipo long 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 (ou 8.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