一.观察现象,提出问题
为什么我们用%f打印整形数值时结果总为0.000000,而用%d打印浮点型数值时结果总很大的一个数字?
为了一次性搞清楚这个问题,我们先来看一个案例:
#include<stdio.h> int main() { int a = 8; //创建整形变量a并赋值一个整数8 float* p = (float*) &a; //取出a的地址,并强制类型转换成(浮点型指针)的形式存储在浮点型指针变量p中 printf("a的值为:%d\n", a); printf("*p的值为:%f\n", *p); //分别以整形和浮点型的方式打印a和*p的值 *p = 8.0; //通过指针解引用的方式将a的值改为8.0 printf("a的值为:%d\n", a); printf("*p的值为:%f\n", *p); //再分别以整形和浮点型的方式打印a和*p的值 return 0; }
该程序放入vs编译器后的运行结果如下:
可以发现一个有趣的现象,当我们使用%f来打印一个整形时,大概率编译器都会打印出一个0.000000出来,而使用%d来打印一个浮点型数据时编译器大概率会打印出一个(看似)非常大且没有规律的数字。
有许多同学会认为这是编译器报错的一种方式,即遇到用%f打印整形的“错误指令”时就固定打印出0.000000来提醒程序员代码写错了,而遇到用%d来打印浮点型的“错误指令”时就打印一个随机值来提醒程序员代码写错了。
但接下来我们一起探究一下整形数据和浮点型数据在内存中的存储后,就能明白其实编译器给出的这些数字是经过非常严格的计算得来的,而不是我们想象的那样是个随机值。
二.了解整形在内存中的存储方式
首先,计算机中的整数有三种2进制表示方法,即原码、反码和补码。三种表示方法均有符号位和数值位两部分,符号位都是用0表示“正”,用1表示“负”。
整形在内存中的存储图示:
要注意的是:
正数的原、反、补码都相同。
负整数的三种表示方法各不相同。
原码:
直接将数值按照正负数的形式翻译成二进制就可以得到原码。
反码:
将原码的符号位不变,其他位依次按位取反就可以得到反码。
补码:
反码+1就得到补码。
我们拿-8来举例:
int b=-8;
首先写出它的原码:1000 0000 0000 0000 0000 0000 0000 1000(原码)
符号位不变,取反:1111 1111 1111 1111 1111 1111 1111 0111(反码)
在给反码加一得到:1111 1111 1111 1111 1111 1111 1111 1000(补码)
补码转换成16进制:F F F F F F F 8
接着我们打开编译器查看内存中变量b的地址:(注:该编译器为小端存储模式,因此是倒着依次存入每个字节的数据的,注意,小端存储模式只是将整形内部的四个字节的顺序颠倒存储,而每个字节内部的信息是不会颠倒的,因此不是8f ff ff ff,而是f8 ff ff ff)
由此可见,对于整形来说:数据存放内存中其实存放的是补码。而以补码的形式存储数据的主要原因是因为计算机cpu只有加法器,使用补码,可以将符号位和数值域统一处理。
三.了解浮点型数据在内存中的存储方式
了解了整形数据在内存中的存储方式后,我们再来看浮点型数据是如何在内存中存储的,
首先我们来看看浮点数是什么:(来源:百度百科)
看定义可能不太好理解,通俗的讲,浮点数之所以叫浮点数就是因为它的小数点是可以左右任意浮动的,看个例子可能就比较好理解了:
......
......
用这种科学计数法的方式表示小数时,小数点的位置就变得「漂浮不定」了,这就是浮点数名字的由来。使用同样的规则,对于二进制数,我们也可以用科学计数法表示,也就是说把基数 10 换成 2 即可。
接下来我们一起看看IEEE 754关于浮点数的定义:(来源:百度百科)
首先要注意,浮点数在内存中的存储不论正数负数一律是原码!接着我们来看看官方是怎样定义浮点数的存储的:
通俗来讲,一个浮点数V必定能够写成以下形式:
其中各个变量的含义为:
- S:符号位,取值 为 0 或 1,决定一个浮点数的符号,0 表示正号,1 表示负号
- F:尾数,用小数表示,如前面所看到的 3.14* 10^0,其中3.14就是尾数
- R:基数,如果表示十进制数 R 就是 10,如果表示二进制数 R 就是 2
- E:指数,用整数表示,如前面看到的 10^-1,-1 即是指数
单抛一个公式可能有点难理解,下面我们来举个例子吧:
float c=5.5;
我们定义一个单精度浮点型变量c并赋值为5.5
而5.5的二进制表示为:101.1
因为是二进制表示数字,所以R=2
因为5.5是正数,所以我们的S=0
而101.1又可以将小数点左移2位得到1.011*2^(2),
所以我们的E取2,即二进制的:10
而最后剩下的1.011就是我们的F.
既然现在我们的变量S,F,R,E都求出来了,现在就剩下怎么向浮点型变量开辟的32/64个比特位填充的问题了:
IEEE 754规定 提供了 2 种浮点格式:
- 单精度浮点数 float:32 位,符号位 S 占 1 bit,指数 E 占 8 bit,尾数 M 占 23 bit
- 双精度浮点数 float:64 位,符号位 S 占 1 bit,指数 E 占 11 bit,尾数 M 占 52 bit
但还要注意:
- 因为F的第一位固定是1.xxx,因此IEEE规定在存储时直接就将这个1省略不存了。这样f有限的23位就可以多存一位有效数据了。
- 因为指数 E 是个无符号整数,表示 float 时,一共占 8 bit,所以它的取值范围为 0 ~ 255。但因为指数可以是负的,所以规定在存入 E 时在它原本的值加上一个中间数 127,这样 E 的取值范围为 -127 ~ 128。表示 double 时,一共占 11 bit,存入 E 时加上中间数 1023,这样取值范围为 -1023 ~ 1024。
除此之外,针对指数E,还有一些相关的规定:
- 指数 E 非全 0 且非全 1:规格化数字,按上面的规则正常计算
- 指数 E 全 0,尾数非 0:非规格化数,尾数隐藏位不再是 1,而是 0(M = 0.xxxxx),这样可以表示 0 和很小的数
- 指数 E 全 1,尾数全 0:正无穷大/负无穷大(正负取决于 S 符号位)
- 指数 E 全 1,尾数非 0:NaN(Not a Number)
编译器内给出的结果和我们的计算值是一致的,证明我们的计算是完全正确的。
四.探究问题成因
掌握了以上知识,我们再回到最开始的那个程序上:
现在我们知道变量a是以整形的方式存入内存空间的,即内存中为a开辟的地址中存储的是数字8的补码,即:0000 0000 0000 0000 0000 0000 0000 1000
当我们以浮点型的视角来读取这个数据时,就会得到:S=0,E=-126,
F=000 0000 0000 1000
根据公式v=(-1)^S*F*R^E=(-1)^0*000 0000 0000 1000*2^(-126)=1.000*2^(-146)
借助计算器,我们可以得到:
这已经是一个非常非常小的数了,甚至我们都可以认为它趋于无穷小了,而计算机的精度最多只能表示到0.000000,所以我们看到的结果就是0.000000。
而*p是以浮点型的方式存入内存空间的,即内存中为*p的地址中存储的是浮点数8.0的v,经过计算,我们可以得到:
8.0的二进制:1000.0000
左移3位,得:1.000*2^3
因此:S=0;F=1.000(但因为F不记录小数点前的1.的值,因此实际的F是0000)。
E=3+127=130,130换为二进制:10000010
即*p在内存中存储的是:0 10000010 00000000000000000000000
为了方便计算每四位分隔开:0100 0001 0000 0000 0000 0000 0000 0000
用计算器进行一下进制转换:
可以发现,该二进制序列转换为10进制后恰好就是我们之前程序输出的值:
综上所述,不论是用%f打印整形数值时结果为0.000000,还是用%d打印浮点型数值的结果是很大的一个数字,都绝不是随便得出的一个随机的结果,而是计算机遵循其数据存储逻辑,经过精密计算的结果。之所以我们之前会误以为它是一个随机的值,那是因为之前我们根本不了解计算机内部的存储数据的逻辑。当了解了这些后,今后我们再遇到这样的情况就不会只是被bug搞得一头雾水,而是反而能很亲切的想到这个“错误”的数值是怎样得来的了。
结语
最后,希望这些小小的知识碎片拼凑起来能让您感受到计算机世界的乐趣。
我真的会赞叹,计算机真是设计的精妙绝伦。