介绍
带小数点的数字
最早大概可能是在小学四年级,我们就已经学习了「小数」,一般来说,即带小数点的数字,像是a.b这样的形式,其中a,b可以是一个或者多个数字的排列,中间把a和b分隔开来的那个点就叫做「小数点」,这样的a.b就称为小数.
我们这么早就开始学习小数,是因为生活中会用到,一罐可乐的价格是2.5元,一包食用盐的价格是3.5元,电池的电压是1.5伏特,圆的周长大概是其直径的3.14倍,猪肉的价格是31元一斤,如果要买半斤,就要付15.5元,等等,还有很多要用到小数的情形.
有理数
到了初中,我们知道小数也是有理数,而所谓有理数,就是能表示为两个数的商的数,比如说
$$ 2.5 = \frac{5}{2}, 15.5 = \frac{31}{2}, 1.25 = \frac{5}{4} $$
我们还知道有理数的和是有理数,有理数的差的有理数,有理数的积的有理数,有理数的商也是有理数,并且任意的有限的小数都能表示为有理分式,这种认识同时给我们带来了:
- 小数的另外一种表示方法
- 小数在这种新表示方法下的运算规则集
任何一个有限位的小数,比如说
$$ a_1 a_2 \dots a_n . b_1 b_2 \dots b_m $$
其中每一个$a_i$和$b_i$都是$0$到$9$范围内的一个数字,要转化为有理数(有理分式)的表示方法,只需先将整数部分提取出来看做分母为1的有理分式,小数点后的数字也拿出来做分子,而分母就是10自乘小数点后的数字的个数次得到的值.
$$
\begin{split}
a_1 a_2 \dots a_n . b_1 b_2 \dots b_m &= \frac{a_1 a_2 \dots a_n}{1} + \frac{b_1 b_2 \dots b_m}{10^m} \\
&= \frac{a_1 a_2 \dots a_n \times 10^m + b_1 b_2 \dots b_m}{10^m}
\end{split}
$$
注意了我这里写的$a_1 a_2 \dots a_n$或者是$b_1 b_2 \dots b_m$其实表示一个个数字的排列,比如说$456$就相当于$a_1 a_2 a_3$并且$a_1=4,a_2=5,a_3=6$,于是所有的有限位小数,如果再运算,只要转化成有理分式,再按照有理分式的运算方法进行运算就可以.要从有理分式转回带小数点的那种「传统」小数也很容易,直接让分子除以分母,得到的商就是原来的小数.
科学计数法
大概还是在初中,在学习有理数的知识的时间的前后,我们还学习了「科学计数法」,科学计数法其实就是将一个有限位的小数或者整数表示为
$$ s \times b^t $$
这样的形式,其中$s$称为尾数(Mantissa)或者有效数(Significant),$b$称为基底(Base),而$t$称为幂次(Exponent).
比如说$10.24$这个数字其实可以表示为$1.024 \times 10^{1}$,$4321.768$表示为$4.768 \times 10^3$,$42$可以表示为$4.2 \times 10.科学计数法从形式上统一了有限位的小数和整数,尤其是大的数字用科学计数法,算乘法的时候更方便一些,比如说
$$
a=3.14 \times 10^6 \\
b=4.28 \times 10^{15}
$$
那么
$$
\begin{split}
a \times b &= 3.14 \times 4.25 \times 10^6 \times 10^{15} \\
&=13.345 \times 10^{6 + 15} \\
&=13.345 \times 10^{21}
\end{split}
$$
这样乘完之后形式上还是没变,仍然是$s \times b^t$的形式只不过$s$和$t$的值分别变.也就是说可以把$s_1 \times b^{t_1}$和$s_2 \times b^{t_2}$中的$s_1,s_2,t_1,t_2$看成是一般的数,来表示两个数用科学计数法表示时乘积的表示方式
$$ (s_1 \times b^{t_1}) \times (s_2 \times b^{t_2}) = s_1 \times s_2 \times b^{t_1 + t_2} $$
科学计数法使得数字的大小容易被比较,由于有效数$s$总是被要求写成$1 \leq |s| \leq 10$的形式,只看$t$就知道整个数字大概有多大,两个数相乘,也容易估算相乘后的数字的大.一句话,科学计数法方便了大的数字和小的数字的运算和表示.
小数在计算机中的表示
人们为了小数在形式上的统一,为了运算和表示的方便,提出了使用科学计数法来表示特别大和特别小的数字,但就计算机的角度来看,幂$t$是整数,是很好表示的,有效数$s$是小数,还是不方便表示,那计算机如何来表示小数呢?
补充:计算机存储数据的方式
在古老的部落时代,靠狩猎为生的远古人类在木块上划出划痕来记录信息,信息的本质是差异(Variation),而差异在这里通过划了划痕和没划划痕的木块的差异体现,并得以记录,计算机中的存储介质的核心部件可以看成是一块大的木板,这个木板由一个个可划上划痕并且抹去划痕的基本存储单元组成,一个这样的基本存储单元能够存储的信息的量为一比特,这样,让计算机存储介质来模仿可以划上划痕(并且可以抹去划痕)的木块,使得信息也得以在计算机中存储.
基本存储单元只有两种状态,如果和可以划上划痕的目标作比较,这两种状态就是划上了划痕,或者没划上划痕(可能有过划痕但是抹掉了),如果和灯泡做比较,这两种状态就是灯亮着,或者灯没亮,只有两种内部状态的基本存储单元只能和只有两种内部状态的对象做类比,而闭着眼睛指向小数$a_1 \dots a_n.b_1 \dots b_n$中的一个位置,可能指到$0$到$9$中的一个数字,也可能是小数点,但不会是两种状态,所以小数不能像写在纸上那样,直接写入计算机存储媒介中.
分别存储分子和分母
前面我们说过,有限位小数是有理数,可以表示成两个整数的商,而这两个整数一个做分子,一个做分母,就得到了和原来的小数相等的有理分式,计算机分别存储这个有理分式的分子和分母,也就存储了这个小数,而且有理分式的分子和分母总是能化为整数的形式,所以存储起来也方便.
为什么?因为由于约定了用这两个整数来表示,那只要计算机程序被编写成按照这种约定工作,就无须再把分式中间的那一横或者小数中间的那一点额外地存储到计算机,问题就只剩下了对两个整数(分子和分母)的存储,那基本存储单元只有两种可能的状态,一个整数有那么多种可能的状态,又怎么存储整数呢?
首先利用这么样的一个数学上的事实:任何一个十进制整数都能表示成一个二进制整数,而任何一个二进制整数也同样能表示成一个十进制的整数,十进制的四则运算结果也等价于等价的二进制数的二进制的四则运算的运算结果,举个例子:
1234看成是十进制的1234,而与十进制的1234等价的二进制数字是10011010010,571看成是十进制的571,而与十进制的571等价的二进制的数字是1000111011,按照十进制的运算法则
$$ 1234+571 = 1805 $$
而按照二进制数的运算法则
$$ 10011010010 + 1000111011 = 11100001101 $$
再按照十进制数和二进制数之间相互转换的转换规则,十进制的1805可以转换为二进制的11100001101,而二进制的11100001101也同样可以转化为十进制的1805.
由此一来,十进制整数可被二进制整数表示和运算,而二进制整数便于拆分成一个个的二进制数码,分别被一个个的计算机基本存储单元(叫做比特,bit)存储,于是可以同时存储两个相关联的整数表示小数,就得到了这么一种存储小数的方式.
然而根据有理分式的加法公式
$$ \frac{a}{b} + \frac{c}{d} = \frac{ad + bc}{bd} $$
和乘法公式
$$ \frac{a}{b} \times \frac{c}{d} = \frac{ac}{bd} $$
和除法公式
$$ \frac{a}{b} \div \frac{c}{d} = \frac{ad}{bc} $$
可以看到,如果在计算机内部要使用两个整数来表示小数的话,运算起来太麻烦了,尤其是,一次加(减)法运算,竟然涉及到三次整数的乘法运算和一次整数的加法运算,且不说由有理分式输出小数还要再做一次除法运算.
按浮点数的方式存储
如果说用分别存储两个整数来存储小数的方法可看成是借鉴了有理分式的表示法,那浮点数可以看成是借鉴了科学计数法的表示法,前面我们曾说过,使用科学技术法,一个有限位的小数可以表示成
$$ s \times b^{t} $$
的形式,其中
$$ 1 \leq |s| < 10, t \in \mathbb{N}^{\star} $$
但是在这里有效数$s$还是可以带小数点的,所以,计算机要模仿科学计数法表示小数,可以将小数表示为
$$ m \times 2^{n} $$
并且这时
$$ m \in \mathbb{Z}, n \in \mathbb{N}^{\star} $$
与科学计数法所不同的是,这里的有效数$m$一定是整数了,并且是二进制表示的整数,二进制数中的一位,刚好就可以用一个基本存储单元(比特)来存储,并且当$n$取不同的值时,相当于等价的小数的小数点移动了,于是就叫做「浮点数」,即小数点是「浮动的」.
这里采用$2$做基底,是因为$m$为了方便存储和在计算机内部进行运算,是用二进制的形式表示(和存储),而假如有另外一个浮点数和这个浮点数相乘,那么乘积可以表示为
$$ m \times 2^n \times m_1 \times 2^{n_1} = m \times m_1 \times 2^{n+n_1} $$
那么乘积中的有效数部分
$$ m \times m_1 $$
后边可能会有$0$,即,乘完之后有效数出现了进位的情况,那么这时采用$2$作为基底,对于化简
$$ m \times m_1 \times 2^{n+n_1} $$
其实是很有帮助的.
举个例子,设
$$
a = 10011 \times 2^{4} \\
b = 1100010 \times 2^{8}
$$
那么$a$和$b$相乘就是
$$
\begin{split}
a \times b &= 10011 \times 1100010 \times 2^{4+8} \\
&= 11101000110 \times 2^{12} \\
&= 1110100011 \times 2^{13}
\end{split}
$$
这样我们就将最初的乘积
$$ 11101000110 \times 2^{12} $$
等级地化简为了
$$ 1110100011 \times 2^{13} $$
即可以认为浮点数采用$2$作为基底,是有利于简化运算结果的.
浮点数在计算机内部的具体存储实现和运算
经过前面的讨论,我们已经感觉得到以
$$ m \times 2^{n}, m \in \mathbb{Z}, n \in \mathbb{N}^{\star} $$
的形式表示小数,并且将整数$m$和$n$分别转化为二进制的形式再存储到计算机存储媒介的基本存储单元阵列中,是一种基本上可行的方.但是即使是有了这样的知识,对于浮点数在计算机内部具体地存储方式和运算方式,似乎仍然是有一些不确定和不够清楚的地方,这一节来讨论这个.
概括地说,大概有以下这三个问题等着我们去讨论:
问题1:要用多长的存储空间来存储一个浮点数
比如说,我们知道,计算机的内存是有限的,即基本存储单元阵列的「长度」或者说最大存储「容量」(总比特数)是有限的,并且,在不同的具体情形,人们对小数的精度和表示范围的要求也不同,要表示人的身高(单位是米),取值范围在3以内一般就够了,而精度可以只精确到小数点后两位,比如说一个人的身高是1米82,也就是1.82米,而一个班级的男生的平均身高,可能是1米76,即1.76米,在这种情形下,不管是小数的表示,还是小数的运算,都只要求小数点后两位的精度,而取值范围也总是在3以.而在天文学领域,质量和能量可能要用到几十位数的小数来表示,精度如果这时还是只保证两位小数的精确,那肯定是不够.
这个问题可以归结为:为了满足大多数情形的需要,为了表示一个小数,我们要从有限的计算机内存中拿出多少比特用于存储有效数$m$,又拿出多少比特用于存储幂$n$?
问题2:精度和表示范围
高速运转的计算机繁忙而不间断地执行着复杂的计算任务,为了性能考虑,在实现浮点数存储和运算功能时,人们一般会将$m$和$n$的长度(比特数)设置为固定的,或者分别设计几种浮点数对应不同长度的$m$和$n$,但不论如何,$m$和$n$的长度如果给定了,是很少动态改变的,这时假如说$m$和$n$的长度是固定的,那么
$$ m \times 2^n $$
这样的浮点数最精确能有多精确?最大能有多大?最小能有多小?假如说能表示的范围是$D$,那在范围$[\min(D), \max(D)]$或者$(\min(D), \max(D))$中,有$m \times 2^n$所不能表示的数吗?
问题3:怎样实现浮点数的数学运算
给定两个浮点数
$$
a = m_1 \times 2^{n_1} \\
b = m_2 \times 2^{n_2}
$$
其中$(m_1, n_1)$和$(m_2, n_2)$可以看做是被计算机完全已知的,那么在知道$(m_1, n_1)$和$(m_2, n_2)$的具体的值甚至知道具体的每一位的情况下,如何再用浮点数来表示$a$与$b$的和
$$ s = a + b = m_? \times 2^{n_?} $$
以及$a$与$b$的差
$$ d = a - b = m_? \times 2^{n_?} $$
以及$a$与$b$的积
$$ p = a \times b = m_? \times 2^{n_?} $$
以及$a$除以$b$所得的商
$$ q = a \div b = m_? \times 2^{n_?} $$
也就是说,如何再用浮点数来表示上列四则运算的运算结果呢?
存储问题的解答
浮点数现行的通用标准基本上是沿用了CPU设计中所设置的基本数据类型的长度:Byte(1个字节,8个比特),Word(2个字节,16个比特),DoubleWord(4个字节,32个比特),QuadWord(8个字节,64个比特),在IEEE 754-1985规范中,定义了两种基本的浮点数格式,一种叫做「单精度浮点数」(Single Precision),对应于DoubleWord长度,另外一种叫做「双精度浮点数」(Double Precision),对应于QuadWord长度.
IEEE754 Single Precision
如您所见,单精度浮点数需要32个比特,也就是4个字节,其中索引号(从0到31)为31的,也就是最后一个比特(我们把它画在最左边),用来表示浮点数的正负,我们称这个比特为sign bit
,如果sign bit=1
,就代表存储的浮点数是负的,如果sign bit=0
,就代表存储的浮点数是非负.其中exponent bits
存储的其实是「偏差了的」exponent
,而significant bits
存储了的是小数点后边的小数,怎么说?举个例子:假如说我们有一个小数$0.15625$要存储进一个单精度浮点数变量中,根据IEEE-754,算法是这样的:
- 首先将$0.15625$等价地改写为有理分式$5/32$,也就是二进制中的$101/100000$.
- 二进制的$101/100000$实际上就是$101 \times 10^{-101}$(都是二进制)
- 进行化简,得到$1.01 \times 10^{-11}$(都是二进制)
- 将$1.01$小数点左边的$1$根据Leading 1 Convention去掉(约定得到
significant bits
之后左边加上小数点和1即可),去掉后就是01
(0和1都保留),得到significant bits
是01
. - $1.01 \times 10^{-11}$当中的$-11$其实是真实的幂,要给他加上$127$(偏差),也就是加上$1111111$,得到$1111100$,即
exponent bits
就是1111100
,这样子会使得硬件层面上更容易比较数字的大小.
这样子十进制的$0.15625$根据IEEE-754,在一个32位比特长度的单精度浮点数变量中是这样存储的:
0 01111100 01000000000000000000000
为了看起来更清晰,我用空格分开了sign bit
,exponent bits
,和significant bits
,并且可以看到sign bit
,由于$0.15625 >= 0$,所以是1.
IEEE754 Double Precision
对于双精度浮点数,转换的规则是类似的,只不过这时exponent bits
足足有11比特的长度,因此此时为了迎合2的补码的机器,偏差不再是单精度浮点数中的127(二进制的01111111
),而是1023(二进制的01111111111
).
为了更加清晰地验证问题,我自己写了一个C语言的简单的程序,这个程序可以打印出存储小数的内存区域的二进制数值,这样我们可以检验刚才这一堆说法的正确性:看编译器究竟是不是这样实现单精度浮点数算法的.
#include <stdio.h>
/*
* print float x's memory, in binary form.
*/
void printFloatBits(float x) {
// initialize storage as buffer
unsigned char *storage[8];
storage[0] = 0;
storage[1] = 0;
storage[2] = 0;
storage[3] = 0;
storage[4] = 0;
storage[5] = 0;
storage[6] = 0;
storage[7] = 0;
// write float x to buffer so that we can testify it.
float *y = storage;
*y = x;
// bit-wisely check float x's bits
unsigned long *tester1 = storage;
for (int i = 31; i >= 0; i = i - 1) {
// printf("%d\n", i);
unsigned long result = (*tester1) & (0b1 << i);
if (result) {
putchar('1');
}
else {
putchar('0');
}
if (i == 31 || i == 23) {
putchar(' ');
}
}
putchar('\n');
}
int main(int argc, char *argv[]) {
// test each fractional
printFloatBits(0.15625);
printFloatBits(0.3125);
printFloatBits(0.625);
printFloatBits(3.141);
printFloatBits(3.14159);
printFloatBits(62.125);
printFloatBits(-3.141);
printFloatBits(-3.14159);
return 0;
}
编译和运行方式:
gcc main.c # 编译
./a.out # 运行
我先将运行结果贴在这:
0 01111100 01000000000000000000000
0 01111101 01000000000000000000000
0 01111110 01000000000000000000000
0 10000000 10010010000011000100101
0 10000000 10010010000111111010000
0 10000100 11110001000000000000000
1 10000000 10010010000011000100101
1 10000000 10010010000111111010000
可以看到,但凡是负数的,[31]位
都是1
,而但凡是非负数的,[31]位
都是0
,第[30]
到第[23]
位是幂次加上127
后的结果,也就是真实的幂次加上01111111
之后的结果,而第[22]
到第[0]
位则是尾数,例如最后一个,尾数,或者说,有效数的完整表述应该是
1.10010010000111111010000
即取[22]
到[0]
这段出来,左边加上1和小数点,然后它代表的数实际上是
$$ -1.10010010000111111010000 \times 10^{10000000-01111111} $$
也就是
$$ -1.10010010000111111010000 \times 10^1 $$
注意,这上边的数都是二进制的,比如说上边的10其实是二进制的10也就是十进制的2. 这样我们就基本知道小数如何存储在一个32位的float
变量中,并且知道如何由这样一个32位的float
变量的比特值得到原来的小数.
精度问题
为了看这么一个32位的float
变量都能表示哪些小数,我们尝试遍历32个比特的所有可能的取值的组合——具体地,有32种,其中,符号位可取0到1,两种组合,而幂次位可取00000000
到11111111
,最大值(内存值)是11111111
,即最大$2^8-1$,而尾数位最小00000000000000000000000
,最大11111111111111111111111
,即最大$2^{23}-1$,可分别遍历符号位,幂次位和尾数位,每个区的取值如下图所示:

32位的float变量分别遍历的取值范围
换句话说,我们用$i,j,k$这三个非负整数变量来构造一个32位的非负整数变量$x$,那么如果令$x=i\times 2^{31} + j \times 2^{23} + k$,遍历$i \in [0, 2^1-1], j \in [0, 2^8-1], k \in [0, 2^{23}-1]$就可以遍历$x \in [0, 2^{32-1}]$,因为对于$x$在$[0,2^{32-1}]$范围内的所有取值都能找到一组$(i,j,k)$使得$x=i\times 2^{31} + j \times 2^{23} + k$. 这是显然的.
首先让我们来测试一下,我们先按照这种思路来构造一个float
数,然后试着将其打印出来
float makeFloat(
unsigned int signedBit,
unsigned int exponentBit,
unsigned int mantissaBit
){
unsigned int storage = 0;
float *f1 = (float *) &storage;
unsigned int i = signedBit;
unsigned int j = exponentBit;
unsigned int k = mantissaBit;
storage = (i << 31) + (j << 23) + k;
float answer = *f1;
return answer;
}
int main(int argc, char *argv[]) {
printFloatBits(makeFloat(1,3,4));
return 0;
}
输出的结果是
1 00000011 00000000000000000000100
这表明我们已经能够按照自己的意愿「随意」地构造float
.接下来我们可以从理论上遍历一个半精度浮点数变量float x
的所有取值范围,通过把$x$表示为
$$ x = x(i,j,k) = i \times 2^{31} + j \times 2^{23} + k $$
并且通过遍历
$$ i \in [0, 1], j \in [0, 2^{8}-1], k \in [0, 2^{32}-1] $$
你可能会有所疑惑,既然float x
是浮点数,为何$x$又是个整数?这是因为在计算机内存中,浮点数也是以二进制的方式存储的,而每个二进制数都有与之直接对应的非负整数,例如
-3.14159
这个浮点数在计算机中的存储方式是(二进制)
1 10000000 10010010000111111010000
分别观察符号位、幂次位和尾数位,相当于$i=1,j=10000000,k=10010010000111111010000$(二进制),或者$i=1,j=128,k=4788176$(十进制).
现在假设我们已经知道了32位的二进制数
1 10000000 10010010000111111010000
我们怎么知道它表示的是哪一个浮点数呢?其实也非常简单,我们知道尾数位的那23个二进制数实际上仅仅是小数点后的那23位,即
10010010000111111010000
实际上相对于
1.10010010000111111010000
那么写出二进制的分数的形式也就是
1+10010010000111111010000/(2^23)
很好理解,因为例如十进制的
$$ 12.34 $$ 实际上相当于
$$ 12+32/100 $$
也就是
$$ 12+32/10^2 $$
更一般的,一个小数
$$ p_1 p_2 p_3 \cdots p_n . q_1 q_2 \cdots q_m $$
转化为分数其实就是
$$ p_1 p_2 p_3 \cdots p_n + \frac{q_1 q_2 \cdots q_m}{b^m} $$
其中$b$是基底,对于二进制数$b=2$,对于十进制数$b=10.注意这里的$p_1 p_2 \cdots p_n$不是相乘,它们分别表示一个个的数字,$1, 2, \cdots n$表示的是一个个数字的位置而已,例如一个数字$321.23456$相当于$p_1 p_2 p_3.q_1 q_2 q_3 q_4 q_5$并且$p_1=3, p_2=2, p_3=1, q_1=2, q_2=3, q_3=4, q_4=5, q_5=6$.
那么结合上面的讨论,我们已经知道
1 10000000 10010010000111111010000
这个浮点数实际上就相当于
$$ (-1)^1 \times (1.10010010000111111010000) \times 10^{10000000-01111111} $$
更一般地,如果我们知道了$x(i,j,k)$中的$i,j,k$,其中$i,j,k$的范围上面说过了,那么$x$表示的浮点数等于
$$ f(x) = f(x(i,j,k)) = f(i,j,k) = (-1)^i \times (1+\frac{k}{2^{23}}) \times 2^{j-127} $$
下面我们在Python3
中验证一下,看
1 10000000 10010010000111111010000
按照我们的转化方法,和原来的输入-3.14159
相差有多大

在Python3中实现我们的转化算法
接下来我们以我们之前C程序的输出作为样例,看输出是否和原来的浮点数接近

样例输入
程序convertBack.py
如下:
def toFloat(i, j, k):
return ((-1)**i)*(1+(k)/(2**23))*(2**(j-127))
while True:
try:
i, j, k = input().split(" ")
i, j, k = int(i, base=2), int(j, base=2), int(k, base=2)
print(toFloat(i,j,k))
except:
break
输出结果为

输出结果
在比较原来的C程序中的输入
int main(int argc, char *argv[]) {
// test each fractional
printFloatBits(0.15625);
printFloatBits(0.3125);
printFloatBits(0.625);
printFloatBits(3.141);
printFloatBits(3.14159);
printFloatBits(62.125);
printFloatBits(-3.141);
printFloatBits(-3.14159);
return 0;
}
由此我们可以看到,我们所设想的由32位的半精度浮点数的二进制表示转化为真正的小数的转化方法是正确.接下来我们可以放心大胆的用$x(i,j,k)$这个函数来分析浮点数$float(x)$了,其中
$$ x = x(i,j,k) = i \times 2^{31} + j \times 2^{23} + k $$
并且
$$ float(x) = f(x) = f(i,j,k) = (-1)^i \times (1+\frac{k}{2^{23}}) \times 2^{j-127} $$
其中$f(x)$的值域就是一个32位的半精度浮点数变量float x
所能表示的浮点数,而$x$遍历$[0, 2^{32}-1]$同时$i,j,k$分别遍历$[0,1],[0,2^8-1],[0,2^{23}-1]$.
事实上,对于精度问题和表示范围问题,不妨说,更具体地说,我们只关心$|f(x)|$的精度和范围,因为正负号只不过是一个符号,对精度没有影响,而范围仅仅用绝对值也可表.也就是,精度和范围,实质上是由
$$ |f(x)| = (1+\frac{k}{2^{23}}) \times 2^{j-127} $$
的精度和范围来决定的.
特殊地,为了表示浮点数0,即为了表示$f(x)=0$,约定当幂次位$j$和尾数位$k$都为$0$时,浮点数$f(x)=0$,符号位$i=0$表示「正0」,$i=1$表示「负0.并且,当幂次位$j$全为1,即$j=255$时,表示浮点数$f(x)=\infty$,而$i=0,j=255,k=0$表示正无穷$+\infty$,$i=1,j=255,k=0$表示负无穷$-\infty.当表示无效数字NaN
时,例如开负数根号的结果无法用实数表示(半精度浮点数只表示实浮点数),幂次位$j=255$,而尾数位$k$非零.
要使绝对值$|f(x)|$取最小,只有当幂次位$j$取$0$,而尾数位只取$1$,注意当幂次位$j$取$0$时,实质尾数不需要再在尾数前面加$1$,这样绝对值最小就是,根据特例,当幂次位全取$0$时,表示尾数要乘以$2^{126}$,而不是$2^{127}$.
$$ |f(x)|_{\text{min}} = 2^{-23} \times 2^{-126} $$
而当幂次位取$254$(因为幂次位全取1表示异常数),并且尾数位全取1(此时实质尾数是$2-2^{-23}$),最大值是
$$ |f(x)|_{\text{min}} = (2 - 2^{-23}) \times 2^{127} $$
而在这两个范围内,$|f(x)|$无法表示比
$$ 2^{-126} $$
还小的误差.
加法和乘法
设$x$是
$$ f(x) = (-1)^{i_1} \times (1+\frac{k_1}{2^{23}}) \times 2^{j_1-127} $$
设$y$是
$$ f(x) = (-1)^{i_2} \times (1+\frac{k_2}{2^{23}}) \times 2^{j_2-127} $$
易知,要进行加法,只需对符号位和幂次位进行通分,使$j_1-127$等于$j_2-127$,在相加通分后尾数位相应的值即.而要进行乘法,则将符号位的数相加再对2取余数,将尾数位相乘,并将幂次位相加即可.
总结
浮点数在计算机中的存储和计算方式的理解难点实质上仅仅是整数集到小数集的映射,映射法则清晰以后,进行存储就是将符号部分和尾数部分和幂次部分进行简单的处理再以二进制的方式存储,转换回小数则是按照同样的规则转换回来,而运算的方式,同两个数用科学计数法表示时的运算方式,并无不同.
参考文献
[1] Intel® 64 and IA-32 Architectures Software Developer Manuals | Intel® Software
[2] Floating-point arithmetic - Wikipedia
[5] 754-2008 - IEEE Standard for Floating-Point Arithmetic - IEEE Standard