深入理解浮点数

前言

  浮点数因为它的独特的表示方法,造成了比整数表示复杂的多的情况。而在程序中却不得不经常跟浮点数打交道。最近在看《深入理解计算机系统》,于是就想把与浮点数相关的东西整理出来,方便以后翻阅。

浮点数的表示

从科学计数法讲起

  在初高中学过的科学计数法是一种很美妙的表示方法,它可以很容易地表示很小的数和很大的数。下面就是一个十进制数的科学计数法表示:

  浮点数的表示方法也和科学技术法类似。通过观察,很容易就发现,因为尾数的整数部分总为1,于是在存储的时候只用存储小数即可。而对于基呢,只要约定好,那完全可以不用占存储空间。这些数据的存储却和整数的存储不一样,用的不是补码。因为用补码的话,会给浮点数的比较造成困难。对于指数,为了方便比较,用的是移码表示,也就是说,在原码上加上一个偏置量,让其变为一个非负数。而非负数的比较完全可以从最高有效位遍历比较,对于硬件设计来说方便快捷。而尾数的小数部分就按一般的二进制小数来存储。因此,对于浮点数来讲,需要存储的就三个部分的数据,尾数的小数部分,符号以及指数。为了区分,现在把指数加上偏执量,也就是实际存储的数据称为阶码。

  那么,一个浮点数与机器码的对应形式如下,为了方便,假设基为2:

  bias表示的就是移码的偏置量。那么这个偏置量应该是多少呢?

  因为exp为k比特的二进制数,所以它最大值为$2^k - 1$。所以为了它实际表示的指数对称,那么偏置量的取值应该是$\frac {2^k - 1} {2}$左右。于是,现在这个问题就变成了,偏置量是要选$2^{k - 1}$还是$2^{k - 1} - 1$的问题。

  很明显,偏置量取$2^{k - 1} - 1$比取$2^{k - 1}$,所能表示的浮点数范围大一倍。所以显而易见的,偏置量应该选取$2^{k - 1} - 1$。

IEEE的浮点表示

  在很久以前,浮点数很多公司所用的基都不一样。所以为了统一标准,比如基到底是多少比较恰当。IEEE就定了个IEEE754标准来约定,这样方便程序的移植和通用。

  IEEE754规定了基为2,然后还规定了三种情况的值:规格化数,非规格化数,特殊数(无穷大和NaN)。

情况 exp frac
规格化数 既不全0也不全1 \
非规格化数 全0 \
无穷大 全1 全0
NaN 全1 不等于0

  又提出了浮点数的几种类型,float,double和long double。 需要注意的是long double在标准里并没有明确的规定,但标准中对于扩展做了规定,所以下表中的Quadruple和Extended都是合法的。像Intel的协处理器在中间过程中用的就是Extended,为了保持精度。

参数 Single Double Quadruple Extended
阶码的位宽 8 11 15 15
尾数中的小数部分的位宽 23 52 112 64
符号位的位宽 1 1 1 1
总位宽 32 64 128 80
C语言类型 float double long double long double

  为了方便,下面都是默认以单精度作为例子。

规格化数

  规格化数是最普遍的形式。它的阶码既不是全零也不是全一。那么这时候。它的最大值的时候就是他的阶码为除了最后一位为0,其他全为1,尾数的小数部分全为1的时候,这时,用十进制表示就是是$(2 - 2^{-23}) \times 2^{127}$,同理可得最小的正数是$ 2^{-126}$ 。于是就能在数轴上如下图表示。

  可以看见靠近0的地方有个gap,把正数部分的gap附近放大,可以画出大致分布如下图:

  很明显,阶码每大一,所占的范围就翻一倍,而尾数所能表示的个数确实不变的。那也就是说上图中,每段区域里的数值间隔都比左边邻近它区域里的数值间隔大一倍,越靠右的区域越大也越稀疏。最小的区域也就是[$2^{-126}$, $2^{-125}$)里的数值之间的间隔是$2^{-23} \times 2^{-126}$,也就说比前面的GAP小$2^{-23}$倍,这就给实际使用带来很多的不变,于是就有了下面的非规格化数。

非规格化数

  根据上面的讨论,非规格化数就是为了填gap,实现一下均匀过渡的。很明显,正上溢区,也就是上图中的GAP的长度是$2^{-126}$,而非规格化的个数就是尾数所能表达的所有值,一个有$2^{23}$个,那么若均匀分布,间隔就是$2^{-23} \times 2^{-126}$。

  IEEE委员会也是这么考虑的,规定指数为$1-bias$。因为单精度的bias就是127,那么指数就是$-126$,这完全和我们刚才的讨论一样。

  于是非规格化数和机器码的对应公式如下

  当然因为非规格数的指数是固定的,于是也就不用存储指数,因此,机器码里的阶码全为0,这也方便和规格化数分开。

  因此,非规格化数的有以下两个作用

  • 给出了一个对零的表示方法,并且正负零的机器码还不一样。因为规格化数的整数部分总是1,所有它并不能表示零。

  • 表示那些非常接近零的数。也就是前面的负下溢区和正下溢区里面的数值。

特殊数

  最后的这类数值有正负无穷大和NaN。他们是在阶码为1的时候表示的。

浮点数的运算及其精度

  浮点数的运算,就相当于指数运算。因为非规划数的指数相同,就对尾数进行计算即可。重点在规划数上,现设两规格化浮点数分别为 $A = M_a \cdot 2^{E_a}, B = M_b \cdot 2^{E_b}$,则:

  为了方便,现只针对加法运算进行讨论。

  因为数据的存储长度是固定的,那么在加减运算时对阶后,可能会造成很大的精度损失。假设现在进行的是有效位只有三位的加法运算,$1.99 + 4.56 10^2$,那么首先需要对阶。理应变成$0.0199 10^2 + 4.5610^2 = (0.0199 + 4.56) 10^2$,而因为位宽限制,只能变成$(0.01 + 4.56) * 10^2$。这样就会造成很大的精度损失。于是IEEE就规定中间结果必须有两个附加位。它的作用就是左规(结果的整数部分大于1位,这样需要小数点左移,指数变大)和作为最终结果舍入时的参考。

  至于舍入,IEEE规定了四种方式:

  • 向最近的浮点数舍入(默认方式)
  • 向正无穷舍入
  • 向负无穷舍入
  • 向零舍入

  其实舍入,说白了就是因为有效位的限制,所以紧扣有效位,完全能避开舍入陷阱。

  • 从int转化为float时,因为float所能表示的数据范围远大于int,所以不会发生溢出。但是因为他们两个的位宽是一样的,所以很可能会发生舍入。
  • 从int或者float转化为double时,因为double的范围更大,有效位更多,所以能完全保留精确值。
  • 从double转换为float和int时,因为double的范围更大,有效位更多,所以可能会溢出,也可能会舍入。
    -从float或double转化为int时,因为int没有小数部分,所以值会向零舍入。如果没有找到对应的整数,比如(int)1e10,那么这是个未定义行为。Intel的方法是将它变为一个整数不确定值(MSB为1,其余为0)。