你别耍我,0.1+0.2竟然不等于0.3?(浮点数的二进制表示)

原文链接:http://www.juzicode.com/computer-basis-float-point-binary

老规矩先举几个栗子:

第1个例子是单精度浮点数间的比较,定义了几个float型的变量:

//juzicode.com / VX:桔子code  //vs2015
#include "stdio.h"
int main(void)
{
    float f0 = 1.500001f;
    float f1 = 1.5000001f;
    float f2 = 1.50000001f;
    float f3 = 1.500000001f;

    if (f0>f1) printf("f0>f1\n");
    else printf("f0<=f1\n");
    if (f2>f3) printf("f2>f3\n");
    else printf("f2<=f3\n");
    return 0;
}

-----运行结果:
f0>f1
f2<=f3

在这个例子中定义的时候f0大于f1,f2大于f3,但是经过比较后,f2和f3的结果却和实际的数值大小比较不匹配!

第2个例子是经典的0.1+0.2!=0.3的问题,也是一个关于双精度浮点数的问题,这里定义了2个double型的变量f0和f1分别等于0.1和0.2,将f0和f1相加的结果和0.3比较:

//juzicode.com / VX:桔子code //vs2015
#include "stdio.h"
int main(void) 
{
    double f0 = 0.1;
    double f1 = 0.2;

    if (f0 + f1 == 0.3)  printf("f0 + f1 == 0.3\n");  
    else printf("f0 + f1 != 0.3\n");

    printf("f0 + f1 = %.17f\n", f0 + f1);
    return 0;
}
-----运行结果:
f0 + f1 != 0.3
f0 + f1 = 0.30000000000000004

双精度double型的0.1和0.2相加后居然不等于0.3!

在Python3.8上测试下0.1+0.2的问题:

#//juzicode.com / VX:桔子code //python3.8
f0 = 0.1
f1 = 0.2
if f0 + f1 == 0.3 :
    print("f0 + f1 == 0.3")
else:
    print("f0 + f1 != 0.3")
print("f0 + f1 =",f0+f1)

------运行结果:
f0 + f1 != 0.3
f0 + f1 = 0.30000000000000004

结果和在C语言中是一样的,0.1+0.2并不等于0.3!

好了,举了几个例子,挖了这么多坑,接下来就是要一步步填坑了。

小数的二进制表示

我们知道在计算机中数值是以二进制存储的,比如十进制的10,如果用二进制表示就是1010,任何整数理论上都是可以精确地用二进制表示的,但是当用二进制表示小数时,就会遇到最小精度、舍入等问题。

先看一个简单的例子:10.25,首先整数部分10用二进制方法表示为1010,小数点后面的0.25每次乘以2取整,剩下的小数部分则继续乘以2用作下一位的取整:

  • 0.25×2=0.5 取整数0
  • 0.5×2=1.0 取整数1

再往后因为小数部分为0了,乘以2得到的仍然是0,所以不必要继续往后取整了,所以10.25用二进制表示为1010.01。10.25这个数值用二进制表示时取到小数点后面2位就停止了,得到了一个精确表示的值。

接下来我们来看一个不是有限小数的例子,比如要用二进制表示小数10.3,整数部分仍然是1010,小数点后面为0.3,每次乘以2取整,小数部分继续乘以2:

  • 0.3×2=0.6 取整数0
  • 0.6×2=1.2 取整数1
  • 0.2×2=0.4 取整数0
  • 0.4×2=0.8 取整数0
  • 0.8×2=1.6 取整数1
  • 0.6×2=1.2 取整数1 ,到这时开始变成无限循环
  • 0.2×2=0.4 取整数0
  • 0.4×2=0.8 取整数0
  • 0.8×2=1.6 取整数1
  • ……

所以10.3用二进制表示就是1010.010011001……,不再是一个有限小数了。

这时问题就来了,当遇到无限小数,小数点后面应该取多少位?也就是有效值位数的问题。小数点前又应该取多少位?这个是指数的取值问题。这些疑问都可以在IEEE754标准中得到答案。

在IEEE754标准中规定了几种浮点数的表示方法,其中最常见的2种是单精度浮点数和双精度浮点数,其中单精度浮点数用32bit的二进制表示,双精度的浮点数用64bit的二进制表示。

单精度浮点数

接下来我们先聊聊单精度浮点数,32bit位是按照下图所示的3段来存储的:

  • 1)首先为了区分正负数,用最高的bit位表示其符号S,如果是0表示为正数,如果为1表示为负数。
  • 2)接下来的8bit是其指数E,因为指数也有正负之分,但是这时并没有在这8bit指数位中用一个bit表示指数的符号,而是采用偏移127的方式,也就是该8bit表示的数值减去127得到的就是指数,换个说法就是实际的指数加上127得出该8bit的值,比如指数为1就用1+127=0x80=0b10000000表示,如果是-10就用-10+127=0x75=0b01110101表示。
  • 3)剩下的23bit就用来表示其有效值M,也就是小数点后面的数。需要注意的是小数点的位置问题,小数点总是取在第一个1后面,比如1010.01应该将小数点左移3位得到1.01001*p3,移位的过程同时得出指数值和有效数值。在表示有效数值时,小数点前面的数值1总是被省略掉,这样节省下来的1个bit可以用来表示小数点后更多一位的有效值。
  • 4)处理舍入问题,因为转换为二进制小数后可能是无限小数,或者实际的位数大于23位,所以需要根据小数点后面的第24位决定是舍去还是向23位进1,这里遵循就近舍入原则,A)、如果第25位及以后还存在非0的数值,第24位如果为1,向23位进1,如果第24位为0,则舍去后面的值;B)、如果第25位及以后全部为0,则遵循偶数原则,保证第23位为0,具体做法是:第24位如果为1且第23位为1,则向23位进1;第24位为1且第23位为0,则舍去;第24位如果为0也舍去。

以上面的10.3为例,用二进制表示为1010.0100110011001100110011001……,

  • 1)首先符号为正所以符号S=0
  • 2)然后是取有效值,首先移位小数点保证小数点前只有一个1,所以需要将小数点左移3位,因为向左移动了3位所以指数为+3,得到移位后的小数为:1.0100100110011001100110011001……(E=3),同时得到指数位是3+127=130=0x82=0b1000 0010‬,所以指数E=1000 0010‬;
  • 3)小数点后面1-23位是01001001100110011001100
  • 4)再考虑舍入问题,第24bit为1且后面仍然有非0的数值,所以向23位进1,这样最终的有效值M=01001001100110011001101,这样10.3用二进制表示为:01000 001001001001100110011001101‬,16进制表示为0x4124 CCCD。

前面是手动计算的结果,我们可以用一个C语言的联合体来验证下计算结果是否正确,首先定义了一个联合体的数据类型包含2个成员,一个是32bit长度的无符号整型,一个是单精度浮点型,赋值其中的浮点类型,再用16进制的方法打印其无符号整型值,可以方便观察二进制数值:

//juzicode.com / VX:桔子code  //vs2015
#include "stdio.h"
typedef union {
    unsigned int i;
    float f;
}fu32;
int main(void)
{
    fu32 fu1;
    fu1.f = 10.3f;
    printf("fu1.f=%f\n", fu1.f);
    printf("fu1.i=0x%x\n", fu1.i);
    return 0;
}
-----运行结果:
fu1.f=10.300000
fu1.i=0x4124cccd

这里得到的结果为0x4124cccd,和前面手动计算的结果是一样的。

下面再来看一个负数的浮点数-0.2,用乘2取整的方法计算其二进制形式:

  • 0.2*2=0.4 取整为0
  • 0.4*2=0.8 取整为0
  • 0.8*2=1.6 取整为1
  • 0.6*2=1.2 取整为1
  • 0.2*2=0.4 取整为0,接下来是无限循环
  • ……

最后得到的小数为-0.001100110011001100110011001100110011……,

  • 1)符号S=1
  • 2)因为要保证小数点前一位是1,所以小数点要向后移动3位,变成-1.100110011001100110011001100110011……(E=-3)所以指数为-3指数E=-3+127=0x7C=0b01111100;
  • 3)小数点后面1-23位是10011001100110011001100,第24位为1,且后面有非0的数值,这样在第23位上有进位,所以有效数M=10011001100110011001101;
  • 4)最后组合起来就是0b10111110010011001100110011001101=0xBE4C CCCD;

同样可以用联合体的方式打印其十六进制数值进行验证:

//juzicode.com / VX:桔子code  //vs2015
#include "stdio.h"
typedef union {
    unsigned int i;
    float f;
}fu32;
int main(void)
{
    fu32 fu1;
    fu1.f = -0.2f;
    printf("fu1.f=%f\n", fu1.f);
    printf("fu1.i=0x%x\n", fu1.i);
    return 0;
}
-----运行结果:
fu1.f=-0.200000
fu1.i=0xbe4ccccd

双精度浮点数

前面聊了单精度浮点数,下面来聊聊双精度浮点数,双精度浮点数为64bit,按照下图所示的3段来存储的:

  • 1)符号位S仍然用最高的1bit表示;
  • 2)指数位E用接下来的11bit表示,仍然采用偏移方法,实际的指数值加1023得到该11bit的值;
  • 3)有效值M用剩余的52bit表示,和单精度数字一样移位保证小数点前一位是1,取小数点后的52位得出有效值,此步骤操作同时会得出指数值;
  • 4)考虑舍入问题,和单精度浮点数规则一样,考察小数点后第53bit及其后面的数值决定舍入。

下面我们来手动推导0.1的双精度浮点数的二进制存储形式:

  • 0.1*2=0.2 取整为0
  • 0.2*2=0.4 取整为0
  • 0.4*2=0.8 取整为0
  • 0.8*2=1.6 取整为1
  • 0.6*2=1.2 取整为1
  • 0.2*2=0.4 取整为0,接下来又是无限循环
  • ……

最后得到的小数为0.00011001100110011001100110011001100110011001100110011001100110011……..,

  • 1)数值为正符号S=0
  • 2)因为要保证小数点前一位是1,所以小数点要向后移动4位,变成1.1001100110011001100110011001100110011001100110011001100110011…….(E=-4),这样指数为-4指数E=-4+1023=0x3FB=0b01111111011;
  • 3)移位后的小数点后面的1-52位是1001100110011001100110011001100110011001100110011001,第53位为1,且后面有非0的数值,这样在第52位上有进位,所以有效数M=1001100110011001100110011001100110011001100110011010
  • 4)最后组合起来就是0b0011111110111001100110011001100110011001100110011001100110011010=0x3FB9 9999 9999 999A‬‬;

同样可以用联合体的方式打印其十六进制数值进行验证,仍然定义一个联合体,一个成员是64bit的无符号整型,一个是双精度浮点数:

typedef union {
    unsigned long long i;
    double f;
}fu64;

通过该方法赋值浮点数成员f,再用十六进制显示其无符号整型值i:

//juzicode.com / VX:桔子code  //vs2015
#include "stdio.h"
typedef union {
    unsigned long long i;
    double f;
}fu64;
int main(void)
{
    fu64 fu1;
    fu1.f = 0.1;
    printf("fu1.f=%f\n", fu1.f);
    printf("fu1.i=0x%llx\n", fu1.i);
    return 0;
}
-----运行结果:
fu1.f=0.100000
fu1.i=0x3fb999999999999a

联合体方法显示双精度浮点数0.1和前面手动计算的结果是一样的,都是0x3fb999999999999a。

精度问题

回到文章开头的第1个例子,比较单精度数值的问题,我们先用二进制形式显示出其在内存中存储的形式:

//juzicode.com / VX:桔子code  //vs2015
#include "stdio.h"
typedef union {
    unsigned int i;
    float f;
}fu32;
int main(void)
{
    fu32 f0, f1, f2, f3;
    f0.f = 1.500001f;
    f1.f = 1.5000001f;
    f2.f = 1.50000001f;
    f3.f = 1.500000001f;
    printf("f0.i=0x%x\n", f0.i);
    printf("f1.i=0x%x\n", f1.i);
    printf("f2.i=0x%x\n", f2.i);
    printf("f3.i=0x%x\n", f3.i);
    return 0;
}
-----运行结果:
f0.i=0x3fc00008
f1.i=0x3fc00001
f2.i=0x3fc00000
f3.i=0x3fc00000

f0=1.500001和f1=1.5000001的差为0.0000009,f2=1.50000001和f3=1.500000001的差值为0.000000009。单精度浮点数能表示的有效值位数是23,所以其最后一位数值的大小为2**(-23)=0b0.00000000000000000000001,近似于0.000000119,2**(-23)也是单精度浮点数能表示的最小“刻度”的数值大小。当f2和f3相减时,它们的差值为0.000000009要小于2**(-23),从前面的例子也可以看到,f2和f3在内存中存储的数值是一样大小的,所以并没有得到f2大于f3的结果。

加法计算

对于第2个0.1+0.2!=0.3的问题,我们也是先转换为二进制值来分析:

//juzicode.com / VX:桔子code  //vs2015
#include "stdio.h"
typedef union {
    unsigned long long i;
    double f;
}fu64;
int main(void)
{
    fu64 fu1,fu2,fu3;
    fu1.f = 0.1;
    fu2.f = 0.2;
    fu3.f = 0.3;
    printf("fu1.f=%f\n", fu1.f);
    printf("fu1.u=0x%llx\n", fu1.i);
    printf("fu2.f=%f\n", fu2.f);
    printf("fu2.u=0x%llx\n", fu2.i);
    printf("fu3.f=%f\n", fu3.f);
    printf("fu3.u=0x%llx\n", fu3.i);
    fux.f = fu1.f + fu2.f;
    printf("fux.f=%f\n", fux.f);
    printf("fux.i=0x%llx\n", fux.i);
    return 0;
}
-----运行结果:
fu1.f=0.100000
fu1.u=0x3fb999999999999a
fu2.f=0.200000
fu2.u=0x3fc999999999999a
fu3.f=0.300000
fu3.u=0x3fd3333333333333
fux.f=0.300000
fux.i=0x3fd3333333333334

用上面的方法得到4个数值的二进制表示,接下来就是计算下f1和f2相加得到fx,以及fx和f3对比的过程:

在IEEE754中2个浮点数相加,首先要先将浮点数的指数对齐,这里f1的指数是3fb对应-4,f2的指数是3fc对应-3,首先将f1和f2展开成二进制小数,然后将f1左移1位指数对齐到-3,这时f1小数点后的位数加了1位。这样再将f1和f2相加得到fx,fx需要左移1位保证小数点前有一个数值1,因为小数点左移1位同时指数加1变为-2,这时fx小数点后的位数又加了1位,fx小数点后最后多余了2位,根据就近舍入原则,小数点后第53位为1,之后的数值为0,第52位为1,所以需要进位。就是因为这个进位操作,导致了0.1+0.2不等于0.3!

tips:

1、对于双精度浮点数值,也可以用Python中浮点数的hex()方法显示其二进制值,对于非0的数值,其小数点前总是1,最后的p带的是指数值,小数点和p之间是52位有效数字的16进制值:

#//juzicode.com / VX:桔子code //python3.8
print(0.1.hex())
print(0.2.hex())
print(0.3.hex())
print((0.1+0.2).hex())

------运行结果:
0x1.999999999999ap-4
0x1.999999999999ap-3
0x1.3333333333333p-2
0x1.3333333333334p-2

2、在C语言中除了用联合体的方法观察浮点数的二进制存储形式,还可以使用指针的方式进行强制类型转换观察浮点数的二进制存储形式:

//juzicode.com / VX:桔子code  //vs2015
#include "stdio.h"
int main(void)
{
    double f = 0.1;
    unsigned long long i = *((unsigned long long*)(&f));//强制类型转换
    printf("f=%lf\n",  f);
    printf("i=0x%llx\n",  i);
    f = 0.2;
    i = *((unsigned long long*)(&f));
    printf("f=%lf\n", f);
    printf("i=0x%llx\n", i);
    return 0;
}
-----运行结果:
f=0.100000
i=0x3fb999999999999a //这里得到的结果和用联合体方式看到的是一样的
f=0.200000
i=0x3fc999999999999a

扩展阅读:

  1. http://0.30000000000000004.com
  2. https://docs.oracle.com/cd/E19957-01/806-3568/ncg_goldberg.html

发表评论

您的电子邮箱地址不会被公开。 必填项已用*标注