在一些工程领域中单单依靠整数是无法满足他们对精度的需求的,这种时候就需要用到浮点数了。今天着重来聊一聊在计算机底层,浮点数的编码方式,以及它相关值的计算方式。
在介绍浮点数之前先来看看二进制中实数可以如何表示。假设我有一个十进制的小数8.33
,那么它的值可以表示为
8 + 3 / 10 + 3 / 100 = 833 / 100
各个位的权重依次是10 ^ 0 = 1
, 10 ^ -1 = 0.1
, 10 ^ -2 = 0.01
。其实二进制小数也是类似的,只不过它是逢二进一。考虑这个二进制串1001.1111
,它所能够代表的十进制数字是多少呢?简单地可以把它分成整数部分1001
以及小数部分1111
。
整数部分的计算很容易了,在不考虑符号的情况下依次代入相关的权重即可
1 * 2 ^ 3 + 0 * 2 ^ 2 + 0 * 2 ^ 1 + 1 * 2 ^ 1 = 9
小数部分其实类似,只不过相关的权重需要稍微调整一下。
1 * (2 ^ -1) + 1 * (2 ^ -2) + 1 * (2 ^ -3) + 1 * (2 ^ -4) = 1 / 2 + 1 / 4 + 1 / 8 + 1 / 16 = 15 / 16
再换个角度去看这个小数的部分,其实它还等价于1111 / 10000
。
(1 * 2 ^ 3 + 1 * 2 ^ 2 + 1 * 2 ^ 1 + 1 * 2 ^ 0) / (2 ^ 4) = 15 / 16
就是先计算二进制串1111
的值,再把它的小数点往左移动4
位,因此需要除以2 ^ 4
。结合整数部分以及小数部分,可以得到最终结果9 + (15 / 16) = 159 / 16
。
如果用已有的二进制编码知识来表示数值159 / 16
,那么只需要分配一段内存区域来存储159
的二进制串10011111
,然后再利用另一段区域来存储小数点相关的偏移量00000100
即可。不过这种方式灵活性比较低,所能够表示的数值范围也十分有限。那么接下来看看现代机器中浮点数是如何表示的。
基于前面所谈到的原理,了解 IEEE 浮点数就不是什么大问题了。IEEE 浮点数是一个工业上的标准,根据标准来设计的机器彼此之间的兼容性会比较高。
IEEE 浮点数的计算方式稍微有点麻烦,不过原理十分简单,说白了就是科学计数法。假设我有一个十进制小数100.2
那么其实这个数可以表示为0.1002 * (10 ^ 2)
,可以简单地概括成这条公式N * (10 ^ K)
,把这条公式放到二进制领域就是M * (2 ^ E)
。其中 M 是该浮点数的尾数,主要影响浮点数的精度。E 是浮点数的阶码,主要影响浮点数的大小。
IEEE 浮点表示会把一个二进制串分成 3 部分,分别用来存储浮点数的尾数,阶码以及代表浮点数正负的符号位。不过在 IEEE 浮点数中尾数以及阶码并不是直接存储的,而是需要特殊的编码方式。
IEEE 浮点数主要分为单精度浮点数以及双精度浮点数,分别对应 C 语言里面的float
和double
两种类型。
单精度浮点数通过 32 位的二进制串来表示。其中 0~22 位 (23 位长) 用来存储尾数,23~30 位 (8 位长) 用来存储阶码,第 31 位为最高有效位用来表示浮点数的符号。它的示意图大概如下
双精度浮点数所能够表示的精度更大,范围更广。它用 0~51 位 (52 位长) 用来存储尾数,52~62 位(11 位长)用来存储阶码,第 63 位为最高有效位用于表示浮点数的符号,它的示意图大概如下
如今的 C 语言里甚至还有long double
这种数据类型,可以通过下面的代码来测试它的字节长度
#include <stdio.h>
int main() {
printf("%ld", sizeof(long double));
}
在我的 Mac 上的运行结果是 16 个字节长,也就是16 * 8 = 128
位长。不过这种类型在不同的机器或者操作系统上表现可能会有所不同,移植性较差,不建议使用。
简单起见,这里用 32 位的浮点数来详细地讲解一下 IEEE 浮点数值的相关计算方式。在 32 位二进制串中,阶码部分用 8 位来存储,尾数部分用 23 位来存储,还有 1 位是符号位。
在讲具体计算之前先来了解两个特殊值,分别是偏置量以及符号位。
偏置量 Bias 是一个用于计算阶码的特殊值。它的数值跟存储阶码的位长有关,当阶码位长为 k 的时候偏置量的值为2 ^ k - 1
,具体用途稍后会讲到。另外一个需要注意的就是最高有效位,这个位利用原码的相关知识,充当了浮点数的符号位。最高有效位为 0 的时候这个浮点数是正数,最高有效位为 1 的时候这个浮点数是负数。也就是说在 IEEE 浮点数中会出现 +0.0 或者 -0.0 这样的数值。
二进制位的不同“模式”将会有不一样的数值计算方式,不过这两个特殊值的理念在任何情况下几乎是通用的,且容我一一道来。
规格化浮点数的特点是存储阶码的位既不全为 0 也不全为 1,存储尾数的位可以随意定制。示意图如下
可以推断出它所能够表示的无符号数取值范围是1~254
。这种情况下需要配合偏置量来求具体阶码E
的值
E = e - Bias
因此,阶码值的取值范围是-126 ~ 127
。接下来看尾数部分,在规格化的情况下尾数的位模式代表了小数点后面的数值,我们把这部分用 f 表示。不过这还不是真实的尾数,我们还需要把这个数值加 1。于是有
M = 1 + f
举个例子,假设用于存储尾数的位串是11000000000000000000000
,那么在规格化表示中,尾数的实际数值其实是1.11000000000000000000000
。利用这些原理,尝试计算下面的规格化数
1 01111111 10000000000000000000000
127
,那么实际阶码的值是E = e - Bias = 127 - 127 = 0
。M = 1 + f = 1 + 0.10000000000000000000000 = 1.10000000000000000000000
因此位串所代表的浮点数值是- 1.10000000000000000000000 * (2 ^ 0) = - (1 + 1/2) * 1 = - (3 / 2) = -1.5
。其实并不是很难对吧?接下来看非规格化数的计算方式。
非规格化浮点数的特点就是用于存储阶码的所有位全为 0,存储尾数的位可以随意定制。非规格化浮点数主要用于表示那些非常接近于 0 的数。示意图如下
只是它的计算方式跟规格化数相比还是有点区别的,在非规格化浮点表示中,用于存储阶码的 8 位全为 0,因此它所表示的无符号数始终为 0。这个时候偏置量 Bias 依然是2 ^ 8 - 1 = 127
。不过阶码值E
的计算方式却是
E = 1 - Bias
而不是E = 0 - Bias
,这有点违反直觉。不过这都是为了跟规格化浮点数进行一个平滑过渡。
接下来看尾数部分,在规格化浮点数中尾数部分所表示的数值始终需要加 1,这种时候尾数的范围是1 <= M < 2
。而在非规格化表示中尾数部分直接就是存储了真实的尾数值,不需要再进行别的运算了,于是有
M = f
这时尾数的范围是0 <= M < 1
。因此在非规格化表示中尾数部分10000000000000000000000
所对应的尾数值就是0.10000000000000000000000
利用这些原理来计算一个非规格化浮点数
0 00000000 11100000000000000000000
1 - Bias = 1 - 127 = -126
。M = f = 0.11100000000000000000000
因此位串所代表的浮点数值是0.11100000000000000000000 * (2 ^ -126) = (1 / 2 + 1 / 4 + 1 / 8) * (2 ^ -126) = (7 / 8) * (2 ^ -126)
。这个值大概是等于1.0285575569695016e-38
(我应该没算错吧^_^),这是一个非常小的数值。非规格化浮点数的计算方式与规格化差不多,只是处理起来稍稍有点特殊,这都是为了两者间的平滑过渡。
我们可以通过具体示例来看看非规格化数与规格化数之间如何平滑地过渡,根据前面的原理我们还能得出一个结论,就是非规格数始终会比规格化数小。那么他们之间的过渡便可以看成是从最大非规格化数过渡到最小规格化数了(假设数值都是大于 0 的)。
利用前面所讲过的浮点数的原理,我简单地用一个 8 位二进制串来看这个过渡的过程,假设最高有效位为符号位,其中 3 位存储阶码,4 位存储尾数。在数值大于 0 的情况下,最大非规格化数表示为
0 000 1111
最小规格化数是
0 001 0000
这个时候大家偏置量都是2 ^ 3 - 1 = 7
。如果我们的非规格化数00001111
的阶码部分按照E = 0 - e
来计算的话,那么此时的非规格化数的值就是(2 ^ (0 - 7)) * (15 / 16) = 15 / 2048
。而规格化数00010000
的值是(2 ^ (1 - 7)) * 1 = 16 / 1024 = 32 / 2048
。似乎差点意思对吧?
但如果按照标准的计算方式,用E = 1 - e
来计算非规格化数阶码的话,此时浮点数00001111
的数值是(2 ^ (1 - 7)) * (15 / 16) = 15 / 1024
。这个时候它跟最小规格化数值16 / 1024
的差距就非常小了,这就是所谓的平滑过渡。
OK,讲完了需要计算的东西,以及它们之间的平滑过渡,接下来再看一些不需要详细计算的特殊值。
以上两种表示方式已经能够涵盖大量的浮点数了,不过在某些情况下我们要有正无穷,负无穷,以及 NaN 这些特殊值来使编程更加简便,那么在 IEEE 浮点数中这些特殊值要如何表示呢?
在 IEEE 浮点数中无穷的特征是阶码的部分的位全为 1,尾数部分的位全为 0,示意图如下
前面已经谈论过,最高有效位始终代表着浮点数的符号,用于标识正负。这个理论知识在无穷中依然有用。因此在这种模式下,最高有效位为 0 的时候表示正无穷
0 11111111 00000000000000000000000
最高有效位为 1 的时候表示负无穷
1 11111111 00000000000000000000000
另外一个特殊值是 NaN,NaN 翻译过来就是Not A Number
,它的特征是阶码部分全为 1 的同时,尾数部分不全为 0,示意图如下
这种时候无论符号位是 0 还是 1,它始终都是代表着 NaN。
这篇文章简单地讲解了一下 IEEE 浮点标准,并详细谈了如何去计算浮点数的具体数值,规格化与非规格化数的计算规则会有所不同,它们之间如何平滑过渡。当然,除非你参加一些计算机考试,不然一般不需要手动去计算这些数值。此外还讲了像无穷,NaN 这些特殊值的表示方式。虽然日常工作中这些知识用处不大,不过大家开心就好。