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

上节讲到,C语言中的小数可以使用指数形式来表示,即aEnaen,它等价于a×10n

在内存中,小数也是以指数形式来表示的,但又和C语言中的有所区别。小数在被存储到内存前,首先转换为下面的形式:

a × 2 n

a 为尾数,是二进制形式,且 1 ≤ a < 2;n 为指数,是十进制形式。

其中,2 是固定的,不需要在内存中体现出来;正负号、指数(n)、尾数(a) 是变化的,需要占用内存空间来表示。这样,float、double 在内存中就被分成了三部分,如下图所示:

 

例如对于 19.625,整数部分的二进制形式为:

19 = 1×24 + 0×23 + 0×22 + 1×21 + 1×20 = 10011

小数部分的二进制形式为:

0.625 = 1×2-1 + 0×2-2 + 1×2-3 = 101

将整数部分和小数部分合并在一起:

19.625 = 10011.101

由于尾数 a 必须 1 ≤ a < 2,所以还需要再将小数点向左移动4位:

19.625 = 10011.101 = 1.0011101×24

此时尾数为 1.0011101,指数为 4。

所有的小数被转换成指数形式后,尾数的整数部分都为1,无需在内存中提现出来,所以干脆将其截去,只把小数点后面的二进制放入内存中的尾数部分(23Bits)。对于 1.0011101,尾数部分就是 0011101。

C语言把整数作为定点数,而把小数作为浮点数。定点数必须转换为补码再写入内存,浮点数没有这个过程,直接写入原码。小数被转换成指数形式后,指数有正有负,在内存中不但要能表现其值,还要能表现其正负。而指数是以原码形式存储的,没有符号位,所以要设计一个巧妙的办法来区分正负。

对于 float,指数占用8Bits,能表示从 0~255 的值,取其中间值 127,指数在写入内存前先加上127,读取时再减去127,正数负数就显而易见了。19.625 转换后的指数为 4,4+127 = 131 = 1000 0011。

综上所述,float 类型的 19.625 在内存中的值为:0 - 10000011 - 001 1101 0000 0000 0000 0000。

下面我们使用代码来验证一下:

 

 

运行结果:
0, 0X4, 0X1D0000

C语言不能直接输出二进制形式,一般输出十六进制即可,十六进制能够很方便地转换成二进制。

精度

精度指测量值与真实值的接近程度,在C语言中表现为输出值和真实值的接近程度。

float 和 double 的精度是由尾数的位数决定。内存中的尾数只保存了小数点后面的部分,其整数部分始终是一个隐含着的“1“,它是不变的,不会对精度造成影响。

float:2^23 = 8388608,一共七位,这意味着最多能有7位有效数字,但绝对能保证的为6位,也即 float 的精度为 6~7 位有效数字。

double:2^52 = 4503599627370496,一共16位,同理,double 的精度为 15~16 位。

取值范围和近似值

float 和 double 在内存中的指数和尾数的位数都是有限的,小数过大或过小都会发生溢出。float 的取值范围为 -2^128 ~ +2^128,也即 -3.40E+38 ~ +3.40E+38;double 的取值范围为 -2^1024 ~ +2^1024,也即 -1.79E+308 ~ +1.79E+308。

当小数的尾数部分过长时,多出的位数就会被直接截去,这时保存的就不是小数的真实值,而是一个近似值。在上节的示例中,我们看到 128.101 的输出结果就是一个近似值。

128.101 转换成二进制为 10000000.0001100111011011001000101101,向左移动7位后为 1.00000000001100111011011001000101101,由此可见,尾数部分为 000 0000 0001 1001 1101 1011 001000101101,将多出的二进制截去后为 000 0000 0001 1001 1101 1011。下面的代码有力地证明了这一点:

 

 

 

运行结果:
128.100998
0, 0X7, 0X19DB

最后对 float 和 double 做一下总结:

 

在计算机发展过程中,我们使用的小数和实数曾经提出过很多种的表示方法。典型的比如相对于浮点数的定点数(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*22 + 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位 ------------→ |

 

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

0 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进制用我们的计算器换算成二进制:

0 100 0000 1 010 0110 0110 0110 0110 011 0

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

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

 

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

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

 

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

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

1 010 0110 0110 0110 0110 011 0

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

1 . 010 0110 0110 0110 0110 011 0 * 22 

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

1 01 . 0 0110 0110 0110 0110 011 0

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

1 01 . 0 0110 0110 0110 0110 011 0

   5.  xxxxxxxxxxxxxxxxxxxxxxxxxxxxx

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

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

0 .    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 00002 * 2-1

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

.00 0000 0000 0000 0000 00002 * 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;

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