许多程序员认为浮点数没意思,往坏了说,深奥难懂。我们将看到,因为IEEE 格式是定义在一组小而一致的原则上的,所以它实际上是相当优雅和容易理解的。

    首先来看一段代码,请问下描代码输出结果是(  ).

#include <stdlib.h>
#include <stdio.h>

#define LOOP  1000000
#define NUM   500
#define DATA  0.000061

int main() {
  float *pf = malloc(NUM * sizeof(float));
  for (int i = 0; i < NUM; ++i)
    pf[i] = DATA;

  float res = 0.0;
  for (int j = 0; j < LOOP; ++j)
    for (int i = 0; i < NUM; ++i)
      res += pf[i];

  printf("%f\n", res);
  return 0;
}

A. 30500 (0.000061 * 500 * 1000000)
B. 与A同量级的近似值
C. 具体值不知道, 但肯定是小于A量级某个浮点数
D. 1024.0 (精确值)

    相信大部分人答案都是B,说明对浮点有一定了解,但是没有很深入,正确答案是D,很诧异吧!下面我们来深入梳理下这个问题!

一、二进制小数

     理解浮点数的第一步是考虑含有小数值的二进制数字。首先,让我们来看看更熟悉的十进制表示法。十进制表示法使用如下形式的表示: 

    

     其中每个十进制数4 的取值范围是0 9。这个表达描述的数值d 定义如下:

    

      数字权的定义与十进制小数点符号(".") 相关,这意味着小数点左边的数字的权是10的正幂,得到整数值,而小数点右边的数字的权是10 的负幂,得到小数值。例如:

      12.34。表示数字1x10^1 + 2x10^0 + 3x10^-1 + 4x10^-2 = 12.34。       类推到二进制,一个形如的二进制值,其中每个二进制数字,或者称为位,bi的取值范围是0和1,如下图所示,        

                              

    这种表示的数b定义如下: 

                            

    符号“.”现在变成了二进制的点,点左边的位权是2的正幂,点右边的位的权是2的负幂。例如101.11表示的值为:1x2^2 + 0x2^1 + 1x2^0 + 1x2^-1 + 1x2^-2 = 4+0+1+1/2+1/4 = 23/4。   

    假定我们仅考虑有限长度的编码那么十进制表示法不能准确地表达像1/3和5/7这样的分数。 类似小数的二进制表示法只能表示那些能够被写成x*2^y的数。其他的值只能够被近似地表示

例如数字1/5可以用十进制小数 0.20 精确表示不过我们并不能把它准确地表示为一个二进制小数我们只能近似地表示它, 增加二进制表示的长度可以提高表示的精度,如下图所示,随着位数增加,值越趋接近0.20。

                                                             

 

 二、IEEE浮点表示

     第一章中谈到的定点表示法有明显的缺点,不能有效的表示非常大的数字。例如,数字5x2^100需要用二进制101后面跟100个0的位模式来表示,效率低下。相反,我们希望通过给定的x和y值,来表示形如x*2^y的数值。

     IEEE浮点标准用V=(-1)^s * M * 2^E的形式来表示一个浮点数:

     a、符号位(sign):s决定了这数是负数(s=1)还是正数(s=0),而对于数值0的符号位解释作为特殊情况处理。

     b、尾数(significand):M是一个二进制小数,他的范围是1~2-ε,或者是0~1-ε。(2-ε表示无限接近2,1-ε表示无限接近1)

     c、阶码(exponent):E的作用是对浮点数加权,这个权重是2的E次幂,E可能是负数。

     由上述可知,IEEE标准将浮点数的位划分成了3个字段来表示,三个字段单独编码:

     a、一个单独的符号位s,直接编码;

     b、k个比特位的阶码字段exp,用来编码阶码E;

     c、n个比特位的小数字段 frac 编码尾数M;

     如下图所示,单精度(float)中,s、exp和frac字段分别固定位1bit、k=8bit和n=23bit,一共32bit;双精度(double)中,s、exp和frac字段分别固定位1bit、k=11bit和n=52bit,一共64bit。                      

                                                          

     根据exp的值不同,被编码的值可以分成三种不同的情况,分别为:

     1、规格化的;

     2、非规格化的;

     3、无穷大、NAN;

      我们这边只讨论常规的规格化的数值表示,这是最普遍的情况。

      当exp的各个位既不全为0,也不全为1(单精度位255,双精度为2047)时,都属于这类情况。在这种情况下,阶码字段被解释为以偏置(biased)形式表示的有符号整数。为什么要引入偏置呢?主要原因是为了让阶码可以表示负幂,这样可以表示更小的小数。也就是说,阶码字段的实际值:E=e - Bias其中e是无符号数,就是exp字段的字面二进制值,而Bias是个固定的偏置值:Bias=2^(k-1) - 1;(k表示exp字段的比特数目,float时Bias=127,double是1023)。由于引入了偏置,所以阶码E的取值范围变为:-126~+127(float),-1022~+1023(double)。

  小数字段 frac 被解释为描述小数值f, 其中 0<= f <1, 其二进制表示为 0.f(n-1)...f1f0,也就是二进制小数点在最高有效位的左边。尾数定义为 M=1 + f。 有时,这种方式也叫做隐含的以 1 开头的(implied leading 1 )表示 , 因为我们可以把 M 看成一个二进制表达式为 1.f(n-1)...f1f0的数字。既然我们总是能够调整阶码E,使得尾数 M 在范围 1<=M<2 之中(E增加,M就会相应减小),那么这种表示方法是一种轻松获得一个额外精度位的技巧。既然第一位总是等于 1,那么我们就不需要显式地表示它。

       我们通过几个例子来实际理解下IEEE浮点数的计算方法,我们以float类型为例子:

       例1、十进制数值是0.125,求对应的float存储内容。

             i)、0.125首先转换为V=(-1)^s * M * 2^E的形式,需要保证M处于1~2-ε之间,可以得到:0.125 = 1.0 * 1/8 = (-1)^0 * 1.0 * 2^-3

             ii)、可以得到flaot表达式的各个值,s=0, M=1.0,E=-3。进而得到阶码字段e = E+Bias = -3 + 127 = 124;尾数:f = M - 1 = 1.0 - 1 = 0

             iii)、通过s=0,e=124,f=0,得到float对应的二进制数为:0 0111 1100  0000 0000 0000 0000 0000 000

       例2、十进制数值是4.5,求对应的float存储内容。

             i)、4.5 首先转换为V=(-1)^s * M * 2^E的形式,需要保证M处于1~2-ε之间,可以得到:4.5 = 1.125 * 4 = (-1)^0 * 1.125 * 2^2

             iii)、可以得到flaot表达式的各个值,s=0, M=1.125,E=2。进而得到阶码字段e = E+Bias = 2 + 127 = 129;尾数:f = M - 1 = 1.125 - 1 = 0.125

             iv)、通过s=0,e=129,f=0.125,得到float对应的二进制数为:0 1000 0001  0010 0000 0000 0000 0000 000

      我们通过一段C程序运行下验算是否正确,代码如下:

#include <stdio.h>
#include <stdlib.h>

char *printfloat(float f, char *str)
{
    int offset = 0;
    int bitcnt = 0;
    unsigned int *pui = (unsigned int *)&f;
    while(bitcnt < 32)
    {
        if (*pui & (0x80000000 >> bitcnt))
            str[offset++] = '1';
        else
            str[offset++] = '0';
        if (bitcnt%4 == 0)
            str[offset++] = ' ';
            
        bitcnt++;
    }
    
    str[offset] = '\0';

    return str;
}
int main(void)
{
    float a = 0.125;
    float b = 4.5;
    char s[50];
    
    printf("a=%f, [%s]\n", a, printfloat(a, s));
    printf("b=%f, [%s]\n", b, printfloat(b, s));
    
    return 0;
}
View Code

  代码运行结果如下图所示,可以发现和演算的是一致的。

       

、浮点加减运算

       前面两章我们深入理解了浮点数格式,目前为止并没有涉及到开篇题目的问题点。不急,量变导致质变,基础知识是良好的地基。从第二章的浮点数表示方法可以看出来,其限制了浮点数的范围和精度,所以浮点运算只能近似地表示实数运算。

       由于浮点数的数值: V=(-1)^s * M * 2^E, 所以当有两个浮点数加/减操作时,相当于是两个等式计算,不能直接使用二进制位操作。

       例如,f1 = (-1)^s1 * M1 * 2^E1, f2 = (-1)^s2 * M2 * 2^E2,求f1+f2?显然,必须等通过变换,使f1、f2式子中有三个变量只有一个不同是,运用乘法结合律才能求值,这个变换就就是对阶,对阶目的就是使两浮点数 E 相同,

       浮点数的加减运算过程如下图所示:

                           

          ①、对阶:是两数的小数点位置对齐,小阶码转换为大阶码。就是f1和f2的E要相同,因为E+1,相应的M2*2^-1,等式结果还是不变的。

          ②、尾数求和:将对阶后的两浮点数的尾数M做加减运算求和(差);

          ③、舍入:为提高精度,要考虑尾数右移时丢失的数值位;

          ④、判断结果:判断结果是否溢出;

           例3:float a = 1.00001; float b = 100.0;求a+b?

           根据第二章所述:

           a = 1.00001 = (-1)^0 * 1.00001 * 2^0; 得到s=0, f = M-1 = 0.00001,e = E + 127 = 0 + 127 = 127;

                  二进制原码表示:由于float的尾数精度=1/(2^23) ≈ 0.00000011920928955078125;所以不能精确的表示0.00001(不能整除),f = 0.00001/(1/2^23) ≈ 84 = 0101 0100

                                            所以a = 0 0111 1111 0000 0000 0000 0000 1010 100

           b = 100.0 = (-1)^0 * 1.5625 * 2^6;得到s=0, f = M-1 = 0.5625,e = 6 + 127 = 133;

                  二进制原码表示: 0 1000 0101 1001 0000 0000 0000 0000 000

          1、由于a、b两数的阶码E不同,所以需要先对阶,对齐到较大的b的阶码,从0->6;

               对于a,e = 6 + 127 = 133,对应的需要将M右移6位;变换后,a = 0 1000 0101 0000 0100 0000 0000 0000 001,可以看到对阶后a的尾数发生了损失,同时右移需要带上尾数M的个位1;

          2、对阶后可以对a和b求值了,由于都是正数,相当于是尾数M直接相加;

               M =  0000 0100 0000 0000 0000 001 + 1001 0000 0000 0000 0000 000 = 1001 0100 0000 0000 0000 001

               带上符合位s和阶码e:0 1000 0101 1001 0100 0000 0000 0000 001 = 0x42CA0001 = 101.00000762939453125 ≈ 101.000008

               运行结果如下所示:             

        
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    float a = 1.00001;
    float b = 100.0;
    
    printf("a+b=%f\n", a+b);    
    return 0;
}
View Code

                

               可以看到a+b结果并不是等于101.0001,由于对阶过程的精度损失,实际结果等于101.000008!

四、答案解析

       同理,回顾开篇的试题,本题考察的知识点就是浮点数运算中对阶过程的精度损失问题。分析代码运行,res会从0+0.000061。。。程序将经历如下阶段:

       ①、res逐步累加,每次增加0.000061;

       ②、由于res足够大,导致运算时0.000061的尾数需要做右移,以符合与res的对阶要求,此时res每次累加小于0.000061;

       ③、随着res进一步加大,测试0.000061由于对阶,全部实际尾数变成了0,测试res每次实际累加0,大小不变。

       那res要多大值时,才能达到第三阶段呢?这个取决于每次累加的大小,本题中=0.000061;

        0.000061 = (-1)^0 * 2^0 * (0.000061) =  (-1)^0 * 2^0 * (1000000000);可以看出,当阶码E=0时,尾数M=512=1000000000;所以当res足够大,需要M右移10位对阶时,此时M损失掉数值,M=0;

        所以,可以得到第③阶段时,res的阶码E=10;此时res =  (-1)^0 * 2^10 * (1.0) =  1024.0

 

参考:

1、https://www.cnblogs.com/Five100Miles/p/13171185.html

2、IEEE754:http://kfe.fjfi.cvut.cz/~klimo/nm/ieee754.pdf

3、《深入理解计算机系统》

posted on 2023-07-20 22:33  沉默的思想  阅读(141)  评论(0编辑  收藏  举报