【CSAPP笔记】3. 浮点数

回想起刚学C语言时,我对浮点数的印象大概是“能够表示小数”的数据类型。还死记硬背过例如什么“小数用 double 存,用 %f 输出”这类的话。实际上呢,浮点数可以用这么一个公式来概括:

$$ \sum_{k=-j}^i b_k×2^k$$

除了我们所熟知的,表示小数之外,它对执行涉及非常大的数字、非常接近于0的数字,以及更普遍地作为对实数运算的结果的近似值,是非常有用的。

以下摘取一些书本中的内容:

20世界80年代,每个计算机制造商都指定了自己的表示浮点数的规则,以及对浮点数执行运算的细节。因此,这对不同的计算机之间的协同工作带来了很大的不便。这一切都随着IEEE 754标准的推出而改变了。这是一项由Intel公司赞助的计划,详细地制定了浮点数的表示方法和运算标准。因为其具有足够的合理性和先进性,被IEEE采纳为浮点数的标准,在1985年发布。目前,实际上所有的计算机都支持这个后来被称为IEEE浮点的标准。大大提高了各种程序在不同机器上的可移植性。

旁注:IEEE(读作“Eye-Triple-Eee”),指电器和电子工程师协会,是一个包括所有电子和计算机技术的专业团体。它出版刊物、举办会议、建立委员会来定义各种标准,涉及范围从电力传输到软件工程。

这一节中,我们将看到IEEE 754 浮点格式是如何表示数字的,以及有关的其他细节。许多程序员认为浮点数没甚意思(往坏了说,深奥难懂)。但我们将看到,IEEE标准是相当优雅和容易理解的。

二进制小数

理解浮点数的第一步是理解含有小数部分的二进制数字。对于我们熟悉的十进制来说,一个小数可以表示为

$$ d_md_{m-1}…d_1d_0.d_{-1}d_{-2}…d_{-n}$$

每个数位上面的数字的范围是0~9。上面的一长串表示法所表示的数值d为:

$$ d = \sum_{i=-n}^m = 10^i×d_i$$

十进制,意味着数字权为10。小数点左边的数字的权是10的正幂或0次幂,乘起来之后累加会得到整数部分的值。小数点右边的数字的权是10的负幂,乘起来之后累加会得到小数部分的值。下面举一个 $12.34_{(10)}$ 的运算过程作为例子。

$$ 1×10^1+2×10^0+3×10^{-1}+4×10^{-2} = 12\frac{34}{100}$$

其实二进制小数不会很难理解,跟10进制不同的是,把10的幂次改成2的幂次,仅此而已。还有就是,二进制只有0和1组成。下面举一个$101.11_{(2)}$的运算过程作为例子。

$$ 1×2^2+0×2^1+1×2^0+1×2^{-1}+1×2^{-2} = 4+0+1+\frac{1}{2}+\frac{1}{4}=5\frac{3}{4}$$

对于十进制小数的一些性质,完全能够在二进制小数中发现类似的。例如,对于十进制小数来说,把小数点右移一位相当于乘以10。那么,对于二进制小数来说,把小数点往右移一位相当于乘以2。

由于二进制小数:$0.111111….$ 非常接近于1,因此我们用 $1.0−ϵ$来表示。

很显然,编码长度是有限的。十进制表示法是无法准确地表达像三分之一这样的无限循环小数。其究极原因是在于,如果把十进制小数想象成一种编码的话,那么这种编码方案的精度不够,所以无法准确地把三分之一表示出来(这个例子有点牵强,因为三分之一是无限循环的)。由此,我们可以想象,二进制数表示法只能表示那些能够被写成 $x×2^y$ 的小数。例如,数字 $\frac{1}{5}$ 可以被精确地表示成十进制小数 0.2,但没办法用一个二进制小数准确地表示它,只能通过提高二进制表示位的长度来提高精度,从而近似地去表示。这是一个很重要的理念。

IEEE 754 浮点标准

在正式进入对754标准的讲解之前,我想先回到我之前在博客中提到过的一句话:

0和1这种离散值在用机器来表示时是非常方便的……虽然0和1是离散的,但大量的0和1,足够让我们认为是“连续”的。

什么叫做“大量的离散,足够让我们认为是连续的”呢?对于整数编码了解后,我们知道其实计算机能够表示的整数的范围很有限。但如果用比较长的字长,例如32位,那么对于普通人来说,他们日常需要用到的加减法是绝对够用了。但是,整数编码是表示不了特别大的数的,也不能表示特别小的负数、以及小数。例如,如果你用一个32位的int整型去存储计算阶乘!n的结果,那么计算到13的阶乘时就已经溢出了。即使你用long long去存,算到20的阶乘也就溢出了。

下面你将能够看到,浮点数不仅仅能表示小数,还具有表示特别大/特别小的数的能力。但通过二进制小数我们也了解到,不是每个数都能够被“精确”地表示,有的时候只能用另一个近似数通过“舍入”去表示,因此也增加了对于编码设计、数值分析方面的挑战性。综上所诉,我们可以这样认为:整型只能表示范围较小的数,但它能表示的每个数都是准确的。浮点数虽然能编码一个很大的范围,但这种表示只是近似的。

下面介绍IEEE 754标准的内容。IEEE浮点标准使用 $ V=(-1)^s×M×2^E$ 的形式来表示一个数。

  • 符号位(sign),与补码类似,s为1代表这个数为负,s为0代表这个数为正
  • 阶码(exponent)E的作用是对浮点数加权,权重是2的E次幂。
  • 尾数(significand)M是一个二进制小数,范围是$1到2−ϵ$(规格化)或者$0到1−ϵ$(非规格化)

标准浮点格式把位划分成三个字段,分别对上面这三种值进行编码

  • 开头第一位代表符号位
  • 接下来是 k 位的字段 exp,编码的是阶码,但是结果要减去偏置值。(需要重点理解)
  • 在接下来 n 位的小数字段 frac,编码尾数M,但是编码出来的值依赖于这个数是规格化数or非规格化数(后面会解释,需要重点理解)

比较常见的是 32 位和 64 位的浮点数编码。在C语言中,32 位对于的就是数据类型float,称为单精度浮点数(single precision)。64 位的是double,称为双精度浮点数(double precision)。下面是他们的编码格式。float 和 double 的区别其实就是所占字节不一样,实质上就是 k 和 n 位数的不同。

规格化和非规格化的值

规格化的值(Normalized Values)

在 exp ≠ 000…0 和 exp ≠ 111…1 时(阶码部分不全为0或全为1),表示的值就叫规格化值。到底规格化是什么意思呢?对于规格化数来说,用二进制数来表示时,原本连续的值会被规范到有限的定值上。如果把规格化的数放到数轴上表示,那他们之间的距离是不同的。(后面会举例子解释,现在不懂不用太担心)

对于规格化值,阶码字段(exp这部分)的编码区域的无符号值为e,但阶码E的值是$E = e - Bias$,注意这里不要把大小写e和E搞混。

Bias是指偏移量,值为$ 2^{k-1}-1 $,k是阶码字段的位数。

也就是说

  • 单精度规格化数的 Bias 是 127,e 的范围是 1 ~ 254,E 的范围是 -126 ~ 127
  • 双精度规格化数的 Bias 是 1023,e 的范围是 1 ~ 2046,E 的范围是 -1022 ~ 1023

之所以需要采用一个偏移量,是为了保证 exp 编码只需要以无符号数来处理。

还记得吗?阶码值E,是用来做浮点数的权。可以看到,这个权在 float 最大可以达到 127,最小可以达到 -126,double 的范围就更大了。想想看,用float可以表示的最大的数是表示为某个小数乘以 2 的 127 次方,这就是为什么浮点数可以表示很大的数与很小的数的原因,相比之下,int 能存下的最大的数也就 10 的 ⑨ 次方啊。

对于小数字段frac,它就是描述小数值f,0 ≤ f ≤ 1 。对于规格化数,尾数M定义为 M = 1 + f。你也可以这样理解:M 一定是一个以 1 开头的小数,形如 M=1.xxxxx.xx,那些 xxx 就是 frac 的编码部分。当 frac 全为 0 时 M最小(M=1.0),当 frac 全为 1 时 M最大(M = 2 - ϵ,无限接近于 2)。之所以采取这种方式,是因为如果阶码没有溢出,我们总是可以调整阶码(想象一下,也就是移动小数点的位置)来把尾数控制在 1 ≤ M < 2 之间。这种方式就是轻松获得一个额外精度位的技巧。也可以理解为:开头的 1 是白送的,不需要额外的编码位

举个例子:

1
2
int x = 12345
printf("%f",(float)x);

在强制类型转换时,到底发生了什么呢?

首先,用二进制来表示十进制的12345:

$$12345_{10}=11000000111001$$

将其小数点左移13位,创造一个规格化的形式:

$$12345 = 1.1000000111001_2 × 2^{13}$$

依照此式,构造 float 型的 32 位浮点数编码:

  • 正数,所以符号位为 0

  • 阶码的值 E 为 13,∵ E = e - Bias, ∴ e = E + Bias = E + 127 = 140。所以阶码字段就是 140 的八位(float 的 E 一共 8 位,可以回过头再看看那张表)二进制表示,即 10001100,这就是 exp 字段。

  • 将上述规格化形式的小数部分,后续添 0 添到23位,也就是添10个0,构造frac字段,即 10000001110010000000000

将其组合在一起,就形成了32位单精度浮点数12345的编码

1
2
0  10001100  10000001110010000000000
s exp frac

非规格化的值(Denormalized Values)

当阶码域exp全为0时,所表示的数就是非规格化形式。这里的意思是,原本用二进制表示的连续值,它们之间的间距是一样的

在这种情况下有两点不同。对于阶码字段,阶码值E改成了E = 1 - Bias。也就是说

  • 单精度非规格化数的 E 是 -126
  • 双精度非规格化数的 E 是 -1022

对于尾数字段,尾数的值是 M = f。也就是说,M不再是一个以 1 开头的小数,而是以 0 开头的小数。这所以这样设置是有原因的。首先,非规格化数定义了一种表示零的方法,如果使用规格化数,由于总有 M ≥ 1,那么我们就不能表示 0。实际上,零的表示就是expfrac字段全为 0,因此,对于浮点数来说,还有正零和负零的区别。非规格化数的另一个用途就是表示非常接近零的数。这种机制实现了由最大非规格化数到最小规格化数的平滑转换

特殊值

第一类是当exp全为1,frac全为0时,表示的值为无穷。当符号位 s = 1 代表负无穷,s = 0 代表正无穷。当小数域不是0,那就代表 NaN(Not a Number),不是一个数,表示出错。例如计算 -1 开根号的值就是 NaN。

实例分析

我们采取一位符号位,4 位 exp 位,3 位 frac 位,bias = 2 ^ 3 - 1 = 7的编码方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
   符号 阶码 尾数  
s exp frac e E 十进制值
小数部分 * 2的E次方
---------------------------------------------------------------------
# 这部分是非规范化数值,E = 1 - bias = -6
0 0000 000 0 -6 0
0 0000 001 0 -6 1/8 * 1/64 = 1/512 # 能表示的最接近零的值
0 0000 010 0 -6 2/8 * 1/64 = 2/512
...
0 0000 110 0 -6 6/8 * 1/64 = 6/512
0 0000 111 0 -6 7/8 * 1/64 = 7/512 # 能表示的最大非规范化值
---------------------------------------------------------------------
# 这部分是规范化值,E = e - bias

0 0001 000 1 -6 8/8 * 1/64 = 8/512 # 能表示的最小规范化值
0 0001 001 1 -6 9/8 * 1/64 = 9/512
...
0 0110 110 6 -1 14/8 * 1/2 = 14/16
0 0110 111 6 -1 15/8 * 1/2 = 15/16 # 最接近且小于 1 的值
0 0111 000 7 0 8/8 * 1 = 1
0 0111 001 7 0 9/8 * 1 = 9/8 # 最接近且大于 1 的值
0 0111 010 7 0 10/8 * 1 = 10/8
...
0 1110 110 14 7 14/8 * 128 = 224
0 1110 111 14 7 15/8 * 128 = 240 # 能表示的最大规范化值
------------------------------------------------------------------
# 特殊值
0 1111 000 价码部分全为 1,小数部分全为 0,正无穷大
1 1111 000 无穷大的符号位是 1,代表负无穷大
0 1111 001 阶码部分全为 1,小数部分不全为 0,NaN

观察表格,我们更好地可以理解之前的几个细节:

  • 连续非规格化值之间的间距是一样的,上面这个例子中,都是差了1/512
  • 连续的规格化值之间的间距不同。这是由于 exp 的不同而导致的。比方说最接近 1 的数字是 15/16 和 9/8,分别相差 1/16 和 1/8。
  • “浮点数能编码一个很大的范围,但这种表示只是近似的。”我们能想象,如果frac位的长度越长,那么对于小数表示的精度就能越高。如果exp位的长度越长,那么能够表示的范围就越大。

上面是一个数轴,表示的是这种编码方式下的能表示的数在数轴上排列的情况。可以看到,浮点数表示的范围是很大的,也能够表示特别大和特别小的数。但是数的分布不是均匀的——在数轴两端,数字比较稀疏;在越靠近原点的地方,数字越稠密。在最靠近原点的地方,是非规格化数。

舍入(rounding)

因为表示方法限定了浮点数不是精确的,因此浮点运算只能近似地表示实数运算。因此,对于某个运算结果 x,我们需要想出一个系统的方法,找到一个能够用浮点编码表示的最接近的匹配值 x1,用 x1 来表示运算结果。这就是舍入的任务。

浮点数采取的规则是舍入到偶数,即:将数字向上或向下舍入,使得结果的最低有效数字是偶数。舍入到偶数我认为可以这么理解:四舍六入,五到偶

例如,我们要把一个一位小数舍入到整数位,那么1.4会舍入成1,1.6会舍入成2。如果是按照四舍五入的话,2.5和1.5会舍入成3和2。如果是按照舍入到偶数,那么2.5和1.5都是舍入成2的。

为什么倾向于采取这种方法?我们很容易想到这样的情景:用某种方法舍入一组数值,必然会在用这些数值做某种计算的时候产生误差,误差的类型依照舍入方法而不同。例如,如果都采取向上舍入,那么一组数据经舍入后,求这组数据的平均值,那么平均值肯定是偏大的。之所以采取舍入到偶数,可以认为舍入到偶数是一种比四舍五入更加“公平”的舍入方法,我们把中间值5,一半向上舍入,一半向下舍入,那么就在大多数的现实情况中避免了计算的统计偏差。

浮点运算

浮点加法

$$ (-1)^{s1}M_1·2^{E_1} + (-1)^{s2}M_2·2^{E_2} $$

结果:(这里假设 E1 ≥ E2)
$$(-1)^{s}M·2^{E}$$

其中
$$ s = s1 ∧s2,M = M1 + M2, E = E1$$

过程如下:

  1. 如果两个浮点数的阶码不一样,那么首先要对阶。因为假设了E1 > E2,所以可以把第二个数的小数点往左移动,然后增加E2的值。
  2. 尾数相加得结果M。
  3. 规格化,把M通过左移or右移小数点来表示为1.xxx的形式,并更新E的值。
  4. 如果不能完成规格化的操作,那么就是非规格化数。
  5. 如果超过了可以表示的范围,那么就是溢出。
  6. 最后把M舍入到frac的精度。

浮点乘法

$$ (-1)^{s1}M_1·2^{E_1} × (-1)^{s2}M_2·2^{E_2} $$

结果:
$$(-1)^{s}M·2^{E}$$

其中
$$ s = s1 ∧ s2,M = M1 × M2, E = E1+E2$$

仍然需要进行规格化操作、也需要舍入、也有可能溢出。

浮点运算性质

对于浮点值x和y,如果对它们进行某种运算,那么计算的结果实际上是将精确的计算结果舍入后的结果。因此,浮点运算有以下几条重要的性质:

  • 满足交换率,但不满足结合律

例如表达式(3.14 + 1e10) - 1e10的结果会是0.0——因为舍入的缘故,3.14就丢失了。然而,表达式3.14 + (1e10 - 1e10)会得到正确结果3.14

  • 浮点加法满足单调性原则。

如果 a ≥ b ,那么对于任何的x值,除了产生NaN,都有x + a ≥ x + b。

  • 浮点加法不具有可结合性。

(1e20 × 1e20)× 1e-20的值为正无穷,然而 1e20 × (1e20 × 1e-20)的值为1e20。

  • 不具备分配性。

1e20 ×(1e20-1e20) 的值为0.0,然而1e20 × 1e20 - 1e20 × 1e20会得到NaN。

int、float、double间的强制类型转换

在没有了解编码方面的细节前,我们对这个问题只能说知其然而不知其所以然。

  • int转换成float,数字不会溢出,但是可能被舍入。
  • int或float转换成double,因为double有比这两者都来得更大的范围,和更高的精度,所以能够保留精确的数值。
  • double转成float,范围会变小,有可能得到值正负无穷。由于精度也变小了,所以可能被舍入。
  • float或double转成int,值将会像零舍入。例如:1.999会变成1,-1.999会变成-1。值有可能溢出。C语言标准没有对这种情况制定固定的结果。

对于浮点数的一些总结,以及特殊的浮点数

第一章小结

好了,我们也看完了浮点数的全部内容,也就了解了信息在计算机内的存储方式这一章的全部内容。

我们从最基本的元素——bit开始,了解了整型和浮点数两个非常重要的数据类型的编码方式。只有了解了位的知识、以及编码方式,我们才可能对这些数据类型涉及的其他方方面面的概念,例如:掩码运算、强制类型转换、扩展、截断、舍入、溢出等等。如果不了解位,那么对于上述这些概念只能是知其然而不知其所以然(好吧,原谅我的词穷,这个词我好像在本篇博客中用了好几遍。。但我觉得知道深层的原理是很有帮助的)。

这些内容很重要,但我觉得比较好的学习方法是举例子来理解。这本书每个小节后面配有很好的题目,可以帮助大家理解这些知识点。

See Also