【C/C++语言入门篇】– 剖析浮点数

前面一篇我们讲了位运算后,基本C语言的大块都提及了,一些细节和用法暂时不再本模块介绍了。希望我的心愿能够在我毕业之前给我的大学生涯划上一个圆满的句号。加油努力。

 

在本模块的第三篇就已经讲了基本的数据类型,其中把浮点数刻意留在了后面来介绍。我的理解是在我们理解了内存,指针,位运算等后,再来介绍浮点这个特殊而又普通的数据类型比较好理解。浮点数和基本类型数据的存储差别比较大,这里不是说存储形式的差别,而是浮点数存放的时候是要经过运算后再转换成整数的4字节或8字节的形式,然后再存放到内存里。因此,只通过16进制数是看不出来和整数有什么差别。同样,浮点数具体是怎么存储的,在大学的课程上一般不会细细讲解,一般是我们自己有兴趣再查阅资料。包括本篇的内容,如果你不是一个自学者或者充满好奇心,你也不会看下去,也不会找到本篇的URL。因此,包括很多已经工作很多年的程序员都不知道浮点数具体是怎么运算然后存储的。就我来讲,认为还是非常有必要了解这个常用的数据类型的换算过程,虽然我们个人来讲很难去打破当前浮点数的计算规则以至于将他的精度提高,但是了解下底层工作者们的辛苦,我们应该向他们真诚的致敬。因为有他们,我们便有了大树可以乘凉。

 

好了,废话不多说。本篇的目的就是为了让更多的人了解浮点数存储的基本原理,还是那句话,学习的同时带着思考。同样这里不讨论浮点数的精度损失和数值的计算理论。直接讲实质的表现。

 

在计算机发展过程中,我们使用的小数和实数曾经提出过很多种的表示方法。典型的比如相对于浮点数的定点数(Fixed Point Number)。在这种表达方式中,小数点固定的位于实数所有数字中间的某个位置。货币的表达就可以使用这种方式,比如 88.22 或者 22.88 可以用于表达具有四位精度(Precision),小数点后有两位的货币值。由于小数点位置固定,所以可以直接用四位数值来表达相应的数值。SQL 中的 NUMBER 数据类型就是利用定点数来定义的。还有一种提议的表达方式为有理数表达方式,即用两个整数的比值来表达实数。

 

很显然,上面的定点数表示法有缺陷,不能表示很小的数或者很大的数。于是,为了解决这种问题,我们的前辈们自然想到了科学技术法的形式来表示,即用一个尾数(Mantissa ),一个基数(Base),一个指数(Exponent)以及一个表示正负的符号来表达实数。比如 123.456 用十进制科学计数法可以表达为 1.23456 × 102 ,其中 1.23456 为尾数,10 为基数,2 为指数。浮点数利用指数达到了浮动小数点的效果,从而可以灵活地表达更大范围的实数。

 

大约就在1985年,IEEE标准754的推出,它是一个仔细制定的表示浮点数及其运算的标准。这项工作是从1976年Intel发起8087的设计开始的,8087是一种为8086处理器提供浮点支持的芯片,他们雇佣了William Kahan,加州大学伯克利分校的一位教授,作为帮助设计未来处理器浮点标准的顾问。他们支持Kahan加入一个IEEE资助的制订工业标准的委员会。这个委员会最终采纳了一个非常接近于Kahan为Intel设计的标准。目前,实际上所有的计算机够支持这个后来被称为IEEE浮点(IEEE floating point)的标准。这大大改善了科学应用程序在不同机器上的可移植性。所谓IEEE就是电器和电子工程师协会。

 

介绍完了历史,先来看看浮点数最直接的表示。在数学上:

12.341010 = 1*101   +  2*100   +  3*10-1   +  4*10-2   = 12(34/100) (这里由于编辑器的原因,只能写这么机械了)。

在比如二进制:

101.112 = 1*2+ 0*21 + 1*20 + 1*2-1 + 1*2-2 = 4 + 0 + 1 + 1/2 + 1/4 = 5(3/4)。

 

上面简单的描述了在数学意义上的浮点数表示,但是在计算机中,我们存放在内存中的直观上看16进制数,那么这些16进制数是怎么表示我们浮点数的二进制形式呢?

 

在 IEEE 标准中,浮点数是将特定长度的连续字节的所有二进制位分割为特定宽度的符号域,指数域和尾数域三个域,其中保存的值分别用于表示给定二进制浮点数中的符号,指数和尾数。这样,通过尾数和可以调节的指数(所以称为”浮点”)就可以表达给定的数值了。具体的格式:

符号位     阶码      尾数     长度
float             1         8       23      32
double          1        11       52      64

我们都知道浮点数在32位机子上有两种精度,float占32位,double占64位。很多朋友喜欢把double用于8字节的数据存储。从这点我们应该不要特殊看到浮点数的内存存储形式,他跟整数没有什么区别,只是在这4字节或者8字节里有3个区域,整数有符号只有符号位及后面的数值,之所以最高位表示有符号数的符号位。原因之一在于0x7fffffff位最大整数,为整个32位所能表示的最大无符号整数0xffffffff的一半减一,也就是:比如1字节:无符号是:0xff,有符号正数为:(0, 127],负数为[-128, 0)。在8位有符号时,肯定内存值大于等于: 0x80。二进制就是1000 0000,比他大,只会在低7位上变化,最高位已经是1了,变了就变小了。所以这里也是一个比较巧用的地方,一举两得。

 

 

那么,我们先来看32位浮点数 的换算:

1. 从浮点数到16进制数

float  var = 5.2f;

就这个浮点数,我们一步一步将它转换为16进制数。

首先,整数部分5,4位二进制表示为:0101。

其次,小数部分0.2,我们应该学了小数转换为二进制的计算方法,那么就是依次乘以2,取整数部分作为二进制数,取小数部分继续乘以2,一直算到小数结果为0为止。那么对0.2进行计算:

0.2*2 = 0.4 * 2 = 0.8 * 2 = 1.6(0.6) * 2 = 1.2(0.2)*2 = 0.4 * 2 = 0.8 * 2 = 1.6(0.6) * 2 = 1.2 ... ...

                0              0            1                     1                  0             0             1                  1   ... ...

因此,这里把0.2的二进制就计算出来了,结果就为:0.00110011... ... 这里的省略号是你没有办法计算完。二进制序列无限循环,没有到达结果为0的那一天。那么此时我们该怎么办?这里就得取到一定的二进制位数后停止计算,然后舍入。我们知道,float是32位,后面尾数的长度只能最大23位。因此,计算结束的时候,整数部分加上小数部分的二进制一共23位二进制。因此5.2的二进制表示就为:

101.00110011001100110011

一共23位。

此时,使用科学计数法表示,结果为:

1.0100110011001100110011 * 22

由于我们规定,使用二进制科学计数法后,小数点左边必须为1(肯定为1嘛,为0的话那不就是0.xxxx*sxxx 了,这样没有什么意义),这里不能为0是有一个很大的好处的,为什么?因为规定为1,这样这个1就不用存储了,我们在从16进制数换算到浮点数的时候加上这个1就是了,因为我们知道这里应该有个1,省略到这个1的目的是为了后面的小数部分能够多表示一位,精度就更高一些了哟。那么省略到小数点前面的1后的结果为:

.01001100110011001100110 * 22

这里后面蓝色的0就是补上的,这里不是随便补的一个0,而是0.2的二进制在这一位上本来就应该为0,如果该为1,我们就得补上一个1.是不是这样多了一位后,实际上我们用23位表示了24位的数据量。有一个位是隐藏了,固定为1的。我们不必记录它。

但是,在对阶或向右规格化时,尾数要向右移位,这样被右移的尾数的低位部分会被丢掉,从而造成一定的误差,因此要进行舍入处理。 常用的舍入方法有两种:一种是“0舍1入”法,即如果右移时被丢掉数位的最高位为0则舍去,为1则将尾数的末位加“1”,另一种是“恒置1”,即只要数位被移掉,就在尾数的末位恒置“1”。

 

举个例子:

123.456的二进制表示:

123.456的二进制到23位时:111 1011.0111 0100 1011 1100 01...

后面还有依次为01...等低位,由于最高位的1会被隐藏,向后扩展一位如果不做舍入操作则结果为:

1.11 1011 0111 0100 1011 1100 0 * 26

但是经过舍入操作后,由于被舍掉的位的最高位是1,或者“恒置1”法,最后面的0都应该是1。因此最终就应该是:

1.11 1011 0111 0100 1011 1100 1 * 26

在这里需要说明,不管是恒置1,还是0舍1入法,其根本都是为了减小误差。

 

好了,尾数在这里就计算好了,他就是 01001100110011001100110 

再来看阶数,这里我们知道是2^2次方,那么指数就是2。同样IEEE标准又规定了,因为中间的 阶码在float中是占8位,而这个 阶码又是有符号的(意思就是说,可以有2^-2次方的形式)。

float 类型的 偏置量 Bias = 2k-1 -1 = 28-1 -1 = 127 ,但还要补上刚才因为左移作为小数部分的 2 位(也就是科学技术法的指数),因此偏置量为 127 + 2=129 ,就是 IEEE 浮点数表示标准:

        V = (-1)s × M × 2E

        E = e - Bias

中的 e ,此前计算 Bias=127 ,刚好验证了 E = 129 - 127 = 2 。

这里的阶码就是12910 ,二进制就是:1000 00012 。

因此,拼接起来后:

1000 0001 01001100110011001100110

| ←   8位 → | | ←------------- 23位 -------------→ |

一共就是31位了,这里还差一位,那就是符号位,我们定义的是5.2,正数。因此这里最高位是0,1表示负数。

而后结果就是:

  0 1000 0001 01001100110011001100110

1位 | ← 8位 → | | ←-------------- 23位 ------------→ |

 

到这里,我们内存里面的十六进制数产生了,分开来看:

100 0000 1 010 0110 0110 0110 0110 0110

    4       0        A        6       6        6        6        6

因此,我们看到的就是0x40A66666, 此就是5.2最终的整数形式。

 

2.从十六进制数到浮点数

我们还是可以用上面5.2的例子,再将0x40A66666换算回去,用同样一个例子,结果更直观,逆运算更好理解。那我们就开始吧。

首先,要还原回去,必须将这个16进制用我们的计算器换算成二进制:

100 0000 1 010 0110 0110 0110 0110 011 0

我是COPY上面的。这里颜色已经很明显了,我划分成了3个区域 。 

首先确定符号,这里是0,因此是正数。

 

其次看绿色的8位,换成10进制就是:12910

我们逆运算,知道这里需要129 - 127 = 2得到指数,得到了指数,我们便知道我们小数点是向哪个方向移动了好多位。脑子里已经有了一个科学计数法的锥形。

 

再次把红色的23位提取出来,这里不把它换成10进制,因为我们指数是表示的二进制上移动了多少位,底数是2,而不是10。

这里因为之前我们都知道有个固定的1给省略了,因此这里要给加上去。加上去之后:

010 0110 0110 0110 0110 011 0

这里是24位,我们先不管,小数点添进去:

1 . 010 0110 0110 0110 0110 011 0 * 22

然后将科学计数法变换成普通的二进制小数:

01 0 0110 0110 0110 0110 011 0

到这里,就真正可以把整数部分换成十进制了:

01 0 0110 0110 0110 0110 011 0

5.  xxxxxxxxxxxxxxxxxxxxxxxxxxxxx

我们知道了,整数部分是5,后面的小数部分再进行逆运算:

这里我们就应该想想小数到二进制数是乘法,这里逆运算就应该除以2,因此就可以表示为:

   0 0110 0110 0110 0110 011 0

0 + 0*2-1 + 0*2-2 + 1*2-3 + 1*2-4 + 0*2-5 + 0*2-6 + 1*2-7 + ... ... + 0*2-21 这样一个式子,我们算出结果来,放在浮点数里:

5.1999998。

因此我们可以看到精度已经有损失了。

 

问题一:写写-5.2的16进制数?

 

再来看一个例子:

float var = 0.5, 算16进制数。

首先,0.5整数部分为0,这里就不处理了。

其次,0.5小数部分,二进制表示为:0.1

这里是0.1,将尾数补满23位则是:

0.10 0000 0000 0000 0000 0002

由于小数点左边是0,因此需要向右移动一位 ,因此:

1.0 0000 0000 0000 0000 0000* 2-1

这里1又被省略掉,所以23位全部变成了0 ,因此:

.00 0000 0000 0000 0000 0000* 2-1

然后,因为这里指数是-1,因此阶码就是:-1 + 127 = 126 = 0111 11102

这样一来,阶码就有了,由于又是正数,那么组合起来:

0 01111110 00000000000000000000000

这样一来,最终的16进制数则为:0x3f000000.

是不是很简单啊。


64位浮点数 的换算:

这里就不再具体说明怎么换算的了,只需要提到2个地方:

一是,中间的阶码在double中占有11位,因此就不是+127了,而是加上1023,因为11位能表示的最大无符号数是2047,因此有符号范围[-1024, 1023]。

二是,尾数是52位,因此精度更高,能表示的数也就越大。我们在换算5.2的时候,后面的小数二进制+前面的5的二进制再省略一位后的总位数要填满52位。

 

好了,浮点数也没有太多要说的,就到这里吧,在用的时候注意精度和范围就可以了。

 

最后在提一个问题:

问题二:

float var0 = 5.2;

float var1 = 500.2;

float var2 = 50000.2;

float var3 = 5000000.2;

观察这几个数,加深一下那三个域的计算方式,并说出这些数据有什么规律?





本文来源于网络,仅代表作者观点,如果您是原作者,可联系我们删除本文

【C/C++语言入门篇】– 剖析浮点数:等您坐沙发呢!

发表评论

Spam Protection by WP-SpamFree

快捷键:Ctrl+Enter