本节书摘来自华章计算机《编写高质量代码:改善c程序代码的125个建议》一书中的第1章,建议3-1,作者:马 伟 更多章节内容可以访问云栖社区“华章计算机”公众号查看。
建议3-1:了解IEEE 754浮点数
1.浮点数简介
在计算机系统的发展过程中,业界曾经提出过许多种实数的表达方法,比较典型的有相对于浮点数(Floating Point Number)的定点数(Fixed Point Number)。在定点数表达法中,其小数点固定地位于实数所有数字中间的某个位置。例如,货币的表达就可以采用这种表达方式,如 55.00 或者 00.55 可以用于表达具有4位精度,小数点后有两位的货币值。由于小数点位置固定,所以可以直接用4位数值来表达相应的数值。但我们不难发现,定点数表达法的缺点就在于其形式过于僵硬,固定的小数点位置决定了固定位数的整数部分和小数部分,不利于同时表达特别大的数或者特别小的数。因此,最终绝大多数现代的计算机系统都采纳了所谓的浮点数表达法。
浮点数表达法采用了科学计数法来表达实数,即用一个有效数字。一个基数(Base)、一个指数(Exponent)以及一个表示正负的符号来表达实数。比如,666.66 用十进制科学计数法可以表达为6.6666×102(其中,6.6666 为有效数字,10 为基数,2 为指数)。浮点数利用指数达到了浮动小数点的效果,从而可以灵活地表达更大范围的实数。
当然,对实数的浮点表示仅作如上的规定是不够的,因为同一实数的浮点表示还不是唯一的。例如,上面例子中的666.66可以表达为0.66666×103、6.6666×102或者66.666×101三种方式。因为这种表达的多样性,因此有必要对其加以规范化以达到统一表达的目标。规范的浮点数表达方式具有如下形式:
±d.dd…d×βe (0≤di<β)
其中,d.dd…d为有效数字,β为基数,e为指数。
有效数字中数字的个数称为精度,我们可以用 p 来表示,即可称为p位有效数字精度。每个数字 d 介于 0 和基数β之间,包括 0。更精确地说,±d0.d1d2…dp-1×βe表示以下数:
±(d0+d1β-1+…+dp-1β-(p-1))βe (0≤di<β)
其中,对十进制的浮点数,即基数β 等于 10 的浮点数而言,上面的表达式非常容易理解。如12.34,我们可以根据上面的表达式表达为:1×101+2×100+3×10-1+4×10-2,其规范浮点数表达为1.234×101。
但对二进制来说,上面的表达式同样可以简单地表达。唯一不同之处在于:二进制的β等于2,而每个数字 d 只能在 0 和 1 之间取值。如二进制数 1001.101,我们可以根据上面的表达式表达为:1×23+0×22+0×21+1×20+1×2-1+0×2-2+1×2-3,其规范浮点数表达为1.001101×23。
现在,我们就可以这样简单地把二进制转换为十进制,如二进制数 1001.101转换成十进制为:
由上面的等式,我们可以得出:向左移动二进制小数点一位相当于这个数除以2,而向右移动二进制小数点一位相当于这个数乘以2。如101.11=,而10.111=。除此之外,我们还可以得到这样一个基本规律:一个十进制小数要能用浮点数精确地表示,最后一位必须是5(当然这是必要条件,并非充分条件)。规律推演如下面的示例所示:
https://yqfile.alicdn.com/bd9317df32c2c7f1687be80f69a5d54c8fb31c15.png" >
代码清单1-17 浮点数示例代码
#include <stdio.h>
int main(void)
{
float f1=34.6;
float f2=34.5;
float f3=34.0;
printf("34.6-34.0=%f\n",f1-f3);
printf("34.5-34.0=%f\n",f2-f3);
return 0;
}
代码清单1-17的运行结果如图1-19所示。
https://yqfile.alicdn.com/11b36b691e1a3bb8be6fb40ce2101f52a4aa5f58.png" >
之所以“34.6-34.0=0.599998”,产生这个误差的原因是 34.6 无法精确地表达为相应的浮点数,而只能保存为经过舍入的近似值。而这个近似值与34.0之间的运算自然无法产生精确的结果。
上面阐述了二进制数转换十进制数,如果你要将十进制数转换成二进制数,则需要把整数部分和小数部分分别转换。其中,整数部分除以2,取余数;小数部分乘以2,取整数位。如将13.125转换成二进制数如下:
首先转换整数部分(13),除以2,取余数,所得结果为1101。
其次转换小数部分(0.125),乘以2,取整数位。转换过程如下:
0.125×2 = 0.25 取整数位0
0.25×2 = 0.5 取整数位0
0.5×2 = 1 取整数位1
小数部分所得结果为001,即13.125=1101.001,用规范浮点数表达为1.101001×23。
除此之外,与浮点表示法相关联的其他两个参数是“最大允许指数”和“最小允许指数”,即emax和emin。由于存在βp个可能的有效数字,以及emax-emin+1个可能的指数,因此浮点数可以按 [log2(emax-emin+1)]+[log2(βp)]+1位编码,其中最后的 +1 用于符号位。
2.浮点数表示法
直到20世纪80年代(即在没有制定IEEE 754标准之前),业界还没有一个统一的浮点数标准。相反,很多计算机制造商根据自己的需要来设计自己的浮点数表示规则,以及浮点数的执行运算细节。另外,他们常常并不太关注运算的精确性,而把实现的速度和简易性看得比数字的精确性更重要,而这就给代码的可移植性造成了重大的障碍。
直到1976年,Intel公司打算为其8086微处理器引进一种浮点数协处理器时,意识到作为芯片设计者的电子工程师和固体物理学家也许并不能通过数值分析来选择最合理的浮点数二进制格式。于是,他们邀请加州大学伯克利分校的 William Kahan教授(当时最优秀的数值分析家)来为8087浮点处理器(FPU)设计浮点数格式。而这时,William Kahan教授又找来两个专家协助他,于是就有了KCS组合(Kahn、Coonan和Stone),并共同完成了Intel公司的浮点数格式设计。
由于Intel公司的KCS浮点数格式完成得如此出色,以致IEEE(Institute of Electrical and Electronics Engineers,电子电气工程师协会)决定采用一个非常接近KCS的方案作为IEEE的标准浮点格式。于是,IEEE于1985年制订了二进制浮点运算标准IEEE 754(IEEE Standard for Binary Floating-Point Arithmetic,ANSI/IEEE Std 754-1985),该标准限定指数的底为2,并于同年被美国引用为ANSI标准。目前,几乎所有的计算机都支持IEEE 754标准,它大大地改善了科学应用程序的可移植性。
考虑到IBM System/370的影响,IEEE于1987年推出了与底数无关的二进制浮点运算标准IEEE 854,并于同年被美国引用为ANSI标准。1989年,国际标准组织IEC批准IEEE 754/854为国际标准IEC 559:1989。后来经修订后,标准号改为IEC 60559。现在,几乎所有的浮点处理器完全或基本支持IEC 60559。同时,C99的浮点运算也支持IEC 60559。
IEEE浮点数标准是从逻辑上用三元组{S,E,M}来表示一个数V的,即V=(-1)S×
M×2E,如图1-20所示。
其中:
- 符号位s(Sign)决定数是正数(s=0)还是负数(s=1),而对于数值0的符号位解释则作为特殊情况处理。
- 有效数字位M(Significand)是二进制小数,它的取值范围为1~2-ε,或者为0~
- 1-ε。它也被称为尾数位(Mantissa)、系数位(Coefficient),甚至还被称作“小数”。
- 指数位E(Exponent)是2的幂(可能是负数),它的作用是对浮点数加权。
浮点数格式是一种数据结构,它规定了构成浮点数的各个字段、这些字段的布局及算术解释。IEEE 754浮点数的数据位被划分为三个段,从而对以上这些值进行编码。其中:
- 一个单独的符号位s直接编码符号s。
- k位的指数段exp=ek-1…e1e0,编码指数E。
- n位的小数段frac=fn-1…f1f0,编码有效数字M,但是被编码的值也依赖于指数域的值是否等于0。
根据exp的值,被编码的值可以分为如下几种不同的情况。
(1)格式化值
当指数段exp的位模式既不全为0(即数值0),也不全为1(即单精度数值为255,以单精度数为例,8 位的指数为可以表达 0 ~ 255的 255 个指数值;双精度数值为2047)的时候,就属于这类情况。如图1-21所示。
我们知道,指数可以为正数,也可以为负数。为了处理负指数的情况,实际的指数值按要求需要加上一个偏置(Bias)值作为保存在指数段中的值。因此,这种情况下的指数段被解释为以偏置形式表示的有符号整数。即指数的值为:
E=e-Bias
其中,e是无符号数,其位表示为ek-1…e1e0,而Bias是一个等于2k-1-1(单精度是127,双精度是1023)的偏置值。由此产生指数的取值范围是:单精度为-126~+127,双精度为-1022~+1023。
对小数段frac,可解释为描述小数值f,其中0≤f<1,其二进制表示为0.fn-1…f1f0,也就是二进制小数点在最高有效位的左边。有效数字定义为M = 1 + f。有时候,这种方式也叫作隐含的以1开头的表示法,因为我们可以把M看成一个二进制表达式为1.fn-1fn-2…f0的数字。既然我们总是能够调整指数E,使得有效数字M的范围为1≤M<2(假设没有溢出),那么这种表示方法是一种轻松获得一个额外精度位的技巧。同时,由于第一位总是等于1,因此我们就不需要显式地表示它。拿单精度数为例,按照上面所介绍的知识,实际上可以用 23 位长的有效数字来表达24 位的有效数字。比如,对单精度数而言,二进制的 1001.101(即十进制的 9.625)可以表达为1.001101×23,所以实际保存在有效数字位中的值为:
00110100000000000000000
即去掉小数点左侧的 1,并用 0 在右侧补齐。
根据上面所阐述的规则,下面以实数 -9.625 为例,来看看如何将其表达为单精度的浮点数格式。具体转换步骤如下:
首先,需要将-9.625用二进制浮点数表达出来,然后变换为相应的浮点数格式。即-9.625的二进制为1001.101,用规范的浮点数表达应为1.001101×23。
其次,因为-9.625是负数,所以符号段为1。而这里的指数为 3,所以指数段为3 + 127 = 130,即二进制的10000010。有效数字省略掉小数点左侧的1之后为 001101,然后在右侧用零补齐。因此所得的最终结果为:
最后,我们还可以将浮点数形式表示为十六进制的数据,如下所示:
https://yqfile.alicdn.com/63cb03bd0b9d2ab5d62b535107633bbc52e89066.png" >
即最终的十六进制结果为 0xC11A0000。
(2)特殊数值
IEEE 标准指定了以下特殊值:±0、反向规格化的数、±∞和NaN(如表1-8所示)。这些特殊值都是使用emax+1或emin-1的指数进行编码的。
1)NaN。
当指数段exp全为1时,小数段为非零时,结果值就被称为“NaN”(Not any Number),如图1-22所示。
一般情况下,我们将0/0或视为导致计算终止的不可恢复错误。但是,一些示例表明在这样的情况下继续进行计算是有意义的。这时候就可以通过引入特殊值NaN,并指定诸如0/0或之类的表达式计算来生成 NaN而不是停止计算,从而避免此问题。表1-9中列出了一些可以导致 NaN 的情况。
2)无穷。
当指数段exp全为1,小数段全为0时,得到的值表示无穷。当s = 0 时是+∞,或者当 s = 1时是-∞。如图1-23所示。
https://yqfile.alicdn.com/ac96095d56e667f2f5d0935280640ff26ac058d0.png" >
无穷用于表达计算中产生的上溢问题。比如两个极大的数相乘时,尽管两个操作数本身可以保存为浮点数,但其结果可能大到无法保存为浮点数,必须进行舍入操作。根据 IEEE 标准,此时不能将结果舍入为可以保存的最大浮点数(因为这个数可能与实际的结果相差太远而毫无意义),而应将其舍入为无穷。对于结果为负数的情况也是如此,只不过此时会舍入为负无穷,也就是说符号域为 1 的无穷。
3)非格式化值。
当指数段exp全为0时,所表示的数就是非规格化形式,如图1-24所示。
https://yqfile.alicdn.com/1fe9c2b4e98ca6d6e1187810796de22d6360eab5.png" >
在这种情况下,指数值E = 1 - Bias,而有效数字的值M = f,也就是说它是小数段的值,不包含隐含的开头的1。
非规格化值有两个用途:
第一,它提供了一种表示数值0的方法。因为规格化数必须得使有效数字M在范围1≤M<2之中,即M≥1,因此它就不能表示0。实际上,+0.0的浮点表示的位模式为全0(即符号位是0,指数段全为0,而小数段也全为0),这就得到M = f = 0。令人奇怪的是,当符号位为1,而其他段全为0时,就会得到值-0.0。根据IEEE的浮点格式来看,值+0.0和-0.0在某些方面是不同的。
第二,它表示那些非常接近于0.0的数。它们提供了一种属性,称为逐渐下溢出。其中,可能的数值分布均匀地接近于0.0。
下面的单精度浮点数就是一个非格式化的示例。
https://yqfile.alicdn.com/36d9b00ca83b565aa956df5782d60673682fb40e.png" >
它被转换成十进制表示大约等于1.4×10-45,实际上它就是单精度浮点数所能表达的最小非格式化数。以此类推,格式化值和非格式化值所能表达的非负数值范围如表1-10所示。
3.标准浮点格式
IEEE 754标准准确地定义了单精度和双精度浮点格式,并为这两种基本格式分别定义了扩展格式,如下所示:
- 单精度浮点格式(32位)。
- 双精度浮点格式(64位)。
- 扩展单精度浮点格式(≥43位,不常用)。
- 扩展双精度浮点格式(≥79位,一般情况下,Intel x86 结构的计算机采用的是80位,而SPARC结构的计算机采用的是128位)。
其中,只有32位单精度浮点数是本标准强烈要求支持的,其他都是可选部分。下面就来对单精度浮点与双精度浮点的存储格式做一些简要的阐述。
(1)单精度浮点格式
单精度浮点格式共32位,其中,s、exp和frac段分别为1位、k = 8位和n = 23位,如图1-25所示。
https://yqfile.alicdn.com/b5afe08c128a8e76d19041cd9c9adc59078519c9.png" >
其中,32位中的第0位存放小数段frac的最低有效位LSB(least significant bit),第22位存放小数段frac的最高有效位MSB(most significant bit);第23位存放指数段exp的最低有效位LSB,第30位存放指数段exp的最高有效位MSB;最高位,即第31位存放符号s。例如,单精度数8.25的存储方式如图1-26所示。
(2)双精度浮点格式
双精度浮点格式共64位,其中,s、exp和frac段分别为1位、k = 11位和n = 52位,如图1-27所示。
其中,frac[31:0]存放小数段的低32位(即第0位存放整个小数段的最低有效位LSB,第31位存放小数段低32位的最高有效位MSB);frac [51:32]存放小数段的高20位(即第32位存放高20位的最低有效位LSB,第51位存放整个小数段的最高有效位MSB);第52位存放指数段exp的最低有效位LSB,第62位存放指数段exp的最高有效位MSB;最高位,即第63位存放符号s。
在Intel x86结构的计算机中,数据存放采用的是小端法(Little Endian),故较低地址的32位的字中存放小数段的frac[31:0]位。而在SPARC结构的计算机中,因其数据存放采用的是大端法(Big Endian),故较高地址的32位字中存放小数段的frac[31:0]位。
前面主要讨论了IEEE 754的单精度与双精度浮点格式,表1-11对浮点数的相关参数进行了总结,有兴趣的读者可以根据此表对其他浮点格式进行深入解读。
https://yqfile.alicdn.com/b4e715607b3ec993834b2e8243883a6eeb6030ee.png" >
https://yqfile.alicdn.com/4511d3f38110f845b5ac743ba89034ded28be3ba.png" >
4.舍入误差
舍入误差是指运算得到的近似值和精确值之间的差异。大家知道,由于计算机的字长有限,因此在进行数值计算的过程中,对计算得到的中间结果数据要使用相关的舍入规则来取近似值,而这导致计算结果产生误差。
在浮点数的舍入问题上,IEEE浮点格式定义了4种不同的舍入方式,如表1-12所示。其中,默认的舍入方法是向偶数舍入,而其他三种可用于计算上界和下界。
表1-13是4种舍入方式的应用举例。这里需要特别说明的是,向偶数舍入(向最接近的值舍入)方式会试图找到一个最接近的匹配值。因此,它将1.4舍入成1,将1.6舍入成2,而将1.5和2.5都舍入成2。
https://yqfile.alicdn.com/ac7ddd7d04c767b1dc28883361c5de1cefdb840b.png" >
或许看了上面的内容你会问:为什么要采用向偶数舍入这样的舍入策略,而不直接使用我们已经习惯的“四舍五入”呢?
其原因我们可以这样来理解:在进行舍入的时候,最后一位数字从1到9,舍去的有1、2、3、4;它正好可以和进位的9、8、7、6相对应,而5却被单独留下。如果我们采用四舍五入每次都将5进位的话,在进行一些大量数据的统计时,就会累积比较大的偏差。而如果采用向偶数舍入的策略,在大多数情况下,5舍去还是进位概率是差不多的,统计时产生的偏差也就相应要小一些。
同样,针对浮点数据,向偶数舍入方式只需要简单地考虑最低有效数字是奇数还是偶数即可。例如,假设我们想将十进制数舍入到最接近的百分位。不管用哪种舍入方式,我们都将把1.2349999舍入到1.23,而将1.2350001舍入到1.24,因为它们不是在1.23和1.24的正中间。另一方面我们将把两个数1.2350000和1.2450000都舍入到1.24,因为4是偶数。
由IEEE浮点格式定义的舍入方式可知,不论使用哪种舍入方式,都会产生舍入误差。如果在一系列运算中的一步或几步产生了舍入误差,在某些情况下,这个误差将会随着运算次数的增加而积累得很大,最终会得出没有意义的运算结果。因此,建议不要将浮点数用于精确计算。
当然,理论上增加数字位数可以减少可能会产生的舍入误差。但是,位数是有限的,在表示无限浮点数时仍然会产生误差。在用常规方法表示浮点数的情况下,这种误差是不可避免的,但是可以通过设置警戒位来减小。
除此之外,IEEE 754还提出5种类型的浮点异常,即上溢、下溢、除以零、无效运算和不精确。其中,每类异常都有单独的状态标志。鉴于篇幅有限,本节就不再详细介绍,有兴趣的读者可以参考IEEE 754标准文档《IEEE Standard 754 for Binary Floating-Point Arithmetic》进行学习。