#+title: 9 -- Conversão de representações de ponto flutuante #+author: Blau Araujo #+email: cursos@blauaraujo.com #+options: toc:3 * 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: [[exemplos/10/precision.c][precision.c]] #+begin_src c :tangle exemplos/10/precision.c #include #include // 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; } #+end_src 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: #+begin_example :~$ gcc -Wall precision.c :~$ ./a.out float (6): 3.33333325386047363281 double (15): 3.33333333333333348136 long double (18): 3.33333333333333333326 #+end_example De fato, os resultados são precisos até os dígitos decimais esperados: #+begin_example 6 | float (6): 3.33333325386047363281 15 | double (15): 3.33333333333333348136 18 | long double (18): 3.33333333333333333326 #+end_example * Representações em expressões constantes Ainda no código do exemplo, observe como os valores de ponto flutuante foram escritos: #+begin_src c float f = 10.0f / 3.0f; double d = 10.0 / 3.0; long double ld = 10.0L / 3.0L; #+end_src 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= | #+begin_quote 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. #+end_quote 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: #+begin_example 63 62 51 0 ┌───┬──────────┬────────────────────────────┐ │ S │ EXPOENTE │ MANTISSA │ └───┴──────────┴────────────────────────────┘ #+end_example De modo a representar números da faixa normal (parte inteira ~>=1~) como: #+begin_example (-1)^sinal × (1 + (mantissa/2^52)) × 2^(expoente - 1023) #+end_example #+begin_quote *Nota:* /mantissa/ é como chamamos a parte fracionária do número. #+end_quote ** 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:* #+begin_example 0x0000000000000000 => +0.0 0x0000000000000001 => Menor subnormal positivo #+end_example 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:* #+begin_example 0x7ff0000000000000 => +Infinito (0x7ff = 2047) 0x7ff0000000000001 => NaN #+end_example 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:* #+begin_example 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 #+end_example * 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: #+begin_example 31 30 22 0 ┌───┬──────────┬────────────────────────────┐ │ S │ EXPOENTE │ MANTISSA │ └───┴──────────┴────────────────────────────┘ #+end_example Deste modo, valores normalizados (parte inteira ~>=1~) podem ser calculados pela fórmula: #+begin_example (-1)^sinal × (1 + (mantissa/2^23)) × 2^(expoente - 127) #+end_example ** 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. #+begin_quote *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. #+end_quote * 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: #+begin_example 12.75 × 2^23 = 1065353216 #+end_example #+begin_quote 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. #+end_quote 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... #+begin_src asm ; ---------------------------------------------------------- ; 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 #+end_src 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... #+begin_src asm ; ---------------------------------------------------------- ; 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 '.'... #+end_src 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: #+begin_example 1.xxxxxx × 2^n #+end_example 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=: #+begin_example 1100.11 = 1.10011 × 2^3 #+end_example *Passo 3: calcular o expoente com bias* Como o deslocamento da normalização foi de =2^3=, o expoente com /bias/ será: #+begin_example 3 + 127 (bias IEEE 754) = 130 130 = 10000010 na base 2 #+end_example *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... #+begin_example Mantissa de 1.10011 => 10011 Mantissa armazenada => 10011000000000000000000 #+end_example *Empacotamento dos campos* #+begin_example Sinal : 0 (positivo) Expoente : 10000010 (130 = 127 + 3) Mantissa : 10011000000000000000000 Resultado: 01000001010011000000000000000000 => 0x41480000 em hexa #+end_example 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 =.= para representações de números de ponto flutuante como inteiros de 32 bits escalados. - =str_to_float_ieee754=: converte strings no formato =.= 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= #+begin_src asm ; ---------------------------------------------------------- ; Converte uma string no formato . 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: ; -------------------------------------------------- .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: . ; -------------------------------------------------- .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 #+end_src *** =str_to_float_ieee754= #+begin_src asm ; ---------------------------------------------------------- ; Converte uma string no formato . 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 #+end_src * Referências - [[https://www-users.cse.umn.edu/~vinals/tspot_files/phys4041/2020/IEEE%20Standard%20754-2019.pdf?utm_source=chatgpt.com][IEEE Standard for Floating-Point Arithmetic]] - [[https://en.wikipedia.org/wiki/IEEE_754?utm_source=chatgpt.com][Wikipedia: IEEE 754]]