浮点数和整数相比,它在就计算机中的方式又是另外一番风景,我们一起来了解一下吧!
我们将从以下几个方面去给大家进行介绍:
一、浮点数和浮点型家族:
首先我们计算机中常常说的浮点数就是我们日常生活中所说的小数,为什么会将小数叫浮点数呢?因为我们注意到,对于一个小数而言,它的表示方式其实并不是唯一的,eg:小数1.23也可以写成:12.3*10^(-1)或者0.123*10的形式,我们发现它们都表示小数1.23,只是小数点的位置在这个过程中发生了浮动,因此计算机中更习惯将小数就叫做浮点数。
但是我们日常看到的浮点数表现形式主要还是以下两种:
一、浮点数常量:比如3.1415926这种的。
二、用科学计数法表示的浮点数:0.123=1.23*10^(-1)=1.23 E (-1)
接下来我们也会将重点放在第二种的议论上。
然后在C语言中,常见的浮点型家族主要是两个:float:单精度浮点类型;double:双精度浮点型。现将它们各自的比较重要的参数收集整理如下:
float 单精度浮点型 | 数据类型大小:32比特位——4个字节 |
double 双精度浮点型 | 数据类型大小:64比特位——8个字节 |
当然了,实际生活中的浮点型其实还包括了:延伸单精度浮点型(43bit位及以上)和延伸双精度浮点型(79bit位及以上)。当然了,关于这两个类型在C语言阶段我们不做讨论。
二、浮点数的存储规则:
2.1:验证浮点数和整数存储方式存在差异
浮点数和整数在存储大小上其实差别不是很大:int是四个字节,float也是四个字节;long long是八个字节,double也是八个字节……我们先来做这样一件事情:
我们先定义一个整型变量n,并且将它的地址给到一个float类型的指针变量pFloat,通过对指针解引用来看一下它真正存进去的内容是什么?如果浮点数的存储方式和整型相同的话,结果应该是相同的。
然后呢,我通过这个指针去修改一下整型变量n,看一下通过一个浮点型的指针去修改它的值,会发生什么样的事情。下面提供源代码:
#include<stdio.h>
int main()
{
int n = 9;
float* pFloat = (float*)&n;
printf("n的值为%d\n", n);
printf("*pFloat的值为%f\n\n", *pFloat);
*pFloat = 9.0;
printf("n的值为%d\n", n);
printf("*pFloat的值为%f", *pFloat);
return 0;
}
运行结果:
通过运行截图,我们不难总结出以下两点重要讯息:
- 一个指针变量存放整型变量的地址,解引用操作看指针所指向的内容,其值已不再是之前的整型变量的值;
- 通过一个浮点型的指针变量去修改一个整型变量的值,修改之后的内容其实不及预期。
因此我们也认识到了,浮点型的变量存储方式和整型变量确实是有差异。
2.2:IEEE 754标准下的浮点数存储规则
IEEE是Institute of Electrical and Electronics Engineers的缩写,中文全称:国际电气和电子工程协会。这个协会给浮点数在计算机中的存储方式制定了一个标准,即二进制浮点数算数标准(IEEE 754标准),是二十世纪八十年代以来最广泛使用的浮点数运算标准,为许多的CPU和浮点运算器所采用。
IEEE 754标准规定说:任意一个浮点数以二进制形式可以表示成以下形式:
V=(-1)^S x M x 2^E
- 其中V表示当前的二进制浮点数;
- (-1)^S是符号位:S为0表示当前浮点数是正数,S为1表示当前浮点数是负数;
- M是有效数字,范围区间为[1,2);
- 2^E是指数位:就像前面0.123=1.23*10^(-1)里面-1一样,能反映出当前小数点浮动的位置。
举个栗子(●'◡'●):
十进制浮点数5.5写成上述二进制浮点数形式,应该如何操作呢?可以这么来做:
于是十进制5.5=二进制101.1,现在我们来观察5.5的二进制序列101.1:
首先它是一个正数所以S=0;
其次101.1=1.011*2^2,以此来确定了M和指数E的值:M=1.011,E=2;
所以101.1=(-1)^0 x 1.011 x 2^2。
然后假设呢,我们的5.5这个数据我用一个float类型的变量存储起来啦,这个时候CPU就给它分配了四个字节的空间,也就是32个比特位。然后呢S,M,E都分别占据了其中一部分的空间,具体的内存布局是什么样的呢,我们来看一下:
那同样的double类型的变量它也有它所对应内存布局,我们也可以来看一下:
现在对于浮点数的每一个参数S,M,E我均分配好了空间,可是每一个参数具体在存取的时候又有一些讲究,具体是怎么一回事呢?我们接着往下进行探讨。
2.3:S, M, E存取规则的介绍
这三个参数实际在存储到计算机内部的时候有一些细节上的东西需要给大家进行一些介绍,我们将按照这三个参数存取数据时的复杂程度按顺序一一给大家进行介绍
参数S的存取规则(英文名:sign,译为符号位):
S参数在存取的时候十分单纯,根据当前浮点数的正负性:是正数就存0,反之如果是负数就存1。
参数M的存取规则(英文名:fraction,译为分数值):
M是有效数字,并且前面也有提到过,它的范围在[1,2)之间。即M总是等于1.xxxx的这种形式,即整数部分是固定为1的小数形式。
于是,IEEE 754标准就规定当计算机在存储参数M的时候,默认这个数的第一位总是1,所以我们在存储参数M的时候,只对它的小数部分进行存储:
就拿我们前面的小数5.5来说,5.5=101.1=(-1)^0 x 1.011 x 2^2,于是在存储M=1.011的时候,只存储了011的部分(二进制的011),而将最前面的1给省略了。然后等到我们要用到这个5.5,在读取的时候,再由系统将这个1加上去。
这样做的好处是:在存储参数M的时候,可以多存储一位有效数字,以提高浮点类型在存储数据时的精度。
来到这里,姑且不考虑指数E的部分,对于浮点数5.5 = (-1)^0 * 1.011*2^2来说(假设数据类型是float),它的四个字节里面存储的数据是:
参数E的存取规则(英文名:exponent,译为指数):
对于这个参数的存取,首先IEEE 754标准规定说:E是一个无符号的整数。同时:
当参数E为8位时,即数据类型为float:取值范围为:0~255之间;
当参数E为11位时,即数据类型为double:取值范围为:0~2047之间。
注:这里的取值范围不是无中生有,是根据E的类型和它在内存中的空间大小推导出来的结果。因为E是无符号整数,所以最小的无符号整数应该是0,同时所有的二进制位都是有效位,大家可以依照这两个点去自行推导一下。
但是我们知道,在科学计数法中,参数E作为指数部分是可以出现负数的,可是E是一个无符号的整数啊!诶,别急,我们接着往下看:
IEEE 754也看到了这个问题,于是规定了:真正存储到内存中E的真实值是原来的指数值再加上一个中间数的结果。这个中间数有一个专业的术语——指数偏移值(exponent bias),IEEE 754标准规定说指数偏移值bias=2^(e-1)-1,其中指数e的值等于E在内存中所占据的比特位。对于float类型而言这个值是8,double类型而言这个值是11.
指数e后面我们仍然会用到,但不再进行说明。因此希望小伙伴们在这里留个心眼。
因此由上我们可以推出:
对于8位的E来说:这个中间值是2^7-1=127;
对于11位的E来说:这个中间值是2^10-1=1023。
就比如说:假设有一个float变量的101.1=(-1)^0 x 1.011 x 2^2在存储指数E的时候,真正存储到计算机中不是原来的2,而是原来的指数值再加上中间数的结果,即2+127=129。转换成8位二进制序列就是1000 0001。
好的,参数E的存储我想我应该是给大家讲明白了。但是在实际取出参数E的时候,又分为以下三种情况:
情况一:正常情况——E的二进制既有0又有1的情况。
这种情况下,E的编码值范围:(0,2^e-2],即:
如果是float类型的浮点数:其指数E的值域为0000 0001~1111 1110
如果是double类型的浮点数:其指数E的值域为0000 0000 001~1111 1111 110
注:这里E的值域中少了各自的最小值序列(全0)和最大值序列(全一),其实这两种序列有着特殊的作用,常用于表示一些特殊情况下的数字!
这个时候对E的处理,是先将指数E的二进制转换为十进制,再将计算值减去127(E为8位时)或者1023(E为11位时),得到E的真实值,同时再在有效数字M前加上第一位的1即可。
同时正常情况下得到的浮点数,叫做规约形式的浮点数,所谓的规约:是指能用唯一确定的二进制浮点形式进行表示。
情况二:E的二进制序列为全0的情况。
出现这种情况的时候,就一般会分为以下两种情况进行讨论:
- M的小数部分也为全0的形式,那么这个时候的浮点数表示的含义就是正负0,而具体是正数还是负数这取决于参数S的值;
- M的小数部分为非零的形式,这个时候所表示的浮点数统称为非规约形式的浮点数,也是一个非常接近0的数字。这个非常接近0是相对于所有的规约浮点数而言的,非规约浮点数比任何一个规约浮点数都更加接近于0,因此它们的绝对值小于所有规约形式的浮点数。
其次还有以下两点说明:
其一IEEE 754标准规定说:非规约形式浮点数(包括M为全0情况)的指数偏移值(即中间数)Bias比规约形式浮点数的指数偏移值小1,什么意思呢?我们说最小的规约浮点数的E的二进制编码是00000001,它的真实值是1-127=-126;最小的非规约浮点数的二进制编码是00000000,它的真实值也是0-126=-126,而不是-127。
其二非规约浮点数的M的值不再加上第一位的1,而被还原为0.xxxxxx的形式,以此用来表示一个很接近于0的数。
总而言之:这个时候表示的浮点数就是一个绝对值很小很小的,很接近于0的浮点数或者0本身。
情况三:E的二进制序列为全一的情况。
E为什么会出现全一,它是在加上一个中间数127的时候变成全一的,说明E的真实值:2
这种情况下也被分为两种情况进行讨论:
- M为全0的情况,这个时候浮点数表示的含义就是正负无穷大,当然这个具体是正数还是负数取决于当前参数S。
- M为非0的情况,这个时候的表示的就不是一个数,即NaN——not a number,且IEEE 754在浮点数的比较中规定:NaN与任何浮点数(包括其自身)进行比较的结果均为false。
来到这里,我们已经可以完整地画出浮点数5.5在内存中地存储,由于5.5的指数部分E = 2,所以实际存储在计算机内部的是2 + 127 = 129 = 10000001 ,如图所示:
2.4:总结:
我们可以用下面这张表格(以float数据类型为例)将前面的内容系统总结如下:
类别 | 参数S(0正1负) | 真实指数 | 实际指数 | 指数域 | M域 |
0 | 0 | -126 | -126+126=0 | 0000 0000 | 全0 |
-0 | 1 | -126 | -126+126=0 | 0000 0000 | 全0 |
最小规约数 | * | -126 | -126+127=1 | 0000 0001 | 全0 |
最大规约数 | * | 127 | 127+127=254 | 1111 1110 | 全1 |
最小非规约数 | * | -126 | -126+126=0 | 0000 0000 | 00...01 |
中间非规约数 | * | -126 | -126+126=0 | 0000 0000 | 10...00 |
最大非规约数 | * | -126 | -126+126=0 | 0000 0000 | 全1 |
正无穷 | 0 | 128 | 128+127=255 | 1111 1111 | 全0 |
负无穷 | 1 | 128 | 128+127=255 | 1111 1111 | 全0 |
NaN | * | 128 | 128+127=255 | 1111 1111 | 非全0非全1 |
三、来分析一些代码:
3.1:代码一
最后我们回到前面我们2.1节提到的那个代码,它当时为什么会出现下面的运行结果呢?
分析:
首先int类型9的二进制补码:
00000000 00000000 00000000 00001001
从float的角度去看这个数据:
0 00000000 00000000000000000001001
这是一个非规约浮点数,一个很接近0的数字,因此会打印数字0.000000。
将9.0存储到pfloat指针里面,9.0=(-1)^0×1.001×2^3它的二进制补码为:
0 10000010 00100000000000000000000
从int类型的角度去看这个数据:
01000001 00010000 00000000 00000000
这个数的高位为0,是正数,原码和补码相同,结果就等于1091567616。
3.2:代码二
之前的程序已经给大家分析清楚了,现在我想做这样一件事情,我想从float角度去打印十六进制数0x7f800001,这个数的二进制序列为:
0 11111111 0000 0000 0000 0000 0000 001
按照前面的理论,我们这个数的结果就是一个NaN,我们来测试一下:
#include<stdio.h>
int main()
{
int n = 0x7f800001;
float* pfloat = (float*)&n;
printf("%d\n", n);
printf("%f", *pfloat);
return 0;
}
运行结果:
你会发现确实就是这样一回事呢。
四、写在最后的话:
最后也非常高兴,将这个系列彻底完成了,也非常感谢大家一直以来的支持和鼓励。
然后这篇浮点数在计算机中的存储,限于作者的知识水平,所以只是带着大家揭开了一点点它的·面纱。很多的内容并没有给大家讲清楚,比如精度的问题,浮点数的比较和数据的取舍等等……
如果大家感兴趣,推介一本非常经典的书籍叫做《深入理解计算机系统》,这本书带着大家详细探究了计算机中的这些底层逻辑的东西。看完之后,我相信你会大有收益的。不过这本书阅读起来也确实有点难度,可以在日常学习和生活中慢慢去研读。我们一起加油,与君共勉😊!